astel 0.2.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: f3aa35253044b63edd2bcce05297d78c15740ac93627b8d32ec909eba8dd2ce1
4
- data.tar.gz: 495161fcfbd3b410f7e220002f177afa8a5c9892f97ab6a3a7360b8005e414c6
3
+ metadata.gz: 85e86fbd64b1c816e86880477cc01cf530f8ce5aa83b712324e789bb831b81a4
4
+ data.tar.gz: 7d38a73bdb0382f836c8c145fc9bb998555cf808507a27579fc3ec26fc0674b5
5
5
  SHA512:
6
- metadata.gz: aee6be8604da7c4b6bfa7ff2d4c010ba987501d4c1238852095201144a1f8068807eeeaf52b59b32d6b3e2adcbe4a52703f97c2b09f082bab76ce979986c11df
7
- data.tar.gz: 9365ceb989c157954fbb88fd473c6c73a6c8e8548ddd0642da65ae890a5af51ae9dcba9e8512433cb3dcac08ce19672325be307b4df319ff83de4dc44c7b8ce6
6
+ metadata.gz: ae4f791ade056336ef1e7364197ba6a73dbec1daf9e8c5488ebd2936e45e61d05014a1bf7b7d439d81f74acca421b3211e7f70ce5f8a96b3d0d648e265cf61e3
7
+ data.tar.gz: 9cb66e271a5c5111bace0dbf59528fffd8341fc1b4db935329e1a2911c67b7e47e1810848ad34785706dad5ade7920700afe7070296badb79a5ceb6a7bf30327
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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
+
8
23
  ## [0.2.0] - 2026-08-30
9
24
 
10
25
  - Add atomic `Rewriter#transaction` and `#merge!` operations
