Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
Skip to content

Commit bfa8c39

Browse files
committed
Add an expr CLI command
1 parent 0c5ebad commit bfa8c39

File tree

6 files changed

+241
-136
lines changed

6 files changed

+241
-136
lines changed

lib/syntax_tree.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require_relative "syntax_tree/visitor/with_environment"
2222

2323
require_relative "syntax_tree/parser"
24+
require_relative "syntax_tree/pattern"
2425
require_relative "syntax_tree/search"
2526

2627
# Syntax Tree is a suite of tools built on top of the internal CRuby parser. It

lib/syntax_tree/cli.rb

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,20 @@ def run(item)
188188
end
189189
end
190190

191+
# An action of the CLI that outputs a pattern-matching Ruby expression that
192+
# would match the first expression of the input given.
193+
class Expr < Action
194+
def run(item)
195+
case item.handler.parse(item.source)
196+
in Program[statements: Statements[body: [expression]]]
197+
puts expression.construct_keys
198+
else
199+
warn("The input to `stree expr` must be a single expression.")
200+
exit(1)
201+
end
202+
end
203+
end
204+
191205
# An action of the CLI that formats the input source and prints it out.
192206
class Format < Action
193207
def run(item)
@@ -219,10 +233,15 @@ class Search < Action
219233

220234
def initialize(query)
221235
query = File.read(query) if File.readable?(query)
222-
@search = SyntaxTree::Search.new(query)
223-
rescue SyntaxTree::Search::UncompilableError => error
224-
warn(error.message)
225-
exit(1)
236+
pattern =
237+
begin
238+
Pattern.new(query).compile
239+
rescue Pattern::CompilationError => error
240+
warn(error.message)
241+
exit(1)
242+
end
243+
244+
@search = SyntaxTree::Search.new(pattern)
226245
end
227246

228247
def run(item)
@@ -281,6 +300,10 @@ def run(item)
281300
#{Color.bold("stree doc [--plugins=...] [-e SCRIPT] FILE")}
282301
Print out the doc tree that would be used to format the given files
283302
303+
#{Color.bold("stree expr [-e SCRIPT] FILE")}
304+
Print out a pattern-matching Ruby expression that would match the first
305+
expression of the given files
306+
284307
#{Color.bold("stree format [--plugins=...] [--print-width=NUMBER] [-e SCRIPT] FILE")}
285308
Print out the formatted version of the given files
286309
@@ -436,6 +459,8 @@ def run(argv)
436459
Debug.new(options)
437460
when "doc"
438461
Doc.new(options)
462+
when "e", "expr"
463+
Expr.new(options)
439464
when "f", "format"
440465
Format.new(options)
441466
when "help"

