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 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
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here following Keep a Changelog, and the
4
+ project follows Semantic Versioning.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-07-15
9
+
10
+ - Initial release
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
+ [![Ruby](https://github.com/ydah/astel/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/astel/actions/workflows/main.yml)
4
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism'
4
+
5
+ module Astel
6
+ module NodeExt
7
+ refine Prism::Node do
8
+ def source_text
9
+ location.slice
10
+ end
11
+ end
12
+ end
13
+ end