Skip to content

Commit be16acd

Browse files
committed
Add Prism.node_for(Method|UnboundMethod|Proc) to get a Prism node for a callable
* See https://bugs.ruby-lang.org/issues/21005 and https://bugs.ruby-lang.org/issues/20999
1 parent f781416 commit be16acd

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

lib/prism/parse_result.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ def code_units_column(byte_offset, encoding)
143143
code_units_offset(byte_offset, encoding) - code_units_offset(line_start(byte_offset), encoding)
144144
end
145145

146+
# Returns the byte offset for a given line number and column number
147+
def line_and_column_to_byte_offset(line, column)
148+
line_start = offsets[line - 1]
149+
line_end = offsets[line]
150+
line_start + @source.byteslice(line_start, line_end)[0...column].bytesize
151+
end
152+
146153
# Freeze this object and the objects it contains.
147154
def deep_freeze
148155
source.freeze
@@ -272,6 +279,13 @@ def code_units_cache(encoding)
272279
def code_units_column(byte_offset, encoding)
273280
byte_offset - line_start(byte_offset)
274281
end
282+
283+
# Specialized version of `line_and_column_to_byte_offset`
284+
# which does not need to access the source String
285+
def line_and_column_to_byte_offset(line, column)
286+
line_start = offsets[line - 1]
287+
line_start + column
288+
end
275289
end
276290

277291
# This represents a location in the source.
@@ -895,4 +909,46 @@ def initialize(locals, forwarding)
895909
def self.scope(locals: [], forwarding: [])
896910
Scope.new(locals, forwarding)
897911
end
912+
913+
# Given a Method, UnboundMethod or Proc, use its #source_location to parse the file and return a Prism node representing it.
914+
# The returned node will either be a DefNode, LambdaNode or CallNode.
915+
# Raises ArgumentError if it cannot be found for any reason.
916+
# Only works on Ruby 4+ as it needs #source_location to contain column and end line information.
917+
def self.node_for(callable)
918+
unless callable.is_a?(Method) || callable.is_a?(UnboundMethod) || callable.is_a?(Proc)
919+
raise ArgumentError, 'Prism.node_for requires a Method, UnboundMethod or Proc'
920+
end
921+
source_location = callable.source_location
922+
raise ArgumentError, "#source_location is nil for #{callable}" if source_location.nil?
923+
raise ArgumentError, '#source_location does not contain column and end_line, this method only works on Ruby 4+' if source_location.size != 5
924+
file, start_line, start_column, end_line, end_column = source_location
925+
926+
unless File.exist?(file)
927+
raise ArgumentError, "#source_location[0] is #{file} but this file does not exist"
928+
end
929+
930+
parse_result = Prism.parse_file(file)
931+
unless parse_result.success?
932+
raise ArgumentError, "#{file} has syntax errors: #{parse_result.errors_format}"
933+
end
934+
root = parse_result.value
935+
start_offset = parse_result.source.line_and_column_to_byte_offset(start_line, start_column)
936+
end_offset = parse_result.source.line_and_column_to_byte_offset(end_line, end_column)
937+
938+
found = root.breadth_first_search do |node|
939+
case node
940+
when DefNode
941+
node.start_offset == start_offset && node.end_offset == end_offset
942+
when LambdaNode
943+
# Proc#source_location returns start_column 2 for `-> { ... }` (just after the `->`)
944+
node.operator_loc.end_offset == start_offset && node.end_offset == end_offset
945+
when CallNode
946+
# Proc#source_location returns start_column 5 for `proc { ... }` (the `{`)
947+
node.block && node.block.opening_loc.start_offset == start_offset && node.end_offset == end_offset
948+
end
949+
end
950+
951+
raise ArgumentError, "Could not find node for #{callable} in #{file} at (#{start_line},#{start_column})-(#{end_line},#{end_column})" unless found
952+
found
953+
end
898954
end

sig/prism/parse_result.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module Prism
2424
def code_units_offset: (Integer byte_offset, Encoding encoding) -> Integer
2525
def code_units_cache: (Encoding encoding) -> _CodeUnitsCache
2626
def code_units_column: (Integer byte_offset, Encoding encoding) -> Integer
27+
def line_and_column_to_byte_offset: (Integer line, Integer column) -> Integer
2728
def deep_freeze: () -> void
2829

