astel 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19a38e5beaa849b77b5d60ea2d46b1913e589a1892dd36fc6797e34e5dbd2738
4
- data.tar.gz: 7de828db0d6c9e6ea66179f2f931308b9609ccade0047dc99e0b5f77e61ead5f
3
+ metadata.gz: 85e86fbd64b1c816e86880477cc01cf530f8ce5aa83b712324e789bb831b81a4
4
+ data.tar.gz: 7d38a73bdb0382f836c8c145fc9bb998555cf808507a27579fc3ec26fc0674b5
5
5
  SHA512:
6
- metadata.gz: 8364cddfb8fe41deb8770e0f3ca105467820030d77c3563184aa9d087cbd8de2649d05db67058b18f04476ef410f945ef948dc12f2226bd55d607fa4ba683dfb
7
- data.tar.gz: 4feb4b4cd00035245cf4b992612f0d51c67e2d7fa74decaea77b59912a481e9cc6129938ded3882b3aecd33a186535783ecf00f755e7347b08ca820d14bbd550
6
+ metadata.gz: ae4f791ade056336ef1e7364197ba6a73dbec1daf9e8c5488ebd2936e45e61d05014a1bf7b7d439d81f74acca421b3211e7f70ce5f8a96b3d0d648e265cf61e3
7
+ data.tar.gz: 9cb66e271a5c5111bace0dbf59528fffd8341fc1b4db935329e1a2911c67b7e47e1810848ad34785706dad5ade7920700afe7070296badb79a5ceb6a7bf30327
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ project follows Semantic Versioning.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0] - 2026-09-07
9
+
10
+ - Add the primary opt-in extension APIs `Astel::NodeSourceText`, `Astel::UnifiedDiff`, and `Astel::Diffable`, plus structured source-editing methods for `Astel::Rewriter` loaded through `astel/rewriter/structured`
11
+ - Support operator, setter, Unicode, underscore, and quoted symbol names in `NodePattern` expressions
12
+ - Keep `Astel::NodeExt`, `Astel::Refactor`, `Astel::Diff`, and `Astel::RewriterDiff`, together with their previous require paths, as compatibility entry points
13
+ - Restrict `NodePattern` attributes to Prism node fields and side-effect-free node predicates; invalid node types and attributes now report their actual pattern position
14
+ - Return unified diff output as a binary string so UTF-8 paths can be combined safely with source in other encodings
15
+ - Preserve the source encoding when rewriting: representable replacements are transcoded, while invalid or unrepresentable byte sequences are rejected before an edit is registered
16
+ - Preserve positional and keyword argument semantics when adding, removing, or renaming keywords, including omitted values, keyword splats, forwarded and literal blocks, surrounding comments, and multibyte source
17
+ - Keep heredoc bodies and terminators attached to their expressions during argument removal, keyword-value replacement, line insertion, commenting, and comment-aware removal, including interpolated and multiple heredocs
18
+ - Keep body insertions inside inline and nested definitions, place normal-body insertions before `rescue`, and preserve multiline literal contents, indentation, line endings, and non-UTF-8 source text
19
+ - Use whitespace separators for `%w`, `%W`, `%i`, and `%I` collections and keep comments when inserting into otherwise empty collections
20
+ - Recognize only effective frozen-string magic comments, including Emacs-style directives, and return valid empty line ranges at EOF after LF or CRLF
21
+ - Generate sparse unified diffs with Myers' algorithm and use a bounded-memory full-replacement fallback for large edit distances instead of allocating a quadratic LCS matrix
22
+
23
+ ## [0.2.0] - 2026-08-30
24
+
25
+ - Add atomic `Rewriter#transaction` and `#merge!` operations
26
+ - Accept same-offset insertions by default, with an opt-in strict policy
27
+ - Add rewrite syntax validation, wrapping, and encoding compatibility checks
28
+ - Add source formatting and location APIs
29
+ - Add opt-in refactoring helpers with `require "astel/refactor"`
30
+ - Add dependency-free unified diffs with `require "astel/diff"`
31
+ - Add weekly rewrite-and-reparse fuzzing against real gem sources
32
+ - Keep wrapping and comment attachment atomic and idempotent across edge cases
33
+
8
34
  ## [0.1.0] - 2026-07-15
