Skip to content

Commit 833ce3a

Browse files
authored
Merge pull request #180 from ruby-syntax-tree/search
stree search
2 parents 2ecde4c + 7c72062 commit 833ce3a

File tree

9 files changed

+226
-12
lines changed

9 files changed

+226
-12
lines changed

.rubocop.yml

+3
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ Lint/InterpolationCheck:
2828
Lint/MissingSuper:
2929
Enabled: false
3030

31+
Lint/RedundantRequireStatement:
32+
Enabled: false
33+
3134
Lint/UnusedMethodArgument:
3235
AllowUnusedKeywordArguments: true
3336

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

+41-3
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
@@ -434,7 +472,7 @@ def run(argv)
434472
.glob(pattern)
435473
.each do |filepath|
436474
if File.readable?(filepath) &&
437-
options.ignore_files.none? { File.fnmatch?(_1, filepath) }
475+
options.ignore_files.none? { File.fnmatch?(_1, filepath) }
438476
queue << FileItem.new(filepath)
439477
end
440478
end

lib/syntax_tree/node.rb

+6-6
Original file line numberDiff line numberDiff line change
@@ -1657,12 +1657,12 @@ class Binary < Node
16571657
# for older Ruby versions.
16581658
unless :+.respond_to?(:name)
16591659
using Module.new {
1660-
refine Symbol do
1661-
def name
1662-
to_s.freeze
1663-
end
1664-
end
1665-
}
1660+
refine Symbol do
1661+
def name
1662+
to_s.freeze
1663+
end
1664+
end
1665+
}
16661666
end
16671667

16681668
# [untyped] the left-hand side of the expression

lib/syntax_tree/search.rb

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
module SyntaxTree
4+
# Provides an interface for searching for a pattern of nodes against a
5+
# subtree of an AST.
6+
class Search
7+
class UncompilableError < StandardError
8+
end
9+
10+
attr_reader :matcher
11+
12+
def initialize(query)
13+
root = SyntaxTree.parse("case nil\nin #{query}\nend")
14+
@matcher = compile(root.statements.body.first.consequent.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+
raise NoMatchingPatternError unless keyword.is_a?(Label)
55+
[keyword.value.chomp(":").to_sym, compile(value)]
56+
end
57+
58+
compiled_keywords = ->(node) do
59+
deconstructed = node.deconstruct_keys(preprocessed_keywords.keys)
60+
preprocessed_keywords.all? do |keyword, matcher|
61+
matcher.call(deconstructed[keyword])
62+
end
63+
end
64+
65+
->(node) do
66+
compiled_constant.call(node) && compiled_keywords.call(node)
67+
end
68+
in RegexpLiteral[parts: [TStringContent[value:]]]
69+
regexp = /#{value}/
70+
71+
->(attribute) { regexp.match?(attribute) }
72+
in StringLiteral[parts: [TStringContent[value:]]]
73+
->(attribute) { attribute == value }
74+
in VarRef[value: Const => value]
75+
compile(value)
76+
end
77+
rescue NoMatchingPatternError
78+
raise UncompilableError, <<~ERROR
79+
Syntax Tree was unable to compile the pattern you provided to search
80+
into a usable expression. It failed on the node within the pattern
81+
matching expression represented by:
82+
83+
#{PP.pp(pattern, +"").chomp}
84+
85+
Note that not all syntax supported by Ruby's pattern matching syntax is
86+
also supported by Syntax Tree's code search. If you're using some syntax
87+
that you believe should be supported, please open an issue on the GitHub
88+
repository at https://github.com/ruby-syntax-tree/syntax_tree.
89+
ERROR
90+
end
91+
end
92+
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)