Skip to content

Commit 7bce2b8

Browse files
committed
stree search
1 parent 2ecde4c commit 7bce2b8

File tree

8 files changed

+221
-5
lines changed

8 files changed

+221
-5
lines changed

.rubocop.yml

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ AllCops:
88
TargetRubyVersion: 2.7
99
Exclude:
1010
- '{bin,coverage,pkg,test/fixtures,vendor,tmp}/**/*'
11+
- lib/syntax_tree/search.rb
1112
- test.rb
1213

1314
Layout/LineLength:
@@ -28,6 +29,9 @@ Lint/InterpolationCheck:
2829
Lint/MissingSuper:
2930
Enabled: false
3031

32+
Lint/RedundantRequireStatement:
33+
Enabled: false
34+
3135
Lint/UnusedMethodArgument:
3236
AllowUnusedKeywordArguments: true
3337

Gemfile.lock

+3-3
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ GEM
1919
rake (13.0.6)
2020
regexp_parser (2.6.0)
2121
rexml (3.2.5)
22-
rubocop (1.37.0)
22+
rubocop (1.37.1)
2323
json (~> 2.3)
2424
parallel (~> 1.10)
2525
parser (>= 3.1.2.1)
2626
rainbow (>= 2.2.2, < 4.0)
2727
regexp_parser (>= 1.8, < 3.0)
2828
rexml (>= 3.2.5, < 4.0)
29-
rubocop-ast (>= 1.22.0, < 2.0)
29+
rubocop-ast (>= 1.23.0, < 2.0)
3030
ruby-progressbar (~> 1.7)
3131
unicode-display_width (>= 1.4.0, < 3.0)
32-
rubocop-ast (1.22.0)
32+
rubocop-ast (1.23.0)
3333
parser (>= 3.1.1.0)
3434
ruby-progressbar (1.11.0)
3535
simplecov (0.21.2)

README.md