9
35
 
10
36
  - Initial release
data/README.md CHANGED
@@ -1,37 +1,92 @@
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.
1
+ <h1 align="center">Astel</h1>
2
+
3
+ <p align="center">
4
+ <strong>Fast, Prism-native building blocks for Ruby source analysis and transformation</strong>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/astel"><img src="https://img.shields.io/gem/v/astel.svg?colorB=319e8c" alt="Gem Version"></a>
9
+ <a href="https://rubygems.org/gems/astel"><img src="https://img.shields.io/gem/dt/astel.svg" alt="Downloads"></a>
10
+ <img src="https://img.shields.io/badge/ruby-%3E%3D%203.3-ruby.svg" alt="Ruby Version">
11
+ <a href="https://github.com/ydah/astel/actions/workflows/main.yml"><img src="https://github.com/ydah/astel/actions/workflows/main.yml/badge.svg" alt="Ruby"></a>
12
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="#features">Features</a> ·
17
+ <a href="#installation">Installation</a> ·
18
+ <a href="#quick-start">Quick Start</a> ·
19
+ <a href="#core-apis">Core APIs</a> ·
20
+ <a href="#extensions">Extensions</a> ·
21
+ <a href="#development">Development</a>
22
+ </p>
23
+
24
+ ---
25
+
26
+ Astel parses Ruby source once with [Prism](https://github.com/ruby/prism), then
27
+ provides focused APIs for AST traversal, declarative node matching, and
28
+ non-destructive source rewriting. It is designed as a library for codemods,
29
+ linters, and other Ruby tooling rather than as a rule framework or CLI.
30
+
31
+ ## Features
32
+
33
+ - Parse source once and retain its AST, comments, errors, and formatting
34
+ - Visit the AST once while dispatching multiple node callbacks
35
+ - Compile reusable node patterns with captures and alternatives
36
+ - Record byte-safe edits with deterministic conflict detection
37
+ - Group related edits in atomic transactions and validate rewritten syntax
38
+ - Opt into semantic refactoring helpers and unified diffs
39
+ - Preserve source encodings, indentation, and CRLF line endings
10
40
 
11
41
  ## Installation
12
42
 
13
- Install Astel with Bundler:
43
+ Add Astel to your bundle:
14
44
 
15
45
  ```sh
16
46
  bundle add astel
17
47
  ```
18
48
 
19
- Or install it directly with RubyGems:
49
+ Or install it directly:
20
50
 
21
51
  ```sh
22
52
  gem install astel
23
53
  ```
24
54
 
25
- Astel requires Ruby 3.3 or newer and Prism 0.30 or newer, but earlier than 2.0.
55
+ ### Requirements
26
56
 
27
- ## Usage
57
+ - Ruby 3.3+
58
+ - Prism 0.30 or newer, but earlier than 2.0
28
59
 
29
- Require Astel before using its APIs:
60
+ ## Quick Start
61
+
62
+ Parse, find, and rewrite a method call:
30
63
 
31
64
  ```ruby
32
65
  require "astel"
66
+
67
+ source = Astel::SourceFile.from_string("old_name\n", path: "example.rb")
68
+ rewriter = Astel::Rewriter.new(source)
69
+ pattern = Astel::NodePattern.compile("(call_node name: :old_name)")
70
+
71
+ dispatcher = Astel::Dispatcher.new
72
+ dispatcher.on(:call_node) do |node|
73
+ rewriter.replace(node.location, "new_name") if pattern.match?(node)
74
+ end
75
+ dispatcher.run(source.ast)
76
+
77
+ rewriter.rewrite # => "new_name\n"
78
+ source.source # => "old_name\n"
33
79
  ```
34
80
 
81
+ ## Core APIs
82
+
83
+ | API | Purpose |
84
+ |-----|---------|
85
+ | `Astel::SourceFile` | Parse source and expose its AST, errors, comments, and formatting |
86
+ | `Astel::Dispatcher` | Visit the tree once and invoke callbacks by Prism node type |
87
+ | `Astel::NodePattern` | Compile and reuse declarative AST matchers |
88
+ | `Astel::Rewriter` | Record, validate, and apply non-destructive source edits |
89
+
35
90
  ### Parse source
36
91
 
37
92
  Parse a file from disk:
@@ -45,7 +100,7 @@ source.errors # => Prism parse errors
45
100
  source.valid? # => true when there are no parse errors
46
101
  ```
47
102
 
48
- Use `Astel::SourceFile.from_string` when the source is already in memory:
103
+ Use `from_string` when the source is already in memory:
49
104
 
50
105
  ```ruby
51
106
  source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")
@@ -53,8 +108,8 @@ source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")
53
108
 
54
109
  ### Traverse the AST
55
110
 
56
- `Astel::Dispatcher` walks the tree once and invokes callbacks registered for
57
- specific Prism node types:
111
+ `Astel::Dispatcher` invokes every callback registered for a visited Prism node
112
+ type:
58
113
 
59
114
  ```ruby
60
115
  source = Astel::SourceFile.from_string(<<~RUBY)
@@ -71,8 +126,7 @@ Multiple callbacks can be registered for the same node type.
71
126
 
72
127
  ### Match nodes
73
128
 
74
- `Astel::NodePattern` compiles a declarative pattern that can be reused across
75
- nodes:
129
+ `Astel::NodePattern` compiles a pattern that can be reused across nodes:
76
130
 
77
131
  ```ruby
78
132
  source = Astel::SourceFile.from_string('"value".freeze')
@@ -103,35 +157,91 @@ captures.
103
157
 
104
158
  ### Rewrite source
105
159
 
106
- `Astel::Rewriter` records edits without modifying the original `SourceFile`:
160
+ `Astel::Rewriter` supports `replace`, `remove`, `insert_before`, `insert_after`,
161
+ and `wrap`. It records edits without modifying the original `SourceFile`:
107
162
 
108
163
  ```ruby
109
- source = Astel::SourceFile.from_string("old_name\n")
110
- node = source.ast.statements.body.first
164
+ rewriter.replace(node.location, "new_name")
165
+ rewriter.rewrite(validate: :parse)
166
+ ```
167
+
168
+ Overlapping edits raise `Astel::Rewriter::ConflictError`. Same-offset
169
+ insertions are emitted in registration order; pass
170
+ `duplicate_insertions: :raise` for strict duplicate handling.
171
+
172
+ Group related edits atomically:
173
+
174
+ ```ruby
175
+ rewriter.transaction do |rw|
176
+ rw.replace(first.location, "first")
177
+ rw.replace(second.location, "second")
178
+ end
179
+ ```
180
+
181
+ The transaction returns `false` without registering any edits when a conflict
182
+ occurs. Pass `raise_on_conflict: true` to raise instead.
183
+
184
+ ### Inspect source formatting
185
+
186
+ `SourceFile` exposes byte-based positions and detected formatting:
187
+
188
+ ```ruby
189
+ source.line_at(node.location.start_offset)
190
+ source.column_at(node.location.start_offset)
191
+ source.indentation_at(node.location.start_offset)
192
+ source.newline
193
+ source.indent_unit
194
+ ```
195
+
196
+ ## Extensions
197
+
198
+ Extensions are opt-in and add no dependencies beyond Astel's core requirements.
199
+
200
+ ### Structured edits
201
+
202
+ ```ruby
203
+ require "astel/rewriter/structured"
111
204
 
112
205
  rewriter = Astel::Rewriter.new(source)
113
- rewriter.replace(node.location, "new_name")
206
+ rewriter.remove_keyword_argument(call_node, :required)
207
+ rewriter.insert_into_body(class_node, "def added\nend", position: :before_private)
208
+ ```
114
209
 
115
- rewriter.rewrite # => "new_name\n"
116
- source.source # => "old_name\n"
210
+ See [Refactoring recipes](docs/refactor.md) for the complete API and examples.
211
+
212
+ ### Unified diffs
213
+
214
+ ```ruby
215
+ require "astel/unified_diff"
216
+
217
+ puts rewriter.to_diff(context: 3)
117
218
  ```
118
219
 
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.
220
+ As with Git-generated zero-context patches, `context: 0` requires
221
+ `git apply --unidiff-zero`.
222
+
223
+ See the [codemod guide](docs/codemod-guide.md) for an end-to-end example using
224
+ dispatch, patterns, transactions, validation, and diffs.
122
225
 
123
- ## Performance and concurrency
226
+ ## How It Works
124
227
 
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.
228
+ 1. `SourceFile` parses source once with Prism and retains the parse result.
229
+ 2. `Dispatcher` and `NodePattern` locate relevant nodes without reparsing.
230
+ 3. `Rewriter` records flat edits and rejects overlaps when they are registered.
231
+ 4. `rewrite` applies the edits in one pass and can validate the result with Prism.
232
+ 5. The optional diff extension renders the final change as a unified patch.
127
233
 
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.
234
+ ## Performance and Concurrency
132
235
 
133
- Benchmarks for dispatching, node patterns, and rewriting are available under
134
- `benchmark/`:
236
+ Compile node patterns once and reuse them for every candidate node. Astel keeps
237
+ a bounded cache of compiler output for repeated pattern strings.
238
+
239
+ For repository-wide tools, process independent files in worker processes at the
240
+ application layer. Keep each `SourceFile` and its Prism AST inside the worker
241
+ that parsed it; Astel intentionally does not own a process pool or move ASTs
242
+ between workers.
243
+
244
+ Benchmarks are available under `benchmark/`:
135
245
 
136
246
  ```sh
137
247
  bundle exec ruby benchmark/dispatch_bench.rb
@@ -139,21 +249,37 @@ bundle exec ruby benchmark/node_pattern_bench.rb
139
249
  bundle exec ruby benchmark/rewriter_bench.rb
140
250
  ```
141
251
 
252
+ ## Scope
253
+
254
+ Astel intentionally does not provide a nested action tree, file discovery,
255
+ parallel execution, a CLI, a rule framework, or a parser compatibility layer.
256
+ Those concerns stay in applications built on Astel. Source edits remain flat;
257
+ overlapping ranges raise instead of being silently reordered or discarded.
258
+
142
259
  ## Development
143
260
 
144
- After checking out the repository, install dependencies and run the test suite:
261
+ Install dependencies and run the test suite:
145
262
 
146
263
  ```sh
147
264
  bundle install
148
265
  bundle exec rake
149
266
  ```
150
267
 
151
- Verify the packaged gem with:
268
+ Verify the packaged gem:
152
269
 
153
270
  ```sh
154
271
  bundle exec ruby script/package_smoke.rb
155
272
  ```
156
273
 
274
+ Run the deterministic source-rewrite fuzz check against Ruby source trees:
275
+
276
+ ```sh
277
+ SEED=123 bundle exec ruby script/fuzz.rb path/to/gem/sources
278
+ ```
279
+
280
+ CI runs the same check weekly against Rails, RuboCop, Sidekiq, Faraday, and
281
+ Prism source releases.
282
+
157
283
  ## Contributing
158
284
 
159
285
  Bug reports and pull requests are welcome on
@@ -164,5 +290,4 @@ See [CHANGELOG.md](CHANGELOG.md) for notable changes.
164
290
 
165
291
  ## License
166
292
 
167
- The gem is available as open source under the terms of the
168
- [MIT License](LICENSE.txt).
293
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,51 @@
1
+ # Codemod guide
2
+
3
+ A codemod can parse, traverse, match, edit, validate, and display a diff using
4
+ Astel and Prism alone.
5
+
6
+ ```ruby
7
+ require "astel"
8
+ require "astel/rewriter/structured"
9
+ require "astel/unified_diff"
10
+
11
+ source = Astel::SourceFile.parse(path: "app/types/user_type.rb")
12
+ raise "parse failed" unless source.valid?
13
+
14
+ rewriter = Astel::Rewriter.new(source)
15
+ pattern = Astel::NodePattern.compile(
16
+ "(call_node receiver: nil name: :field)"
17
+ )
18
+
19
+ dispatcher = Astel::Dispatcher.new
20
+ dispatcher.on(:call_node) do |node|
21
+ next unless pattern.match?(node)
22
+ next unless rewriter.find_keyword_argument(node, :required)
23
+
24
+ rewriter.transaction do |rw|
25
+ rw.remove_keyword_argument(node, :required)
26
+ rw.insert_keyword_argument(node, "null: false")
27
+ end
28
+ end
29
+ dispatcher.run(source.ast)
30
+
31
+ puts rewriter.to_diff
32
+ File.write(source.path, rewriter.rewrite(validate: :parse)) unless rewriter.edits.empty?
33
+ ```
34
+
35
+ Transactions keep rule-level changes atomic. If one staged edit conflicts, none
36
+ of that transaction's edits are registered. Overlapping ranges remain errors;
37
+ Astel does not build or reconcile a nested action tree.
38
+
39
+ ## TreeRewriter compatibility
40
+
41
+ For non-overlapping ASCII edits, Astel's output is regression-tested against
42
+ `Parser::Source::TreeRewriter` with 5,000 randomized operation sequences.
43
+
44
+ There are two intentional semantic differences:
45
+
46
+ - Astel uses byte offsets, while parser uses character offsets.
47
+ - Same-offset insertions are emitted in registration order. TreeRewriter's
48
+ `insert_before` emits repeated insertions in reverse registration order.
49
+
50
+ Use `duplicate_insertions: :raise` when duplicate insertions should be treated
51
+ as conflicts.
data/docs/refactor.md ADDED
@@ -0,0 +1,87 @@
1
+ # Refactoring recipes
2
+
3
+ Load the helpers explicitly. Requiring `astel/rewriter/structured` adds them to every
4
+ `Astel::Rewriter` instance.
5
+
6
+ ```ruby
7
+ require "astel"
8
+ require "astel/rewriter/structured"
9
+
10
+ source = Astel::SourceFile.parse(path: "example.rb")
11
+ rewriter = Astel::Rewriter.new(source)
12
+ ```
13
+
14
+ ## Keyword arguments and lists
15
+
16
+ Find, remove, replace, or insert keyword arguments:
17
+
18
+ ```ruby
19
+ argument = rewriter.find_keyword_argument(call_node, :required)
20
+ rewriter.remove_keyword_argument(call_node, :required)
21
+ rewriter.replace_keyword_key(argument, :method)
22
+ rewriter.replace_keyword_value(argument, "false")
23
+ rewriter.insert_keyword_argument(call_node, "null: false")
24
+ ```
25
+
26
+ The list APIs accept `Prism::ArgumentsNode`, `Prism::ArrayNode`,
27
+ `Prism::KeywordHashNode`, and `Prism::HashNode`:
28
+
29
+ ```ruby
30
+ rewriter.remove_list_element(arguments, arguments.arguments.first)
31
+ rewriter.remove_list_elements(arguments, arguments.arguments.values_at(0, 2))
32
+ rewriter.insert_list_element(arguments, 1, "new_value")
33
+ ```
34
+
35
+ Multiple removals are calculated together and registered as one edit, keeping
36
+ comma handling atomic.
37
+
38
+ ## Add a method
39
+
40
+ `insert_into_body` supports classes, modules, methods, singleton classes, and
41
+ blocks. It detects the file's indentation and newline style.
42
+
43
+ ```ruby
44
+ rewriter.insert_into_body(class_node, <<~RUBY, position: :before_private)
45
+ def active?
46
+ true
47
+ end
48
+ RUBY
49
+ ```
50
+
51
+ Positions are `:beginning`, `:end`, `:before_private`, `{ before: node }`, and
52
+ `{ after: node }`.
53
+
54
+ ## Rewrite a constant path
55
+
56
+ Use the core `replace` API when the AST location already identifies the exact
57
+ source range:
58
+
59
+ ```ruby
60
+ pattern = Astel::NodePattern.compile(
61
+ "(constant_path_node name: :OldClient)"
62
+ )
63
+
64
+ dispatcher = Astel::Dispatcher.new
65
+ dispatcher.on(:constant_path_node) do |node|
66
+ rewriter.replace(node.location, "Services::NewClient") if pattern.match?(node)
67
+ end
68
+ dispatcher.run(source.ast)
69
+ ```
70
+
71
+ ## Formatting, lines, and comments
72
+
73
+ ```ruby
74
+ rewriter.indentation_of(node)
75
+ rewriter.indent(text, level: 1)
76
+ rewriter.dedent(text)
77
+ rewriter.reindent(text, to: node)
78
+
79
+ rewriter.remove_line(node)
80
+ rewriter.insert_line_before(node, "before")
81
+ rewriter.insert_line_after(node, "after")
82
+ rewriter.comment_out(node)
83
+
84
+ rewriter.leading_comments(node)
85
+ rewriter.trailing_comment(node)
86
+ rewriter.remove_with_comments(node)
87
+ ```
data/lib/astel/diff.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'unified_diff'
4
+
5
+ module Astel
6
+ Diff = UnifiedDiff
7
+ RewriterDiff = Diffable
8
+ end
@@ -32,7 +32,18 @@ module Astel
32
32
  end
33
33
 
34
34
  def self.install_traversal_method(type, node_class)
35
- lines = [
35
+ source = traversal_method_source(type, node_class)
36
+ Traversal.class_eval(source, __FILE__, __LINE__)
37
+ end
38
+ private_class_method :install_traversal_method
39
+
40
+ def self.traversal_method_source(type, node_class)
41
+ [*callback_dispatch_source(type), *child_traversal_source(node_class), 'end'].join("\n")
42
+ end
43
+ private_class_method :traversal_method_source
44
+
45
+ def self.callback_dispatch_source(type)
46
+ [
36
47
  "def visit_#{type}(node)",
37
48
  " callbacks = @callbacks[:#{type}]",
38
49
  ' if Astel::Dispatcher::CallbackList === callbacks',
@@ -41,34 +52,33 @@ module Astel
41
52
  ' callbacks.call(node)',
42
53
  ' end'
43
54
  ]
55
+ end
56
+ private_class_method :callback_dispatch_source
44
57
 
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 }'
58
+ def self.child_traversal_source(node_class)
59
+ unless defined?(Prism::Reflection)
60
+ return [' node.child_nodes.reverse_each { |child| @stack << child if child }']
49
61
  end
50
62
 
51
- lines << 'end'
52
- Traversal.class_eval(lines.join("\n"), __FILE__, __LINE__)
63
+ Prism::Reflection.fields_for(node_class).reverse_each.flat_map do |field|
64
+ reflected_child_source(field)
65
+ end
53
66
  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
67
+ private_class_method :child_traversal_source
68
+
69
+ def self.reflected_child_source(field)
70
+ case field
71
+ when Prism::Reflection::NodeField
72
+ [" @stack << node.#{field.name}"]
73
+ when Prism::Reflection::OptionalNodeField
74
+ [" if (child = node.#{field.name})", ' @stack << child', ' end']
75
+ when Prism::Reflection::NodeListField
76
+ [" node.#{field.name}.reverse_each { |child| @stack << child }"]
77
+ else
78
+ []
69
79
  end
70
80
  end
71
- private_class_method :append_reflected_children
81
+ private_class_method :reflected_child_source
72
82
 
73
83
  NODE_CLASSES.each { |type, node_class| install_traversal_method(type, node_class) }
74
84
 
@@ -84,14 +94,7 @@ module Astel
84
94
  raise ArgumentError, "unknown Prism node type: #{node_type}" unless NODE_TYPES.include?(type)
85
95
  raise ArgumentError, 'callback must respond to call' unless callback.respond_to?(:call)
86
96
 
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
97
+ register_callback(type, callback)
95
98
  self
96
99
  end
97
100
 
@@ -101,5 +104,18 @@ module Astel
101
104
  Traversal.new(@callbacks).run(ast)
102
105
  self
103
106
  end
107
+
108
+ private
109
+
110
+ def register_callback(type, callback)
111
+ registered = @callbacks[type]
112
+ if registered.is_a?(CallbackList)
113
+ registered << callback
114
+ elsif registered
115
+ @callbacks[type] = CallbackList.new([registered, callback])
116
+ else
117
+ @callbacks[type] = callback
118
+ end
119
+ end
104
120
  end
105
121
  end
@@ -33,5 +33,74 @@ module Astel
33
33
  def range
34
34
  start_offset...end_offset
35
35
  end
36
+
37
+ def empty?
38
+ start_offset == end_offset
39
+ end
40
+
41
+ def contains?(other)
42
+ other = self.class.from(other)
43
+ start_offset <= other.start_offset && end_offset >= other.end_offset
44
+ end
45
+
46
+ def overlaps?(other)
47
+ other = self.class.from(other)
48
+ start_offset < other.end_offset && other.start_offset < end_offset
49
+ end
50
+
51
+ def join(other)
52
+ other = self.class.from(other)
53
+ first = start_offset <= other.start_offset ? self : other
54
+ last = end_offset >= other.end_offset ? self : other
55
+ self.class.new(
56
+ start_offset: first.start_offset,
57
+ end_offset: last.end_offset,
58
+ start_line: first.start_line,
59
+ start_column: first.start_column,
60
+ end_line: last.end_line,
61
+ end_column: last.end_column
62
+ )
63
+ end
64
+
65
+ def adjust(start: 0, **changes)
66
+ ending = changes.fetch(:end, 0)
67
+ raise ArgumentError, "unknown keyword: #{changes.keys.first}" unless (changes.keys - [:end]).empty?
68
+
69
+ adjusted_start = start_offset + start
70
+ adjusted_end = end_offset + ending
71
+ adjusted_start_column = start_column + start
72
+ adjusted_end_column = end_column + ending
73
+ unless valid_adjustment?(
74
+ start_offset: adjusted_start,
75
+ end_offset: adjusted_end,
76
+ start_column: adjusted_start_column,
77
+ end_column: adjusted_end_column
78
+ )
79
+ raise RangeError, 'adjusted location is invalid'
80
+ end
81
+
82
+ self.class.new(
83
+ start_offset: adjusted_start,
84
+ end_offset: adjusted_end,
85
+ start_line: start_line,
86
+ start_column: adjusted_start_column,
87
+ end_line: end_line,
88
+ end_column: adjusted_end_column
89
+ )
90
+ end
91
+
92
+ def begin_point
93
+ self.class.point(offset: start_offset, line: start_line, column: start_column)
94
+ end
95
+
96
+ def end_point
97
+ self.class.point(offset: end_offset, line: end_line, column: end_column)
98
+ end
99
+
100
+ private
101
+
102
+ def valid_adjustment?(start_offset:, end_offset:, start_column:, end_column:)
103
+ start_offset >= 0 && start_offset <= end_offset && start_column >= 0 && end_column >= 0
104
+ end
36
105
  end
37
106
  end