diff --git a/Gemfile.lock b/Gemfile.lock index df2b429..b695efd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -45,7 +45,7 @@ GEM rubocop-ast (>= 0.4.0) ruby-prof (1.4.3) ruby-progressbar (1.11.0) - stackprof (0.2.16) + stackprof (0.2.17) standard (1.3.0) rubocop (= 1.20.0) rubocop-performance (= 1.11.5) diff --git a/lib/dead_end/api.rb b/lib/dead_end/api.rb index 683a0e4..fda28d8 100644 --- a/lib/dead_end/api.rb +++ b/lib/dead_end/api.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative "version" require "tmpdir" @@ -60,6 +62,41 @@ def self.handle_error(e, re_raise: true, io: $stderr) # # Main private interface def self.call(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_dir: DEFAULT_VALUE, timeout: TIMEOUT_DEFAULT, io: $stderr) + call_now(source: source, filename: filename, terminal: terminal, record_dir: record_dir, timeout: timeout, io: io) + # call_old(source: source, filename: filename, terminal: terminal, record_dir: record_dir, timeout: timeout, io: io) + end + + def self.call_now(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_dir: DEFAULT_VALUE, timeout: TIMEOUT_DEFAULT, io: $stderr) + search = nil + code_lines = nil + filename = nil if filename == DEFAULT_VALUE + Timeout.timeout(timeout) do + code_lines = CleanDocument.new(source: source).call.lines + + if DeadEnd.valid?(code_lines) + io.puts "Syntax OK" + obj = Object.new + def obj.document_ok?; true; end + return obj + end + + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + end + + blocks = search.finished.map(&:node).map {|node| CodeBlock.new(lines: node.lines) } + + DisplayInvalidBlocks.new( + io: io, + blocks: blocks, + filename: filename, + terminal: terminal, + code_lines: code_lines, + ).call + end + + def self.call_old(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_dir: DEFAULT_VALUE, timeout: TIMEOUT_DEFAULT, io: $stderr) search = nil filename = nil if filename == DEFAULT_VALUE Timeout.timeout(timeout) do @@ -73,7 +110,7 @@ def self.call(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_ blocks: blocks, filename: filename, terminal: terminal, - code_lines: search.code_lines + code_lines: search.code_lines, ).call rescue Timeout::Error => e io.puts "Search timed out DEAD_END_TIMEOUT=#{timeout}, run with DEBUG=1 for more info" @@ -85,13 +122,15 @@ def self.call(source:, filename: DEFAULT_VALUE, terminal: DEFAULT_VALUE, record_ # Used to generate a unique directory to record # search steps for debugging def self.record_dir(dir) - time = Time.now.strftime("%Y-%m-%d-%H-%M-%s-%N") - dir = Pathname(dir) - symlink = dir.join("last").tap { |path| path.delete if path.exist? } - dir.join(time).tap { |path| - path.mkpath - FileUtils.symlink(path.basename, symlink) - } + @record_dir ||= begin + time = Time.now.strftime("%Y-%m-%d-%H-%M-%s-%N") + dir = Pathname(dir) + symlink = dir.join("last").tap { |path| path.delete if path.exist? } + dir.join(time).tap { |path| + path.mkpath + FileUtils.symlink(path.basename, symlink) + } + end end # DeadEnd.valid_without? [Private] @@ -187,12 +226,20 @@ def self.valid?(source) require_relative "lex_all" require_relative "code_line" require_relative "code_block" -require_relative "block_expand" +require_relative "lex_pair_diff" require_relative "ripper_errors" require_relative "priority_queue" require_relative "unvisited_lines" require_relative "around_block_scan" +require_relative "indent_block_expand" require_relative "priority_engulf_queue" require_relative "pathname_from_message" require_relative "display_invalid_blocks" require_relative "parse_blocks_from_indent_line" + +require_relative "block_node" +require_relative "indent_tree" +require_relative "block_document" + +require_relative "indent_search" +require_relative "diagnose_node" diff --git a/lib/dead_end/around_block_scan.rb b/lib/dead_end/around_block_scan.rb index 8c12c68..2076850 100644 --- a/lib/dead_end/around_block_scan.rb +++ b/lib/dead_end/around_block_scan.rb @@ -121,7 +121,7 @@ def capture_neighbor_context break end - lines << line + lines << line if line.is_kw? || line.is_end? end lines.reverse! @@ -140,7 +140,7 @@ def capture_neighbor_context break end - lines << line + lines << line if line.is_kw? || line.is_end? end lines diff --git a/lib/dead_end/block_document.rb b/lib/dead_end/block_document.rb new file mode 100644 index 0000000..67bc6a8 --- /dev/null +++ b/lib/dead_end/block_document.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module DeadEnd + # Convert an array of code lines to a linked list of BlockNodes + # + # Each BlockNode is connected to the node above and below it. + # A BlockNode can "capture" other nodes. This process is recursively + # performed to build a hierarchical tree structure via IndentTree + # + # document = BlockDocument.new(code_lines: code_lines) + # document.call + # + # A BlockDocument also holds a priority queue for all BlockNodes + # + # Empty lines are ignored so blocks in the list may have gaps in + # line/index numbers. + # + # A core operation is the ability to to "capture" + # several blocks immutably. This process creates a new block + # that holds the captured state and substitutes it into the graph + # data structure for the original block + # + # node.above.leaning # => :left + # node.below.leaning # => :right + + # block = document.capture_all([node.above, node.below]) + # block.leaning # => :equal + # + class BlockDocument + attr_reader :blocks, :queue, :root, :code_lines + + include Enumerable + + def initialize(code_lines:) + @code_lines = code_lines + @queue = InsertionSortQueue.new + @root = nil + end + + def to_a + map(&:itself) + end + + def each + node = @root + while node + yield node + node = node.below + end + end + + def to_s + string = +"" + each do |block| + string << block.to_s + end + string + end + + def call + last = nil + blocks = @code_lines.filter_map do |line| + next if line.empty? + + node = BlockNode.new(lines: line, indent: line.indent) + @root ||= node + node.above = last + last&.below = node + last = node + node + end + + if last.above + last.above.below = last + end + + # Need all above/below set to determine correct next_indent + @queue.replace(blocks.sort) + + self + end + + def capture_all(inner) + now = BlockNode.from_blocks(inner) + while queue&.peek&.deleted? + queue.pop + end + + if inner.first == @root + @root = now + end + + if inner.first&.above + inner.first.above.below = now + now.above = inner.first.above + end + + if inner.last&.below + inner.last.below.above = now + now.below = inner.last.below + end + now + end + + def capture(node:, captured:) + inner = [] + inner.concat(Array(captured)) + inner << node + inner.sort_by! { |block| block.start_index } + + capture_all(inner) + end + + def pop + @queue.pop + end + + def peek + @queue.peek + end + + def inspect + "#" + end + end +end diff --git a/lib/dead_end/block_node.rb b/lib/dead_end/block_node.rb new file mode 100644 index 0000000..20c25e0 --- /dev/null +++ b/lib/dead_end/block_node.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +module DeadEnd + # A core data structure + # + # A block node keeps a reference to the block above it + # and below it. In addition a block can "capture" another + # block. Block nodes are treated as immutable(ish) so when that happens + # a new node is created that contains a reference to all the blocks it was + # derived from. These are known as a block's "parents". + # + # If you walk the parent chain until it ends you'll end up with nodes + # representing individual lines of code (generated from a CodeLine). + # + # An important concept in a block is that it knows how it is "leaning" + # based on it's internal LexPairDiff. If it's leaning `:left` that means + # it needs to capture something to it's right/down to be balanced again. + # + # Note: that that the capture method is on BlockDocument since it needs to + # retain a valid reference to it's root. + # + # Another important concept is that blocks know their current indentation + # as well as can accurately derive their "next" indentation for when/if + # they're expanded. To be calculated a nodes above and below blocks must + # be accurately assigned. So this property cannot be calculated at creation + # time. + class BlockNode + # Helper to create a block from other blocks + # + # parents = node.parents + # expect(parents[0].leaning).to eq(:left) + # expect(parents[2].leaning).to eq(:right) + # + # block = BlockNode.from_blocks([parents[0], parents[2]]) + # expect(block.leaning).to eq(:equal) + def self.from_blocks(parents, above: nil, below: nil) + lines = [] + while parents.length == 1 && parents.first.parents.any? + parents = parents.first.parents + end + indent = parents.first.indent + lex_diff = LexPairDiff.new_empty + parents.each do |block| + lines.concat(block.lines) + lex_diff.concat(block.lex_diff) + indent = block.indent if block.indent < indent + block.delete + end + + above ||= parents.first.above + below ||= parents.last.below + + + parents = [] if parents.length == 1 + + node = BlockNode.new( + lines: lines, + lex_diff: lex_diff, + indent: indent, + parents: parents + ) + + node.above = above + node.below = below + node + end + + attr_accessor :above, :below, :left, :right, :parents + attr_reader :lines, :start_index, :end_index, :lex_diff, :indent, :starts_at, :ends_at + + def initialize(lines:, indent:, next_indent: nil, lex_diff: nil, parents: []) + lines = Array(lines) + @lines = lines + @deleted = false + + @end_index = lines.last.index + @start_index = lines.first.index + @indent = indent + @next_indent = next_indent + + @starts_at = @start_index + 1 + @ends_at = @end_index + 1 + + @parents = parents + + if lex_diff.nil? + set_lex_diff_from(@lines) + else + @lex_diff = lex_diff + end + end + + # Used to determine when to expand up in building + # a tree. Also used to calculate the `next_indent`. + # + # There is a tight coupling between the two concepts + # as the `next_indent` is used to determine node expansion + # priority + def expand_above?(with_indent: indent) + return false if above.nil? + + # Above node needs to expand up too, make sure that happens first + return false if above.leaf? && above.leaning == :right + + # Special case first move + if leaf? + # We need to expand down on first move, not up + return false if leaning == :left + + # If we're unbalanced both ways, prefer to be unbalanced in only one way + return true if leaning == :both && above.leaning == :left + end + + # Capturing a :left or :both could change our leaning, do so with caution + if above.leaning == :left || above.leaning == :both + above.indent >= with_indent + else + true + end + end + + # Used to determine when to expand down in building + # a tree. Also used to calculate the `next_indent`. + # + # There is a tight coupling between the two concepts + # as the `next_indent` is used to determine node expansion + # priority + def expand_below?(with_indent: indent) + return false if below.nil? + + # Below node needs to expand down, make sure that happens first + return false if below.leaf? && below.leaning == :left + + # Special case first move + if leaf? + # We need to expand up on first move, not down + return false if leaning == :right + + # If we're unbalanced both ways, prefer to be unbalanced in only one way + return true if leaning == :both && below.leaning == :right + end + + # Capturing a :right or both could change our leaning, do so with caution + if below.leaning == :right || below.leaning == :both + below.indent >= with_indent + else + true + end + end + + def leaf? + parents.empty? + end + + # Given a node, it's above and below links + # returns the next indentation. + # + # The algorithm for the logic follows: + # + # Expand given the current rules and current indentation + # keep doing that until we can't anymore. When we can't + # then pick the lowest indentation that will capture above + # and below blocks. + # + # The results of this algorithm are tightly coupled to + # tree building and therefore search. + def self.next_indent(above, node, below) + return node.indent if node.expand_above? || node.expand_below? + + value = if above + if below + case above.indent <=> below.indent + when 1 then below.indent + when 0 then above.indent + when -1 then above.indent + end + else + above.indent + end + elsif below + below.indent + else + node.indent + end + + value > node.indent ? node.indent : value + end + + # Calculating the next_indent must be done after above and below + # have been assigned (otherwise we would have a race condition). + def next_indent + @next_indent ||= self.class.next_indent(above, self, below) + end + + # It's useful to be able to mark a node as deleted without having + # to iterate over a data structure to remove it. + # + # By storing a deleted state of a node we can instead lazilly ignore it + # as needed. This is a performance optimization. + def delete + @deleted = true + end + + def deleted? + @deleted + end + + # Code within a given node is not syntatically valid + def invalid? + !valid? + end + + # Code within a given node is syntatically valid + # + # Value is memoized for performance + def valid? + return @valid if defined?(@valid) + + @valid = DeadEnd.valid?(@lines.join) + end + + # Opposite of `balanced?` + def unbalanced? + !balanced? + end + + # A node that is `leaning == :equal` is determined to be "balanced". + # + # Alternative states include :left, :right, or :both + def balanced? + @lex_diff.balanced? + end + + # Returns the direction a block is leaning + # + # States include :equal, :left, :right, and :both + def leaning + @lex_diff.leaning + end + + def to_s + @lines.join + end + + # Determines priority of node within a priority data structure + # (such as a priority queue). + # + # This is tightly coupled to tree building and search. + # + # It's also a performance sensitive area. An optimization + # not yet taken would be to re-encode the same data as a string + # so a node with next indent of 8, current indent of 10 and line + # of 100 might possibly be encoded as `008001000100` which would + # sort the same as this logic. Preliminary benchmarks indicate a + # rough 2x speedup + def <=>(other) + case next_indent <=> other.next_indent + when 1 then 1 + when -1 then -1 + when 0 + case indent <=> other.indent + when 1 then 1 + when -1 then -1 + when 0 + + end_index <=> other.end_index + end + end + end + + def hidden? + false + end + + # Provide meaningful diffs in rspec + def inspect + "#" + end + + # Generate a new lex pair diff given an array of lines + private def set_lex_diff_from(lines) + @lex_diff = LexPairDiff.new_empty + lines.each do |line| + @lex_diff.concat(line.lex_diff) + end + end + + # Needed for meaningful rspec assertions + def ==(other) + return false if other.nil? + + @lines == other.lines && @indent == other.indent && next_indent == other.next_indent && @parents == other.parents + end + end +end diff --git a/lib/dead_end/block_recorder.rb b/lib/dead_end/block_recorder.rb new file mode 100644 index 0000000..cb5a68a --- /dev/null +++ b/lib/dead_end/block_recorder.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module DeadEnd + # Records a BlockNode to a folder on disk + # + # This class allows for tracing the algorithm + class BlockRecorder + # Convienece constructor for building a BlockRecorder given + # a directory object. + # + # When nil and debug env vars have not been triggered, a + # NullRecorder instance will be returned + # + # Multiple different processes may be logging to the same + # directory, so writing to a subdir is recommended + def self.from_dir(dir, subdir:, code_lines:) + if dir == DEFAULT_VALUE + dir = ENV["DEAD_END_RECORD_DIR"] || ENV["DEBUG"] ? DeadEnd.record_dir("tmp") : nil + end + + if dir.nil? + NullRecorder.new + else + dir = Pathname(dir) + dir = dir.join(subdir) + dir.mkpath + BlockRecorder.new(dir: dir, code_lines: code_lines) + end + end + + def initialize(dir:, code_lines:) + @code_lines = code_lines + @dir = Pathname(dir) + @tick = 0 + @name_tick = Hash.new { |h, k| h[k] = 0 } + end + + def capture(block, name:) + @tick += 1 + + filename = "#{@tick}-#{name}-#{@name_tick[name] += 1}-(#{block.starts_at}__#{block.ends_at}).txt" + @dir.join(filename).open(mode: "a") do |f| + document = DisplayCodeWithLineNumbers.new( + lines: @code_lines, + terminal: false, + highlight_lines: block.lines + ).call + + f.write(" Block lines: #{(block.starts_at)..(block.ends_at)} (#{name})\n") + f.write(" indent: #{block.indent} next_indent: #{block.next_indent}\n\n") + f.write(document.to_s) + end + end + end + + # Used when recording isn't needed + class NullRecorder + def capture(block, name:) + end + end +end diff --git a/lib/dead_end/code_frontier.rb b/lib/dead_end/code_frontier.rb index f9e6920..71af0b5 100644 --- a/lib/dead_end/code_frontier.rb +++ b/lib/dead_end/code_frontier.rb @@ -50,6 +50,8 @@ module DeadEnd # CodeFrontier#detect_invalid_blocks # class CodeFrontier + attr_reader :queue + def initialize(code_lines:, unvisited: UnvisitedLines.new(code_lines: code_lines)) @code_lines = code_lines @unvisited = unvisited diff --git a/lib/dead_end/code_line.rb b/lib/dead_end/code_line.rb index 6520518..f40609b 100644 --- a/lib/dead_end/code_line.rb +++ b/lib/dead_end/code_line.rb @@ -38,7 +38,7 @@ def self.from_source(source, lines: nil) end end - attr_reader :line, :index, :lex, :line_number, :indent + attr_reader :line, :index, :lex, :line_number, :indent, :lex_diff def initialize(line:, index:, lex:) @lex = lex @line = line @@ -59,6 +59,10 @@ def initialize(line:, index:, lex:) set_kw_end end + def balanced? + @lex_diff.balanced? + end + # Used for stable sort via indentation level # # Ruby's sort is not "stable" meaning that when @@ -206,7 +210,10 @@ def trailing_slash? end_count = 0 @ignore_newline_not_beg = false + + left_right = LeftRightLexCount.new @lex.each do |lex| + left_right.count_lex(lex) kw_count += 1 if lex.is_kw? end_count += 1 if lex.is_end? @@ -234,6 +241,13 @@ def trailing_slash? @is_kw = (kw_count - end_count) > 0 @is_end = (end_count - kw_count) > 0 + + @lex_diff = LexPairDiff.new( + curly: left_right.curly_diff, + square: left_right.square_diff, + parens: left_right.parens_diff, + kw_end: kw_count - end_count + ) end end end diff --git a/lib/dead_end/code_search.rb b/lib/dead_end/code_search.rb index 19b5bc8..7d46060 100644 --- a/lib/dead_end/code_search.rb +++ b/lib/dead_end/code_search.rb @@ -15,7 +15,7 @@ module DeadEnd # # - 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) + # - IndentBlockExpand (Expands existing blocks to search more code) # # ## Syntax error detection # @@ -61,7 +61,7 @@ def initialize(source, record_dir: DEFAULT_VALUE) @code_lines = CleanDocument.new(source: source).call.lines @frontier = CodeFrontier.new(code_lines: @code_lines) - @block_expand = BlockExpand.new(code_lines: @code_lines) + @indent_block_expand = IndentBlockExpand.new(code_lines: @code_lines) @parse_blocks_from_indent_line = ParseBlocksFromIndentLine.new(code_lines: @code_lines) end @@ -88,6 +88,7 @@ def record(block:, name: "record") end end + # Add a block back onto the frontier def push(block, name:) record(block: block, name: name) @@ -100,6 +101,10 @@ def push(block, name:) def create_blocks_from_untracked_lines max_indent = frontier.next_indent_line&.indent + # Expand an unvisited line into a block and put it on the frontier + # This registers all lines and removes "univisted" lines from the + # frontier. The process continues until all unvisited lines at a given + # indentation are added 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") @@ -115,7 +120,8 @@ def expand_existing record(block: block, name: "before-expand") - block = @block_expand.call(block) + block = @indent_block_expand.call(block) + push(block, name: "expand") end diff --git a/lib/dead_end/diagnose_node.rb b/lib/dead_end/diagnose_node.rb new file mode 100644 index 0000000..e7a3752 --- /dev/null +++ b/lib/dead_end/diagnose_node.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module DeadEnd + # Explore and diagnose problems with a block + # + # Given an invalid node, the root cause of the syntax error + # may exist in that node, or in one or more of it's parents. + # + # The DiagnoseNode class is responsible for determining the most reasonable next move to + # make. + # + # Results can be best effort, i.e. they must be re-checked against a document + # before being recommended to a user. We still want to take care in making the + # best possible suggestion as a bad suggestion may halt the search at a suboptimal + # location. + # + # The algorithm here is tightly coupled to the nodes produced by the current IndentTree + # implementation. + # + # Possible problem states: + # + # - :self - The block holds no parents, if it holds a problem its in the current node. + # + # - :invalid_inside_split_pair - An invalid block is splitting two valid leaning blocks, return the middle. + # + # - :remove_pseudo_pair - Multiple invalid blocks in isolation are present, but when paired with external leaning + # blocks above and below they become valid. Remove these and group the leftovers together. i.e. don't + # scapegoat `else/ensure/rescue`, remove them from the block and retry with whats leftover. + # + # - :extract_from_multiple - Multiple invalid blocks in isolation are present, but we were able to find one that could be removed + # to make a valid set along with outer leaning i.e. `[`, `in)&lid` , `vaild`, `]`. Different from :invalid_inside_split_pair because + # the leaning elements come from different blocks above & below. At the end of a journey split_leaning might break one invalid + # node into multiple parents that then hit :extract_from_multiple + # + # - :one_invalid_parent - Only one parent is invalid, better investigate. + # + # - :multiple_invalid_parents - Multiple blocks are invalid, they cannot be reduced or extracted, we will have to fork the search and + # explore all of them independently. + # + # Returns the next 0, 1 or N node(s) based on the given problem state. + # + # - 0 nodes returned by :self + # - 1 node returned by :invalid_inside_split_pair, :remove_pseudo_pair, :extract_from_multiple, :one_invalid_parent + # - N nodes returned by :multiple_invalid_parents + # + # Usage example: + # + # diagnose = DiagnoseNode.new(block).call + # expect(diagnose.problem).to eq(:multiple_invalid_parents) + # expect(diagnose.next.length).to eq(2) + # + class DiagnoseNode + attr_reader :block, :problem, :next + + def initialize(block) + @block = block + @problem = nil + @next = [] + end + + def call + invalid = get_invalid + return self if invalid.empty? + + @next = if @problem == :multiple_invalid_parents + invalid.map { |b| BlockNode.from_blocks([b]) } + else + invalid + end + + self + end + + # Checks for the common problem states a node might face. + # returns an array of 0, 1 or N blocks + private def get_invalid + out = diagnose_self + return out if out + + out = diagnose_left_right + return out if out + + out = diagnose_above_below + return out if out + + diagnose_one_or_more_parents + end + + # Diagnose left/right + # + # Handles cases where the block is made up of a several nodes and is book ended by + # nodes leaning in the correct direction that pair with one another. For example [`{`, `b@&[d`, `}`] + # + # This is different from above/below which also has matching blocks, but those are outside of the current + # block array (they are above and below it respectively) + # + # ## (:invalid_inside_split_pair) Handle case where keyword/end (or any pair) is falsely reported as invalid in isolation but + # holds a syntax error inside of it. + # + # Example: + # + # ``` + # def cow # left, invalid in isolation, valid when paired with end + # inv&li) code # Actual problem to be isolated + # end # right, invalid in isolation, valid when paired with def + # ``` + private def diagnose_left_right + invalid = block.parents.select(&:invalid?) + return false if invalid.length < 3 + + left = invalid.detect { |block| block.leaning == :left } + right = invalid.reverse_each.detect { |block| block.leaning == :right } + + if left && right && BlockNode.from_blocks([left, right]).valid? + @problem = :invalid_inside_split_pair + + invalid.reject! { |b| b == left || b == right } + + # If the left/right was not mapped properly or we've accidentally got a :multiple_invalid_parents + # we can get a false positive, double check the invalid lines fully capture the problem + if DeadEnd.valid_without?( + code_lines: block.lines, + without_lines: invalid.flat_map(&:lines) + ) + + invalid + end + end + end + + # ## (:remove_pseudo_pair) Handle else/ensure case + # + # Example: + # + # ``` + # def cow # above + # print inv&li) # Actual problem + # rescue => e # Invalid in isolation, valid when paired with above/below + # end # below + # ``` + # + # ## (:extract_from_multiple) Handle syntax seems fine in isolation, but not when combined with above/below leaning blocks + # + # Example: + # + # ``` + # [ # above + # missing_comma_not_okay + # missing_comma_okay + # ] # below + # ``` + # + private def diagnose_above_below + invalid = block.parents.select(&:invalid?) + + above = block.above if block.above&.leaning == :left + below = block.below if block.below&.leaning == :right + return false if above.nil? || below.nil? + + if invalid.reject! { |block| + b = BlockNode.from_blocks([above, block, below]) + b.leaning == :equal && b.valid? + } + + if invalid.any? + # At this point invalid array was reduced and represents only + # nodes that are invalid when paired with it's above/below + # however, we may need to split the node apart again + @problem = :remove_pseudo_pair + + [BlockNode.from_blocks(invalid, above: above, below: below)] + else + invalid = block.parents.select(&:invalid?) + + # If we can remove one node from many blocks to make the other blocks valid then, that + # block must be the problem + if (b = invalid.detect { |b| BlockNode.from_blocks([above, invalid - [b], below].flatten).valid? }) + @problem = :extract_from_multiple + [b] + end + end + end + end + + # We couldn't detect any special cases, either return 1 or N invalid nodes + private def diagnose_one_or_more_parents + invalid = block.parents.select(&:invalid?) + if invalid.length > 1 + if (b = invalid.detect { |b| BlockNode.from_blocks([invalid - [b]].flatten).valid? }) + @problem = :extract_from_multiple + [b] + else + @problem = :multiple_invalid_parents + invalid + end + else + @problem = :one_invalid_parent + invalid + end + end + + private def diagnose_self + if block.parents.empty? + @problem = :self + [] + end + end + end +end diff --git a/lib/dead_end/display_invalid_blocks.rb b/lib/dead_end/display_invalid_blocks.rb index fff6023..8dd05a1 100644 --- a/lib/dead_end/display_invalid_blocks.rb +++ b/lib/dead_end/display_invalid_blocks.rb @@ -6,13 +6,14 @@ module DeadEnd # Used for formatting invalid blocks class DisplayInvalidBlocks - attr_reader :filename + attr_reader :filename, :code_lines - def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: DEFAULT_VALUE) + def initialize(code_lines:, blocks:, io: $stderr, filename: nil, terminal: DEFAULT_VALUE, capture_mode: :old) @io = io @blocks = Array(blocks) @filename = filename @code_lines = code_lines + @capture_mode = capture_mode @terminal = terminal == DEFAULT_VALUE ? io.isatty : terminal end @@ -44,12 +45,16 @@ def call code_lines: block.lines ).call - # Enhance code output - # Also handles several ambiguious cases - lines = CaptureCodeContext.new( - blocks: block, - code_lines: @code_lines - ).call + if @capture_mode == :old + # Enhance code output + # Also handles several ambiguious cases + lines = CaptureCodeContext.new( + blocks: block, + code_lines: @code_lines + ).call + else + lines = block.lines + end # Build code output document = DisplayCodeWithLineNumbers.new( diff --git a/lib/dead_end/block_expand.rb b/lib/dead_end/indent_block_expand.rb similarity index 91% rename from lib/dead_end/block_expand.rb rename to lib/dead_end/indent_block_expand.rb index 7f3396f..29bbca5 100644 --- a/lib/dead_end/block_expand.rb +++ b/lib/dead_end/indent_block_expand.rb @@ -10,7 +10,7 @@ module DeadEnd # puts "wow" # end # - # block = BlockExpand.new(code_lines: code_lines) + # block = IndentBlockExpand.new(code_lines: code_lines) # .call(CodeBlock.new(lines: code_lines[1])) # # puts block.to_s @@ -21,7 +21,7 @@ module DeadEnd # Once a code block has captured everything at a given indentation level # then it will expand to capture surrounding indentation. # - # block = BlockExpand.new(code_lines: code_lines) + # block = IndentBlockExpand.new(code_lines: code_lines) # .call(block) # # block.to_s @@ -30,7 +30,7 @@ module DeadEnd # puts "wow" # end # - class BlockExpand + class IndentBlockExpand def initialize(code_lines:) @code_lines = code_lines end diff --git a/lib/dead_end/indent_search.rb b/lib/dead_end/indent_search.rb new file mode 100644 index 0000000..4b12e0d --- /dev/null +++ b/lib/dead_end/indent_search.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative "journey" +require_relative "block_recorder" + +module DeadEnd + # Search for the cause of a syntax error + # + # Starts with a BlockNode tree built from IndentTree + # this has the property of the entire document starting + # as a single root. From there we inspect the "parents" of + # the document node to follow the invalid blocks. + # + # This process is recorded via one or more `Journey` instances. + # + # The search enforces the property that all nodes on a journey + # would produce a valid document if removed. This holds true + # from the root node as removing all source code would produce + # a parsable document + # + # After each step in a search, the step is evaluated to see if + # it preserves the Journey property. If not, it means we've looked + # too far and have over-shot our syntax error. Or we've made a bad + # move. In either case we terminate the journey and report its last block. + # + # When done, the journey instances can be accessed in the `finished` + # array + class IndentSearch + attr_reader :finished + + def initialize(tree:, record_dir: DEFAULT_VALUE) + @tree = tree + @root = tree.root + @finished = [] + @frontier = [Journey.new(@tree.root)] + @recorder = BlockRecorder.from_dir(record_dir, subdir: "search", code_lines: tree.code_lines) + end + + def call + while (journey = @frontier.pop) + diagnose = DiagnoseNode.new(journey.node).call + problem = diagnose.problem + nodes = diagnose.next + + @recorder.capture(journey.node, name: "pop_#{problem}") + + if nodes.empty? || !holds_all_errors?(nodes) + @recorder.capture(journey.node, name: "skip_capture_and_exit_#{problem}") + @finished << journey + else + nodes.each do |block| + @recorder.capture(block, name: "explore_#{problem}") + route = journey.deep_dup + route << Step.new(block) + @frontier.unshift(route) + end + end + end + + @finished.sort_by! { |j| j.node.starts_at } + + self + end + + # Check if a given set of blocks holds + # syntax errors in the context of the document + # + # The frontier + finished arrays should always + # hold all errors for the document. + # + # When reducing a node or nodes we need to make sure + # that while they seem to hold a syntax error in isolation + # that they also hold it in the full document context. + # + # This method accounts for the need to branch/fork a + # search for multiple syntax errors + private def holds_all_errors?(blocks) + blocks = Array(blocks).clone + blocks.concat(@finished.map(&:node)) + blocks.concat(@frontier.map(&:node)) + + without_lines = blocks.flat_map do |block| + block.lines + end + + DeadEnd.valid_without?( + without_lines: without_lines, + code_lines: @root.lines + ) + end + end + + def inspect + "#" + end +end diff --git a/lib/dead_end/indent_tree.rb b/lib/dead_end/indent_tree.rb new file mode 100644 index 0000000..d70ecf2 --- /dev/null +++ b/lib/dead_end/indent_tree.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative "block_recorder" + +module DeadEnd + # Transform a BlockDocument into a Tree + # + # tree = IndentTree.new(document: document).call + # expect(tree.root.lines).to eq(document.code_lines) + # + # Nodes are put into a queue (provided by the document) + # and are pulled out in a specific priority order (high coupling). + # + # A node then attempts to expand up and down according to rules here + # and in `BlockNode#expand_above?` and `BlockNode#expand_below?` + # + # While this process tends to produce valid code blocks from valid code + # it's not guaranteed. Since we will ultimately search for invalid code + # it's not an ideal property. + class IndentTree + attr_reader :document, :code_lines + + def initialize(document:, record_dir: DEFAULT_VALUE) + @document = document + @code_lines = document.code_lines + @last_length = Float::INFINITY + + @recorder = BlockRecorder.from_dir(record_dir, subdir: "build_tree", code_lines: @code_lines) + end + + def peek + document.peek + end + + def root + @document.root + end + + def step + block = document.pop + return nil if block.nil? + + @recorder.capture(block, name: "pop") + + blocks = [block] + indent = block.next_indent + + # Look up + while blocks.last.expand_above?(with_indent: indent) + above = blocks.last.above + blocks << above + + break if above.leaning == :left + break if above.leaning == :both && above.leaf? + end + + blocks.reverse! + + # Look down + while blocks.last.expand_below?(with_indent: indent) + below = blocks.last.below + blocks << below + + break if below.leaning == :right + break if below.leaning == :both && below.leaf? + end + + if blocks.length > 1 + now = document.capture_all(blocks) + @recorder.capture(now, name: "expand") + document.queue << now + now + else + block + end + end + + def call + while step + end + self + end + + def to_s + @document.to_s + end + end +end diff --git a/lib/dead_end/journey.rb b/lib/dead_end/journey.rb new file mode 100644 index 0000000..4834274 --- /dev/null +++ b/lib/dead_end/journey.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module DeadEnd + # Each journey represents a walk of the graph to eliminate + # invalid code + # + # We can check the a step's validity by asserting that it's removal produces + # valid code from it's parent + # + # node = tree.root + # journey = Journey.new(node) + # journey << Step.new(node.parents[0]) + # expect(journey.node).to eq(node.parents[0]) + # + class Journey + attr_reader :steps + + def initialize(root) + @root = root + @steps = [Step.new(root)] + end + + # Needed so we don't internally mutate the @steps array + def deep_dup + j = Journey.new(@root) + steps.each do |step| + j << step + end + j + end + + def to_s + node.to_s + end + + def <<(step) + @steps << step + end + + def node + @steps.last.block + end + end + + class Step + attr_reader :block + + def initialize(block) + @block = block + end + + def to_s + block.to_s + end + end +end diff --git a/lib/dead_end/left_right_lex_count.rb b/lib/dead_end/left_right_lex_count.rb index 3b71ade..6ddf731 100644 --- a/lib/dead_end/left_right_lex_count.rb +++ b/lib/dead_end/left_right_lex_count.rb @@ -22,6 +22,8 @@ module DeadEnd # left_right.missing.first # # => "}" class LeftRightLexCount + attr_reader :kw_count, :end_count + def initialize @kw_count = 0 @end_count = 0 @@ -37,6 +39,16 @@ def initialize } end + def concat(other) + @count_for_char.each do |(k, _)| + @count_for_char[k] += other[k] + end + + @kw_count += other.kw_count + @end_count += other.end_count + self + end + def count_kw @kw_count += 1 end @@ -45,6 +57,14 @@ def count_end @end_count += 1 end + def count_lines(lines) + lines.each do |line| + line.lex.each do |lex| + count_lex(lex) + end + end + end + # Count source code characters # # Example: @@ -121,6 +141,18 @@ def missing "(" => ")" }.freeze + def curly_diff + @count_for_char["{"] - @count_for_char["}"] + end + + def square_diff + @count_for_char["["] - @count_for_char["]"] + end + + def parens_diff + @count_for_char["("] - @count_for_char[")"] + end + # Opening characters like `{` need closing characters # like `}`. # # When a mis-match count is detected, suggest the diff --git a/lib/dead_end/lex_pair_diff.rb b/lib/dead_end/lex_pair_diff.rb new file mode 100644 index 0000000..e602579 --- /dev/null +++ b/lib/dead_end/lex_pair_diff.rb @@ -0,0 +1,108 @@ +module DeadEnd + # Holds a diff of lexical pairs + # + # Example: + # + # diff = LexPairDiff.from_lex(LexAll.new("}"), is_kw: false, is_end: false) + # diff.curly # => 1 + # diff.balanced? # => false + # diff.leaning # => :right + # + # two = LexPairDiff.from_lex(LexAll.new("{"), is_kw: false, is_end: false) + # two.curly => -1 + # + # diff.concat(two) + # diff.curly # => 0 + # diff.balanced? # => true + # diff.leaning # => :equal + # + # Internally a pair is stored as a single value + # positive indicates more left elements, negative + # indicates more right elements, and zero indicates + # balanced pairs. + class LexPairDiff + # Convienece constructor + def self.from_lex(lex:, is_kw:, is_end:) + left_right = LeftRightLexCount.new + lex.each do |l| + left_right.count_lex(l) + end + + kw_end = 0 + kw_end += 1 if is_kw + kw_end -= 1 if is_end + + LexPairDiff.new( + curly: left_right.curly_diff, + square: left_right.square_diff, + parens: left_right.parens_diff, + kw_end: kw_end + ) + end + + def self.new_empty + new(curly: 0, square: 0, parens: 0, kw_end: 0) + end + + attr_reader :curly, :square, :parens, :kw_end + + def initialize(curly:, square:, parens:, kw_end:) + @curly = curly + @square = square + @parens = parens + @kw_end = kw_end + end + + def each + yield @curly + yield @square + yield @parens + yield @kw_end + end + + # Returns :left if all there are more unmatched pairs to + # left i.e. "{" + # Returns :right if all there are more unmatched pairs to + # left i.e. "}" + # + # If pairs are unmatched like "(]" returns `:both` + # + # If everything is balanced returns :equal + def leaning + dir = 0 + each do |v| + case v <=> 0 + when 1 + return :both if dir == -1 + dir = 1 + when -1 + return :both if dir == 1 + dir = -1 + end + end + + case dir + when 1 + :left + when 0 + :equal + when -1 + :right + end + end + + # Returns true if all pairs are equal + def balanced? + @curly == 0 && @square == 0 && @parens == 0 && @kw_end == 0 + end + + # Mutates the existing diff with contents of another diff + def concat(other) + @curly += other.curly + @square += other.square + @parens += other.parens + @kw_end += other.kw_end + self + end + end +end diff --git a/lib/dead_end/parse_blocks_from_indent_line.rb b/lib/dead_end/parse_blocks_from_indent_line.rb index 11fa2b8..ec2dc98 100644 --- a/lib/dead_end/parse_blocks_from_indent_line.rb +++ b/lib/dead_end/parse_blocks_from_indent_line.rb @@ -26,6 +26,10 @@ module DeadEnd # # At this point it has no where else to expand, and it will yield this inner # code as a block + # + # The other major concern is eliminating all lines that do not contain + # an end. In the above example, if we started from the top and moved + # down we might accidentally eliminate everything but `end` class ParseBlocksFromIndentLine attr_reader :code_lines @@ -42,6 +46,19 @@ def each_neighbor_block(target_line) neighbors = scan.code_block.lines + # Block production here greatly affects quality and performance. + # + # Larger blocks produce a faster search as the frontier must go + # through fewer iterations. However too large of a block, will + # degrade output quality if too many unrelated lines are caught + # in an invalid block. + # + # Another concern is being too clever with block production. + # Quality of the end result depends on sometimes including unrelated + # lines. For example in code like `deffoo; end` we want to match + # both lines as the programmer's mistake was missing a space in the + # `def` even though technically we could make it valid by simply + # removing the "extra" `end`. block = CodeBlock.new(lines: neighbors) if neighbors.length <= 2 || block.valid? yield block diff --git a/lib/dead_end/priority_queue.rb b/lib/dead_end/priority_queue.rb index 3621e70..2962a0f 100644 --- a/lib/dead_end/priority_queue.rb +++ b/lib/dead_end/priority_queue.rb @@ -1,6 +1,71 @@ # frozen_string_literal: true module DeadEnd + # Sort elements on insert + # + # Instead of constantly calling `sort!`, put + # the element where it belongs the first time + # around + # + # Example: + # + # sorted = InsertionSort.new + # sorted << 33 + # sorted << 44 + # sorted << 1 + # puts sorted.to_a + # # => [1, 44, 33] + # + class InsertionSortQueue + def initialize + @array = [] + end + + def replace(array) + @array = array + end + + def <<(value) + index = @array.bsearch_index do |existing| + case value <=> existing + when -1 + true + when 0 + false + when 1 + false + end + end || @array.length + + @array.insert(index, value) + end + + def to_a + @array + end + + def pop + @array.pop + end + + def length + @array.length + end + + def empty? + @array.empty? + end + + def peek + @array.last + end + + # Legacy for testing PriorityQueue + def sorted + @array + end + end + # Holds elements in a priority heap on insert # # Instead of constantly calling `sort!`, put diff --git a/spec/integration/dead_end_spec.rb b/spec/integration/dead_end_spec.rb index 926383a..03343cf 100644 --- a/spec/integration/dead_end_spec.rb +++ b/spec/integration/dead_end_spec.rb @@ -4,9 +4,7 @@ module DeadEnd RSpec.describe "Integration tests that don't spawn a process (like using the cli)" do - it "does not timeout on massive files" do - next unless ENV["DEAD_END_TIMEOUT"] - + it "does not timeout on massive files", slow: true do file = fixtures_dir.join("syntax_tree.rb.txt") lines = file.read.lines lines.delete_at(768 - 1) @@ -27,15 +25,16 @@ module DeadEnd expect(io.string).to include(<<~'EOM') 6 class SyntaxTree < Ripper - 170 def self.parse(source) - 174 end + 727 class Args + 750 end ❯ 754 def on_args_add(arguments, argument) - ❯ 776 class ArgsAddBlock - ❯ 810 end + 776 class ArgsAddBlock + 810 end 9233 end EOM end + it "re-checks all block code, not just what's visible issues/95" do file = fixtures_dir.join("ruby_buildpack.rb.txt") io = StringIO.new @@ -54,10 +53,9 @@ module DeadEnd expect(io.string).to_not include("def ruby_install_binstub_path") expect(io.string).to include(<<~'EOM') - ❯ 1067 def add_yarn_binary - ❯ 1068 return [] if yarn_preinstalled? + 16 class LanguagePack::Ruby < LanguagePack::Base ❯ 1069 | - ❯ 1075 end + 1344 end EOM end @@ -72,10 +70,10 @@ module DeadEnd debug_display(io.string) expect(io.string).to include(<<~'EOM') - 1 Rails.application.routes.draw do + 1 Rails.application.routes.draw do + 107 constraints -> { Rails.application.config.non_production } do + 111 end ❯ 113 namespace :admin do - ❯ 116 match "/foobar(*path)", via: :all, to: redirect { |_params, req| - ❯ 120 } 121 end EOM end @@ -95,7 +93,6 @@ module DeadEnd 22 it "body" do 27 query = Cutlass::FunctionQuery.new( ❯ 28 port: port - ❯ 29 body: body 30 ).call 34 end 35 end @@ -115,12 +112,9 @@ module DeadEnd 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 + 18 def self.reset! + 25 end 73 end 74 end EOM @@ -140,9 +134,11 @@ module DeadEnd expect(out).to include(<<~EOM) 16 class Rexe - ❯ 77 class Lookups + 77 class Lookups ❯ 78 def input_modes - ❯ 148 end + 87 def input_formats + 94 end + 148 end 551 end EOM end @@ -160,10 +156,11 @@ module DeadEnd out = io.string expect(out).to include(<<~EOM) 16 class Rexe - 18 VERSION = '1.5.1' - ❯ 77 class Lookups + 77 class Lookups + 124 def formatters + 137 end ❯ 140 def format_requires - ❯ 148 end + 148 end 551 end EOM end @@ -182,9 +179,9 @@ def call # 0 ) out = io.string expect(out).to include(<<~EOM) - ❯ 1 def call # 0 + 1 def call # 0 ❯ 3 end # one # 2 - ❯ 4 end # two # 3 + 4 end # two # 3 EOM end @@ -202,10 +199,429 @@ def bark ) out = io.string expect(out).to include(<<~EOM) - ❯ 1 class Dog + 1 class Dog ❯ 2 def bark - ❯ 4 end + 4 end + EOM + end + + it "missing `do` highlights more than `end` simple" do + source = <<~'EOM' + describe "things" do + it "blerg" do + end + + it "flerg" + end + + it "zlerg" do + end + end + EOM + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + out = io.string + expect(out).to include(<<~EOM) + 1 describe "things" do + 2 it "blerg" do + 3 end + ❯ 5 it "flerg" + ❯ 6 end + 8 it "zlerg" do + 9 end + 10 end + EOM + end + + it "missing `do` highlights more than `end`, with internal contents" do + source = <<~'EOM' + describe "things" do + it "blerg" do + end + + it "flerg" + doesnt + show + extra + stuff() + that_s + not + critical + inside + end + + it "zlerg" do + foo + end + end + EOM + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + out = io.string + + expect(out).to include(<<~EOM) + 1 describe "things" do + 2 it "blerg" do + 3 end + ❯ 5 it "flerg" + ❯ 14 end + 16 it "zlerg" do + 18 end + 19 end + EOM + end + + it "works with valid code" do + source = <<~'EOM' + class OH + def hello + end + def hai + end + end + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + out = io.string + + expect(out).to include(<<~EOM) + Syntax OK + EOM + end + + it "squished do regression" do + source = <<~'EOM' + def call + trydo + @options = CommandLineParser.new.parse + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + @callable = create_callable + init_rexe_context + init_parser_and_formatters + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + output_log_entry + end # one + end # two EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + out = io.string + + expect(out).to eq(<<~'EOM'.indent(2)) + 1 def call + ❯ 2 trydo + ❯ 15 end # one + 16 end + EOM + end + + it "handles mismatched }" do + source = <<~EOM + class Blerg + Foo.call do { + puts lol + class Foo + end # two + end # three + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 1 class Blerg + ❯ 2 Foo.call do { + 4 class Foo + 5 end # two + 6 end # three + EOM + end + + it "handles no spaces between blocks and trailing slash" do + source = <<~'EOM' + require "rails_helper" + RSpec.describe Foo, type: :model do + describe "#bar" do + context "context" do + it "foos the bar with a foo and then bazes the foo with a bar to"\ + "fooify the barred bar" do + travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + foo = build(:foo) + end + end + end + end + describe "#baz?" do + context "baz has barred the foo" do + it "returns true" do # <== HERE + end + end + end + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 2 RSpec.describe Foo, type: :model do + 13 describe "#baz?" do + ❯ 14 context "baz has barred the foo" do + 16 end + 17 end + 18 end + EOM + end + + it "handles no spaces between blocks" do + source = <<~'EOM' + context "foo bar" do + it "bars the foo" do + travel_to DateTime.new(2020, 10, 1, 10, 0, 0) do + end + end + end + context "test" do + it "should" do + end + EOM + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 7 context "test" do + ❯ 8 it "should" do + 9 end + EOM + end + + it "finds hanging def in this project" do + source = fixtures_dir.join("this_project_extra_def.rb.txt").read + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 1 module SyntaxErrorSearch + 3 class DisplayInvalidBlocks + 17 def call + 34 end + ❯ 36 def filename + 38 def code_with_filename + 45 end + 63 end + 64 end + EOM + end + + it "Format Code blocks real world example" do + source = <<~'EOM' + require 'rails_helper' + RSpec.describe AclassNameHere, type: :worker do + describe "thing" do + context "when" do + let(:thing) { stuff } + let(:another_thing) { moarstuff } + subject { foo.new.perform(foo.id, true) } + it "stuff" do + subject + expect(foo.foo.foo).to eq(true) + end + end + end # line 16 accidental end, but valid block + context "stuff" do + let(:thing) { create(:foo, foo: stuff) } + let(:another_thing) { create(:stuff) } + subject { described_class.new.perform(foo.id, false) } + it "more stuff" do + subject + expect(foo.foo.foo).to eq(false) + end + end + end # mismatched due to 16 + end + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 1 require 'rails_helper' + 2 + 3 RSpec.describe AclassNameHere, type: :worker do + ❯ 4 describe "thing" do + ❯ 16 end # line 16 accidental end, but valid block + ❯ 30 end # mismatched due to 16 + 31 end + EOM + end + + it "returns syntax error in outer block without inner block" do + source = <<~'EOM' + Foo.call + def foo + puts "lol" + puts "lol" + end # one + end # two + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 1 Foo.call + ❯ 6 end # two + EOM + end + + it "finds multiple syntax errors" do + source = <<~'EOM' + describe "hi" do + Foo.call + end + end + it "blerg" do + Bar.call + end + end + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + 1 describe "hi" do + ❯ 2 Foo.call + ❯ 3 end + 4 end + EOM + + expect(io.string).to include(<<~'EOM') + 5 it "blerg" do + ❯ 6 Bar.call + ❯ 7 end + 8 end + EOM + end + + it "finds a naked end" do + source = <<~'EOM' + def foo + end # one + end # two + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + ❯ end # one + EOM + end + + it "is harder" do + source = <<~EOM + class Blerg + Foo.call } + print haha + print lol + end # one + print lol + class Foo + end # two + end # three + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + puts io.string + raise "not implemented" + end + + it "handles mismatched |" do + source = <<~EOM + class Blerg + Foo.call do |a + print lol + end # one + print lol + class Foo + end # two + end # three + EOM + + io = StringIO.new + DeadEnd.call( + io: io, + source: source + ) + + expect(io.string).to include(<<~'EOM') + Unmatched `|', missing `|' ? + Unmatched keyword, missing `end' ? + + 1 class Blerg + ❯ 2 Foo.call do |a + 5 class Foo + 6 end # two + 7 end # three + Unmatched `end', missing keyword (`do', `def`, `if`, etc.) ? + + 1 class Blerg + ❯ 3 end # one + 5 class Foo + 6 end # two + 7 end # three + EOM + + raise("this should be one failure, not two") end end end diff --git a/spec/integration/exe_cli_spec.rb b/spec/integration/exe_cli_spec.rb index 5a49d9a..75e3bbf 100644 --- a/spec/integration/exe_cli_spec.rb +++ b/spec/integration/exe_cli_spec.rb @@ -14,7 +14,7 @@ def exe(cmd) out end - it "prints the version" do + it "prints the version", slow: true do out = exe("-v") expect(out.strip).to include(DeadEnd::VERSION) end diff --git a/spec/integration/ruby_command_line_spec.rb b/spec/integration/ruby_command_line_spec.rb index e124287..a9399f9 100644 --- a/spec/integration/ruby_command_line_spec.rb +++ b/spec/integration/ruby_command_line_spec.rb @@ -4,7 +4,7 @@ module DeadEnd RSpec.describe "Requires with ruby cli" do - it "namespaces all monkeypatched methods" do + it "namespaces all monkeypatched methods", slow: true do Dir.mktmpdir do |dir| tmpdir = Pathname(dir) script = tmpdir.join("script.rb") @@ -43,7 +43,7 @@ module DeadEnd end end - it "detects require error and adds a message with auto mode" do + it "detects require error and adds a message with auto mode", slow: true do Dir.mktmpdir do |dir| tmpdir = Pathname(dir) script = tmpdir.join("script.rb") @@ -52,8 +52,7 @@ module DeadEnd it "blerg" do end - it "flerg" - end + def foo it "zlerg" do end @@ -68,7 +67,7 @@ module DeadEnd out = `ruby -I#{lib_dir} -rdead_end #{require_rb} 2>&1` expect($?.success?).to be_falsey - expect(out).to include('❯ 5 it "flerg"').once + expect(out).to include('❯ 5 def foo').once end end end diff --git a/spec/unit/block_document_spec.rb b/spec/unit/block_document_spec.rb new file mode 100644 index 0000000..a37fb3e --- /dev/null +++ b/spec/unit/block_document_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe BlockDocument do + it "captures" do + source = <<~'EOM' + if true + print 'huge 1' + print 'huge 2' + print 'huge 3' + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + + blocks = document.to_a + + node = document.capture(node: blocks[3], captured: blocks[1..2]) + + expect(node.to_s).to eq(code_lines[1..3].join) + expect(node.start_index).to eq(1) + expect(node.indent).to eq(2) + expect(node.next_indent).to eq(0) + expect(document.map(&:itself).length).to eq(3) + + # Document has changed, rebuild blocks to array + blocks = document.to_a + node = document.capture(node: blocks[1], captured: [blocks[0], blocks[2]]) + + expect(node.to_s).to eq(code_lines.join) + expect(node.parents.length).to eq(3) + end + + it "captures complicated" do + source = <<~'EOM' + if true # 0 + print 'huge 1' # 1 + end # 2 + + if true # 4 + print 'huge 2' # 5 + end # 6 + + if true # 8 + print 'huge 3' # 9 + end # 10 + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + + blocks = document.to_a + document.capture(node: blocks[1], captured: [blocks[0], blocks[2]]) + blocks = document.to_a + document.capture(node: blocks[2], captured: [blocks[1], blocks[3]]) + blocks = document.to_a + document.capture(node: blocks[3], captured: [blocks[2], blocks[4]]) + + blocks = document.to_a + expect(blocks.length).to eq(3) + root = document.root + document.capture(node: root, captured: blocks[1..-1]) + + blocks = document.to_a + expect(blocks.length).to eq(1) + expect(document.root.parents.length).to eq(3) + expect(document.root.parents[0].to_s).to eq(<<~'EOM') + if true # 0 + print 'huge 1' # 1 + end # 2 + EOM + + expect(document.root.parents[1].to_s).to eq(<<~'EOM') + if true # 4 + print 'huge 2' # 5 + end # 6 + EOM + + expect(document.root.parents[2].to_s).to eq(<<~'EOM') + if true # 8 + print 'huge 3' # 9 + end # 10 + EOM + end + + it "prioritizes indent" do + code_lines = CodeLine.from_source(<<~'EOM') + def foo + end # one + end # two + EOM + + document = BlockDocument.new(code_lines: code_lines).call + one = document.queue.pop + expect(one.to_s.strip).to eq("end # one") + end + + it "Block document dequeues from bottom to top" do + code_lines = CodeLine.from_source(<<~'EOM') + Foo.call + end + EOM + + document = BlockDocument.new(code_lines: code_lines).call + one = document.queue.pop + expect(one.to_s.strip).to eq("end") + + two = document.queue.pop + expect(two.to_s.strip).to eq("Foo.call") + + expect(one.above).to eq(two) + expect(two.below).to eq(one) + + expect(document.queue.pop).to eq(nil) + end + end +end diff --git a/spec/unit/block_node_spec.rb b/spec/unit/block_node_spec.rb new file mode 100644 index 0000000..530319c --- /dev/null +++ b/spec/unit/block_node_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe BlockNode do + it "Can figure out it's own next_indentation" do + source = <<~'EOM' + if true + print 'huge' + print 'huge' + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + + expect(document.map(&:next_indent)).to eq([0, 2, 2, 0]) + + source = <<~'EOM' + if true + print 'huge' + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + + expect(document.map(&:next_indent)).to eq([0, 0, 0]) + end + end +end diff --git a/spec/unit/block_expand_spec.rb b/spec/unit/indent_block_expand_spec.rb similarity index 89% rename from spec/unit/block_expand_spec.rb rename to spec/unit/indent_block_expand_spec.rb index dc4dade..ca9afcc 100644 --- a/spec/unit/block_expand_spec.rb +++ b/spec/unit/indent_block_expand_spec.rb @@ -3,7 +3,7 @@ require_relative "../spec_helper" module DeadEnd - RSpec.describe BlockExpand do + RSpec.describe IndentBlockExpand do it "captures multiple empty and hidden lines" do source_string = <<~EOM def foo @@ -22,7 +22,7 @@ def foo code_lines[6].mark_invisible block = CodeBlock.new(lines: [code_lines[3]]) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = expansion.call(block) expect(block.to_s).to eq(<<~EOM.indent(4)) @@ -47,7 +47,7 @@ def foo code_lines = code_line_array(source_string) block = CodeBlock.new(lines: [code_lines[3]]) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = expansion.call(block) expect(block.to_s).to eq(<<~EOM.indent(4)) @@ -71,7 +71,7 @@ def foo code_lines = code_line_array(source_string) block = CodeBlock.new(lines: [code_lines[3]]) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = expansion.call(block) expect(block.to_s).to eq(<<~EOM.indent(4)) @@ -104,7 +104,7 @@ def foo code_lines = code_line_array(source_string) block = CodeBlock.new(lines: [code_lines[2]]) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = expansion.call(block) expect(block.to_s).to eq(<<~EOM.indent(2)) @@ -138,7 +138,7 @@ def foo lines: code_lines[6] ) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = expansion.call(block) expect(block.to_s).to eq(<<~EOM.indent(2)) @@ -171,7 +171,7 @@ def foo EOM code_lines = code_line_array(source_string) - expansion = BlockExpand.new(code_lines: code_lines) + expansion = IndentBlockExpand.new(code_lines: code_lines) block = CodeBlock.new(lines: code_lines[3]) block = expansion.call(block) diff --git a/spec/unit/indent_search_spec.rb b/spec/unit/indent_search_spec.rb new file mode 100644 index 0000000..831afad --- /dev/null +++ b/spec/unit/indent_search_spec.rb @@ -0,0 +1,1316 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe IndentSearch do + def tmp_capture_context(finished) + code_lines = finished.first.steps[0].block.lines + blocks = finished.map(&:node).map {|node| CodeBlock.new(lines: node.lines )} + lines = CaptureCodeContext.new(blocks: blocks , code_lines: code_lines).call + lines + end + + it "large both" do + source = <<~'EOM' + def dog + end + + [ + one, + two, + three + ].each do |i| + print i { + end + + def cat + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + # context = BlockNodeContext.new(search.finished[0]).call + expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + [ + one, + two, + ].each do |i| + print i { + end + EOM + end + + + it "finds missing do in an rspec context same indent when the problem is in the middle and blocks do not have inner contents" do + source = <<~'EOM' + describe "things" do + it "blerg" do + end # one + + it "flerg" + end # two + + it "zlerg" do + end # three + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.highlight.join).to eq(<<~'EOM'.indent(2)) + end # two + EOM + + # expect(context.lines.join).to eq(<<~'EOM'.indent(2)) + # it "flerg" + # end # two + # EOM + end + + it "finds missing do in an rspec context same indent when the problem is in the middle and blocks HAVE inner contents" do + source = <<~'EOM' + describe "things" do + it "blerg" do + print foo1 + end # one + + it "flerg" + print foo2 + end # two + + it "zlerg" do + print foo3 + end # three + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.highlight.join).to eq(<<~'EOM'.indent(2)) + end # two + EOM + + # expect(context.lines.join).to eq(<<~'EOM'.indent(2)) + # it "flerg" + # print foo2 + # end # two + # EOM + end + + it "finds a mis-matched def" do + source = <<~'EOM' + def foo + def blerg + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + def foo + def blerg + end + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(0)) + def foo + EOM + end + + it "finds a typo def" do + source = <<~'EOM' + defzfoo + puts "lol" + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + end + EOM + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + defzfoo + puts "lol" + end + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(0)) + end + EOM + end + + it "finds a naked end" do + source = <<~'EOM' + def foo + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + end # two + EOM + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + def foo + end # one + end # two + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(0)) + end # two + EOM + end + + it "finds multiple syntax errors" do + source = <<~'EOM' + describe "hi" do + Foo.call + end # one + end # two + + it "blerg" do + Bar.call + end # three + end # four + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(2)) + end # one + end # three + EOM + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(2)) + Foo.call + end # one + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(2)) + end # one + EOM + + context = BlockNodeContext.new(search.finished[1]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(2)) + Bar.call + end # three + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(2)) + end # three + EOM + end + + it "doesn't just return an empty `end`" do + source = <<~'EOM' + Foo.call + end # one + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + end # one + EOM + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + Foo.call + end # one + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(0)) + end # one + EOM + end + + class BlockNodeContext + attr_reader :blocks + + def initialize(journey) + @journey = journey + @blocks = [] + end + + def call + node = @journey.node + @blocks << node + + if node.leaning == :right && node.leaf? + while @blocks.last.above && @blocks.last.above.leaning == :equal + @blocks << @blocks.last.above + end + end + + if node.leaning == :left && node.leaf? + while @blocks.last.below && @blocks.last.below.leaning == :equal + @blocks << @blocks.last.below + end + end + + @blocks.sort_by! {|block| block.start_index } + self + end + + def highlight + @journey.node.lines + end + + def lines + blocks.flat_map(&:lines).sort_by {|line| line.number } + end + end + + it "returns syntax error in outer block without inner block" do + source = <<~'EOM' + Foo.call + def foo + puts "lol" + puts "lol" + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + Foo.call + def foo + puts "lol" + puts "lol" + end # one + end # two + EOM + + expect(context.highlight.join).to eq(<<~'EOM'.indent(0)) + end # two + EOM + end + + it "won't show valid code when two invalid blocks are splitting it" do + source = <<~'EOM' + { + print ( + } + + print 'haha' + + { + print ) + } + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + { + print ( + } + { + print ) + } + EOM + end + + it "only returns the problem line and not all lines on a long inner section" do + source = <<~'EOM' + { + foo: :bar, + bing: :baz, + blat: :flat # problem + florg: :blorg, + bling: :blong + } + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM'.indent(2)) + blat: :flat # problem + EOM + end + + it "invalid if and else" do + source = <<~'EOM' + def dog + end + + if true + puts ( + else + puts } + end + + def cat + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + + context = BlockNodeContext.new(search.finished[0]).call + expect(search.finished.join).to eq(<<~'EOM'.indent(2)) + puts ( + puts } + EOM + end + + it "smaller rexe input_modes" do + source = <<~'EOM' + class Lookups + def input_modes + @input_modes ||= { + 'l' => :line, + 'e' => :enumerator, + 'b' => :one_big_string, + 'n' => :none + } + # missing end problem here + + + def input_formats + @input_formats ||= { + 'j' => :json, + 'm' => :marshal, + 'n' => :none, + 'y' => :yaml, + } + end + + + def input_parsers + @input_parsers ||= { + json: ->(string) { JSON.parse(string) }, + marshal: ->(string) { Marshal.load(string) }, + none: ->(string) { string }, + yaml: ->(string) { YAML.load(string) }, + } + end + + + def output_formats + @output_formats ||= { + 'a' => :amazing_print, + 'i' => :inspect, + 'j' => :json, + 'J' => :pretty_json, + 'm' => :marshal, + 'n' => :none, + 'p' => :puts, # default + 'P' => :pretty_print, + 's' => :to_s, + 'y' => :yaml, + } + end + + + def formatters + @formatters ||= { + amazing_print: ->(obj) { obj.ai + "\n" }, + inspect: ->(obj) { obj.inspect + "\n" }, + json: ->(obj) { obj.to_json }, + marshal: ->(obj) { Marshal.dump(obj) }, + none: ->(_obj) { nil }, + pretty_json: ->(obj) { JSON.pretty_generate(obj) }, + pretty_print: ->(obj) { obj.pretty_inspect }, + puts: ->(obj) { require 'stringio'; sio = StringIO.new; sio.puts(obj); sio.string }, + to_s: ->(obj) { obj.to_s + "\n" }, + yaml: ->(obj) { obj.to_yaml }, + } + end + + + def format_requires + @format_requires ||= { + json: 'json', + pretty_json: 'json', + amazing_print: 'amazing_print', + pretty_print: 'pp', + yaml: 'yaml' + } + end + end + + + + class CommandLineParser + + include Helpers + + attr_reader :lookups, :options + + def initialize + @lookups = Lookups.new + @options = Options.new + end + + + # Inserts contents of REXE_OPTIONS environment variable at the beginning of ARGV. + private def prepend_environment_options + env_opt_string = ENV['REXE_OPTIONS'] + if env_opt_string + args_to_prepend = Shellwords.shellsplit(env_opt_string) + ARGV.unshift(args_to_prepend).flatten! + end + end + + + private def add_format_requires_to_requires_list + formats = [options.input_format, options.output_format, options.log_format] + requires = formats.map { |format| lookups.format_requires[format] }.uniq.compact + requires.each { |r| options.requires << r } + end + + + private def help_text + unless @help_text + @help_text ||= <<~HEREDOC + + rexe -- Ruby Command Line Executor/Filter -- v#{VERSION} -- #{PROJECT_URL} + + Executes Ruby code on the command line, + optionally automating management of standard input and standard output, + and optionally parsing input and formatting output with YAML, JSON, etc. + + rexe [options] [Ruby source code] + + Options: + + -c --clear_options Clear all previous command line options specified up to now + -f --input_file Use this file instead of stdin for preprocessed input; + if filespec has a YAML and JSON file extension, + sets input format accordingly and sets input mode to -mb + -g --log_format FORMAT Log format, logs to stderr, defaults to -gn (none) + (see -o for format options) + -h, --help Print help and exit + -i, --input_format FORMAT Input format, defaults to -in (None) + -ij JSON + -im Marshal + -in None (default) + -iy YAML + -l, --load RUBY_FILE(S) Ruby file(s) to load, comma separated; + ! to clear all, or precede a name with '-' to remove + -m, --input_mode MODE Input preprocessing mode (determines what `self` will be) + defaults to -mn (none) + -ml line; each line is ingested as a separate string + -me enumerator (each_line on STDIN or File) + -mb big string; all lines combined into one string + -mn none (default); no input preprocessing; + self is an Object.new + -n, --[no-]noop Do not execute the code (useful with -g); + For true: yes, true, y, +; for false: no, false, n + -o, --output_format FORMAT Output format, defaults to -on (no output): + -oa Amazing Print + -oi Inspect + -oj JSON + -oJ Pretty JSON + -om Marshal + -on No Output (default) + -op Puts + -oP Pretty Print + -os to_s + -oy YAML + If 2 letters are provided, 1st is for tty devices, 2nd for block + --project-url Outputs project URL on Github, then exits + -r, --require REQUIRE(S) Gems and built-in libraries to require, comma separated; + ! to clear all, or precede a name with '-' to remove + -v, --version Prints version and exits + + --------------------------------------------------------------------------------------- + + In many cases you will need to enclose your source code in single or double quotes. + + If source code is not specified, it will default to 'self', + which is most likely useful only in a filter mode (-ml, -me, -mb). + + If there is a .rexerc file in your home directory, it will be run as Ruby code + before processing the input. + + If there is a REXE_OPTIONS environment variable, its content will be prepended + to the command line so that you can specify options implicitly + (e.g. `export REXE_OPTIONS="-r amazing_print,yaml"`) + + HEREDOC + + @help_text.freeze + end + + @help_text + end + + + # File file input mode; detects the input mode (JSON, YAML, or None) from the extension. + private def autodetect_file_format(filespec) + extension = File.extname(filespec).downcase + if extension == '.json' + :json + elsif extension == '.yml' || extension == '.yaml' + :yaml + else + :none + end + end + + + private def open_resource(resource_identifier) + command = case (`uname`.chomp) + when 'Darwin' + 'open' + when 'Linux' + 'xdg-open' + else + 'start' + end + + `#{command} #{resource_identifier}` + end + + + # Using 'optparse', parses the command line. + # Settings go into this instance's properties (see Struct declaration). + def parse + + prepend_environment_options + + OptionParser.new do |parser| + + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + end + + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end + options.input_filespec = v + options.input_format = autodetect_file_format(v) + if [:json, :yaml].include?(options.input_format) + options.input_mode = :one_big_string + end + end + + parser.on('-g', '--log_format FORMAT', 'Log format, logs to stderr, defaults to none (see -o for format options)') do |v| + options.log_format = lookups.output_formats[v] + if options.log_format.nil? + raise("Output mode was '#{v}' but must be one of #{lookups.output_formats.keys}.") + end + end + + parser.on("-h", "--help", "Show help") do |_help_requested| + puts help_text + exit + end + + parser.on('-i', '--input_format FORMAT', + 'Mode with which to parse input values (n = none (default), j = JSON, m = Marshal, y = YAML') do |v| + + options.input_format = lookups.input_formats[v] + if options.input_format.nil? + raise("Input mode was '#{v}' but must be one of #{lookups.input_formats.keys}.") + end + end + + parser.on('-l', '--load RUBY_FILE(S)', 'Ruby file(s) to load, comma separated, or ! to clear') do |v| + if v == '!' + options.loads.clear + else + loadfiles = v.split(',').map(&:strip).map { |s| File.expand_path(s) } + removes, adds = loadfiles.partition { |filespec| filespec[0] == '-' } + + existent, nonexistent = adds.partition { |filespec| File.exists?(filespec) } + if nonexistent.any? + raise("\nDid not find the following files to load: #{nonexistent}\n\n") + else + existent.each { |filespec| options.loads << filespec } + end + + removes.each { |filespec| options.loads -= [filespec[1..-1]] } + end + end + + parser.on('-m', '--input_mode MODE', + 'Mode with which to handle input (-ml, -me, -mb, -mn (default)') do |v| + + options.input_mode = lookups.input_modes[v] + if options.input_mode.nil? + raise("Input mode was '#{v}' but must be one of #{lookups.input_modes.keys}.") + end + end + + # See https://stackoverflow.com/questions/54576873/ruby-optionparser-short-code-for-boolean-option + # for an excellent explanation of this optparse incantation. + # According to the answer, valid options are: + # -n no, -n yes, -n false, -n true, -n n, -n y, -n +, but not -n -. + parser.on('-n', '--[no-]noop [FLAG]', TrueClass, "Do not execute the code (useful with -g)") do |v| + options.noop = (v.nil? ? true : v) + end + + parser.on('-o', '--output_format FORMAT', + 'Mode with which to format values for output (`-o` + [aijJmnpsy])') do |v| + options.output_format_tty = lookups.output_formats[v[0]] + options.output_format_block = lookups.output_formats[v[-1]] + options.output_format = ($stdout.tty? ? options.output_format_tty : options.output_format_block) + if [options.output_format_tty, options.output_format_block].include?(nil) + raise("Bad output mode '#{v}'; each must be one of #{lookups.output_formats.keys}.") + end + end + + parser.on('-r', '--require REQUIRE(S)', + 'Gems and built-in libraries (e.g. shellwords, yaml) to require, comma separated, or ! to clear') do |v| + if v == '!' + options.requires.clear + else + v.split(',').map(&:strip).each do |r| + if r[0] == '-' + options.requires -= [r[1..-1]] + else + options.requires << r + end + end + end + end + + parser.on('-v', '--version', 'Print version') do + puts VERSION + exit(0) + end + + # Undocumented feature: open Github project with default web browser on a Mac + parser.on('', '--open-project') do + open_resource(PROJECT_URL) + exit(0) + end + + parser.on('', '--project-url') do + puts PROJECT_URL + exit(0) + end + + end.parse! + + # We want to do this after all options have been processed because we don't want any clearing of the + # options (by '-c', etc.) to result in exclusion of these needed requires. + add_format_requires_to_requires_list + + options.requires = options.requires.sort.uniq + options.loads.uniq! + + options + + end + end + + + class Main + + include Helpers + + attr_reader :callable, :input_parser, :lookups, + :options, :output_formatter, + :log_formatter, :start_time, :user_source_code + + + def initialize + @lookups = Lookups.new + @start_time = DateTime.now + end + + + private def load_global_config_if_exists + filespec = File.join(Dir.home, '.rexerc') + load(filespec) if File.exists?(filespec) + end + + + private def init_parser_and_formatters + @input_parser = lookups.input_parsers[options.input_format] + @output_formatter = lookups.formatters[options.output_format] + @log_formatter = lookups.formatters[options.log_format] + end + + + # Executes the user specified code in the manner appropriate to the input mode. + # Performs any optionally specified parsing on input and formatting on output. + private def execute(eval_context_object, code) + if options.input_format != :none && options.input_mode != :none + eval_context_object = input_parser.(eval_context_object) + end + + value = eval_context_object.instance_eval(&code) + + unless options.output_format == :none + print output_formatter.(value) + end + rescue Errno::EPIPE + exit(-13) + end + + + # The global $RC (Rexe Context) OpenStruct is available in your user code. + # In order to make it possible to access this object in your loaded files, we are not creating + # it here; instead we add properties to it. This way, you can initialize an OpenStruct yourself + # in your loaded code and it will still work. If you do that, beware, any properties you add will be + # included in the log output. If the to_s of your added objects is large, that might be a pain. + private def init_rexe_context + $RC ||= OpenStruct.new + $RC.count = 0 + $RC.rexe_version = VERSION + $RC.start_time = start_time.iso8601 + $RC.source_code = user_source_code + $RC.options = options.to_h + + def $RC.i; count end # `i` aliases `count` so you can more concisely get the count in your user code + end + + + private def create_callable + eval("Proc.new { #{user_source_code} }") + end + + + private def lookup_action(mode) + input = options.input_filespec ? File.open(options.input_filespec) : STDIN + { + line: -> { input.each { |l| execute(l.chomp, callable); $RC.count += 1 } }, + enumerator: -> { execute(input.each_line, callable); $RC.count += 1 }, + one_big_string: -> { big_string = input.read; execute(big_string, callable); $RC.count += 1 }, + none: -> { execute(Object.new, callable) } + }.fetch(mode) + end + + + private def output_log_entry + if options.log_format != :none + $RC.duration_secs = Time.now - start_time.to_time + STDERR.puts(log_formatter.($RC.to_h)) + end + end + + + # Bypasses Bundler's restriction on loading gems + # (see https://stackoverflow.com/questions/55144094/bundler-doesnt-permit-using-gems-in-project-home-directory) + private def require!(the_require) + begin + require the_require + rescue LoadError => error + gem_path = `gem which #{the_require}` + if gem_path.chomp.strip.empty? + raise error # re-raise the error, can't fix it + else + load_dir = File.dirname(gem_path) + $LOAD_PATH += load_dir + require the_require + end + end + end + + + # This class' entry point. + def call + + try do + + @options = CommandLineParser.new.parse + + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + + @callable = create_callable + + init_rexe_context + init_parser_and_formatters + + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + + output_log_entry + end + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + def input_modes + EOM + end + + it "handles heredocs indentation building microcase outside missing end" do + source = <<~'EOM' + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end + options.input_filespec = v + options.input_format = autodetect_file_format(v) + if [:json, :yaml].include?(options.input_format) + options.input_mode = :one_big_string + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(0)) + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + EOM + end + + it "rexe missing if microcase" do + source = <<~'EOM' + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + end # one + + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end # two + options.input_filespec = v + options.input_format = autodetect_file_format(v) + + + # missing if here: if [:json, :yaml].include?(options.input_format) + options.input_mode = :one_big_string + end # three + end # four + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.highlight.join).to eq(<<~'EOM'.indent(2)) + end # three + EOM + + # expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + # def input_modes + # @input_modes ||= { + # 'l' => :line, + # 'e' => :enumerator, + # 'b' => :one_big_string, + # 'n' => :none + # } + # EOM + end + + it "handles heredocs" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(85 - 1) + source = lines.join + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(4)) + def input_modes + EOM + + context = BlockNodeContext.new(search.finished[0]).call + expect(context.highlight.join).to eq(<<~'EOM'.indent(4)) + def input_modes + EOM + + # expect(context.lines.join).to eq(<<~'EOM'.indent(0)) + # def input_modes + # @input_modes ||= { + # 'l' => :line, + # 'e' => :enumerator, + # 'b' => :one_big_string, + # 'n' => :none + # } + # EOM + end + + it "handles derailed output issues/50" do + source = fixtures_dir.join("derailed_require_tree.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(4)) + def initialize(name) + EOM + end + + it "handles multi-line-methods issues/64" do + source = fixtures_dir.join("webmock.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(6)) + port: port + EOM + end + + it "returns good results on routes.rb" do + source = fixtures_dir.join("routes.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + namespace :admin do + EOM + end + + it "doesn't scapegoat rescue" do + source = <<~'EOM' + def compile + instrument 'ruby.compile' do + # check for new app at the beginning of the compile + new_app? + Dir.chdir(build_path) + remove_vendor_bundle + warn_bundler_upgrade + warn_bad_binstubs + install_ruby(slug_vendor_ruby, build_ruby_path) + setup_language_pack_environment( + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", } + bundle_default_without: "development:test" + ) + allow_git do + install_bundler_in_app(slug_vendor_base) + load_bundler_cache + build_bundler + post_bundler + create_database_yml + install_binaries + run_assets_precompile_rake_task + end + config_detect + best_practice_warnings + warn_outdated_ruby + setup_profiled(ruby_layer_path: "$HOME", gem_layer_path: "$HOME") # $HOME is set to /app at run time + setup_export + cleanup + super + end + rescue => e + warn_outdated_ruby + raise e + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to include(<<~'EOM'.indent(6)) + bundle_path: "vendor/bundle", } + EOM + + expect(search.finished.join).to eq(<<~'EOM'.indent(6)) + bundle_path: "vendor/bundle", } + EOM + end + + it "finds hanging def in this project" do + source = fixtures_dir.join("this_project_extra_def.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(4)) + def filename + EOM + end + + it "regression dog test" do + source = <<~'EOM' + class Dog + def bark + puts "woof" + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + def bark + EOM + end + + it "regression test ambiguous end" do + # Even though you would think the first step is to + # expand the "print" line, we base priority off of + # "next_indent" so the actual highest "next indent" line + # comes from "end # one" which captures "print", then it + # expands out from there + source = <<~'EOM' + def call + print "lol" + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + end # one + EOM + end + + it "squished do regression" do + source = <<~'EOM' + def call + trydo + + @options = CommandLineParser.new.parse + + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + + @callable = create_callable + + init_rexe_context + init_parser_and_formatters + + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + + output_log_entry + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + end # one + EOM + end + + it "rexe regression" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(148 - 1) + source = lines.join + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(4)) + def format_requires + EOM + end + + it "invalid if/else end with surrounding code" do + source = <<~'EOM' + class Foo + def to_json(*opts) + { type: :args, parts: parts, loc: location }.to_json(*opts) + end + end + + def on_args_add(arguments, argument) + if arguments.parts.empty? + Args.new(parts: [argument], location: argument.location) + else + + Args.new( + parts: arguments.parts << argument, + location: arguments.location.to(argument.location) + ) + end + # Missing end here, comments are erased via CleanDocument + + class ArgsAddBlock + attr_reader :arguments + + attr_reader :block + + attr_reader :location + + def initialize(arguments:, block:, location:) + @arguments = arguments + @block = block + @location = location + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM') + def on_args_add(arguments, argument) + EOM + end + + it "extra space before end" do + source = <<~'EOM' + Foo.call + def foo + print "lol" + print "lol" + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM') + end # two + EOM + end + + it "finds random pipe (|) wildly misindented" do + source = fixtures_dir.join("ruby_buildpack.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.join).to eq(<<~'EOM') + | + EOM + end + + it "syntax tree search" do + file = fixtures_dir.join("syntax_tree.rb.txt") + lines = file.read.lines + lines.delete_at(768 - 1) + source = lines.join + + tree = nil + document = nil + debug_perf do + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(2)) + def on_args_add(arguments, argument) + EOM + end + end + + it "finds missing comma in array" do + source = <<~'EOM' + def animals + [ + cat, + dog + horse + ] + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + search = IndentSearch.new(tree: tree).call + + expect(search.finished.first.node.to_s).to eq(<<~'EOM'.indent(4)) + cat, + dog + horse + EOM + end + end +end diff --git a/spec/unit/indent_tree_spec.rb b/spec/unit/indent_tree_spec.rb new file mode 100644 index 0000000..d530ad7 --- /dev/null +++ b/spec/unit/indent_tree_spec.rb @@ -0,0 +1,1249 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe IndentTree do + it "large both" do + source = <<~'EOM' + [ + one, + two, + three + ].each do |i| + print i { + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + three + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(2)) + one, + two, + three + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + print i { + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(0)) + print i { + end + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + one, + two, + three + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(0)) + [ + one, + two, + three + ].each do |i| + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(0)) + [ + one, + two, + three + ].each do |i| + print i { + end + EOM + end + + it "finds missing do in an rspec context same indent when the problem is in the middle and blocks HAVE inner contents" do + source = <<~'EOM' + describe "things" do + it "blerg" do + print foo1 + end # one + + it "flerg" + print foo2 + end # two + + it "zlerg" do + print foo3 + end # three + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + print foo2 + EOM + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "flerg" + print foo2 + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(4)) + print foo3 + EOM + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "zlerg" do + print foo3 + end # three + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(4)) + print foo1 + EOM + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "blerg" do + print foo1 + end # one + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + end # two + EOM + + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "blerg" do + print foo1 + end # one + it "flerg" + print foo2 + end # two + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "blerg" do + print foo1 + end # one + it "flerg" + print foo2 + end # two + EOM + + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "blerg" do + print foo1 + end # one + it "flerg" + print foo2 + end # two + it "zlerg" do + print foo3 + end # three + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + it "blerg" do + print foo1 + end # one + it "flerg" + print foo2 + end # two + it "zlerg" do + print foo3 + end # three + EOM + + node = tree.step + expect(node.to_s).to eq(<<~'EOM') + describe "things" do + it "blerg" do + print foo1 + end # one + it "flerg" + print foo2 + end # two + it "zlerg" do + print foo3 + end # three + end + EOM + end + + it "rexe missing if microcase" do + source = <<~'EOM' + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + end # one + + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end # two + options.input_filespec = v + options.input_format = autodetect_file_format(v) + + + # missing if here: if [:json, :yaml].include?(options.input_format) + options.input_mode = :one_big_string + end # three + end # four + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + options.input_mode = :one_big_string + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(2)) + options.input_filespec = v + options.input_format = autodetect_file_format(v) + options.input_mode = :one_big_string + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(0)) + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + raise "File #{v} does not exist." + EOM + + expect(tree.step.to_s).to eq(<<~'EOM'.indent(2)) + unless File.exist?(v) + raise "File #{v} does not exist." + end # two + EOM + + node = tree.peek + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + end # three + EOM + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + unless File.exist?(v) + raise "File #{v} does not exist." + end # two + options.input_filespec = v + options.input_format = autodetect_file_format(v) + options.input_mode = :one_big_string + end # three + EOM + + expect(tree.peek).to eq(node) + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(0)) + parser.on('-f', '--input_file FILESPEC', + 'Use this file instead of stdin; autodetects YAML and JSON file extensions') do |v| + unless File.exist?(v) + raise "File #{v} does not exist." + end # two + options.input_filespec = v + options.input_format = autodetect_file_format(v) + options.input_mode = :one_big_string + end # three + end # four + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + options.clear + EOM + node = tree.step + + expect(node.to_s).to eq(<<~'EOM'.indent(0)) + parser.on('-c', '--clear_options', "Clear all previous command line options") do |v| + options.clear + end # one + EOM + end + + # If you put an indented "print" in there then + # the problem goes away, I think it's fine to not handle + # this (hopefully rare) case. If we showed you there was a problem + # on this line, deleting it would actually fix the problem + # even if the resultant code would be misindented + # + # We could also handle it in post though if we want to + it "ambiguous end, only a problem if nothing internal" do + source = <<~'EOM' + class Cow + end # one + end # two + EOM + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM') + end # two + EOM + end + + it "ambiguous kw" do + source = <<~'EOM' + class Cow + def speak + end + EOM + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(node.parents.length).to eq(2) + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM') + class Cow + EOM + end + + it "fork invalid" do + source = <<~'EOM' + class Cow + def speak + print "moo" + end + + class Buffalo + print "buffalo" + end # buffalo one + end + EOM + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:multiple_invalid_parents) + forks = diagnose.next + + node = forks.first + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + def speak + EOM + + node = forks.last + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + end # buffalo one + EOM + end + + it "invalid if and else" do + source = <<~'EOM' + if true + print ( + else + print } + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + print } + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + print ( + else + print } + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(0)) + end + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + if true + print ( + else + print } + end + EOM + end + + it "(smaller) finds random pipe (|) wildly misindented" do + source = <<~'EOM' + class LanguagePack::Ruby < LanguagePack::Base + def add_node_js_binary + print add_node_js_binary + end # one + + def add_yarn_binary + return [] if yarn_preinstalled? + | # problem is here + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + end # three misindented but fine + + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + alias :node_js_preinstalled? :node_preinstall_bin_path + end # five + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(6)) + [] + EOM + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(4)) + [@yarn_installer.name] + else + [] + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + end # two + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(4)) + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + EOM + + expect(tree.peek.to_s).to eq(last.to_s) + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + return [] if yarn_preinstalled? + | # problem is here + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + print node_preinstall_bin_path + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(2)) + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(4)) + print add_node_js_binary + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(2)) + def add_node_js_binary + print add_node_js_binary + end # one + EOM + + expect(tree.peek.to_s).to eq(<<~'EOM'.indent(2)) + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(2)) + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + alias :node_js_preinstalled? :node_preinstall_bin_path + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + def add_yarn_binary + return [] if yarn_preinstalled? + | # problem is here + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + alias :node_js_preinstalled? :node_preinstall_bin_path + end # five + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + class LanguagePack::Ruby < LanguagePack::Base + def add_node_js_binary + print add_node_js_binary + end # one + def add_yarn_binary + return [] if yarn_preinstalled? + | # problem is here + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + end # three misindented but fine + EOM + + last = tree.step + expect(last.to_s).to eq(<<~'EOM'.indent(0)) + class LanguagePack::Ruby < LanguagePack::Base + def add_node_js_binary + print add_node_js_binary + end # one + def add_yarn_binary + return [] if yarn_preinstalled? + | # problem is here + if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + [@yarn_installer.name] + else + [] + end # two + end # three misindented but fine + def node_preinstall_bin_path + print node_preinstall_bin_path + end # four + alias :node_js_preinstalled? :node_preinstall_bin_path + end # five + EOM + + ## That's the whole document + + # HEY: Weird that this is picking the wrong end + tree = tree.call # Resolve all steps + search = IndentSearch.new(tree: tree).call + + # expect(search.finished.join).to eq(<<~'EOM'.indent(0)) + # def add_yarn_binary + # return [] if yarn_preinstalled? + # | # problem is here + # if Pathname(build_path).join("yarn.lock").exist? || bundler.has_gem?('webpacker') + # [@yarn_installer.name] + # else + # [] + # end # two + # end # five + # EOM + end + + it "doesn't scapegoat rescue" do + source = <<~'EOM' + def compile + instrument 'ruby.compile' do + # check for new app at the beginning of the compile + new_app? + Dir.chdir(build_path) + remove_vendor_bundle + warn_bundler_upgrade + warn_bad_binstubs + install_ruby(slug_vendor_ruby, build_ruby_path) + setup_language_pack_environment( + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", } # problem + bundle_default_without: "development:test" + ) + allow_git do + install_bundler_in_app(slug_vendor_base) + load_bundler_cache + build_bundler + post_bundler + create_database_yml + install_binaries + run_assets_precompile_rake_task + end + config_detect + best_practice_warnings + warn_outdated_ruby + setup_profiled(ruby_layer_path: "$HOME", gem_layer_path: "$HOME") # $HOME is set to /app at run time + setup_export + cleanup + super + end + rescue => e + warn_outdated_ruby + raise e + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:remove_pseudo_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + expect(node.to_s).to eq(<<~'EOM'.indent(4)) + setup_language_pack_environment( + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", } # problem + bundle_default_without: "development:test" + ) + EOM + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + expect(node.to_s).to eq(<<~'EOM'.indent(6)) + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", } # problem + bundle_default_without: "development:test" + EOM + + diagnose = DiagnoseNode.new(node).call + node = diagnose.next[0] + expect(diagnose.problem).to eq(:remove_pseudo_pair) + + expect(node.to_s).to eq(<<~'EOM'.indent(6)) + ruby_layer_path: File.expand_path("."), + gem_layer_path: File.expand_path("."), + bundle_path: "vendor/bundle", } # problem + EOM + + diagnose = DiagnoseNode.new(node).call + node = diagnose.next[0] + expect(diagnose.problem).to eq(:remove_pseudo_pair) + + expect(node.to_s).to eq(<<~'EOM'.indent(6)) + bundle_path: "vendor/bundle", } # problem + EOM + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + + expect(node.to_s).to eq(<<~'EOM'.indent(6)) + bundle_path: "vendor/bundle", } # problem + EOM + end + + it "finds random pipe (|) wildly misindented" do + source = fixtures_dir.join("ruby_buildpack.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM') + | + EOM + end + + it "finds hanging def in this project" do + source = fixtures_dir.join("this_project_extra_def.rb.txt").read + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(4)) + def filename + EOM + end + + it "regression dog test" do + source = <<~'EOM' + class Dog + def bark + print "woof" + end + EOM + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + def bark + EOM + end + + it "regression test ambiguous end" do + # Even though you would think the first step is to + # expand the "print" line, we base priority off of + # "next_indent" so the actual highest "next indent" line + # comes from "end # one" which captures "print", then it + # expands out from there + source = <<~'EOM' + def call + print "lol" + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + end # one + EOM + end + + it "squished do regression" do + source = <<~'EOM' + def call + trydo + + @options = CommandLineParser.new.parse + + options.requires.each { |r| require!(r) } + load_global_config_if_exists + options.loads.each { |file| load(file) } + + @user_source_code = ARGV.join(' ') + @user_source_code = 'self' if @user_source_code == '' + + @callable = create_callable + + init_rexe_context + init_parser_and_formatters + + # This is where the user's source code will be executed; the action will in turn call `execute`. + lookup_action(options.input_mode).call unless options.noop + + output_log_entry + end # one + end # two + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + end # one + EOM + end + + it "simpler rexe regression" do + source = <<~'EOM' + module Helpers + def output_formats + @output_formats ||= { + 'a' => :amazing_print, + 'i' => :inspect, + 'j' => :json, + 'J' => :pretty_json, + 'm' => :marshal, + 'n' => :none, + 'p' => :puts, # default + 'P' => :pretty_print, + 's' => :to_s, + 'y' => :yaml, + } + end + + + def formatters + @formatters ||= { + amazing_print: ->(obj) { obj.ai + "\n" }, + inspect: ->(obj) { obj.inspect + "\n" }, + json: ->(obj) { obj.to_json }, + marshal: ->(obj) { Marshal.dump(obj) }, + none: ->(_obj) { nil }, + pretty_json: ->(obj) { JSON.pretty_generate(obj) }, + pretty_print: ->(obj) { obj.pretty_inspect }, + puts: ->(obj) { require 'stringio'; sio = StringIO.new; sio.puts(obj); sio.string }, + to_s: ->(obj) { obj.to_s + "\n" }, + yaml: ->(obj) { obj.to_yaml }, + } + end + + + def format_requires + @format_requires ||= { + json: 'json', + pretty_json: 'json', + amazing_print: 'amazing_print', + pretty_print: 'pp', + yaml: 'yaml' + } + end + + class CommandLineParser + + include Helpers + + attr_reader :lookups, :options + + def initialize + @lookups = Lookups.new + @options = Options.new + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + def format_requires + EOM + end + + it "rexe regression" do + lines = fixtures_dir.join("rexe.rb.txt").read.lines + lines.delete_at(148 - 1) + source = lines.join + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(4)) + def format_requires + EOM + end + + it "syntax_tree.rb.txt for performance validation" do + file = fixtures_dir.join("syntax_tree.rb.txt") + lines = file.read.lines + lines.delete_at(768 - 1) + source = lines.join + + tree = nil + document = nil + debug_perf do + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:invalid_inside_split_pair) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:extract_from_multiple) + node = diagnose.next[0] + + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM'.indent(2)) + def on_args_add(arguments, argument) + EOM + end + end + + it "invalid if/else end with surrounding code" do + source = <<~'EOM' + class Foo + def to_json(*opts) + { type: :args, parts: parts, loc: location }.to_json(*opts) + end + end + + def on_args_add(arguments, argument) + if arguments.parts.empty? + Args.new(parts: [argument], location: argument.location) + else + + Args.new( + parts: arguments.parts << argument, + location: arguments.location.to(argument.location) + ) + end + # Missing end here, comments are erased via CleanDocument + + class ArgsAddBlock + attr_reader :arguments + + attr_reader :block + + attr_reader :location + + def initialize(arguments:, block:, location:) + @arguments = arguments + @block = block + @location = location + end + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM') + def on_args_add(arguments, argument) + EOM + end + + it "extra space before end" do + source = <<~'EOM' + Foo.call + def foo + print "lol" + print "lol" + end # one + end # two + EOM + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document).call + + node = tree.root + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:one_invalid_parent) + node = diagnose.next[0] + + diagnose = DiagnoseNode.new(node).call + expect(diagnose.problem).to eq(:self) + expect(node.to_s).to eq(<<~'EOM') + end # two + EOM + end + + it "captures complicated" do + source = <<~'EOM' + if true # 0 + print 'huge 1' # 1 + end # 2 + + if true # 4 + print 'huge 2' # 5 + end # 6 + + if true # 8 + print 'huge 3' # 9 + end # 10 + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + tree.call + + blocks = document.to_a + expect(blocks.length).to eq(1) + + expect(document.root.parents.length).to eq(3) + expect(document.root.parents[0].to_s).to eq(<<~'EOM') + if true # 0 + print 'huge 1' # 1 + end # 2 + EOM + + expect(document.root.parents[1].to_s).to eq(<<~'EOM') + if true # 4 + print 'huge 2' # 5 + end # 6 + EOM + + expect(document.root.parents[2].to_s).to eq(<<~'EOM') + if true # 8 + print 'huge 3' # 9 + end # 10 + EOM + end + + it "prioritizes indent" do + code_lines = CodeLine.from_source(<<~'EOM') + def foo + end # one + end # two + EOM + + document = BlockDocument.new(code_lines: code_lines).call + one = document.queue.pop + expect(one.to_s.strip).to eq("end # one") + end + + it "captures" do + source = <<~'EOM' + if true + print 'huge 1' + print 'huge 2' + print 'huge 3' + end + EOM + + code_lines = CleanDocument.new(source: source).call.lines + document = BlockDocument.new(code_lines: code_lines).call + tree = IndentTree.new(document: document) + tree.call + + # blocks = document.to_a + expect(document.root.to_s).to eq(code_lines.join) + expect(document.to_a.length).to eq(1) + expect(document.root.parents.length).to eq(3) + end + end +end diff --git a/spec/unit/left_right_lex_count_spec.rb b/spec/unit/left_right_lex_count_spec.rb new file mode 100644 index 0000000..ce6ee51 --- /dev/null +++ b/spec/unit/left_right_lex_count_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe LeftRightLexCount do + end +end diff --git a/spec/unit/lex_pair_diff_spec.rb b/spec/unit/lex_pair_diff_spec.rb new file mode 100644 index 0000000..bceb2f6 --- /dev/null +++ b/spec/unit/lex_pair_diff_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +module DeadEnd + RSpec.describe "LexPairDiff" do + it "leans unknown" do + diff = LexPairDiff.from_lex( + lex: LexAll.new(source: "[}").to_a, + is_kw: false, + is_end: false + ) + expect(diff.leaning).to eq(:both) + end + + it "leans right" do + diff = LexPairDiff.from_lex( + lex: LexAll.new(source: "}").to_a, + is_kw: false, + is_end: false + ) + expect(diff.leaning).to eq(:right) + end + + it "leans left" do + diff = LexPairDiff.from_lex( + lex: LexAll.new(source: "{").to_a, + is_kw: false, + is_end: false + ) + expect(diff.leaning).to eq(:left) + end + + it "leans equal" do + diff = LexPairDiff.from_lex( + lex: LexAll.new(source: "{}").to_a, + is_kw: false, + is_end: false + ) + expect(diff.leaning).to eq(:equal) + end + end +end diff --git a/spec/unit/priority_queue_spec.rb b/spec/unit/priority_queue_spec.rb index 7381559..ce07d80 100644 --- a/spec/unit/priority_queue_spec.rb +++ b/spec/unit/priority_queue_spec.rb @@ -20,6 +20,34 @@ def inspect end RSpec.describe CodeFrontier do + it "benchmark/ips" do + skip unless ENV["DEBUG_PERF"] + require "benchmark/ips" + + values = 5000.times.map { rand(0..100) }.freeze + + Benchmark.ips do |x| + x.report("Bsearch insertion") { + q = InsertionSortQueue.new + values.each do |v| + q << v + end + while q.pop + end + } + + x.report("Priority queue ") { + q = PriorityQueue.new + values.each do |v| + q << v + end + while q.pop + end + } + x.compare! + end + end + it "works" do q = PriorityQueue.new q << 1 @@ -63,7 +91,7 @@ def inspect end it "priority queue" do - frontier = PriorityQueue.new + frontier = InsertionSortQueue.new frontier << CurrentIndex.new(0) frontier << CurrentIndex.new(1)