summaryrefslogtreecommitdiff
path: root/lib/syntax_suggest/code_search.rb
blob: 7628dcd1312f577eec9095cc647cc243c473e5d8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# frozen_string_literal: true

module SyntaxSuggest
  # Searches code for a syntax error
  #
  # There are three main phases in the algorithm:
  #
  # 1. Sanitize/format input source
  # 2. Search for invalid blocks
  # 3. Format invalid blocks into something meaninful
  #
  # This class handles the part.
  #
  # The bulk of the heavy lifting is done in:
  #
  #  - CodeFrontier (Holds information for generating blocks and determining if we can stop searching)
  #  - ParseBlocksFromLine (Creates blocks into the frontier)
  #  - BlockExpand (Expands existing blocks to search more code)
  #
  # ## Syntax error detection
  #
  # When the frontier holds the syntax error, we can stop searching
  #
  #   search = CodeSearch.new(<<~EOM)
  #     def dog
  #       def lol
  #     end
  #   EOM
  #
  #   search.call
  #
  #   search.invalid_blocks.map(&:to_s) # =>
  #   # => ["def lol\n"]
  #
  class CodeSearch
    private

    attr_reader :frontier

    public

    attr_reader :invalid_blocks, :record_dir, :code_lines

    def initialize(source, record_dir: DEFAULT_VALUE)
      record_dir = if record_dir == DEFAULT_VALUE
        (ENV["SYNTAX_SUGGEST_RECORD_DIR"] || ENV["SYNTAX_SUGGEST_DEBUG"]) ? "tmp" : nil
      else
        record_dir
      end

      if record_dir
        @record_dir = SyntaxSuggest.record_dir(record_dir)
        @write_count = 0
      end

      @tick = 0
      @source = source
      @name_tick = Hash.new { |hash, k| hash[k] = 0 }
      @invalid_blocks = []

      @code_lines = CleanDocument.new(source: source).call.lines

      @frontier = CodeFrontier.new(code_lines: @code_lines)
      @block_expand = BlockExpand.new(code_lines: @code_lines)
      @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines)
    end

    # Used for debugging
    def record(block:, name: "record")
      return unless @record_dir
      @name_tick[name] += 1
      filename = "#{@write_count += 1}-#{name}-#{@name_tick[name]}-(#{block.starts_at}__#{block.ends_at}).txt"
      if ENV["SYNTAX_SUGGEST_DEBUG"]
        puts "\n\n==== #{filename} ===="
        puts "\n```#{block.starts_at}..#{block.ends_at}"
        puts block
        puts "```"
        puts "  block indent:      #{block.current_indent}"
      end
      @record_dir.join(filename).open(mode: "a") do |f|
        document = DisplayCodeWithLineNumbers.new(
          lines: @code_lines.select(&:visible?),
          terminal: false,
          highlight_lines: block.lines
        ).call

        f.write("    Block lines: #{block.starts_at..block.ends_at} (#{name}) \n\n#{document}")
      end
    end

    def push(block, name:)
      record(block: block, name: name)

      block.mark_invisible if block.valid?
      frontier << block
    end

    # Parses the most indented lines into blocks that are marked
    # and added to the frontier
    def create_blocks_from_untracked_lines
      max_indent = frontier.next_indent_line&.indent

      while (line = frontier.next_indent_line) && (line.indent == max_indent)
        @parse_blocks_from_indent_line.each_neighbor_block(frontier.next_indent_line) do |block|
          push(block, name: "add")
        end
      end
    end

    # Given an already existing block in the frontier, expand it to see
    # if it contains our invalid syntax
    def expand_existing
      block = frontier.pop
      return unless block

      record(block: block, name: "before-expand")

      block = @block_expand.call(block)
      push(block, name: "expand")
    end

    # Main search loop
    def call
      until frontier.holds_all_syntax_errors?
        @tick += 1

        if frontier.expand?
          expand_existing
        else
          create_blocks_from_untracked_lines
        end
      end

      @invalid_blocks.concat(frontier.detect_invalid_blocks)
      @invalid_blocks.sort_by! { |block| block.starts_at }
      self
    end
  end
end