data/README.md CHANGED
@@ -1,38 +1,92 @@
1
- # Astel
2
-
3
- [![Gem Version](https://badge.fury.io/rb/astel.svg)](https://badge.fury.io/rb/astel)
4
- [![Ruby](https://github.com/ydah/astel/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/astel/actions/workflows/main.yml)
5
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
-
7
- Astel provides fast, reusable building blocks for Ruby source analysis and
8
- transformation on top of [Prism](https://github.com/ruby/prism). It parses each
9
- source once and provides APIs for AST traversal, declarative node matching, and
10
- 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
11
40
 
12
41
  ## Installation
13
42
 
14
- Install Astel with Bundler:
43
+ Add Astel to your bundle:
15
44
 
16
45
  ```sh
17
46
  bundle add astel
18
47
  ```
19
48
 
20
- Or install it directly with RubyGems:
49
+ Or install it directly:
21
50
 
22
51
  ```sh
23
52
  gem install astel
24
53
  ```
25
54
 
26
- Astel requires Ruby 3.3 or newer and Prism 0.30 or newer, but earlier than 2.0.
55
+ ### Requirements
56
+
57
+ - Ruby 3.3+
58
+ - Prism 0.30 or newer, but earlier than 2.0
27
59
 
28
- ## Usage
60
+ ## Quick Start
29
61
 
30
- Require Astel before using its APIs:
62
+ Parse, find, and rewrite a method call:
31
63
 
32
64
  ```ruby
33
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"
34
79
  ```
35
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
+
36
90
  ### Parse source
37
91
 
38
92
  Parse a file from disk:
@@ -46,7 +100,7 @@ source.errors # => Prism parse errors
46
100
  source.valid? # => true when there are no parse errors
47
101
  ```
48
102
 
49
- Use `Astel::SourceFile.from_string` when the source is already in memory:
103
+ Use `from_string` when the source is already in memory:
50
104
 
51
105
  ```ruby
52
106
  source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")
@@ -54,8 +108,8 @@ source = Astel::SourceFile.from_string("value = 1\n", path: "example.rb")
54
108
 
55
109
  ### Traverse the AST
56
110
 
57
- `Astel::Dispatcher` walks the tree once and invokes callbacks registered for
58
- specific Prism node types:
111
+ `Astel::Dispatcher` invokes every callback registered for a visited Prism node
112
+ type:
59
113
 
60
114
  ```ruby
61
115
  source = Astel::SourceFile.from_string(<<~RUBY)
@@ -72,8 +126,7 @@ Multiple callbacks can be registered for the same node type.
72
126
 
73
127
  ### Match nodes
74
128
 
75
- `Astel::NodePattern` compiles a declarative pattern that can be reused across
76
- nodes:
129
+ `Astel::NodePattern` compiles a pattern that can be reused across nodes:
77
130
 
78
131
  ```ruby
79
132
  source = Astel::SourceFile.from_string('"value".freeze')
@@ -104,26 +157,19 @@ captures.
104
157
 
105
158
  ### Rewrite source
106
159
 
107
- `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`:
108
162
 
109
163
  ```ruby
110
- source = Astel::SourceFile.from_string("old_name\n")
111
- node = source.ast.statements.body.first
112
-
113
- rewriter = Astel::Rewriter.new(source)
114
164
  rewriter.replace(node.location, "new_name")
115
-
116
- rewriter.rewrite # => "new_name\n"
117
- source.source # => "old_name\n"
165
+ rewriter.rewrite(validate: :parse)
118
166
  ```
119
167
 
120
- The rewriter supports `replace`, `remove`, `insert_before`, `insert_after`, and
121
- `wrap`. Overlapping edits raise `Astel::Rewriter::ConflictError`, and
122
- `Astel::Rewriter#edits` returns an immutable snapshot of registered edits.
123
- Same-offset insertions are concatenated in registration order. Pass
124
- `duplicate_insertions: :raise` to retain the strict behavior from Astel 0.1.
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.
125
171
 
126
- Group related edits atomically with a transaction:
172
+ Group related edits atomically:
127
173
 
128
174
  ```ruby
129
175
  rewriter.transaction do |rw|
@@ -133,13 +179,11 @@ end
133
179
  ```
134
180
 
135
181
  The transaction returns `false` without registering any edits when a conflict
136
- occurs. Pass `raise_on_conflict: true` to raise instead. Use
137
- `rewrite(validate: :parse)` to reject rewritten source containing syntax errors.
182
+ occurs. Pass `raise_on_conflict: true` to raise instead.
138
183
 
139
- ### Source formatting
184
+ ### Inspect source formatting
140
185
 
141
- `SourceFile` exposes byte-based line and column lookup, line locations, source
142
- slicing, dominant newline detection, and indentation detection:
186
+ `SourceFile` exposes byte-based positions and detected formatting:
143
187
 
144
188
  ```ruby
145
189
  source.line_at(node.location.start_offset)
@@ -149,12 +193,14 @@ source.newline
149
193
  source.indent_unit
150
194
  ```
151
195
 
152
- ### Refactoring helpers
196
+ ## Extensions
197
+
198
+ Extensions are opt-in and add no dependencies beyond Astel's core requirements.
153
199
 
154
- Load semantic source-editing helpers explicitly:
200
+ ### Structured edits
155
201
 
156
202
  ```ruby
157
- require "astel/refactor"
203
+ require "astel/rewriter/structured"
158
204
 
159
205
  rewriter = Astel::Rewriter.new(source)
160
206
  rewriter.remove_keyword_argument(call_node, :required)
@@ -165,10 +211,8 @@ See [Refactoring recipes](docs/refactor.md) for the complete API and examples.
165
211
 
166
212
  ### Unified diffs
167
213
 
168
- Diff support is also opt-in and has no additional runtime dependency:
169
-
170
214
  ```ruby
171
- require "astel/diff"
215
+ require "astel/unified_diff"
172
216
 
173
217
  puts rewriter.to_diff(context: 3)
174
218
  ```
@@ -177,20 +221,27 @@ As with Git-generated zero-context patches, `context: 0` requires
177
221
  `git apply --unidiff-zero`.
178
222
 
179
223
  See the [codemod guide](docs/codemod-guide.md) for an end-to-end example using
180
- the dispatcher, node patterns, transactions, validation, and diffs.
224
+ dispatch, patterns, transactions, validation, and diffs.
225
+
226
+ ## How It Works
227
+
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.
181
233
 
182
- ## Performance and concurrency
234
+ ## Performance and Concurrency
183
235
 
184
- Compile node patterns once and reuse them for every candidate node. Astel also
185
- keeps a bounded cache of compiler output for repeated pattern strings.
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.
186
238
 
187
- For repository-wide tools, process independent files in worker processes at
188
- the application layer. Keep each `SourceFile` and its Prism AST inside the
189
- worker that parsed it; Astel intentionally does not own a process pool or move
190
- ASTs between workers.
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.
191
243
 
192
- Benchmarks for dispatching, node patterns, and rewriting are available under
193
- `benchmark/`:
244
+ Benchmarks are available under `benchmark/`:
194
245
 
195
246
  ```sh
196
247
  bundle exec ruby benchmark/dispatch_bench.rb
@@ -207,20 +258,20 @@ overlapping ranges raise instead of being silently reordered or discarded.
207
258
 
208
259
  ## Development
209
260
 
210
- After checking out the repository, install dependencies and run the test suite:
261
+ Install dependencies and run the test suite:
211
262
 
212
263
  ```sh
213
264
  bundle install
214
265
  bundle exec rake
215
266
  ```
216
267
 
217
- Verify the packaged gem with:
268
+ Verify the packaged gem:
218
269
 
219
270
  ```sh
220
271
  bundle exec ruby script/package_smoke.rb
221
272
  ```
222
273
 
223
- Run the deterministic source-rewrite fuzz check against Ruby source trees with:
274
+ Run the deterministic source-rewrite fuzz check against Ruby source trees:
224
275
 
225
276
  ```sh
226
277
  SEED=123 bundle exec ruby script/fuzz.rb path/to/gem/sources
@@ -239,5 +290,4 @@ See [CHANGELOG.md](CHANGELOG.md) for notable changes.
239
290
 
240
291
  ## License
241
292
 
242
- The gem is available as open source under the terms of the
243
- [MIT License](LICENSE.txt).
293
+ Released under the [MIT License](LICENSE.txt).
@@ -5,8 +5,8 @@ Astel and Prism alone.
5
5
 
6
6
  ```ruby
7
7
  require "astel"
8
- require "astel/refactor"
9
- require "astel/diff"
8
+ require "astel/rewriter/structured"
9
+ require "astel/unified_diff"
10
10
 
11
11
  source = Astel::SourceFile.parse(path: "app/types/user_type.rb")
12
12
  raise "parse failed" unless source.valid?
data/docs/refactor.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Refactoring recipes
2
2
 
3
- Load the helpers explicitly. Requiring `astel/refactor` adds them to every
3
+ Load the helpers explicitly. Requiring `astel/rewriter/structured` adds them to every
4
4
  `Astel::Rewriter` instance.
5
5
 
6
6
  ```ruby
7
7
  require "astel"
8
- require "astel/refactor"
8
+ require "astel/rewriter/structured"
9
9
 
10
10
  source = Astel::SourceFile.parse(path: "example.rb")
11
11
  rewriter = Astel::Rewriter.new(source)
data/lib/astel/diff.rb CHANGED
@@ -1,135 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../astel'
3
+ require_relative 'unified_diff'
4
4
 
5
5
  module Astel
6
- module Diff
7
- module_function
8
-
9
- def unified(before, after, path:, context: 3)
10
- validate_options(path, context)
11
- return '' if before == after
12
-
13
- operations = operations(before.lines(chomp: false), after.lines(chomp: false))
14
- annotated = annotate(operations)
15
- hunks = hunk_ranges(annotated, context)
16
- output = +"--- a/#{path}\n+++ b/#{path}\n"
17
- hunks.each { |range| append_hunk(output, annotated[range]) }
18
- output
19
- end
20
-
21
- def operations(before, after)
22
- prefix = 0
23
- prefix += 1 while prefix < before.length && prefix < after.length && before[prefix] == after[prefix]
24
- suffix = 0
25
- while suffix < before.length - prefix && suffix < after.length - prefix &&
26
- before[-suffix - 1] == after[-suffix - 1]
27
- suffix += 1
28
- end
29
-
30
- old_middle = before[prefix...(before.length - suffix)]
31
- new_middle = after[prefix...(after.length - suffix)]
32
- before.first(prefix).map { |line| [:equal, line] } +
33
- lcs_operations(old_middle, new_middle) +
34
- (suffix.zero? ? [] : before.last(suffix).map { |line| [:equal, line] })
35
- end
36
- private_class_method :operations
37
-
38
- def lcs_operations(before, after)
39
- # ponytail: LCS is O(n*m) on the changed middle; switch to Myers if large rewrites become common.
40
- lengths = Array.new(before.length + 1) { Array.new(after.length + 1, 0) }
41
- (before.length - 1).downto(0) do |old_index|
42
- (after.length - 1).downto(0) do |new_index|
43
- lengths[old_index][new_index] = if before[old_index] == after[new_index]
44
- lengths[old_index + 1][new_index + 1] + 1
45
- else
46
- [lengths[old_index + 1][new_index],
47
- lengths[old_index][new_index + 1]].max
48
- end
49
- end
50
- end
51
-
52
- result = []
53
- old_index = 0
54
- new_index = 0
55
- while old_index < before.length || new_index < after.length
56
- if old_index < before.length && new_index < after.length && before[old_index] == after[new_index]
57
- result << [:equal, before[old_index]]
58
- old_index += 1
59
- new_index += 1
60
- elsif new_index == after.length ||
61
- (old_index < before.length && lengths[old_index + 1][new_index] >= lengths[old_index][new_index + 1])
62
- result << [:delete, before[old_index]]
63
- old_index += 1
64
- else
65
- result << [:insert, after[new_index]]
66
- new_index += 1
67
- end
68
- end
69
- result
70
- end
71
- private_class_method :lcs_operations
72
-
73
- def annotate(operations)
74
- old_line = 1
75
- new_line = 1
76
- operations.map do |type, line|
77
- entry = { type: type, line: line, old_line: old_line, new_line: new_line }
78
- old_line += 1 unless type == :insert
79
- new_line += 1 unless type == :delete
80
- entry
81
- end
82
- end
83
- private_class_method :annotate
84
-
85
- def hunk_ranges(operations, context)
86
- changed = operations.each_index.reject { |index| operations[index][:type] == :equal }
87
- changed.map { |index| [[index - context, 0].max, [index + context, operations.length - 1].min] }
88
- .each_with_object([]) do |(start_index, end_index), ranges|
89
- if ranges.last && start_index <= ranges.last.end + 1
90
- ranges[-1] = ranges.last.begin..[ranges.last.end, end_index].max
91
- else
92
- ranges << (start_index..end_index)
93
- end
94
- end
95
- end
96
- private_class_method :hunk_ranges
97
-
98
- def append_hunk(output, entries)
99
- old_count = entries.count { |entry| entry[:type] != :insert }
100
- new_count = entries.count { |entry| entry[:type] != :delete }
101
- old_start = entries.first[:old_line] - (old_count.zero? ? 1 : 0)
102
- new_start = entries.first[:new_line] - (new_count.zero? ? 1 : 0)
103
- output << "@@ -#{range(old_start, old_count)} +#{range(new_start, new_count)} @@\n"
104
- entries.each do |entry|
105
- marker = { equal: ' ', delete: '-', insert: '+' }.fetch(entry[:type])
106
- output << marker << entry[:line]
107
- next if entry[:line].end_with?("\n")
108
-
109
- output << "\n\\n"
110
- end
111
- end
112
- private_class_method :append_hunk
113
-
114
- def range(start_line, count)
115
- count == 1 ? start_line.to_s : "#{start_line},#{count}"
116
- end
117
- private_class_method :range
118
-
119
- def validate_options(path, context)
120
- if path.to_s.empty? || path.to_s.match?(/[\t\r\n]/)
121
- raise ArgumentError, 'path must not be empty or contain control separators'
122
- end
123
- raise ArgumentError, 'context must be a non-negative integer' unless context.is_a?(Integer) && context >= 0
124
- end
125
- private_class_method :validate_options
126
- end
127
-
128
- module RewriterDiff
129
- def to_diff(context: 3, path: @source_file.path)
130
- Diff.unified(@source_file.source, rewrite, path: path, context: context)
131
- end
132
- end
6
+ Diff = UnifiedDiff
7
+ RewriterDiff = Diffable
133
8
  end
134
-
135
- Astel::Rewriter.include(Astel::RewriterDiff)
@@ -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
@@ -70,8 +70,12 @@ module Astel
70
70
  adjusted_end = end_offset + ending
71
71
  adjusted_start_column = start_column + start
72
72
  adjusted_end_column = end_column + ending
73
- if adjusted_start.negative? || adjusted_start > adjusted_end ||
74
- adjusted_start_column.negative? || adjusted_end_column.negative?
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
+ )
75
79
  raise RangeError, 'adjusted location is invalid'
76
80
  end
77
81
 
@@ -92,5 +96,11 @@ module Astel
92
96
  def end_point
93
97
  self.class.point(offset: end_offset, line: end_line, column: end_column)
94
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
95
105
  end
96
106
  end
@@ -1,13 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'prism'
3
+ require_relative 'node_source_text'
4
4
 
5
5
  module Astel
6
- module NodeExt
7
- refine Prism::Node do
8
- def source_text
9
- location.slice
10
- end
11
- end
12
- end
6
+ NodeExt = NodeSourceText
13
7
  end