astel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +168 -0
- data/Rakefile +8 -0
- data/benchmark/dispatch_bench.rb +51 -0
- data/benchmark/node_pattern_bench.rb +83 -0
- data/benchmark/rewriter_bench.rb +105 -0
- data/lib/astel/dispatcher.rb +105 -0
- data/lib/astel/location.rb +37 -0
- data/lib/astel/node_ext.rb +13 -0
- data/lib/astel/node_pattern/lexer.rb +110 -0
- data/lib/astel/node_pattern/matcher_compiler.rb +68 -0
- data/lib/astel/node_pattern/parser.rb +92 -0
- data/lib/astel/node_pattern/predicate_compiler.rb +62 -0
- data/lib/astel/node_pattern.rb +90 -0
- data/lib/astel/node_type.rb +14 -0
- data/lib/astel/rewriter.rb +171 -0
- data/lib/astel/source_file.rb +68 -0
- data/lib/astel/version.rb +5 -0
- data/lib/astel.rb +15 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 19a38e5beaa849b77b5d60ea2d46b1913e589a1892dd36fc6797e34e5dbd2738
|
|
4
|
+
data.tar.gz: 7de828db0d6c9e6ea66179f2f931308b9609ccade0047dc99e0b5f77e61ead5f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8364cddfb8fe41deb8770e0f3ca105467820030d77c3563184aa9d087cbd8de2649d05db67058b18f04476ef410f945ef948dc12f2226bd55d607fa4ba683dfb
|
|
7
|
+
data.tar.gz: 4feb4b4cd00035245cf4b992612f0d51c67e2d7fa74decaea77b59912a481e9cc6129938ded3882b3aecd33a186535783ecf00f755e7347b08ca820d14bbd550
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Astel
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ydah/astel/actions/workflows/main.yml)
|
|
4
|
+
[](LICENSE.txt)
|
|
5
|
+
|
|
6
|
+
Astel provides fast, reusable building blocks for Ruby source analysis and
|
|
7
|
+
transformation on top of [Prism](https://github.com/ruby/prism). It parses each
|
|
8
|
+
source once and provides APIs for AST traversal, declarative node matching, and
|
|
9
|
+
non-destructive source rewriting.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install Astel with Bundler:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bundle add astel
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it directly with RubyGems:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
gem install astel
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Astel requires Ruby 3.3 or newer and Prism 0.30 or newer, but earlier than 2.0.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
Require Astel before using its APIs:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "astel"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Parse source
|
|
36
|
+
|
|
37
|
+
Parse a file from disk:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
source = Astel::SourceFile.parse(path: "example.rb")
|
|
41
|
+
|
|
42
|
+
source.ast # => Prism::ProgramNode
|
|
43
|
+
source.comments # => Prism comments
|
|
44
|
+
source.errors # => Prism parse errors
|
|
45
|
+
source.valid? # => true when there are no parse errors
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use `Astel::SourceFile.from_string` when the source is already in memory:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Traverse the AST
|
|
55
|
+
|
|
56
|
+
`Astel::Dispatcher` walks the tree once and invokes callbacks registered for
|
|
57
|
+
specific Prism node types:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
source = Astel::SourceFile.from_string(<<~RUBY)
|
|
61
|
+
puts "hello"
|
|
62
|
+
"value".freeze
|
|
63
|
+
RUBY
|
|
64
|
+
|
|
65
|
+
dispatcher = Astel::Dispatcher.new
|
|
66
|
+
dispatcher.on(:call_node) { |node| puts node.name }
|
|
67
|
+
dispatcher.run(source.ast)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Multiple callbacks can be registered for the same node type.
|
|
71
|
+
|
|
72
|
+
### Match nodes
|
|
73
|
+
|
|
74
|
+
`Astel::NodePattern` compiles a declarative pattern that can be reused across
|
|
75
|
+
nodes:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
source = Astel::SourceFile.from_string('"value".freeze')
|
|
79
|
+
node = source.ast.statements.body.first
|
|
80
|
+
|
|
81
|
+
pattern = Astel::NodePattern.compile(<<~PATTERN)
|
|
82
|
+
(call_node receiver: (string_node) name: :freeze)
|
|
83
|
+
PATTERN
|
|
84
|
+
|
|
85
|
+
pattern.match?(node) # => true
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prefix a subpattern with `$` to capture its matched value:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
pattern = Astel::NodePattern.compile(
|
|
92
|
+
"(call_node receiver: $(string_node) name: $:freeze)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
captures = pattern.match(node)
|
|
96
|
+
captures.first # => Prism::StringNode
|
|
97
|
+
captures.last # => :freeze
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Patterns support node types, named fields, `_` for any non-`nil` value, `nil`,
|
|
101
|
+
symbol, string, integer, and boolean literals, `{ ... }` alternatives, and `$`
|
|
102
|
+
captures.
|
|
103
|
+
|
|
104
|
+
### Rewrite source
|
|
105
|
+
|
|
106
|
+
`Astel::Rewriter` records edits without modifying the original `SourceFile`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
source = Astel::SourceFile.from_string("old_name\n")
|
|
110
|
+
node = source.ast.statements.body.first
|
|
111
|
+
|
|
112
|
+
rewriter = Astel::Rewriter.new(source)
|
|
113
|
+
rewriter.replace(node.location, "new_name")
|
|
114
|
+
|
|
115
|
+
rewriter.rewrite # => "new_name\n"
|
|
116
|
+
source.source # => "old_name\n"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The rewriter supports `replace`, `remove`, `insert_before`, and `insert_after`.
|
|
120
|
+
Overlapping edits raise `Astel::Rewriter::ConflictError`, and
|
|
121
|
+
`Astel::Rewriter#edits` returns an immutable snapshot of registered edits.
|
|
122
|
+
|
|
123
|
+
## Performance and concurrency
|
|
124
|
+
|
|
125
|
+
Compile node patterns once and reuse them for every candidate node. Astel also
|
|
126
|
+
keeps a bounded cache of compiler output for repeated pattern strings.
|
|
127
|
+
|
|
128
|
+
For repository-wide tools, process independent files in worker processes at
|
|
129
|
+
the application layer. Keep each `SourceFile` and its Prism AST inside the
|
|
130
|
+
worker that parsed it; Astel intentionally does not own a process pool or move
|
|
131
|
+
ASTs between workers.
|
|
132
|
+
|
|
133
|
+
Benchmarks for dispatching, node patterns, and rewriting are available under
|
|
134
|
+
`benchmark/`:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
bundle exec ruby benchmark/dispatch_bench.rb
|
|
138
|
+
bundle exec ruby benchmark/node_pattern_bench.rb
|
|
139
|
+
bundle exec ruby benchmark/rewriter_bench.rb
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
After checking out the repository, install dependencies and run the test suite:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
bundle install
|
|
148
|
+
bundle exec rake
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Verify the packaged gem with:
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
bundle exec ruby script/package_smoke.rb
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Contributing
|
|
158
|
+
|
|
159
|
+
Bug reports and pull requests are welcome on
|
|
160
|
+
[GitHub](https://github.com/ydah/astel). Please include tests for behavior
|
|
161
|
+
changes and keep the existing test and performance gates passing.
|
|
162
|
+
|
|
163
|
+
See [CHANGELOG.md](CHANGELOG.md) for notable changes.
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
The gem is available as open source under the terms of the
|
|
168
|
+
[MIT License](LICENSE.txt).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'astel'
|
|
5
|
+
|
|
6
|
+
path = ARGV.fetch(0, __FILE__)
|
|
7
|
+
iterations = Integer(ENV.fetch('ITERATIONS', '1000'), 10)
|
|
8
|
+
ast = Astel::SourceFile.parse(path: path).ast
|
|
9
|
+
dispatcher = Astel::Dispatcher.new
|
|
10
|
+
50.times do
|
|
11
|
+
dispatcher.on(:call_node) { |_node| }
|
|
12
|
+
dispatcher.on(:def_node) { |_node| }
|
|
13
|
+
dispatcher.on(:string_node) { |_node| }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
visitor_class = Class.new(Prism::Visitor) do
|
|
17
|
+
end
|
|
18
|
+
visitors = Array.new(50) { visitor_class.new }
|
|
19
|
+
|
|
20
|
+
astel_elapsed = Benchmark.realtime { iterations.times { dispatcher.run(ast) } }
|
|
21
|
+
prism_elapsed = Benchmark.realtime do
|
|
22
|
+
iterations.times { visitors.each { |visitor| visitor.visit(ast) } }
|
|
23
|
+
end
|
|
24
|
+
ratio = prism_elapsed / astel_elapsed
|
|
25
|
+
|
|
26
|
+
dispatcher.run(ast)
|
|
27
|
+
GC.start
|
|
28
|
+
allocated_before = GC.stat(:total_allocated_objects)
|
|
29
|
+
iterations.times { dispatcher.run(ast) }
|
|
30
|
+
allocations_per_run = (GC.stat(:total_allocated_objects) - allocated_before).fdiv(iterations)
|
|
31
|
+
|
|
32
|
+
callback = ->(_node) {}
|
|
33
|
+
single_ast = Astel::SourceFile.from_string(Array.new(100, 'value.freeze').join("\n")).ast
|
|
34
|
+
single_dispatcher = Astel::Dispatcher.new.on(:call_node, callback)
|
|
35
|
+
list_dispatcher = Astel::Dispatcher.new.on(:call_node, callback)
|
|
36
|
+
list_dispatcher.instance_variable_get(:@callbacks)[:call_node] = Astel::Dispatcher::CallbackList.new([callback])
|
|
37
|
+
single_elapsed = Benchmark.realtime { iterations.times { single_dispatcher.run(single_ast) } }
|
|
38
|
+
list_elapsed = Benchmark.realtime { iterations.times { list_dispatcher.run(single_ast) } }
|
|
39
|
+
single_speedup = list_elapsed / single_elapsed
|
|
40
|
+
|
|
41
|
+
puts format('Astel dispatcher: %.4fs', astel_elapsed)
|
|
42
|
+
puts format('50 Prism visitors: %.4fs', prism_elapsed)
|
|
43
|
+
puts format('speedup: %.2fx', ratio)
|
|
44
|
+
puts format('allocations: %.2f/run', allocations_per_run)
|
|
45
|
+
puts format('single callback speedup: %.2fx', single_speedup)
|
|
46
|
+
|
|
47
|
+
if ENV['VERIFY'] == '1'
|
|
48
|
+
abort(format('Dispatcher regression: only %.2fx faster', ratio)) if ratio < 2.0
|
|
49
|
+
abort(format('Dispatcher regression: %.2f allocations/run', allocations_per_run)) if allocations_per_run > 10.0
|
|
50
|
+
abort(format('Dispatcher single callback regression: only %.2fx faster', single_speedup)) if single_speedup < 1.05
|
|
51
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'astel'
|
|
5
|
+
require 'prism'
|
|
6
|
+
|
|
7
|
+
iterations = Integer(ENV.fetch('ITERATIONS', '1000000'), 10)
|
|
8
|
+
compile_iterations = Integer(ENV.fetch('COMPILE_ITERATIONS', '10000'), 10)
|
|
9
|
+
verify = ENV['VERIFY'] == '1'
|
|
10
|
+
node = Prism.parse('"value".freeze').value.statements.body.first
|
|
11
|
+
miss = Prism.parse('"value".upcase').value.statements.body.first
|
|
12
|
+
pattern = Astel::NodePattern.compile('(call_node receiver: (string_node) name: :freeze)')
|
|
13
|
+
capture_source = '(call_node receiver: $(string_node) name: :freeze)'
|
|
14
|
+
capture_pattern = Astel::NodePattern.compile(capture_source)
|
|
15
|
+
|
|
16
|
+
pattern_elapsed = Benchmark.realtime { iterations.times { pattern.match?(node) } }
|
|
17
|
+
manual_elapsed = Benchmark.realtime do
|
|
18
|
+
iterations.times do
|
|
19
|
+
node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::StringNode) && node.name == :freeze
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ratio = pattern_elapsed / manual_elapsed
|
|
24
|
+
|
|
25
|
+
capture_elapsed = Benchmark.realtime { iterations.times { capture_pattern.match(node) } }
|
|
26
|
+
manual_capture_elapsed = Benchmark.realtime do
|
|
27
|
+
iterations.times do
|
|
28
|
+
[node.receiver] if node.is_a?(Prism::CallNode) &&
|
|
29
|
+
node.receiver.is_a?(Prism::StringNode) &&
|
|
30
|
+
node.name == :freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
capture_ratio = capture_elapsed / manual_capture_elapsed
|
|
34
|
+
|
|
35
|
+
capture_miss_elapsed = Benchmark.realtime { iterations.times { capture_pattern.match(miss) } }
|
|
36
|
+
manual_capture_miss_elapsed = Benchmark.realtime do
|
|
37
|
+
iterations.times do
|
|
38
|
+
[miss.receiver] if miss.is_a?(Prism::CallNode) &&
|
|
39
|
+
miss.receiver.is_a?(Prism::StringNode) &&
|
|
40
|
+
miss.name == :freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
capture_miss_ratio = capture_miss_elapsed / manual_capture_miss_elapsed
|
|
44
|
+
|
|
45
|
+
allocation_iterations = [iterations, 100_000].min
|
|
46
|
+
capture_pattern.match(miss)
|
|
47
|
+
GC.start
|
|
48
|
+
allocated_before = GC.stat(:total_allocated_objects)
|
|
49
|
+
allocation_iterations.times { capture_pattern.match(miss) }
|
|
50
|
+
allocations_per_miss = (GC.stat(:total_allocated_objects) - allocated_before).fdiv(allocation_iterations)
|
|
51
|
+
|
|
52
|
+
compile_elapsed = Benchmark.realtime do
|
|
53
|
+
compile_iterations.times { Astel::NodePattern.compile(capture_source) }
|
|
54
|
+
end
|
|
55
|
+
compile_microseconds = compile_elapsed * 1_000_000 / compile_iterations
|
|
56
|
+
|
|
57
|
+
puts format('NodePattern: %.4fs', pattern_elapsed)
|
|
58
|
+
puts format('Hand-written: %.4fs', manual_elapsed)
|
|
59
|
+
puts format('ratio: %.2fx', ratio)
|
|
60
|
+
puts format('NodePattern with capture: %.4fs', capture_elapsed)
|
|
61
|
+
puts format('Hand-written with capture: %.4fs', manual_capture_elapsed)
|
|
62
|
+
puts format('capture ratio: %.2fx', capture_ratio)
|
|
63
|
+
puts format('NodePattern capture miss: %.4fs', capture_miss_elapsed)
|
|
64
|
+
puts format('Hand-written capture miss: %.4fs', manual_capture_miss_elapsed)
|
|
65
|
+
puts format('capture miss ratio: %.2fx', capture_miss_ratio)
|
|
66
|
+
puts format('capture miss allocations: %.2f/call', allocations_per_miss)
|
|
67
|
+
puts format('cached compile: %.2fus/call', compile_microseconds)
|
|
68
|
+
|
|
69
|
+
if verify
|
|
70
|
+
abort(format('NodePattern regression: %.2fx slower than hand-written matching', ratio)) if ratio > 2.5
|
|
71
|
+
if capture_ratio > 2.5
|
|
72
|
+
abort(format('NodePattern capture regression: %.2fx slower than hand-written matching', capture_ratio))
|
|
73
|
+
end
|
|
74
|
+
if capture_miss_ratio > 2.5
|
|
75
|
+
abort(format('NodePattern capture miss regression: %.2fx slower than hand-written matching', capture_miss_ratio))
|
|
76
|
+
end
|
|
77
|
+
if allocations_per_miss > 0.1
|
|
78
|
+
abort(format('NodePattern capture miss regression: %.2f allocations/call', allocations_per_miss))
|
|
79
|
+
end
|
|
80
|
+
if compile_microseconds > 30.0
|
|
81
|
+
abort(format('NodePattern compiler cache regression: %.2fus/call', compile_microseconds))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'benchmark'
|
|
4
|
+
require 'astel'
|
|
5
|
+
|
|
6
|
+
def median_elapsed(samples, &operation)
|
|
7
|
+
Array.new(samples) do
|
|
8
|
+
GC.start
|
|
9
|
+
Benchmark.realtime(&operation)
|
|
10
|
+
end.sort.fetch(samples / 2)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
iterations = Integer(ENV.fetch('ITERATIONS', '20'), 10)
|
|
14
|
+
source = Astel::SourceFile.from_string('a' * 1_000_000)
|
|
15
|
+
rewriter = Astel::Rewriter.new(source)
|
|
16
|
+
|
|
17
|
+
100.times do |index|
|
|
18
|
+
start_offset = index * 10_000
|
|
19
|
+
rewriter.replace(start_offset...(start_offset + 5), 'replacement')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ordered_edits = rewriter.edits.sort_by do |edit|
|
|
23
|
+
[edit.start_offset, edit.end_offset, edit.sequence]
|
|
24
|
+
end
|
|
25
|
+
reference_rewrite = lambda do
|
|
26
|
+
ordered_edits.reverse_each.with_object(source.source.dup) do |edit, result|
|
|
27
|
+
prefix = result.byteslice(0, edit.start_offset)
|
|
28
|
+
suffix = result.byteslice(edit.end_offset, result.bytesize - edit.end_offset)
|
|
29
|
+
result.replace(prefix + edit.replacement + suffix)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
expected = reference_rewrite.call
|
|
34
|
+
raise 'rewriter output mismatch' unless rewriter.rewrite == expected
|
|
35
|
+
|
|
36
|
+
astel_elapsed = Benchmark.realtime { iterations.times { rewriter.rewrite } }
|
|
37
|
+
reference_elapsed = Benchmark.realtime { iterations.times { reference_rewrite.call } }
|
|
38
|
+
ratio = reference_elapsed / astel_elapsed
|
|
39
|
+
|
|
40
|
+
puts format('Astel rewriter: %.4fs', astel_elapsed)
|
|
41
|
+
puts format('Repeated rebuilding: %.4fs', reference_elapsed)
|
|
42
|
+
puts format('speedup: %.2fx', ratio)
|
|
43
|
+
|
|
44
|
+
abort(format('Rewriter regression: only %.2fx faster', ratio)) if ENV['VERIFY'] == '1' && ratio < 5.0
|
|
45
|
+
|
|
46
|
+
edit_count = Integer(ENV.fetch('EDIT_COUNT', '5000'), 10)
|
|
47
|
+
registration_source = Astel::SourceFile.from_string('a' * (edit_count * 3))
|
|
48
|
+
ranges = edit_count.times.map { |index| (index * 3)...(index * 3 + 1) }.reverse
|
|
49
|
+
|
|
50
|
+
optimized_registration = Benchmark.realtime do
|
|
51
|
+
registration_rewriter = Astel::Rewriter.new(registration_source)
|
|
52
|
+
ranges.each { |range| registration_rewriter.replace(range, 'b') }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
linear_registration = Benchmark.realtime do
|
|
56
|
+
registered_ranges = []
|
|
57
|
+
ranges.each do |range|
|
|
58
|
+
raise 'unexpected overlapping benchmark ranges' if registered_ranges.find { |existing| range.overlap?(existing) }
|
|
59
|
+
|
|
60
|
+
registered_ranges << range
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
registration_speedup = linear_registration / optimized_registration
|
|
64
|
+
|
|
65
|
+
puts format('Indexed registration: %.4fs', optimized_registration)
|
|
66
|
+
puts format('Linear conflict scan: %.4fs', linear_registration)
|
|
67
|
+
puts format('registration speedup: %.2fx', registration_speedup)
|
|
68
|
+
|
|
69
|
+
if ENV['VERIFY'] == '1' && registration_speedup < 5.0
|
|
70
|
+
abort(format('Rewriter registration regression: only %.2fx faster', registration_speedup))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
large_edit_count = Integer(ENV.fetch('LARGE_EDIT_COUNT', '100000'), 10)
|
|
74
|
+
large_registration_samples = Integer(ENV.fetch('LARGE_REGISTRATION_SAMPLES', '3'), 10)
|
|
75
|
+
large_source = Astel::SourceFile.from_string('a' * (large_edit_count * 2))
|
|
76
|
+
large_ranges = large_edit_count.times.map { |index| (index * 2)...(index * 2 + 1) }.reverse
|
|
77
|
+
|
|
78
|
+
chunked_operation = lambda do |selected_ranges|
|
|
79
|
+
large_rewriter = Astel::Rewriter.new(large_source)
|
|
80
|
+
selected_ranges.each { |range| large_rewriter.replace(range, 'b') }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
flat_operation = lambda do |selected_ranges|
|
|
84
|
+
registered_ranges = []
|
|
85
|
+
selected_ranges.each do |range|
|
|
86
|
+
index = registered_ranges.bsearch_index { |existing| existing.begin > range.begin } || registered_ranges.length
|
|
87
|
+
registered_ranges.insert(index, range)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
warmup_ranges = large_ranges.first([large_edit_count, 1000].min)
|
|
92
|
+
chunked_operation.call(warmup_ranges)
|
|
93
|
+
flat_operation.call(warmup_ranges)
|
|
94
|
+
|
|
95
|
+
chunked_registration = median_elapsed(large_registration_samples) { chunked_operation.call(large_ranges) }
|
|
96
|
+
flat_registration = median_elapsed(large_registration_samples) { flat_operation.call(large_ranges) }
|
|
97
|
+
large_registration_speedup = flat_registration / chunked_registration
|
|
98
|
+
|
|
99
|
+
puts format('Chunked registration (%d edits): %.4fs', large_edit_count, chunked_registration)
|
|
100
|
+
puts format('Flat sorted registration: %.4fs', flat_registration)
|
|
101
|
+
puts format('large registration speedup: %.2fx', large_registration_speedup)
|
|
102
|
+
|
|
103
|
+
if ENV['VERIFY'] == '1' && large_registration_speedup < 1.25
|
|
104
|
+
abort(format('Large rewriter registration regression: only %.2fx faster', large_registration_speedup))
|
|
105
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module Astel
|
|
7
|
+
class Dispatcher
|
|
8
|
+
class CallbackList < Array; end
|
|
9
|
+
|
|
10
|
+
NODE_CLASSES = Prism.constants.filter_map do |constant|
|
|
11
|
+
value = Prism.const_get(constant)
|
|
12
|
+
next unless value.is_a?(Class) && value < Prism::Node
|
|
13
|
+
|
|
14
|
+
[NodeType.from_class_name(constant.to_s), value]
|
|
15
|
+
rescue NameError
|
|
16
|
+
nil
|
|
17
|
+
end.to_h.freeze
|
|
18
|
+
NODE_TYPES = NODE_CLASSES.keys.to_set.freeze
|
|
19
|
+
|
|
20
|
+
# Each generated visit method pushes children in reverse order so the
|
|
21
|
+
# iterative traversal preserves Prism's pre-order without recursive calls.
|
|
22
|
+
class Traversal
|
|
23
|
+
def initialize(callbacks)
|
|
24
|
+
@callbacks = callbacks
|
|
25
|
+
@stack = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run(ast)
|
|
29
|
+
@stack << ast
|
|
30
|
+
@stack.pop.accept(self) until @stack.empty?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.install_traversal_method(type, node_class)
|
|
35
|
+
lines = [
|
|
36
|
+
"def visit_#{type}(node)",
|
|
37
|
+
" callbacks = @callbacks[:#{type}]",
|
|
38
|
+
' if Astel::Dispatcher::CallbackList === callbacks',
|
|
39
|
+
' callbacks.each { |callback| callback.call(node) }',
|
|
40
|
+
' elsif callbacks',
|
|
41
|
+
' callbacks.call(node)',
|
|
42
|
+
' end'
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if defined?(Prism::Reflection)
|
|
46
|
+
append_reflected_children(lines, node_class)
|
|
47
|
+
else
|
|
48
|
+
lines << ' node.child_nodes.reverse_each { |child| @stack << child if child }'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
lines << 'end'
|
|
52
|
+
Traversal.class_eval(lines.join("\n"), __FILE__, __LINE__)
|
|
53
|
+
end
|
|
54
|
+
private_class_method :install_traversal_method
|
|
55
|
+
|
|
56
|
+
def self.append_reflected_children(lines, node_class)
|
|
57
|
+
Prism::Reflection.fields_for(node_class).reverse_each do |field|
|
|
58
|
+
name = field.name
|
|
59
|
+
case field
|
|
60
|
+
when Prism::Reflection::NodeField
|
|
61
|
+
lines << " @stack << node.#{name}"
|
|
62
|
+
when Prism::Reflection::OptionalNodeField
|
|
63
|
+
lines << " if (child = node.#{name})"
|
|
64
|
+
lines << ' @stack << child'
|
|
65
|
+
lines << ' end'
|
|
66
|
+
when Prism::Reflection::NodeListField
|
|
67
|
+
lines << " node.#{name}.reverse_each { |child| @stack << child }"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :append_reflected_children
|
|
72
|
+
|
|
73
|
+
NODE_CLASSES.each { |type, node_class| install_traversal_method(type, node_class) }
|
|
74
|
+
|
|
75
|
+
def initialize
|
|
76
|
+
@callbacks = {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def on(node_type, callable = nil, &block)
|
|
80
|
+
callback = callable || block
|
|
81
|
+
raise ArgumentError, 'callback is required' unless callback
|
|
82
|
+
|
|
83
|
+
type = node_type.to_sym
|
|
84
|
+
raise ArgumentError, "unknown Prism node type: #{node_type}" unless NODE_TYPES.include?(type)
|
|
85
|
+
raise ArgumentError, 'callback must respond to call' unless callback.respond_to?(:call)
|
|
86
|
+
|
|
87
|
+
registered = @callbacks[type]
|
|
88
|
+
if registered.is_a?(CallbackList)
|
|
89
|
+
registered << callback
|
|
90
|
+
elsif registered
|
|
91
|
+
@callbacks[type] = CallbackList.new([registered, callback])
|
|
92
|
+
else
|
|
93
|
+
@callbacks[type] = callback
|
|
94
|
+
end
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run(ast)
|
|
99
|
+
return self unless ast
|
|
100
|
+
|
|
101
|
+
Traversal.new(@callbacks).run(ast)
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
Location = Data.define(:start_offset, :end_offset, :start_line, :start_column, :end_line, :end_column) do
|
|
5
|
+
def self.from(location)
|
|
6
|
+
return location if location.is_a?(self)
|
|
7
|
+
|
|
8
|
+
new(
|
|
9
|
+
start_offset: location.start_offset,
|
|
10
|
+
end_offset: location.end_offset,
|
|
11
|
+
start_line: location.start_line,
|
|
12
|
+
start_column: location.start_column,
|
|
13
|
+
end_line: location.end_line,
|
|
14
|
+
end_column: location.end_column
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.point(offset:, line:, column:)
|
|
19
|
+
new(
|
|
20
|
+
start_offset: offset,
|
|
21
|
+
end_offset: offset,
|
|
22
|
+
start_line: line,
|
|
23
|
+
start_column: column,
|
|
24
|
+
end_line: line,
|
|
25
|
+
end_column: column
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def length
|
|
30
|
+
end_offset - start_offset
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def range
|
|
34
|
+
start_offset...end_offset
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|