From e517ba2e5b6434e7d2370de7703f481dcb8eecba Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Thu, 1 Jun 2023 17:05:35 +0900 Subject: [PATCH] Bump up syntax_suggest-1.1.0 --- lib/syntax_suggest/api.rb | 6 +- lib/syntax_suggest/around_block_scan.rb | 288 +++++++++--------- lib/syntax_suggest/block_expand.rb | 111 ++++++- .../capture/before_after_keyword_ends.rb | 85 ++++++ .../capture/falling_indent_lines.rb | 71 +++++ lib/syntax_suggest/capture_code_context.rb | 24 +- lib/syntax_suggest/clean_document.rb | 6 +- lib/syntax_suggest/code_line.rb | 8 +- lib/syntax_suggest/core_ext.rb | 6 + .../parse_blocks_from_indent_line.rb | 4 +- lib/syntax_suggest/scan_history.rb | 134 ++++++++ lib/syntax_suggest/syntax_suggest.gemspec | 2 +- lib/syntax_suggest/version.rb | 2 +- .../integration/exe_cli_spec.rb | 3 +- .../integration/ruby_command_line_spec.rb | 18 ++ .../integration/syntax_suggest_spec.rb | 40 ++- spec/syntax_suggest/spec_helper.rb | 6 + .../unit/around_block_scan_spec.rb | 12 +- spec/syntax_suggest/unit/block_expand_spec.rb | 30 ++ .../capture/before_after_keyword_ends_spec.rb | 47 +++ .../unit/capture/falling_indent_lines_spec.rb | 44 +++ .../unit/capture_code_context_spec.rb | 31 +- .../unit/clean_document_spec.rb | 43 +-- spec/syntax_suggest/unit/code_line_spec.rb | 1 + spec/syntax_suggest/unit/core_ext_spec.rb | 34 +++ .../unit/display_invalid_blocks_spec.rb | 2 + spec/syntax_suggest/unit/scan_history_spec.rb | 114 +++++++ 27 files changed, 967 insertions(+), 205 deletions(-) create mode 100644 lib/syntax_suggest/capture/before_after_keyword_ends.rb create mode 100644 lib/syntax_suggest/capture/falling_indent_lines.rb create mode 100644 lib/syntax_suggest/scan_history.rb create mode 100644 spec/syntax_suggest/unit/capture/before_after_keyword_ends_spec.rb create mode 100644 spec/syntax_suggest/unit/capture/falling_indent_lines_spec.rb create mode 100644 spec/syntax_suggest/unit/core_ext_spec.rb create mode 100644 spec/syntax_suggest/unit/scan_history_spec.rb diff --git a/lib/syntax_suggest/api.rb b/lib/syntax_suggest/api.rb index 5b725e13d7..74e53c2563 100644 --- a/lib/syntax_suggest/api.rb +++ b/lib/syntax_suggest/api.rb @@ -78,7 +78,7 @@ module SyntaxSuggest code_lines: search.code_lines ).call rescue Timeout::Error => e - io.puts "Search timed out SYNTAX_SUGGEST_TIMEOUT=#{timeout}, run with DEBUG=1 for more info" + io.puts "Search timed out SYNTAX_SUGGEST_TIMEOUT=#{timeout}, run with SYNTAX_SUGGEST_DEBUG=1 for more info" io.puts e.backtrace.first(3).join($/) end @@ -91,7 +91,9 @@ module SyntaxSuggest dir = Pathname(dir) dir.join(time).tap { |path| path.mkpath - FileUtils.ln_sf(time, dir.join("last")) + alias_dir = dir.join("last") + FileUtils.rm_rf(alias_dir) if alias_dir.exist? + FileUtils.ln_sf(time, alias_dir) } end diff --git a/lib/syntax_suggest/around_block_scan.rb b/lib/syntax_suggest/around_block_scan.rb index 2a57d1b19e..ce00431b3a 100644 --- a/lib/syntax_suggest/around_block_scan.rb +++ b/lib/syntax_suggest/around_block_scan.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative "scan_history" + module SyntaxSuggest # This class is useful for exploring contents before and after # a block @@ -24,201 +26,207 @@ module SyntaxSuggest # 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 - @skip_hidden = false - @skip_empty = false + @stop_after_kw = false + @force_add_empty = false + @force_add_hidden = false + @target_indent = nil + + @scanner = ScanHistory.new(code_lines: code_lines, block: block) end - def skip(name) - case name - when :hidden? - @skip_hidden = true - when :empty? - @skip_empty = true - else - raise "Unsupported skip #{name}" - 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 + stop_next_up = false + stop_next_down = false - kw_count = 0 - end_count = 0 - index = before_lines.reverse_each.take_while do |line| - next false if stop_next - next true if @skip_hidden && line.hidden? - next true if @skip_empty && line.empty? + @scanner.scan( + up: ->(line, kw_count, end_count) { + next false if stop_next_up + 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 + if @stop_after_kw && kw_count > end_count + stop_next_up = true + end - yield line - end.last&.index + yield line + }, + down: ->(line, kw_count, end_count) { + next false if stop_next_down + next true if @force_add_hidden && line.hidden? + next true if @force_add_empty && line.empty? - if index && index < before_index - @before_index = index - end + if @stop_after_kw && end_count > kw_count + stop_next_down = true + 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 @skip_hidden && line.hidden? - next true if @skip_empty && line.empty? + yield line + } + ) - 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 - def capture_neighbor_context - lines = [] + # 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 - before_lines.reverse_each do |line| - next if line.empty? - break if line.indent < @orig_indent - next if line.indent != @orig_indent - + lines.each do |line| 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 end - lines.reverse! + return self if kw_count == end_count # nothing to balance - 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 + @scanner.commit_if_changed # Rollback point if we don't find anything to optimize - 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 + # Try to eat up empty lines + @scanner.scan( + up: ->(line, _, _) { line.hidden? || line.empty? }, + down: ->(line, _, _) { line.hidden? || line.empty? } + ) + + # More ends than keywords, check if we can balance expanding up + next_up = @scanner.next_up + next_down = @scanner.next_down + case end_count - kw_count + when 1 + if next_up&.is_kw? && next_up.indent >= @target_indent + @scanner.scan( + up: ->(line, _, _) { line == next_up }, + down: ->(line, _, _) { false } + ) + @scanner.commit_if_changed end - - lines << line - end - - lines - 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 + when -1 + if next_down&.is_end? && next_down.indent >= @target_indent + @scanner.scan( + up: ->(line, _, _) { false }, + down: ->(line, _, _) { line == next_down } + ) + @scanner.commit_if_changed end end + # Rollback any uncommitted changes + @scanner.stash_changes - 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 + self end - def scan_neighbors - scan_while { |line| line.not_empty? && line.indent >= @orig_indent } - end - - def next_up - @code_lines[before_index.pred] - end - - def next_down - @code_lines[after_index.next] + # Finds code lines at the same or greater indentation and adds them + # to the block + def scan_neighbors_not_empty + @target_indent = @orig_indent + scan_while { |line| line.not_empty? && line.indent >= @target_indent } 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 } + before_after_indent << (@scanner.next_up&.indent || 0) + before_after_indent << (@scanner.next_down&.indent || 0) + + @target_indent = before_after_indent.min + scan_while { |line| line.not_empty? && line.indent >= @target_indent } self end - def start_at_next_line - before_index - after_index - @before_index -= 1 - @after_index += 1 - 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] + @scanner.lines end - def before_index - @before_index ||= @orig_before_index - end - - def after_index - @after_index ||= @orig_after_index - end - - private def before_lines - @code_lines[0...before_index] || [] - end - - private def after_lines - @code_lines[after_index.next..-1] || [] + # Managable rspec errors + def inspect + "#<#{self.class}:0x0000123843lol >" end end end diff --git a/lib/syntax_suggest/block_expand.rb b/lib/syntax_suggest/block_expand.rb index 396b2c3a1a..e9b486c720 100644 --- a/lib/syntax_suggest/block_expand.rb +++ b/lib/syntax_suggest/block_expand.rb @@ -35,30 +35,121 @@ module SyntaxSuggest @code_lines = code_lines end + # Main interface. Expand current indentation, before + # expanding to a lower indentation def call(block) if (next_block = expand_neighbors(block)) - return next_block + next_block + else + expand_indent(block) end - - expand_indent(block) end + # Expands code to the next lowest indentation + # + # For example: + # + # 1 def dog + # 2 print "dog" + # 3 end + # + # If a block starts on line 2 then it has captured all it's "neighbors" (code at + # the same indentation or higher). To continue expanding, this block must capture + # lines one and three which are at a different indentation level. + # + # This method allows fully expanded blocks to decrease their indentation level (so + # they can expand to capture more code up and down). It does this conservatively + # as there's no undo (currently). def expand_indent(block) - AroundBlockScan.new(code_lines: @code_lines, block: block) - .skip(:hidden?) + now = AroundBlockScan.new(code_lines: @code_lines, block: block) + .force_add_hidden .stop_after_kw .scan_adjacent_indent - .code_block + + now.lookahead_balance_one_line + + now.code_block end + # A neighbor is code that is at or above the current indent line. + # + # First we build a block with all neighbors. If we can't go further + # then we decrease the indentation threshold and expand via indentation + # i.e. `expand_indent` + # + # Handles two general cases. + # + # ## Case #1: Check code inside of methods/classes/etc. + # + # It's important to note, that not everything in a given indentation level can be parsed + # as valid code even if it's part of valid code. For example: + # + # 1 hash = { + # 2 name: "richard", + # 3 dog: "cinco", + # 4 } + # + # In this case lines 2 and 3 will be neighbors, but they're invalid until `expand_indent` + # is called on them. + # + # When we are adding code within a method or class (at the same indentation level), + # use the empty lines to denote the programmer intended logical chunks. + # Stop and check each one. For example: + # + # 1 def dog + # 2 print "dog" + # 3 + # 4 hash = { + # 5 end + # + # If we did not stop parsing at empty newlines then the block might mistakenly grab all + # the contents (lines 2, 3, and 4) and report them as being problems, instead of only + # line 4. + # + # ## Case #2: Expand/grab other logical blocks + # + # Once the search algorithm has converted all lines into blocks at a given indentation + # it will then `expand_indent`. Once the blocks that generates are expanded as neighbors + # we then begin seeing neighbors being other logical blocks i.e. a block's neighbors + # may be another method or class (something with keywords/ends). + # + # For example: + # + # 1 def bark + # 2 + # 3 end + # 4 + # 5 def sit + # 6 end + # + # In this case if lines 4, 5, and 6 are in a block when it tries to expand neighbors + # it will expand up. If it stops after line 2 or 3 it may cause problems since there's a + # valid kw/end pair, but the block will be checked without it. + # + # We try to resolve this edge case with `lookahead_balance_one_line` below. def expand_neighbors(block) - expanded_lines = AroundBlockScan.new(code_lines: @code_lines, block: block) - .skip(:hidden?) + now = AroundBlockScan.new(code_lines: @code_lines, block: block) + + # Initial scan + now + .force_add_hidden .stop_after_kw - .scan_neighbors - .scan_while { |line| line.empty? } # Slurp up empties + .scan_neighbors_not_empty + + # Slurp up empties + now + .scan_while { |line| line.empty? } + + # If next line is kw and it will balance us, take it + expanded_lines = now + .lookahead_balance_one_line .lines + # Don't allocate a block if it won't be used + # + # If nothing was taken, return nil to indicate that status + # used in `def call` to determine if + # we need to expand up/out (`expand_indent`) if block.lines == expanded_lines nil else diff --git a/lib/syntax_suggest/capture/before_after_keyword_ends.rb b/lib/syntax_suggest/capture/before_after_keyword_ends.rb new file mode 100644 index 0000000000..f53c57a4d1 --- /dev/null +++ b/lib/syntax_suggest/capture/before_after_keyword_ends.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SyntaxSuggest + module Capture + # 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 + # + # + # Example: + # + # lines = BeforeAfterKeywordEnds.new( + # block: block, + # code_lines: code_lines + # ).call() + # + class BeforeAfterKeywordEnds + def initialize(code_lines:, block:) + @scanner = ScanHistory.new(code_lines: code_lines, block: block) + @original_indent = block.current_indent + end + + def call + lines = [] + + @scanner.scan( + up: ->(line, kw_count, end_count) { + next true if line.empty? + break if line.indent < @original_indent + next true if line.indent != @original_indent + + # If we're going up and have one complete kw/end pair, stop + if kw_count != 0 && kw_count == end_count + lines << line + break + end + + lines << line if line.is_kw? || line.is_end? + true + }, + down: ->(line, kw_count, end_count) { + next true if line.empty? + break if line.indent < @original_indent + next true if line.indent != @original_indent + + # if we're going down and have one complete kw/end pair,stop + if kw_count != 0 && kw_count == end_count + lines << line + break + end + + lines << line if line.is_kw? || line.is_end? + true + } + ) + @scanner.stash_changes + + lines + end + end + end +end diff --git a/lib/syntax_suggest/capture/falling_indent_lines.rb b/lib/syntax_suggest/capture/falling_indent_lines.rb new file mode 100644 index 0000000000..1e046b2ba5 --- /dev/null +++ b/lib/syntax_suggest/capture/falling_indent_lines.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module SyntaxSuggest + module Capture + # Shows the context around code provided by "falling" indentation + # + # If this is the original code lines: + # + # class OH + # def hello + # it "foo" do + # end + # end + # + # And this is the line that is captured + # + # it "foo" do + # + # It will yield its surrounding context: + # + # class OH + # def hello + # end + # end + # + # Example: + # + # FallingIndentLines.new( + # block: block, + # code_lines: @code_lines + # ).call do |line| + # @lines_to_output << line + # end + # + class FallingIndentLines + def initialize(code_lines:, block:) + @lines = nil + @scanner = ScanHistory.new(code_lines: code_lines, block: block) + @original_indent = block.current_indent + end + + def call(&yieldable) + last_indent_up = @original_indent + last_indent_down = @original_indent + + @scanner.commit_if_changed + @scanner.scan( + up: ->(line, _, _) { + next true if line.empty? + + if line.indent < last_indent_up + yieldable.call(line) + last_indent_up = line.indent + end + true + }, + down: ->(line, _, _) { + next true if line.empty? + + if line.indent < last_indent_down + yieldable.call(line) + last_indent_down = line.indent + end + true + } + ) + @scanner.stash_changes + end + end + end +end diff --git a/lib/syntax_suggest/capture_code_context.rb b/lib/syntax_suggest/capture_code_context.rb index 7d6a550612..6dc7047176 100644 --- a/lib/syntax_suggest/capture_code_context.rb +++ b/lib/syntax_suggest/capture_code_context.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true +module SyntaxSuggest + module Capture + end +end + +require_relative "capture/falling_indent_lines" +require_relative "capture/before_after_keyword_ends" + module SyntaxSuggest # Turns a "invalid block(s)" into useful context # @@ -55,6 +63,10 @@ module SyntaxSuggest capture_falling_indent(block) end + sorted_lines + end + + def sorted_lines @lines_to_output.select!(&:not_empty?) @lines_to_output.uniq! @lines_to_output.sort! @@ -76,12 +88,11 @@ module SyntaxSuggest # end # end # - # def capture_falling_indent(block) - AroundBlockScan.new( + Capture::FallingIndentLines.new( block: block, code_lines: @code_lines - ).on_falling_indent do |line| + ).call do |line| @lines_to_output << line end end @@ -116,9 +127,10 @@ module SyntaxSuggest def capture_before_after_kws(block) return unless block.visible_lines.count == 1 - around_lines = AroundBlockScan.new(code_lines: @code_lines, block: block) - .start_at_next_line - .capture_neighbor_context + around_lines = Capture::BeforeAfterKeywordEnds.new( + code_lines: @code_lines, + block: block + ).call around_lines -= block.lines diff --git a/lib/syntax_suggest/clean_document.rb b/lib/syntax_suggest/clean_document.rb index b572189259..2c26061bfc 100644 --- a/lib/syntax_suggest/clean_document.rb +++ b/lib/syntax_suggest/clean_document.rb @@ -110,7 +110,7 @@ module SyntaxSuggest @document.join end - # Remove comments and whitespace only lines + # Remove comments # # replace with empty newlines # @@ -155,8 +155,10 @@ module SyntaxSuggest # ).to eq(2) # def clean_sweep(source:) + # Match comments, but not HEREDOC strings with #{variable} interpolation + # https://rubular.com/r/HPwtW9OYxKUHXQ source.lines.map do |line| - if line.match?(/^\s*(#[^{].*)?$/) # https://rubular.com/r/LLE10D8HKMkJvs + if line.match?(/^\s*#([^{].*|)$/) $/ else line diff --git a/lib/syntax_suggest/code_line.rb b/lib/syntax_suggest/code_line.rb index dc738ab128..a20f34afa4 100644 --- a/lib/syntax_suggest/code_line.rb +++ b/lib/syntax_suggest/code_line.rb @@ -48,12 +48,10 @@ module SyntaxSuggest strip_line = line.dup strip_line.lstrip! - if strip_line.empty? - @empty = true - @indent = 0 + @indent = if (@empty = strip_line.empty?) + line.length - 1 # Newline removed from strip_line is not "whitespace" else - @empty = false - @indent = line.length - strip_line.length + line.length - strip_line.length end set_kw_end diff --git a/lib/syntax_suggest/core_ext.rb b/lib/syntax_suggest/core_ext.rb index aed93e129c..e0fd62b81c 100644 --- a/lib/syntax_suggest/core_ext.rb +++ b/lib/syntax_suggest/core_ext.rb @@ -45,6 +45,8 @@ if SyntaxError.method_defined?(:detailed_message) ) annotation = io.string + annotation += "\n" unless annotation.end_with?("\n") + annotation + message else message @@ -66,9 +68,13 @@ if SyntaxError.method_defined?(:detailed_message) else autoload :Pathname, "pathname" + #-- # Monkey patch kernel to ensure that all `require` calls call the same # method + #++ module Kernel + # :stopdoc: + module_function alias_method :syntax_suggest_original_require, :require diff --git a/lib/syntax_suggest/parse_blocks_from_indent_line.rb b/lib/syntax_suggest/parse_blocks_from_indent_line.rb index d1071732fe..241ed6acb4 100644 --- a/lib/syntax_suggest/parse_blocks_from_indent_line.rb +++ b/lib/syntax_suggest/parse_blocks_from_indent_line.rb @@ -36,8 +36,8 @@ module SyntaxSuggest # Builds blocks from bottom up def each_neighbor_block(target_line) scan = AroundBlockScan.new(code_lines: code_lines, block: CodeBlock.new(lines: target_line)) - .skip(:empty?) - .skip(:hidden?) + .force_add_empty + .force_add_hidden .scan_while { |line| line.indent >= target_line.indent } neighbors = scan.code_block.lines diff --git a/lib/syntax_suggest/scan_history.rb b/lib/syntax_suggest/scan_history.rb new file mode 100644 index 0000000000..d15597c440 --- /dev/null +++ b/lib/syntax_suggest/scan_history.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module SyntaxSuggest + # Scans up/down from the given block + # + # You can try out a change, stash it, or commit it to save for later + # + # Example: + # + # scanner = ScanHistory.new(code_lines: code_lines, block: block) + # scanner.scan( + # up: ->(_, _, _) { true }, + # down: ->(_, _, _) { true } + # ) + # scanner.changed? # => true + # expect(scanner.lines).to eq(code_lines) + # + # scanner.stash_changes + # + # expect(scanner.lines).to_not eq(code_lines) + class ScanHistory + attr_reader :before_index, :after_index + + def initialize(code_lines:, block:) + @code_lines = code_lines + @history = [block] + refresh_index + end + + def commit_if_changed + if changed? + @history << CodeBlock.new(lines: @code_lines[before_index..after_index]) + end + + self + end + + # Discards any changes that have not been committed + def stash_changes + refresh_index + self + end + + # Discard changes that have not been committed and revert the last commit + # + # Cannot revert the first commit + def revert_last_commit + if @history.length > 1 + @history.pop + refresh_index + end + + self + end + + def changed? + @before_index != current.lines.first.index || + @after_index != current.lines.last.index + end + + # Iterates up and down + # + # Returns line, kw_count, end_count for each iteration + def scan(up:, down:) + kw_count = 0 + end_count = 0 + + up_index = before_lines.reverse_each.take_while do |line| + kw_count += 1 if line.is_kw? + end_count += 1 if line.is_end? + up.call(line, kw_count, end_count) + end.last&.index + + kw_count = 0 + end_count = 0 + + down_index = after_lines.each.take_while do |line| + kw_count += 1 if line.is_kw? + end_count += 1 if line.is_end? + down.call(line, kw_count, end_count) + end.last&.index + + @before_index = if up_index && up_index < @before_index + up_index + else + @before_index + end + + @after_index = if down_index && down_index > @after_index + down_index + else + @after_index + end + + self + end + + def next_up + return nil if @before_index <= 0 + + @code_lines[@before_index - 1] + end + + def next_down + return nil if @after_index >= @code_lines.length + + @code_lines[@after_index + 1] + end + + def lines + @code_lines[@before_index..@after_index] + end + + 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 + + private def current + @history.last + end + + private def refresh_index + @before_index = current.lines.first.index + @after_index = current.lines.last.index + self + end + end +end diff --git a/lib/syntax_suggest/syntax_suggest.gemspec b/lib/syntax_suggest/syntax_suggest.gemspec index 73b25c6a5f..0e611c13d0 100644 --- a/lib/syntax_suggest/syntax_suggest.gemspec +++ b/lib/syntax_suggest/syntax_suggest.gemspec @@ -27,6 +27,6 @@ Gem::Specification.new do |spec| `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|assets)/}) } end spec.bindir = "exe" - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.executables = ["syntax_suggest"] spec.require_paths = ["lib"] end diff --git a/lib/syntax_suggest/version.rb b/lib/syntax_suggest/version.rb index d9ea5200e6..ac8c2f62e5 100644 --- a/lib/syntax_suggest/version.rb +++ b/lib/syntax_suggest/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module SyntaxSuggest - VERSION = "1.0.2" + VERSION = "1.1.0" end diff --git a/spec/syntax_suggest/integration/exe_cli_spec.rb b/spec/syntax_suggest/integration/exe_cli_spec.rb index f0b49b4386..b9a3173715 100644 --- a/spec/syntax_suggest/integration/exe_cli_spec.rb +++ b/spec/syntax_suggest/integration/exe_cli_spec.rb @@ -13,7 +13,8 @@ module SyntaxSuggest end def exe(cmd) - out = run!("#{exe_path} #{cmd}", raise_on_nonzero_exit: false) + ruby = ENV.fetch("RUBY", "ruby") + out = run!("#{ruby} #{exe_path} #{cmd}", raise_on_nonzero_exit: false) puts out if ENV["SYNTAX_SUGGEST_DEBUG"] out end diff --git a/spec/syntax_suggest/integration/ruby_command_line_spec.rb b/spec/syntax_suggest/integration/ruby_command_line_spec.rb index 61102dad2a..b41a4c86e3 100644 --- a/spec/syntax_suggest/integration/ruby_command_line_spec.rb +++ b/spec/syntax_suggest/integration/ruby_command_line_spec.rb @@ -46,6 +46,24 @@ module SyntaxSuggest end end + # Since Ruby 3.2 includes syntax_suggest as a default gem, we might accidentally + # be requiring the default gem instead of this library under test. Assert that's + # not the case + it "tests current version of syntax_suggest" do + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + script = tmpdir.join("script.rb") + contents = <<~'EOM' + puts "suggest_version is #{SyntaxSuggest::VERSION}" + EOM + script.write(contents) + + out = `#{ruby} -I#{lib_dir} -rsyntax_suggest/version #{script} 2>&1` + + expect(out).to include("suggest_version is #{SyntaxSuggest::VERSION}").once + end + end + it "detects require error and adds a message with auto mode" do Dir.mktmpdir do |dir| tmpdir = Pathname(dir) diff --git a/spec/syntax_suggest/integration/syntax_suggest_spec.rb b/spec/syntax_suggest/integration/syntax_suggest_spec.rb index bb50fafce7..64dafabcdd 100644 --- a/spec/syntax_suggest/integration/syntax_suggest_spec.rb +++ b/spec/syntax_suggest/integration/syntax_suggest_spec.rb @@ -21,10 +21,11 @@ module SyntaxSuggest filename: file ) end - debug_display(io.string) - debug_display(benchmark) end + debug_display(io.string) + debug_display(benchmark) + expect(io.string).to include(<<~'EOM') 6 class SyntaxTree < Ripper 170 def self.parse(source) @@ -115,9 +116,6 @@ module SyntaxSuggest expect(io.string).to include(<<~'EOM') 5 module DerailedBenchmarks 6 class RequireTree - 7 REQUIRED_BY = {} - 9 attr_reader :name - 10 attr_writer :cost > 13 def initialize(name) > 18 def self.reset! > 25 end @@ -160,7 +158,6 @@ module SyntaxSuggest out = io.string expect(out).to include(<<~EOM) 16 class Rexe - 18 VERSION = '1.5.1' > 77 class Lookups > 140 def format_requires > 148 end @@ -207,5 +204,36 @@ module SyntaxSuggest > 4 end EOM end + + it "empty else" do + source = <<~'EOM' + class Foo + def foo + if cond? + foo + else + + end + end + + # ... + + def bar + if @recv + end_is_missing_here + end + end + EOM + + io = StringIO.new + SyntaxSuggest.call( + io: io, + source: source + ) + out = io.string + expect(out).to include(<<~EOM) + end_is_missing_here + EOM + end end end diff --git a/spec/syntax_suggest/spec_helper.rb b/spec/syntax_suggest/spec_helper.rb index 67d401888b..89bc9f4ab1 100644 --- a/spec/syntax_suggest/spec_helper.rb +++ b/spec/syntax_suggest/spec_helper.rb @@ -16,6 +16,12 @@ RSpec.configure do |config| config.expect_with :rspec do |c| c.syntax = :expect end + + if config.color_mode == :automatic + if config.color_enabled? && ((ENV["TERM"] == "dumb") || ENV["NO_COLOR"]&.slice(0)) + config.color_mode = :off + end + end end # Used for debugging modifications to diff --git a/spec/syntax_suggest/unit/around_block_scan_spec.rb b/spec/syntax_suggest/unit/around_block_scan_spec.rb index 6053c3947e..d6756448bd 100644 --- a/spec/syntax_suggest/unit/around_block_scan_spec.rb +++ b/spec/syntax_suggest/unit/around_block_scan_spec.rb @@ -13,7 +13,7 @@ module SyntaxSuggest code_lines = CodeLine.from_source(source) block = CodeBlock.new(lines: code_lines[1]) expand = AroundBlockScan.new(code_lines: code_lines, block: block) - .scan_neighbors + .scan_neighbors_not_empty expect(expand.code_block.to_s).to eq(source) expand.scan_while { |line| false } @@ -104,8 +104,8 @@ module SyntaxSuggest expand = AroundBlockScan.new(code_lines: code_lines, block: block) expand.scan_while { true } - expect(expand.before_index).to eq(0) - expect(expand.after_index).to eq(6) + expect(expand.lines.first.index).to eq(0) + expect(expand.lines.last.index).to eq(6) expect(expand.code_block.to_s).to eq(source_string) end @@ -149,9 +149,9 @@ module SyntaxSuggest block = CodeBlock.new(lines: code_lines[3]) expand = AroundBlockScan.new(code_lines: code_lines, block: block) - expand.skip(:empty?) - expand.skip(:hidden?) - expand.scan_neighbors + expand.force_add_empty + expand.force_add_hidden + expand.scan_neighbors_not_empty expect(expand.code_block.to_s).to eq(<<~EOM.indent(4)) diff --git a/spec/syntax_suggest/unit/block_expand_spec.rb b/spec/syntax_suggest/unit/block_expand_spec.rb index ba0b0457a1..5cff73621d 100644 --- a/spec/syntax_suggest/unit/block_expand_spec.rb +++ b/spec/syntax_suggest/unit/block_expand_spec.rb @@ -4,6 +4,36 @@ require_relative "../spec_helper" module SyntaxSuggest RSpec.describe BlockExpand do + it "empty line in methods" do + source_string = <<~EOM + class Dog # index 0 + def bark # index 1 + + end # index 3 + + def sit # index 5 + print "sit" # index 6 + end # index 7 + end # index 8 + end # extra end + EOM + + code_lines = code_line_array(source_string) + + sit = code_lines[4..7] + sit.each(&:mark_invisible) + + block = CodeBlock.new(lines: sit) + expansion = BlockExpand.new(code_lines: code_lines) + block = expansion.expand_neighbors(block) + + expect(block.to_s).to eq(<<~EOM.indent(2)) + def bark # index 1 + + end # index 3 + EOM + end + it "captures multiple empty and hidden lines" do source_string = <<~EOM def foo diff --git a/spec/syntax_suggest/unit/capture/before_after_keyword_ends_spec.rb b/spec/syntax_suggest/unit/capture/before_after_keyword_ends_spec.rb new file mode 100644 index 0000000000..02d9be4387 --- /dev/null +++ b/spec/syntax_suggest/unit/capture/before_after_keyword_ends_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +module SyntaxSuggest + RSpec.describe Capture::BeforeAfterKeywordEnds do + it "before after keyword ends" do + source = <<~'EOM' + def nope + print 'not me' + end + + def lol + print 'lol' + end + + def hello # 8 + + def yolo + print 'haha' + end + + def nada + print 'nope' + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[8]) + + expect(block.to_s).to include("def hello") + + lines = Capture::BeforeAfterKeywordEnds.new( + block: block, + code_lines: code_lines + ).call + lines.sort! + + expect(lines.join).to include(<<~'EOM') + def lol + end + def yolo + end + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/capture/falling_indent_lines_spec.rb b/spec/syntax_suggest/unit/capture/falling_indent_lines_spec.rb new file mode 100644 index 0000000000..61d1642d97 --- /dev/null +++ b/spec/syntax_suggest/unit/capture/falling_indent_lines_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../../spec_helper" + +module SyntaxSuggest + RSpec.describe Capture::FallingIndentLines do + it "on_falling_indent" do + source = <<~'EOM' + class OH + def lol + print 'lol + end + + def hello + it "foo" do + end + + def yolo + print 'haha' + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[6]) + + lines = [] + Capture::FallingIndentLines.new( + block: block, + code_lines: code_lines + ).call do |line| + lines << line + end + lines.sort! + + expect(lines.join).to eq(<<~'EOM') + class OH + def hello + end + end + EOM + end + end +end diff --git a/spec/syntax_suggest/unit/capture_code_context_spec.rb b/spec/syntax_suggest/unit/capture_code_context_spec.rb index e1bc281c13..46f13e8961 100644 --- a/spec/syntax_suggest/unit/capture_code_context_spec.rb +++ b/spec/syntax_suggest/unit/capture_code_context_spec.rb @@ -4,6 +4,32 @@ require_relative "../spec_helper" module SyntaxSuggest RSpec.describe CaptureCodeContext do + it "capture_before_after_kws two" do + source = <<~'EOM' + class OH + + def hello + + def hai + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[2]) + + display = CaptureCodeContext.new( + blocks: [block], + code_lines: code_lines + ) + display.capture_before_after_kws(block) + expect(display.sorted_lines.join).to eq(<<~'EOM'.indent(2)) + def hello + def hai + end + EOM + end + it "capture_before_after_kws" do source = <<~'EOM' def sit @@ -16,13 +42,14 @@ module SyntaxSuggest EOM code_lines = CleanDocument.new(source: source).call.lines - block = CodeBlock.new(lines: code_lines[0]) + block = CodeBlock.new(lines: code_lines[3]) display = CaptureCodeContext.new( blocks: [block], code_lines: code_lines ) - lines = display.call + + lines = display.capture_before_after_kws(block).sort expect(lines.join).to eq(<<~'EOM') def sit end diff --git a/spec/syntax_suggest/unit/clean_document_spec.rb b/spec/syntax_suggest/unit/clean_document_spec.rb index 4fb79efd62..25a62e4454 100644 --- a/spec/syntax_suggest/unit/clean_document_spec.rb +++ b/spec/syntax_suggest/unit/clean_document_spec.rb @@ -72,6 +72,24 @@ module SyntaxSuggest EOM end + it "joins multi-line chained methods when separated by comments" do + source = <<~EOM + User. + # comment + where(name: 'schneems'). + # another comment + first + EOM + + doc = CleanDocument.new(source: source).join_consecutive! + code_lines = doc.lines + + expect(code_lines[0].to_s.count($/)).to eq(5) + code_lines[1..-1].each do |line| + expect(line.to_s.strip.length).to eq(0) + end + end + it "helper method: take_while_including" do source = <<~EOM User @@ -92,27 +110,10 @@ module SyntaxSuggest # yolo EOM - out = CleanDocument.new(source: source).lines.join - expect(out.to_s).to eq(<<~EOM) - - puts "what" - - EOM - end - - it "whitespace: removes whitespace" do - source = " \n" + <<~EOM - puts "what" - EOM - - out = CleanDocument.new(source: source).lines.join - expect(out.to_s).to eq(<<~EOM) - - puts "what" - EOM - - expect(source.lines.first.to_s).to_not eq("\n") - expect(out.lines.first.to_s).to eq("\n") + lines = CleanDocument.new(source: source).lines + expect(lines[0].to_s).to eq($/) + expect(lines[1].to_s).to eq('puts "what"' + $/) + expect(lines[2].to_s).to eq($/) end it "trailing slash: does not join trailing do" do diff --git a/spec/syntax_suggest/unit/code_line_spec.rb b/spec/syntax_suggest/unit/code_line_spec.rb index cc4fa48bc9..d5b568fd19 100644 --- a/spec/syntax_suggest/unit/code_line_spec.rb +++ b/spec/syntax_suggest/unit/code_line_spec.rb @@ -48,6 +48,7 @@ module SyntaxSuggest # Indicates line 1 can join 2, 2 can join 3, but 3 won't join it's next line expect(code_lines.map(&:ignore_newline_not_beg?)).to eq([true, true, false, false]) end + it "trailing if" do code_lines = CodeLine.from_source(<<~'EOM') puts "lol" if foo diff --git a/spec/syntax_suggest/unit/core_ext_spec.rb b/spec/syntax_suggest/unit/core_ext_spec.rb new file mode 100644 index 0000000000..802d03ecc0 --- /dev/null +++ b/spec/syntax_suggest/unit/core_ext_spec.rb @@ -0,0 +1,34 @@ +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe "Core extension" do + it "SyntaxError monkepatch ensures there is a newline to the end of the file" do + skip if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2") + + Dir.mktmpdir do |dir| + tmpdir = Pathname(dir) + file = tmpdir.join("file.rb") + file.write(<<~'EOM'.strip) + print 'no newline + EOM + + core_ext_file = lib_dir.join("syntax_suggest").join("core_ext") + require_relative core_ext_file + + original_message = "blerg" + error = SyntaxError.new(original_message) + def error.set_tmp_path_for_testing=(path) + @tmp_path_for_testing = path + end + error.set_tmp_path_for_testing = file + def error.path + @tmp_path_for_testing + end + + detailed = error.detailed_message(highlight: false, syntax_suggest: true) + expect(detailed).to include("'no newline\n#{original_message}") + expect(detailed).to_not include("print 'no newline#{original_message}") + end + end + end +end diff --git a/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb b/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb index 2c59d9dc78..b11d7d242e 100644 --- a/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb +++ b/spec/syntax_suggest/unit/display_invalid_blocks_spec.rb @@ -144,6 +144,7 @@ module SyntaxSuggest expect(io.string).to include([ " 1 class OH", "> 2 def hello", + " 3 def hai", " 4 end", " 5 end", "" @@ -162,6 +163,7 @@ module SyntaxSuggest [ " 1 class OH", ["> 2 ", DisplayCodeWithLineNumbers::TERMINAL_HIGHLIGHT, " def hello"].join, + " 3 def hai", " 4 end", " 5 end", "" diff --git a/spec/syntax_suggest/unit/scan_history_spec.rb b/spec/syntax_suggest/unit/scan_history_spec.rb new file mode 100644 index 0000000000..0e75ac66ce --- /dev/null +++ b/spec/syntax_suggest/unit/scan_history_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module SyntaxSuggest + RSpec.describe ScanHistory do + it "retains commits" do + source = <<~'EOM' + class OH # 0 + def lol # 1 + print 'lol # 2 + end # 3 + + def hello # 5 + it "foo" do # 6 + end # 7 + + def yolo # 8 + print 'haha' # 9 + end # 10 + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[6]) + + scanner = ScanHistory.new(code_lines: code_lines, block: block) + scanner.scan(up: ->(_, _, _) { true }, down: ->(_, _, _) { true }) + + expect(scanner.changed?).to be_truthy + scanner.commit_if_changed + expect(scanner.changed?).to be_falsey + + expect(scanner.lines).to eq(code_lines) + + scanner.stash_changes # Assert does nothing if changes are already committed + expect(scanner.lines).to eq(code_lines) + + scanner.revert_last_commit + + expect(scanner.lines.join).to eq(code_lines[6].to_s) + end + + it "is stashable" do + source = <<~'EOM' + class OH # 0 + def lol # 1 + print 'lol # 2 + end # 3 + + def hello # 5 + it "foo" do # 6 + end # 7 + + def yolo # 8 + print 'haha' # 9 + end # 10 + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[6]) + + scanner = ScanHistory.new(code_lines: code_lines, block: block) + scanner.scan(up: ->(_, _, _) { true }, down: ->(_, _, _) { true }) + + expect(scanner.lines).to eq(code_lines) + expect(scanner.changed?).to be_truthy + expect(scanner.next_up).to be_falsey + expect(scanner.next_down).to be_falsey + + scanner.stash_changes + + expect(scanner.changed?).to be_falsey + + expect(scanner.next_up).to eq(code_lines[5]) + expect(scanner.lines.join).to eq(code_lines[6].to_s) + expect(scanner.next_down).to eq(code_lines[7]) + end + + it "doesnt change if you dont't change it" do + source = <<~'EOM' + class OH # 0 + def lol # 1 + print 'lol # 2 + end # 3 + + def hello # 5 + it "foo" do # 6 + end # 7 + + def yolo # 8 + print 'haha' # 9 + end # 10 + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + block = CodeBlock.new(lines: code_lines[6]) + + scanner = ScanHistory.new(code_lines: code_lines, block: block) + + lines = scanner.lines + expect(scanner.changed?).to be_falsey + expect(scanner.next_up).to eq(code_lines[5]) + expect(scanner.next_down).to eq(code_lines[7]) + + expect(scanner.stash_changes.lines).to eq(lines) + expect(scanner.revert_last_commit.lines).to eq(lines) + + expect(scanner.scan(up: ->(_, _, _) { false }, down: ->(_, _, _) { false }).lines).to eq(lines) + end + end +end