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 +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +166 -41
- data/docs/codemod-guide.md +51 -0
- data/docs/refactor.md +87 -0
- data/lib/astel/diff.rb +8 -0
- data/lib/astel/dispatcher.rb +47 -31
- data/lib/astel/location.rb +69 -0
- data/lib/astel/node_ext.rb +2 -8
- data/lib/astel/node_pattern/lexer.rb +33 -12
- data/lib/astel/node_pattern/matcher_compiler.rb +8 -3
- data/lib/astel/node_pattern/parser.rb +16 -8
- data/lib/astel/node_pattern/predicate_compiler.rb +24 -10
- data/lib/astel/node_pattern.rb +18 -10
- data/lib/astel/node_source_text.rb +13 -0
- data/lib/astel/refactor.rb +7 -0
- data/lib/astel/rewriter/edit_index.rb +84 -0
- data/lib/astel/rewriter/structured/collections.rb +168 -0
- data/lib/astel/rewriter/structured/comments.rb +31 -0
- data/lib/astel/rewriter/structured/formatting.rb +200 -0
- data/lib/astel/rewriter/structured/source_indenter.rb +88 -0
- data/lib/astel/rewriter/structured.rb +163 -0
- data/lib/astel/rewriter.rb +141 -70
- data/lib/astel/source_file.rb +111 -10
- data/lib/astel/unified_diff.rb +205 -0
- data/lib/astel/version.rb +1 -1
- data/lib/astel.rb +1 -0
- metadata +14 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85e86fbd64b1c816e86880477cc01cf530f8ce5aa83b712324e789bb831b81a4
|
|
4
|
+
data.tar.gz: 7d38a73bdb0382f836c8c145fc9bb998555cf808507a27579fc3ec26fc0674b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
49
|
+
Or install it directly:
|
|
20
50
|
|
|
21
51
|
```sh
|
|
22
52
|
gem install astel
|
|
23
53
|
```
|
|
24
54
|
|
|
25
|
-
|
|
55
|
+
### Requirements
|
|
26
56
|
|
|
27
|
-
|
|
57
|
+
- Ruby 3.3+
|
|
58
|
+
- Prism 0.30 or newer, but earlier than 2.0
|
|
28
59
|
|
|
29
|
-
|
|
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 `
|
|
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`
|
|
57
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
##
|
|
226
|
+
## How It Works
|
|
124
227
|
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
data/lib/astel/dispatcher.rb
CHANGED
|
@@ -32,7 +32,18 @@ module Astel
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def self.install_traversal_method(type, node_class)
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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 :
|
|
55
|
-
|
|
56
|
-
def self.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
data/lib/astel/location.rb
CHANGED
|
@@ -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
|