Skip to content

Commit 2a12e96

Browse files
committed
Introduce YARP::Pattern
1 parent 462cb56 commit 2a12e96

File tree

4 files changed

+373
-0
lines changed

4 files changed

+373
-0
lines changed

lib/yarp.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,7 @@ def self.parse_serialize_file(filepath)
609609
require_relative "yarp/ripper_compat"
610610
require_relative "yarp/serialize"
611611
require_relative "yarp/pack"
612+
require_relative "yarp/pattern"
612613

613614
if RUBY_ENGINE == "ruby" and !ENV["YARP_FFI_BACKEND"]
614615
require "yarp/yarp"

lib/yarp/pattern.rb

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
# frozen_string_literal: true
2+
3+
module YARP
4+
# A pattern is an object that wraps a Ruby pattern matching expression. The
5+
# expression would normally be passed to an `in` clause within a `case`
6+
# expression or a rightward assignment expression. For example, in the
7+
# following snippet:
8+
#
9+
# case node
10+
# in ConstantPathNode[ConstantReadNode[name: :YARP], ConstantReadNode[name: :Pattern]]
11+
# end
12+
#
13+
# the pattern is the `ConstantPathNode[...]` expression.
14+
#
15+
# The pattern gets compiled into an object that responds to #call by running
16+
# the #compile method. This method itself will run back through YARP to
17+
# parse the expression into a tree, then walk the tree to generate the
18+
# necessary callable objects. For example, if you wanted to compile the
19+
# expression above into a callable, you would:
20+
#
21+
# callable = YARP::Pattern.new("ConstantPathNode[ConstantReadNode[name: :YARP], ConstantReadNode[name: :Pattern]]").compile
22+
# callable.call(node)
23+
#
24+
# The callable object returned by #compile is guaranteed to respond to #call
25+
# with a single argument, which is the node to match against. It also is
26+
# guaranteed to respond to #===, which means it itself can be used in a `case`
27+
# expression, as in:
28+
#
29+
# case node
30+
# when callable
31+
# end
32+
#
33+
# If the query given to the initializer cannot be compiled into a valid
34+
# matcher (either because of a syntax error or because it is using syntax we
35+
# do not yet support) then a YARP::Pattern::CompilationError will be
36+
# raised.
37+
class Pattern
38+
# Raised when the query given to a pattern is either invalid Ruby syntax or
39+
# is using syntax that we don't yet support.
40+
class CompilationError < StandardError
41+
def initialize(repr)
42+
super(<<~ERROR)
43+
YARP was unable to compile the pattern you provided into a usable
44+
expression. It failed on to understand the node represented by:
45+
46+
#{repr}
47+
48+
Note that not all syntax supported by Ruby's pattern matching syntax
49+
is also supported by YARP's patterns. If you're using some syntax
50+
that you believe should be supported, please open an issue on
51+
GitHub at https://github.com/ruby/yarp/issues/new.
52+
ERROR
53+
end
54+
end
55+
56+
attr_reader :query
57+
58+
def initialize(query)
59+
@query = query
60+
@compiled = nil
61+
end
62+
63+
def compile
64+
result = YARP.parse("case nil\nin #{query}\nend")
65+
compile_node(result.value.statements.body.last.conditions.last.pattern)
66+
end
67+
68+
def scan(root)
69+
return to_enum(__method__, root) unless block_given?
70+
71+
@compiled ||= compile
72+
queue = [root]
73+
74+
while (node = queue.shift)
75+
yield node if @compiled.call(node)
76+
queue.concat(node.child_nodes.compact)
77+
end
78+
end
79+
80+
private
81+
82+
# Shortcut for combining two procs into one that returns true if both return
83+
# true.
84+
def combine_and(left, right)
85+
->(other) { left.call(other) && right.call(other) }
86+
end
87+
88+
# Shortcut for combining two procs into one that returns true if either
89+
# returns true.
90+
def combine_or(left, right)
91+
->(other) { left.call(other) || right.call(other) }
92+
end
93+
94+
# Raise an error because the given node is not supported.
95+
def compile_error(node)
96+
raise CompilationError, node.inspect
97+
end
98+
99+
# in [foo, bar, baz]
100+
def compile_array_pattern_node(node)
101+
compile_error(node) if !node.rest.nil? || node.posts.any?
102+
103+
constant = node.constant
104+
compiled_constant = compile_node(constant) if constant
105+
106+
preprocessed = node.requireds.map { |required| compile_node(required) }
107+
108+
compiled_requireds = ->(other) do
109+
deconstructed = other.deconstruct
110+
111+
deconstructed.length == preprocessed.length &&
112+
preprocessed
113+
.zip(deconstructed)
114+
.all? { |(matcher, value)| matcher.call(value) }
115+
end
116+
117+
if compiled_constant
118+
combine_and(compiled_constant, compiled_requireds)
119+
else
120+
compiled_requireds
121+
end
122+
end
123+
124+
# in foo | bar
125+
def compile_alternation_pattern_node(node)
126+
combine_or(compile_node(node.left), compile_node(node.right))
127+
end
128+
129+
# in YARP::ConstantReadNode
130+
def compile_constant_path_node(node)
131+
parent = node.parent
132+
133+
if parent.is_a?(ConstantReadNode) && parent.slice == "YARP"
134+
compile_node(node.child)
135+
else
136+
compile_error(node)
137+
end
138+
end
139+
140+
# in ConstantReadNode
141+
# in String
142+
def compile_constant_read_node(node)
143+
value = node.slice
144+
145+
if YARP.const_defined?(value, false)
146+
clazz = YARP.const_get(value)
147+
148+
->(other) { clazz === other }
149+
elsif Object.const_defined?(value, false)
150+
clazz = Object.const_get(value)
151+
152+
->(other) { clazz === other }
153+
else
154+
compile_error(node)
155+
end
156+
end
157+
158+
# in InstanceVariableReadNode[name: Symbol]
159+
# in { name: Symbol }
160+
def compile_hash_pattern_node(node)
161+
compile_error(node) unless node.kwrest.nil?
162+
compiled_constant = compile_node(node.constant) if node.constant
163+
164+
preprocessed =
165+
node.assocs.to_h do |assoc|
166+
[assoc.key.unescaped.to_sym, compile_node(assoc.value)]
167+
end
168+
169+
compiled_keywords = ->(other) do
170+
deconstructed = other.deconstruct_keys(preprocessed.keys)
171+
172+
preprocessed.all? do |keyword, matcher|
173+
deconstructed.key?(keyword) && matcher.call(deconstructed[keyword])
174+
end
175+
end
176+
177+
if compiled_constant
178+
combine_and(compiled_constant, compiled_keywords)
179+
else
180+
compiled_keywords
181+
end
182+
end
183+
184+
# in nil
185+
def compile_nil_node(node)
186+
->(attribute) { attribute.nil? }
187+
end
188+
189+
# in /foo/
190+
def compile_regular_expression_node(node)
191+
regexp = Regexp.new(node.unescaped, node.closing[1..])
192+
193+
->(attribute) { regexp === attribute }
194+
end
195+
196+
# in ""
197+
# in "foo"
198+
def compile_string_node(node)
199+
string = node.unescaped
200+
201+
->(attribute) { string === attribute }
202+
end
203+
204+
# in :+
205+
# in :foo
206+
def compile_symbol_node(node)
207+
symbol = node.unescaped.to_sym
208+
209+
->(attribute) { symbol === attribute }
210+
end
211+
212+
# Compile any kind of node. Dispatch out to the individual compilation
213+
# methods based on the type of node.
214+
def compile_node(node)
215+
case node
216+
when AlternationPatternNode
217+
compile_alternation_pattern_node(node)
218+
when ArrayPatternNode
219+
compile_array_pattern_node(node)
220+
when ConstantPathNode
221+
compile_constant_path_node(node)
222+
when ConstantReadNode
223+
compile_constant_read_node(node)
224+
when HashPatternNode
225+
compile_hash_pattern_node(node)
226+
when NilNode
227+
compile_nil_node(node)
228+
when RegularExpressionNode
229+
compile_regular_expression_node(node)
230+
when StringNode
231+
compile_string_node(node)
232+
when SymbolNode
233+
compile_symbol_node(node)
234+
else
235+
compile_error(node)
236+
end
237+
end
238+
end
239+
end

