summaryrefslogtreecommitdiff
path: root/lib/syntax_suggest/around_block_scan.rb
blob: fe63470dee7cc6b0d9609f7de26842e2714f9db2 (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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# frozen_string_literal: true

module SyntaxSuggest
  # This class is useful for exploring contents before and after
  # a block
  #
  # It searches above and below the passed in block to match for
  # whatever criteria you give it:
  #
  # Example:
  #
  #   def dog         # 1
  #     puts "bark"   # 2
  #     puts "bark"   # 3
  #   end             # 4
  #
  #   scan = AroundBlockScan.new(
  #     code_lines: code_lines
  #     block: CodeBlock.new(lines: code_lines[1])
  #   )
  #
  #   scan.scan_while { true }
  #
  #   puts scan.before_index # => 0
  #   puts scan.after_index  # => 3
  #
  # Contents can also be filtered using AroundBlockScan#skip
  #
  # To grab the next surrounding indentation use AroundBlockScan#scan_adjacent_indent
  class AroundBlockScan
    def initialize(code_lines:, block:)
      @code_lines = code_lines
      @orig_before_index = block.lines.first.index
      @orig_after_index = block.lines.last.index
      @orig_indent = block.current_indent
      @skip_array = []
      @after_array = []
      @before_array = []
      @stop_after_kw = false

      @force_add_hidden = false
      @force_add_empty = false
    end

    # When using this flag, `scan_while` will
    # bypass the block it's given and always add a
    # line that responds truthy to `CodeLine#hidden?`
    #
    # Lines are hidden when they've been evaluated by
    # the parser as part of a block and found to contain
    # valid code.
    def force_add_hidden
      @force_add_hidden = true
      self
    end

    # When using this flag, `scan_while` will
    # bypass the block it's given and always add a
    # line that responds truthy to `CodeLine#empty?`
    #
    # Empty lines contain no code, only whitespace such
    # as leading spaces a newline.
    def force_add_empty
      @force_add_empty = true
      self
    end

    # Tells `scan_while` to look for mismatched keyword/end-s
    #
    # When scanning up, if we see more keywords then end-s it will
    # stop. This might happen when scanning outside of a method body.
    # the first scan line up would be a keyword and this setting would
    # trigger a stop.
    #
    # When scanning down, stop if there are more end-s than keywords.
    def stop_after_kw
      @stop_after_kw = true
      self
    end

    # Main work method
    #
    # The scan_while method takes a block that yields lines above and
    # below the block. If the yield returns true, the @before_index
    # or @after_index are modified to include the matched line.
    #
    # In addition to yielding individual lines, the internals of this
    # object give a mini DSL to handle common situations such as
    # stopping if we've found a keyword/end mis-match in one direction
    # or the other.
    def scan_while
      stop_next = false
      kw_count = 0
      end_count = 0
      index = before_lines.reverse_each.take_while do |line|
        next false if stop_next
        next true if @force_add_hidden && line.hidden?
        next true if @force_add_empty && line.empty?

        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?
        if @stop_after_kw && kw_count > end_count
          stop_next = true
        end

        yield line
      end.last&.index

      if index && index < before_index
        @before_index = index
      end

      stop_next = false
      kw_count = 0
      end_count = 0
      index = after_lines.take_while do |line|
        next false if stop_next
        next true if @force_add_hidden && line.hidden?
        next true if @force_add_empty && line.empty?

        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?
        if @stop_after_kw && end_count > kw_count
          stop_next = true
        end

        yield line
      end.last&.index

      if index && index > after_index
        @after_index = index
      end
      self
    end

    # Shows surrounding kw/end pairs
    #
    # The purpose of showing these extra pairs is due to cases
    # of ambiguity when only one visible line is matched.
    #
    # For example:
    #
    #     1  class Dog
    #     2    def bark
    #     4    def eat
    #     5    end
    #     6  end
    #
    # In this case either line 2 could be missing an `end` or
    # line 4 was an extra line added by mistake (it happens).
    #
    # When we detect the above problem it shows the issue
    # as only being on line 2
    #
    #     2    def bark
    #
    # Showing "neighbor" keyword pairs gives extra context:
    #
    #     2    def bark
    #     4    def eat
    #     5    end
    #
    def capture_neighbor_context
      lines = []
      kw_count = 0
      end_count = 0
      before_lines.reverse_each do |line|
        next if line.empty?
        break if line.indent < @orig_indent
        next if line.indent != @orig_indent

        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?
        if kw_count != 0 && kw_count == end_count
          lines << line
          break
        end

        lines << line if line.is_kw? || line.is_end?
      end

      lines.reverse!

      kw_count = 0
      end_count = 0
      after_lines.each do |line|
        next if line.empty?
        break if line.indent < @orig_indent
        next if line.indent != @orig_indent

        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?
        if kw_count != 0 && kw_count == end_count
          lines << line
          break
        end

        lines << line if line.is_kw? || line.is_end?
      end

      lines
    end

    # Shows the context around code provided by "falling" indentation
    #
    # Converts:
    #
    #       it "foo" do
    #
    # into:
    #
    #   class OH
    #     def hello
    #       it "foo" do
    #     end
    #   end
    #
    def on_falling_indent
      last_indent = @orig_indent
      before_lines.reverse_each do |line|
        next if line.empty?
        if line.indent < last_indent
          yield line
          last_indent = line.indent
        end
      end

      last_indent = @orig_indent
      after_lines.each do |line|
        next if line.empty?
        if line.indent < last_indent
          yield line
          last_indent = line.indent
        end
      end
    end

    # Scanning is intentionally conservative because
    # we have no way of rolling back an agressive block (at this time)
    #
    # If a block was stopped for some trivial reason, (like an empty line)
    # but the next line would have caused it to be balanced then we
    # can check that condition and grab just one more line either up or
    # down.
    #
    # For example, below if we're scanning up, line 2 might cause
    # the scanning to stop. This is because empty lines might
    # denote logical breaks where the user intended to chunk code
    # which is a good place to stop and check validity. Unfortunately
    # it also means we might have a "dangling" keyword or end.
    #
    #   1 def bark
    #   2
    #   3 end
    #
    # If lines 2 and 3 are in the block, then when this method is
    # run it would see it is unbalanced, but that acquiring line 1
    # would make it balanced, so that's what it does.
    def lookahead_balance_one_line
      kw_count = 0
      end_count = 0
      lines.each do |line|
        kw_count += 1 if line.is_kw?
        end_count += 1 if line.is_end?
      end

      return self if kw_count == end_count # nothing to balance

      # More ends than keywords, check if we can balance expanding up
      if (end_count - kw_count) == 1 && next_up
        return self unless next_up.is_kw?
        return self unless next_up.indent >= @orig_indent

        @before_index = next_up.index

      # More keywords than ends, check if we can balance by expanding down
      elsif (kw_count - end_count) == 1 && next_down
        return self unless next_down.is_end?
        return self unless next_down.indent >= @orig_indent

        @after_index = next_down.index
      end
      self
    end

    # Finds code lines at the same or greater indentation and adds them
    # to the block
    def scan_neighbors_not_empty
      scan_while { |line| line.not_empty? && line.indent >= @orig_indent }
    end

    # Returns the next line to be scanned above the current block.
    # Returns `nil` if at the top of the document already
    def next_up
      @code_lines[before_index.pred]
    end

    # Returns the next line to be scanned below the current block.
    # Returns `nil` if at the bottom of the document already
    def next_down
      @code_lines[after_index.next]
    end

    # Scan blocks based on indentation of next line above/below block
    #
    # Determines indentaion of the next line above/below the current block.
    #
    # Normally this is called when a block has expanded to capture all "neighbors"
    # at the same (or greater) indentation and needs to expand out. For example
    # the `def/end` lines surrounding a method.
    def scan_adjacent_indent
      before_after_indent = []
      before_after_indent << (next_up&.indent || 0)
      before_after_indent << (next_down&.indent || 0)

      indent = before_after_indent.min
      scan_while { |line| line.not_empty? && line.indent >= indent }

      self
    end

    # Return the currently matched lines as a `CodeBlock`
    #
    # When a `CodeBlock` is created it will gather metadata about
    # itself, so this is not a free conversion. Avoid allocating
    # more CodeBlock's than needed
    def code_block
      CodeBlock.new(lines: lines)
    end

    # Returns the lines matched by the current scan as an
    # array of CodeLines
    def lines
      @code_lines[before_index..after_index]
    end

    # Gives the index of the first line currently scanned
    def before_index
      @before_index ||= @orig_before_index
    end

    # Gives the index of the last line currently scanned
    def after_index
      @after_index ||= @orig_after_index
    end

    # Returns an array of all the CodeLines that exist before
    # the currently scanned block
    private def before_lines
      @code_lines[0...before_index] || []
    end

    # Returns an array of all the CodeLines that exist after
    # the currently scanned block
    private def after_lines
      @code_lines[after_index.next..-1] || []
    end
  end
end