+24
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ It is built with only standard library dependencies. It additionally ships with
1818
- [format](#format)
1919
- [json](#json)
2020
- [match](#match)
21+
- [search](#search)
2122
- [write](#write)
2223
- [Configuration](#configuration)
2324
- [Globbing](#globbing)
@@ -215,6 +216,29 @@ SyntaxTree::Program[
215216
]
216217
```
217218

219+
### search
220+
221+
This command will search the given filepaths against the specified pattern to find nodes that match. The pattern is a Ruby pattern-matching expression that is matched against each node in the tree. It can optionally be loaded from a file if you specify a filepath as the pattern argument.
222+
223+
```sh
224+
stree search VarRef path/to/file.rb
225+
```
226+
227+
For a file that contains `Foo + Bar` you will receive:
228+
229+
```ruby
230+
path/to/file.rb:1:0: Foo + Bar
231+
path/to/file.rb:1:6: Foo + Bar
232+
```
233+
234+
If you put `VarRef` into a file instead (for example, `query.txt`), you would instead run:
235+
236+
```sh
237+
stree search query.txt path/to/file.rb
238+
```
239+
240+
Note that the output of the `match` CLI command creates a valid pattern that can be used as the input for this command.
241+
218242
### write
219243

220244
This command will format the listed files and write that formatted version back to the source files. Note that this overwrites the original content, to be sure to be using a version control system.

lib/syntax_tree.rb

+1
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/search"
2425

2526
# Syntax Tree is a suite of tools built on top of the internal CRuby parser. It
2627
# provides the ability to generate a syntax tree from source, as well as the

lib/syntax_tree/cli.rb

+40-2
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,39 @@ def run(item)
212212
end
213213
end
214214

215+
# An action of the CLI that searches for the given pattern matching pattern
216+
# in the given files.
217+
class Search < Action
218+
attr_reader :search
219+
220+
def initialize(query)
221+
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)
226+
end
227+
228+
def run(item)
229+
search.scan(item.handler.parse(item.source)) do |node|
230+
location = node.location
231+
line = location.start_line
232+
233+
bold_range =
234+
if line == location.end_line
235+
location.start_column...location.end_column
236+
else
237+
location.start_column..
238+
end
239+
240+
source = item.source.lines[line - 1].chomp
241+
source[bold_range] = Color.bold(source[bold_range]).to_s
242+
243+
puts("#{item.filepath}:#{line}:#{location.start_column}: #{source}")
244+
end
245+
end
246+
end
247+
215248
# An action of the CLI that formats the input source and writes the
216249
# formatted output back to the file.
217250
class Write < Action
@@ -263,6 +296,9 @@ def run(item)
263296
#{Color.bold("stree lsp [--plugins=...] [--print-width=NUMBER]")}
264297
Run syntax tree in language server mode
265298
299+
#{Color.bold("stree search PATTERN [-e SCRIPT] FILE")}
300+
Search for the given pattern in the given files
301+
266302
#{Color.bold("stree version")}
267303
Output the current version of syntax tree
268304
@@ -400,6 +436,8 @@ def run(argv)
400436
Debug.new(options)
401437
when "doc"
402438
Doc.new(options)
439+
when "f", "format"
440+
Format.new(options)
403441
when "help"
404442
puts HELP
405443
return 0
@@ -411,8 +449,8 @@ def run(argv)
411449
return 0
412450
when "m", "match"
413451
Match.new(options)
414-
when "f", "format"
415-
Format.new(options)
452+
when "s", "search"
453+
Search.new(arguments.shift)
416454
when "version"
417455
puts SyntaxTree::VERSION
418456
return 0

lib/syntax_tree/search.rb

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# frozen_string_literal: true
2+
3+
module SyntaxTree
4+
class Search
5+
class UncompilableError < StandardError
6+
end
7+
8+
attr_reader :matcher
9+
10+
def initialize(query)
11+
SyntaxTree.parse("case nil\nin #{query}\nend") =>
12+
{ statements: { body: [SyntaxTree::Case[consequent: { pattern: }]] } }
13+
14+
@matcher = compile(pattern)
15+
end
16+
17+
def scan(root)
18+
return to_enum(__method__, root) unless block_given?
19+
queue = [root]
20+
21+
until queue.empty?
22+
node = queue.shift
23+
next unless node
24+
25+
yield node if matcher.call(node)
26+
queue += node.child_nodes
27+
end
28+
end
29+
30+
private
31+
32+
def compile(pattern)
33+
case pattern
34+
in Binary[left:, operator: :|, right:]
35+
compiled_left = compile(left)
36+
compiled_right = compile(right)
37+
38+
->(node) { compiled_left.call(node) || compiled_right.call(node) }
39+
in Const[value:] if SyntaxTree.const_defined?(value)
40+
clazz = SyntaxTree.const_get(value)
41+
42+
->(node) { node.is_a?(clazz) }
43+
in Const[value:] if Object.const_defined?(value)
44+
clazz = Object.const_get(value)
45+
46+
->(node) { node.is_a?(clazz) }
47+
in ConstPathRef[parent: VarRef[value: Const[value: "SyntaxTree"]]]
48+
compile(pattern.constant)
49+
in HshPtn[constant:, keywords:, keyword_rest: nil]
50+
compiled_constant = compile(constant)
51+
52+
preprocessed_keywords =
53+
keywords.to_h do |keyword, value|
54+
keyword => Label
55+
[keyword.value.chomp(":").to_sym, compile(value)]
56+
end
57+
58+
compiled_keywords =
59+
->(node) {
60+
deconstructed = node.deconstruct_keys(preprocessed_keywords.keys)
61+
preprocessed_keywords.all? do |keyword, matcher|
62+
matcher.call(deconstructed[keyword])
63+
end
64+
}
65+
66+
->(node) do
67+
compiled_constant.call(node) && compiled_keywords.call(node)
68+
end
69+
in RegexpLiteral[parts: [TStringContent[value:]]]
70+
regexp = /#{value}/
71+
72+
->(attribute) { regexp.match?(attribute) }
73+
in StringLiteral[parts: [TStringContent[value:]]]
74+
->(attribute) { attribute == value }
75+
in VarRef[value: Const => value]
76+
compile(value)
77+
end
78+
rescue NoMatchingPatternError
79+
raise UncompilableError, <<~ERROR
80+
Syntax Tree was unable to compile the pattern you provided to search
81+
into a usable expression. It failed on the node within the pattern
82+
matching expression represented by:
83+
84+
#{PP.pp(pattern, +"").chomp}
85+
86+
Note that not all syntax supported by Ruby's pattern matching syntax is
87+
also supported by Syntax Tree's code search. If you're using some syntax
88+
that you believe should be supported, please open an issue on the GitHub
89+
repository at https://github.com/ruby-syntax-tree/syntax_tree.
90+
ERROR
91+
end
92+
end
93+
end

test/cli_test.rb

+5
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ def test_match
9494
assert_includes(result.stdio, "SyntaxTree::Program")
9595
end
9696

97+
def test_search
98+
result = run_cli("search", "VarRef", contents: "Foo + Bar")
99+
assert_equal(2, result.stdio.lines.length)
100+
end
101+
97102
def test_version
98103
result = run_cli("version")
99104
assert_includes(result.stdio, SyntaxTree::VERSION.to_s)

test/search_test.rb

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "test_helper"
4+
5+
module SyntaxTree
6+
class SearchTest < Minitest::Test
7+
def test_search_binary_or
8+
root = SyntaxTree.parse("Foo + Bar + 1")
9+
scanned = Search.new("VarRef | Int").scan(root).to_a
10+
11+
assert_equal 3, scanned.length
12+
assert_equal "1", scanned.min_by { |node| node.class.name }.value
13+
end
14+
15+
def test_search_const
16+
root = SyntaxTree.parse("Foo + Bar + Baz")
17+
18+
scanned = Search.new("VarRef").scan(root).to_a
19+
20+
assert_equal 3, scanned.length
21+
assert_equal %w[Bar Baz Foo], scanned.map { |node| node.value.value }.sort
22+
end
23+
24+
def test_search_syntax_tree_const
25+
root = SyntaxTree.parse("Foo + Bar + Baz")
26+
27+
scanned = Search.new("SyntaxTree::VarRef").scan(root).to_a
28+
29+
assert_equal 3, scanned.length
30+
end
31+
32+
def test_search_hash_pattern_string
33+
root = SyntaxTree.parse("Foo + Bar + Baz")
34+
35+
scanned = Search.new("VarRef[value: Const[value: 'Foo']]").scan(root).to_a
36+
37+
assert_equal 1, scanned.length
38+
assert_equal "Foo", scanned.first.value.value
39+
end
40+
41+
def test_search_hash_pattern_regexp
42+
root = SyntaxTree.parse("Foo + Bar + Baz")
43+
44+
query = "VarRef[value: Const[value: /^Ba/]]"
45+
scanned = Search.new(query).scan(root).to_a
46+
47+
assert_equal 2, scanned.length
48+
assert_equal %w[Bar Baz], scanned.map { |node| node.value.value }.sort
49+
end
50+
end
51+
end

0 commit comments

Comments
 (0)