test/yarp/pattern_test.rb

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
module YARP
6+
class PatternTest < Test::Unit::TestCase
7+
def test_invalid_syntax
8+
assert_raises(Pattern::CompilationError) { scan("", "<>") }
9+
end
10+
11+
def test_invalid_constant
12+
assert_raises(Pattern::CompilationError) { scan("", "Foo") }
13+
end
14+
15+
def test_invalid_nested_constant
16+
assert_raises(Pattern::CompilationError) { scan("", "Foo::Bar") }
17+
end
18+
19+
def test_regexp_with_interpolation
20+
assert_raises(Pattern::CompilationError) { scan("", "/\#{foo}/") }
21+
end
22+
23+
def test_string_with_interpolation
24+
assert_raises(Pattern::CompilationError) { scan("", '"#{foo}"') }
25+
end
26+
27+
def test_symbol_with_interpolation
28+
assert_raises(Pattern::CompilationError) { scan("", ":\"\#{foo}\"") }
29+
end
30+
31+
def test_invalid_node
32+
assert_raises(Pattern::CompilationError) { scan("", "IntegerNode[^foo]") }
33+
end
34+
35+
def test_self
36+
assert_raises(Pattern::CompilationError) { scan("", "self") }
37+
end
38+
39+
def test_array_pattern_no_constant
40+
results = scan("1 + 2", "[IntegerNode]")
41+
42+
assert_equal 1, results.length
43+
end
44+
45+
def test_array_pattern
46+
results = scan("1 + 2", "CallNode[name: \"+\", receiver: IntegerNode, arguments: [IntegerNode]]")
47+
48+
assert_equal 1, results.length
49+
end
50+
51+
def test_alternation_pattern
52+
results = scan("Foo + Bar + 1", "ConstantReadNode | IntegerNode")
53+
54+
assert_equal 3, results.length
55+
assert_equal 1, results.grep(IntegerNode).first.value
56+
end
57+
58+
def test_constant_read_node
59+
results = scan("Foo + Bar + Baz", "ConstantReadNode")
60+
61+
assert_equal 3, results.length
62+
assert_equal %w[Bar Baz Foo], results.map(&:slice).sort
63+
end
64+
65+
def test_object_const
66+
results = scan("1 + 2 + 3", "IntegerNode[]")
67+
68+
assert_equal 3, results.length
69+
end
70+
71+
def test_constant_path
72+
results = scan("Foo + Bar + Baz", "YARP::ConstantReadNode")
73+
74+
assert_equal 3, results.length
75+
end
76+
77+
def test_hash_pattern_no_constant
78+
results = scan("Foo + Bar + Baz", "{ name: \"+\" }")
79+
80+
assert_equal 2, results.length
81+
end
82+
83+
def test_hash_pattern_regexp
84+
results = scan("Foo + Bar + Baz", "{ name: /^[[:punct:]]$/ }")
85+
86+
assert_equal 2, results.length
87+
assert_equal ["YARP::CallNode"], results.map { |node| node.class.name }.uniq
88+
end
89+
90+
def test_nil
91+
results = scan("foo", "{ receiver: nil }")
92+
93+
assert_equal 1, results.length
94+
end
95+
96+
def test_regexp_options
97+
results = scan("@foo + @bar + @baz", "InstanceVariableReadNode[name: /^@B/i]")
98+
99+
assert_equal 2, results.length
100+
end
101+
102+
def test_string_empty
103+
results = scan("", "''")
104+
105+
assert_empty results
106+
end
107+
108+
def test_symbol_empty
109+
results = scan("", ":''")
110+
111+
assert_empty results
112+
end
113+
114+
def test_symbol_plain
115+
results = scan("@foo", "{ name: :\"@foo\" }")
116+
117+
assert_equal 1, results.length
118+
end
119+
120+
def test_symbol
121+
results = scan("@foo", "{ name: :@foo }")
122+
123+
assert_equal 1, results.length
124+
end
125+
126+
private
127+
128+
def scan(source, query)
129+
YARP::Pattern.new(query).scan(YARP.parse(source).value).to_a
130+
end
131+
end
132+
end

yarp.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Gem::Specification.new do |spec|
6565
"lib/yarp/mutation_visitor.rb",
6666
"lib/yarp/node.rb",
6767
"lib/yarp/pack.rb",
68+
"lib/yarp/pattern.rb",
6869
"lib/yarp/ripper_compat.rb",
6970
"lib/yarp/serialize.rb",
7071
"src/diagnostic.c",

0 commit comments

Comments
 (0)