2930
def self.for: (String source) -> Source
@@ -40,6 +41,7 @@ module Prism
4041
def code_units_offset: (Integer byte_offset, Encoding encoding) -> Integer
4142
def code_units_cache: (Encoding encoding) -> _CodeUnitsCache
4243
def code_units_column: (Integer byte_offset, Encoding encoding) -> Integer
44+
def line_and_column_to_byte_offset: (Integer line, Integer column) -> Integer
4345
end
4446

4547
class Location

templates/sig/prism.rbs.erb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,6 @@ module Prism
9090
) -> ParseResult
9191

9292
def self.scope: (?locals: Array[Symbol], ?forwarding: Array[Symbol]) -> Scope
93+
94+
def self.node_for: (callable: Method | UnboundMethod | Proc) -> (DefNode | LambdaNode | CallNode | nil)
9395
end

test/prism/ruby/node_for_test.rb

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
# typed: ignore
3+
4+
require_relative "../test_helper"
5+
6+
module Prism
7+
class NodeForTest < TestCase
8+
INDENT = ' ' * 4
9+
10+
def m(foo)
11+
42
12+
end
13+
14+
def inline_method = 42
15+
16+
define_method(:define_method_method) { 42 }
17+
18+
def return_block(&block) = block
19+
20+
def test_def_method
21+
node = Prism.node_for(NodeForTest.instance_method(:m))
22+
assert_instance_of(Prism::DefNode, node)
23+
assert_equal "def m(foo)\n#{INDENT} 42\n#{INDENT}end", node.slice
24+
25+
node = Prism.node_for(method(:m))
26+
assert_instance_of(Prism::DefNode, node)
27+
assert_equal "def m(foo)\n 42\n end", node.slice
28+
end
29+
30+
def test_inline_method
31+
node = Prism.node_for(method(:inline_method))
32+
assert_instance_of(Prism::DefNode, node)
33+
assert_equal "def inline_method = 42", node.slice
34+
end
35+
36+
def test_define_method
37+
node = Prism.node_for(method(:define_method_method))
38+
assert_instance_of(Prism::CallNode, node)
39+
assert_equal "define_method(:define_method_method) { 42 }", node.slice
40+
assert_equal "{ 42 }", node.block.slice
41+
end
42+
43+
def test_lambda
44+
node = Prism.node_for(-> { 42 })
45+
assert_instance_of(Prism::LambdaNode, node)
46+
assert_equal "-> { 42 }", node.slice
47+
assert_equal "{ 42 }", node.opening_loc.join(node.closing_loc).slice
48+
49+
node = Prism.node_for(lambda { 42 })
50+
assert_instance_of(Prism::CallNode, node)
51+
assert_equal "lambda { 42 }", node.slice
52+
assert_equal "{ 42 }", node.block.slice
53+
end
54+
55+
def test_proc
56+
node = Prism.node_for(proc { 42 })
57+
assert_instance_of(Prism::CallNode, node)
58+
assert_equal "proc { 42 }", node.slice
59+
assert_equal "{ 42 }", node.block.slice
60+
61+
node = Prism.node_for(return_block { 42 })
62+
assert_instance_of(Prism::CallNode, node)
63+
assert_equal "return_block { 42 }", node.slice
64+
assert_equal "{ 42 }", node.block.slice
65+
66+
heredoc_proc = proc { <<~END }
67+
heredoc
68+
END
69+
node = Prism.node_for(heredoc_proc)
70+
assert_instance_of(Prism::CallNode, node)
71+
assert_equal "proc { <<~END }", node.slice
72+
assert_equal "heredoc\n", node.block.body.body.first.unescaped
73+
end
74+
75+
def test_method_to_proc
76+
node = Prism.node_for(method(:inline_method).to_proc)
77+
assert_instance_of(Prism::DefNode, node)
78+
assert_equal "def inline_method = 42", node.slice
79+
end
80+
81+
def test_eval
82+
l = eval "-> { 42 }"
83+
e = assert_raise(ArgumentError) { Prism.node_for(l) }
84+
assert_include e.message, 'eval'
85+
86+
l = eval "-> { 42 }", nil, __FILE__, __LINE__
87+
e = assert_raise(ArgumentError) { Prism.node_for(l) }
88+
assert_include e.message, 'Could not find node'
89+
end
90+
end
91+
end

0 commit comments

Comments
 (0)