Skip to content

Commit 8dd1d78

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 * Stub source_location for older Rubies in NodeForTest. * Move out the inline method definition for Ruby 2.7.
1 parent 71138c6 commit 8dd1d78

6 files changed

Lines changed: 201 additions & 3 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,14 @@ 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_character_column_to_byte_offset(line, column)
148+
line_start = offsets[line - 1]
149+
line_end = offsets[line]
150+
byte_column = (@source.byteslice(line_start, line_end) or raise)[0...column]&.bytesize #: Integer
151+
line_start + byte_column
152+
end
153+
146154
# Freeze this object and the objects it contains.
147155
def deep_freeze
148156
source.freeze
@@ -272,6 +280,13 @@ def code_units_cache(encoding)
272280
def code_units_column(byte_offset, encoding)
273281
byte_offset - line_start(byte_offset)
274282
end
283+
284+
# Specialized version of `line_and_character_column_to_byte_offset`
285+
# which does not need to access the source String
286+
def line_and_character_column_to_byte_offset(line, column)
287+
line_start = offsets[line - 1]
288+
line_start + column
289+
end
275290
end
276291

277292
# This represents a location in the source.
@@ -895,4 +910,45 @@ def initialize(locals, forwarding)
895910
def self.scope(locals: [], forwarding: [])
896911
Scope.new(locals, forwarding)
897912
end
913+
914+
# Given a Method, UnboundMethod or Proc, use its #source_location to parse the file and return a Prism node representing it.
915+
# The returned node will either be a DefNode, LambdaNode, CallNode or ForNode.
916+
# Raises ArgumentError if it cannot be found for any reason.
917+
# Only works on Ruby >= 4.1 as it needs #source_location to contain column and end line information.
918+
def self.node_for(callable)
919+
unless callable.is_a?(Method) || callable.is_a?(UnboundMethod) || callable.is_a?(Proc)
920+
raise ArgumentError, 'Prism.node_for requires a Method, UnboundMethod or Proc'
921+
end
922+
source_location = callable.source_location
923+
raise ArgumentError, "#source_location is nil for #{callable}" if source_location.nil?
924+
raise ArgumentError, '#source_location does not contain column and end_line, this method only works on Ruby >= 4.1' if source_location.size != 5
925+
file, start_line, start_column, end_line, end_column = source_location #: [String, Integer, Integer, Integer, Integer]
926+
927+
unless File.exist?(file)
928+
raise ArgumentError, "#source_location[0] is #{file} but this file does not exist"
929+
end
930+
931+
parse_result = Prism.parse_file(file, version: "current")
932+
unless parse_result.success?
933+
raise ArgumentError, "#{file} has syntax errors: #{parse_result.errors_format}"
934+
end
935+
root = parse_result.value
936+
start_offset = parse_result.source.line_and_character_column_to_byte_offset(start_line, start_column)
937+
end_offset = parse_result.source.line_and_character_column_to_byte_offset(end_line, end_column)
938+
939+
found = root.breadth_first_search do |node|
940+
case node
941+
when DefNode, LambdaNode, ForNode
942+
node.start_offset == start_offset && node.end_offset == end_offset
943+
when CallNode
944+
# Proc#source_location returns start_column 5 for `proc { ... }` (the `{`)
945+
node.block.is_a?(BlockNode) && node.block.opening_loc.start_offset == start_offset && node.end_offset == end_offset
946+
else
947+
false
948+
end
949+
end #: DefNode | LambdaNode | CallNode | ForNode
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_character_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_character_column_to_byte_offset: (Integer line, Integer column) -> Integer
4345
end
4446

4547
class Location

templates/sig/prism.rbs.erb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ module Prism
3131
?main_script: bool,
3232
?offset: Integer,
3333
?scopes: Array[Array[Symbol]],
34-
?verbose: bool
34+
?verbose: bool,
35+
?version: String
3536
) -> <%= return_type %>
3637
<%- end -%>
3738

@@ -68,7 +69,8 @@ module Prism
6869
?main_script: bool,
6970
?offset: Integer,
7071
?scopes: Array[Array[Symbol]],
71-
?verbose: bool
72+
?verbose: bool,
73+
?version: String
7274
) -> <%= return_type %>
7375
<%- end -%>
7476

@@ -86,8 +88,11 @@ module Prism
8688
?main_script: bool,
8789
?offset: Integer,
8890
?scopes: Array[Array[Symbol]],
89-
?verbose: bool
91+
?verbose: bool,
92+
?version: String
9093
) -> ParseResult
9194

9295
def self.scope: (?locals: Array[Symbol], ?forwarding: Array[Symbol]) -> Scope
96+
97+
def self.node_for: (Method | UnboundMethod | Proc callable) -> (DefNode | LambdaNode | CallNode | ForNode)
9398
end

test/prism/newline_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class NewlineTest < TestCase
1717
result/breadth_first_search_test.rb
1818
result/static_literals_test.rb
1919
result/warnings_test.rb
20+
ruby/inline_method.rb
2021
ruby/parser_test.rb
2122
ruby/ruby_parser_test.rb
2223
]

test/prism/ruby/inline_method.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# A separate file because 2.7 can't parse this
2+
module Prism
3+
class NodeForTest < TestCase
4+
def inline_method = 42
5+
INLINE_LOCATION_AND_FILE = [[__LINE__-1, 4, __LINE__-1, 26], __FILE__]
6+
end
7+
end