lib/syntax_tree/pattern.rb

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# frozen_string_literal: true
2+
3+
module SyntaxTree
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 Const[value: "SyntaxTree"]
11+
# end
12+
#
13+
# the pattern is the `Const[value: "SyntaxTree"]` expression. Within Syntax
14+
# Tree, every node generates these kinds of expressions using the
15+
# #construct_keys method.
16+
#
17+
# The pattern gets compiled into an object that responds to call by running
18+
# the #compile method. This method itself will run back through Syntax Tree to
19+
# parse the expression into a tree, then walk the tree to generate the
20+
# necessary callable objects. For example, if you wanted to compile the
21+
# expression above into a callable, you would:
22+
#
23+
# callable = SyntaxTree::Pattern.new("Const[value: 'SyntaxTree']").compile
24+
# callable.call(node)
25+
#
26+
# The callable object returned by #compile is guaranteed to respond to #call
27+
# with a single argument, which is the node to match against. It also is
28+
# guaranteed to respond to #===, which means it itself can be used in a `case`
29+
# expression, as in:
30+
#
31+
# case node
32+
# when callable
33+
# end
34+
#
35+
# If the query given to the initializer cannot be compiled into a valid
36+
# matcher (either because of a syntax error or because it is using syntax we
37+
# do not yet support) then a SyntaxTree::Pattern::CompilationError will be
38+
# raised.
39+
class Pattern
40+
class CompilationError < StandardError
41+
def initialize(repr)
42+
super(<<~ERROR)
43+
Syntax Tree was unable to compile the pattern you provided to search
44+
into a usable expression. It failed on to understand the node
45+
represented by:
46+
47+
#{repr}
48+
49+
Note that not all syntax supported by Ruby's pattern matching syntax
50+
is also supported by Syntax Tree's code search. If you're using some
51+
syntax that you believe should be supported, please open an issue on
52+
GitHub at https://github.com/ruby-syntax-tree/syntax_tree/issues/new.
53+
ERROR
54+
end
55+
end
56+
57+
attr_reader :query
58+
59+
def initialize(query)
60+
@query = query
61+
end
62+
63+
def compile
64+
program =
65+
begin
66+
SyntaxTree.parse("case nil\nin #{query}\nend")
67+
rescue Parser::ParseError
68+
raise CompilationError, query
69+
end
70+
71+
compile_node(program.statements.body.first.consequent.pattern)
72+
end
73+
74+
private
75+
76+
def combine_and(left, right)
77+
->(node) { left.call(node) && right.call(node) }
78+
end
79+
80+
def combine_or(left, right)
81+
->(node) { left.call(node) || right.call(node) }
82+
end
83+
84+
def compile_node(node)
85+
case node
86+
in AryPtn[constant:, requireds:, rest: nil, posts: []]
87+
compiled_constant = compile_node(constant) if constant
88+
89+
preprocessed = requireds.map { |required| compile_node(required) }
90+
91+
compiled_requireds = ->(node) do
92+
deconstructed = node.deconstruct
93+
94+
deconstructed.length == preprocessed.length &&
95+
preprocessed.zip(deconstructed).all? do |(matcher, value)|
96+
matcher.call(value)
97+
end
98+
end
99+
100+
if compiled_constant
101+
combine_and(compiled_constant, compiled_requireds)
102+
else
103+
compiled_requireds
104+
end
105+
in Binary[left:, operator: :|, right:]
106+
combine_or(compile_node(left), compile_node(right))
107+
in Const[value:] if SyntaxTree.const_defined?(value)
108+
clazz = SyntaxTree.const_get(value)
109+
110+
->(node) { node.is_a?(clazz) }
111+
in Const[value:] if Object.const_defined?(value)
112+
clazz = Object.const_get(value)
113+
114+
->(node) { node.is_a?(clazz) }
115+
in ConstPathRef[parent: VarRef[value: Const[value: "SyntaxTree"]], constant:]
116+
compile_node(constant)
117+
in DynaSymbol[parts: []]
118+
symbol = "".to_sym
119+
120+
->(node) { node == symbol }
121+
in DynaSymbol[parts: [TStringContent[value:]]]
122+
symbol = value.to_sym
123+
124+
->(attribute) { attribute == value }
125+
in HshPtn[constant:, keywords:, keyword_rest: nil]
126+
compiled_constant = compile_node(constant)
127+
128+
preprocessed =
129+
keywords.to_h do |keyword, value|
130+
raise NoMatchingPatternError unless keyword.is_a?(Label)
131+
[keyword.value.chomp(":").to_sym, compile_node(value)]
132+
end
133+
134+
compiled_keywords = ->(node) do
135+
deconstructed = node.deconstruct_keys(preprocessed.keys)
136+
137+
preprocessed.all? do |keyword, matcher|
138+
matcher.call(deconstructed[keyword])
139+
end
140+
end
141+
142+
if compiled_constant
143+
combine_and(compiled_constant, compiled_keywords)
144+
else
145+
compiled_keywords
146+
end
147+
in RegexpLiteral[parts: [TStringContent[value:]]]
148+
regexp = /#{value}/
149+
150+
->(attribute) { regexp.match?(attribute) }
151+
in StringLiteral[parts: []]
152+
->(attribute) { attribute == "" }
153+
in StringLiteral[parts: [TStringContent[value:]]]
154+
->(attribute) { attribute == value }
155+
in SymbolLiteral[value:]
156+
symbol = value.value.to_sym
157+
158+
->(attribute) { attribute == symbol }
159+
in VarRef[value: Const => value]
160+
compile_node(value)
161+
in VarRef[value: Kw[value: "nil"]]
162+
->(attribute) { attribute.nil? }
163+
end
164+
rescue NoMatchingPatternError
165+
raise CompilationError, PP.pp(node, +"").chomp
166+
end
167+
end
168+
end

lib/syntax_tree/search.rb

Lines changed: 4 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,10 @@ module SyntaxTree
44
# Provides an interface for searching for a pattern of nodes against a
55
# subtree of an AST.
66
class Search
7-
class UncompilableError < StandardError
8-
end
9-
10-
attr_reader :matcher
7+
attr_reader :pattern
118

12-
def initialize(query)
13-
root = SyntaxTree.parse("case nil\nin #{query}\nend")
14-
@matcher = compile(root.statements.body.first.consequent.pattern)
9+
def initialize(pattern)
10+
@pattern = pattern
1511
end
1612

