banister / method_source

return the sourcecode for a method
MIT License
361 stars 43 forks source link

lambda nested in array of hashes causes Proc#source to raise MethodSource::SourceNotFoundError #76

Open dmlary opened 2 years ago

dmlary commented 2 years ago

Issue

Ran into this issue while writing parameterized tests that include an optional block argument. Simplest implementation of the problem:

require "method_source"

a = [
  {block: ->(e) { e << %i[a c] }},
]

puts a.first[:block].source

Output:

/Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source.rb:29:in `rescue in source_helper': Could not parse source for #<Proc:0x0000000100999ef8 ./source.rb:4 (lambda)>: (eval):2: syntax error, unexpected ',', expecting end-of-input (MethodSource::SourceNotFoundError)
...block: ->(e) { e << %i[a c] }},
...                              ^
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source.rb:23:in `source_helper'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source.rb:110:in `source'
    from ./source.rb:7:in `<main>'
/Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:71:in `eval': (eval):2: syntax error, unexpected ',', expecting end-of-input (SyntaxError)
...block: ->(e) { e << %i[a c] }},
...                              ^
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:71:in `block in complete_expression?'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:70:in `catch'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:70:in `complete_expression?'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:97:in `block in extract_first_expression'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:95:in `each'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:95:in `extract_first_expression'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source/code_helpers.rb:30:in `expression_at'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source.rb:27:in `source_helper'
    from /Users/me/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/method_source-1.0.0/lib/method_source.rb:110:in `source'
    from ./source.rb:7:in `<main>'

Possible fix

I recognize this is a major change for the gem, and may not be possible due to the variety of ruby implementations this supports, but the parser gem does an excellent job turning any source file into an AST. The AST can easily be searched for proc/lambda/def calls, and the line numbers and source are easily accessible from any matching node.

This is the workaround I'm using for now to get Proc#source working for my case. I can adapt this and create a pull request for method_source if the maintainers feel that changing the parser is a good idea.

require "dry/core/cache"
require "parser/current"

module ProcSource
  extend Dry::Core::Cache
  extend AST::Sexp

  class SourceNotFound < StandardError; end

  # parse a ruby source file and return the AST; result is cached
  def self.parse(path)
    fetch_or_store(path) do
      source_buffer = Parser::Source::Buffer.new(path).read
      parser = Parser::CurrentRuby.new
      parser.diagnostics.all_errors_are_fatal = true
      parser.diagnostics.ignore_warnings      = true
      parser.parse(source_buffer)
    end
  end

  PROC_NODES = [
    s(:send, nil, :lambda),
    s(:send, nil, :proc),
    s(:send, s(:const, nil, :Proc), :new),
  ]

  module Helpers
    def source
      file, line = source_location
      root = ProcSource.parse(file)

      queue = [root]
      until queue.empty?
        node = queue.shift
        next unless node.is_a?(Parser::AST::Node)
        queue.unshift(*node.children)

        next unless node.type == :block
        next unless node.loc.line == line

        # verify the first child is a send node
        ch = node.children.first
        next unless ch.is_a?(Parser::AST::Node)
        next unless ch.type == :send

        # verify we're calling lambda, proc, or Proc.new
        next unless ProcSource::PROC_NODES.include?(ch)

        return node.loc.expression.source
      end

      raise SourceNotFound, "unable to find source for %p" % self
    end
  end

  Proc.prepend(Helpers)
end