test/prism/ruby/node_for_test.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# frozen_string_literal: true
2+
# typed: ignore
3+
4+
require_relative "../test_helper"
5+
6+
# Needs Prism.parse_file(file, version: "current")
7+
return if RUBY_VERSION < "3.3"
8+
9+
require_relative 'inline_method'
10+
11+
module Prism
12+
class NodeForTest < TestCase
13+
INDENT = ' ' * 4
14+
15+
def m(foo)
16+
42
17+
end
18+
M_LOCATION = [__LINE__-3, 4, __LINE__-1, 7]
19+
20+
define_method(:define_method_method) { 42 }
21+
DEFINE_METHOD_LOCATION = [__LINE__-1, 41, __LINE__-1, 47]
22+
23+
def return_block(&block)
24+
block
25+
end
26+
27+
iter = Object.new
28+
def iter.each(&block)
29+
block.call(block)
30+
end
31+
32+
for pr in iter
33+
42
34+
end
35+
FOR_BODY_PROC = pr
36+
FOR_BODY_PROC_LOCATION = [__LINE__-4, 4, __LINE__-2, 7]
37+
38+
def with_location(callable, locs, file = __FILE__)
39+
source_location = [file, *locs]
40+
if RUBY_VERSION >= "4.1"
41+
assert_equal callable.source_location, source_location
42+
else
43+
callable.define_singleton_method(:source_location) { source_location }
44+
end
45+
callable
46+
end
47+
48+
def test_def_method
49+
node = Prism.node_for(with_location(NodeForTest.instance_method(:m), M_LOCATION))
50+
assert_instance_of(Prism::DefNode, node)
51+
assert_equal "def m(foo)\n#{INDENT} 42\n#{INDENT}end", node.slice
52+
53+
node = Prism.node_for(with_location(method(:m), M_LOCATION))
54+
assert_instance_of(Prism::DefNode, node)
55+
assert_equal "def m(foo)\n 42\n end", node.slice
56+
end
57+
58+
def test_inline_method
59+
node = Prism.node_for(with_location(method(:inline_method), *INLINE_LOCATION_AND_FILE))
60+
assert_instance_of(Prism::DefNode, node)
61+
assert_equal "def inline_method = 42", node.slice
62+
end
63+
64+
def test_define_method
65+
node = Prism.node_for(with_location(method(:define_method_method), DEFINE_METHOD_LOCATION))
66+
assert_instance_of(Prism::CallNode, node)
67+
assert_equal "define_method(:define_method_method) { 42 }", node.slice
68+
assert_equal "{ 42 }", node.block.slice
69+
end
70+
71+
def test_lambda
72+
node = Prism.node_for(with_location(-> { 42 }, [__LINE__, 42, __LINE__, 51]))
73+
assert_instance_of(Prism::LambdaNode, node)
74+
assert_equal "-> { 42 }", node.slice
75+
assert_equal "{ 42 }", node.opening_loc.join(node.closing_loc).slice
76+
77+
node = Prism.node_for(with_location(lambda { 42 }, [__LINE__, 49, __LINE__, 55]))
78+
assert_instance_of(Prism::CallNode, node)
79+
assert_equal "lambda { 42 }", node.slice
80+
assert_equal "{ 42 }", node.block.slice
81+
end
82+
83+
def test_proc
84+
node = Prism.node_for(with_location(proc { 42 }, [__LINE__, 47, __LINE__, 53]))
85+
assert_instance_of(Prism::CallNode, node)
86+
assert_equal "proc { 42 }", node.slice
87+
assert_equal "{ 42 }", node.block.slice
88+
89+
node = Prism.node_for(with_location(return_block { 42 }, [__LINE__, 55, __LINE__, 61]))
90+
assert_instance_of(Prism::CallNode, node)
91+
assert_equal "return_block { 42 }", node.slice
92+
assert_equal "{ 42 }", node.block.slice
93+
94+
heredoc_proc = proc { <<~END }
95+
heredoc
96+
END
97+
node = Prism.node_for(with_location(heredoc_proc, [__LINE__-3, 26, __LINE__-3, 36]))
98+
assert_instance_of(Prism::CallNode, node)
99+
assert_equal "proc { <<~END }", node.slice
100+
assert_equal "heredoc\n", node.block.body.body.first.unescaped
101+
end
102+
103+
def test_method_to_proc
104+
node = Prism.node_for(with_location(method(:inline_method).to_proc, *INLINE_LOCATION_AND_FILE))
105+
assert_instance_of(Prism::DefNode, node)
106+
assert_equal "def inline_method = 42", node.slice
107+
end
108+
109+
def test_for
110+
node = Prism.node_for(with_location(FOR_BODY_PROC, FOR_BODY_PROC_LOCATION))
111+
assert_instance_of(Prism::ForNode, node)
112+
assert_equal "for pr in iter\n#{INDENT} 42\n#{INDENT}end", node.slice
113+
assert_equal "42", node.statements.slice
114+
end
115+
116+
def test_eval
117+
l = with_location(eval("-> { 42 }"), [1, 0, 1, 9], "(eval at #{__FILE__}:#{__LINE__})")
118+
e = assert_raise(ArgumentError) { Prism.node_for(l) }
119+
assert_include e.message, 'eval'
120+
121+
l = eval "-> { 42 }", nil, __FILE__, __LINE__
122+
l = with_location(l, [__LINE__-1, 0, __LINE__-1, 9])
123+
e = assert_raise(ArgumentError) { Prism.node_for(l) }
124+
assert_include e.message, 'Could not find node'
125+
end
126+
end
127+
end

0 commit comments

Comments
 (0)