1713
def scan(root)
@@ -22,110 +18,9 @@ def scan(root)
2218
node = queue.shift
2319
next unless node
2420

25-
yield node if matcher.call(node)
21+
yield node if pattern.call(node)
2622
queue += node.child_nodes
2723
end
2824
end
29-
30-
private
31-
32-
def combine_and(left, right)
33-
->(node) { left.call(node) && right.call(node) }
34-
end
35-
36-
def combine_or(left, right)
37-
->(node) { left.call(node) || right.call(node) }
38-
end
39-
40-
def compile(pattern)
41-
case pattern
42-
in AryPtn[constant:, requireds:, rest: nil, posts: []]
43-
compiled_constant = compile(constant) if constant
44-
45-
preprocessed = requireds.map { |required| compile(required) }
46-
47-
compiled_requireds = ->(node) do
48-
deconstructed = node.deconstruct
49-
50-
deconstructed.length == preprocessed.length &&
51-
preprocessed.zip(deconstructed).all? do |(matcher, value)|
52-
matcher.call(value)
53-
end
54-
end
55-
56-
if compiled_constant
57-
combine_and(compiled_constant, compiled_requireds)
58-
else
59-
compiled_requireds
60-
end
61-
in Binary[left:, operator: :|, right:]
62-
combine_or(compile(left), compile_right)
63-
in Const[value:] if SyntaxTree.const_defined?(value)
64-
clazz = SyntaxTree.const_get(value)
65-
66-
->(node) { node.is_a?(clazz) }
67-
in Const[value:] if Object.const_defined?(value)
68-
clazz = Object.const_get(value)
69-
70-
->(node) { node.is_a?(clazz) }
71-
in ConstPathRef[parent: VarRef[value: Const[value: "SyntaxTree"]]]
72-
compile(pattern.constant)
73-
in DynaSymbol[parts: [TStringContent[value:]]]
74-
symbol = value.to_sym
75-
76-
->(attribute) { attribute == value }
77-
in HshPtn[constant:, keywords:, keyword_rest: nil]
78-
compiled_constant = compile(constant)
79-
80-
preprocessed =
81-
keywords.to_h do |keyword, value|
82-
raise NoMatchingPatternError unless keyword.is_a?(Label)
83-
[keyword.value.chomp(":").to_sym, compile(value)]
84-
end
85-
86-
compiled_keywords = ->(node) do
87-
deconstructed = node.deconstruct_keys(preprocessed.keys)
88-
89-
preprocessed.all? do |keyword, matcher|
90-
matcher.call(deconstructed[keyword])
91-
end
92-
end
93-
94-
if compiled_constant
95-
combine_and(compiled_constant, compiled_keywords)
96-
else
97-
compiled_keywords
98-
end
99-
in RegexpLiteral[parts: [TStringContent[value:]]]
100-
regexp = /#{value}/
101-
102-
->(attribute) { regexp.match?(attribute) }
103-
in StringLiteral[parts: []]
104-
->(attribute) { attribute == "" }
105-
in StringLiteral[parts: [TStringContent[value:]]]
106-
->(attribute) { attribute == value }
107-
in SymbolLiteral[value:]
108-
symbol = value.value.to_sym
109-
110-
->(attribute) { attribute == symbol }
111-
in VarRef[value: Const => value]
112-
compile(value)
113-
in VarRef[value: Kw[value: "nil"]]
114-
->(attribute) { attribute.nil? }
115-
end
116-
rescue NoMatchingPatternError
117-
raise UncompilableError, <<~ERROR
118-
Syntax Tree was unable to compile the pattern you provided to search
119-
into a usable expression. It failed on the node within the pattern
120-
matching expression represented by:
121-
122-
#{PP.pp(pattern, +"").chomp}
123-
124-
Note that not all syntax supported by Ruby's pattern matching syntax is
125-
also supported by Syntax Tree's code search. If you're using some syntax
126-
that you believe should be supported, please open an issue on the GitHub
127-
repository at https://github.com/ruby-syntax-tree/syntax_tree.
128-
ERROR
129-
end
13025
end
13126
end

test/cli_test.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ def test_doc
7979
assert_includes(result.stdio, "test")
8080
end
8181

82+
def test_expr
83+
result = run_cli("expr")
84+
assert_includes(result.stdio, "SyntaxTree::Ident")
85+
end
86+
8287
def test_format
8388
result = run_cli("format")
8489
assert_equal("test\n", result.stdio)

0 commit comments

Comments
 (0)