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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +110 -60
- data/docs/codemod-guide.md +2 -2
- data/docs/refactor.md +2 -2
- data/lib/astel/diff.rb +3 -130
- data/lib/astel/dispatcher.rb +47 -31
- data/lib/astel/location.rb +12 -2
- 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 +2 -187
- 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 +85 -113
- data/lib/astel/source_file.rb +37 -24
- data/lib/astel/unified_diff.rb +205 -0
- data/lib/astel/version.rb +1 -1
- data/lib/astel.rb +1 -0
- metadata +9 -3
- data/lib/astel/refactor/comments.rb +0 -36
- data/lib/astel/refactor/formatting.rb +0 -138
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,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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
49
|
+
Or install it directly:
|
|
21
50
|
|
|
22
51
|
```sh
|
|
23
52
|
gem install astel
|
|
24
53
|
```
|
|
25
54
|
|
|
26
|
-
|
|
55
|
+
### Requirements
|
|
56
|
+
|
|
57
|
+
- Ruby 3.3+
|
|
58
|
+
- Prism 0.30 or newer, but earlier than 2.0
|
|
27
59
|
|
|
28
|
-
##
|
|
60
|
+
## Quick Start
|
|
29
61
|
|
|
30
|
-
|
|
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 `
|
|
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`
|
|
58
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
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
|
|
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.
|
|
137
|
-
`rewrite(validate: :parse)` to reject rewritten source containing syntax errors.
|
|
182
|
+
occurs. Pass `raise_on_conflict: true` to raise instead.
|
|
138
183
|
|
|
139
|
-
###
|
|
184
|
+
### Inspect source formatting
|
|
140
185
|
|
|
141
|
-
`SourceFile` exposes byte-based
|
|
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
|
-
|
|
196
|
+
## Extensions
|
|
197
|
+
|
|
198
|
+
Extensions are opt-in and add no dependencies beyond Astel's core requirements.
|
|
153
199
|
|
|
154
|
-
|
|
200
|
+
### Structured edits
|
|
155
201
|
|
|
156
202
|
```ruby
|
|
157
|
-
require "astel/
|
|
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/
|
|
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
|
-
|
|
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
|
|
234
|
+
## Performance and Concurrency
|
|
183
235
|
|
|
184
|
-
Compile node patterns once and reuse them for every candidate node. Astel
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
243
|
-
[MIT License](LICENSE.txt).
|
|
293
|
+
Released under the [MIT License](LICENSE.txt).
|
data/docs/codemod-guide.md
CHANGED
|
@@ -5,8 +5,8 @@ Astel and Prism alone.
|
|
|
5
5
|
|
|
6
6
|
```ruby
|
|
7
7
|
require "astel"
|
|
8
|
-
require "astel/
|
|
9
|
-
require "astel/
|
|
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/
|
|
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/
|
|
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 '
|
|
3
|
+
require_relative 'unified_diff'
|
|
4
4
|
|
|
5
5
|
module Astel
|
|
6
|
-
|
|
7
|
-
|
|
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)
|
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
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
data/lib/astel/node_ext.rb
CHANGED