astel 0.1.0 → 0.2.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 +11 -0
- data/README.md +77 -2
- data/docs/codemod-guide.md +51 -0
- data/docs/refactor.md +87 -0
- data/lib/astel/diff.rb +135 -0
- data/lib/astel/location.rb +59 -0
- data/lib/astel/refactor/comments.rb +36 -0
- data/lib/astel/refactor/formatting.rb +138 -0
- data/lib/astel/refactor.rb +192 -0
- data/lib/astel/rewriter.rb +115 -16
- data/lib/astel/source_file.rb +91 -3
- data/lib/astel/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3aa35253044b63edd2bcce05297d78c15740ac93627b8d32ec909eba8dd2ce1
|
|
4
|
+
data.tar.gz: 495161fcfbd3b410f7e220002f177afa8a5c9892f97ab6a3a7360b8005e414c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aee6be8604da7c4b6bfa7ff2d4c010ba987501d4c1238852095201144a1f8068807eeeaf52b59b32d6b3e2adcbe4a52703f97c2b09f082bab76ce979986c11df
|
|
7
|
+
data.tar.gz: 9365ceb989c157954fbb88fd473c6c73a6c8e8548ddd0642da65ae890a5af51ae9dcba9e8512433cb3dcac08ce19672325be307b4df319ff83de4dc44c7b8ce6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ project follows Semantic Versioning.
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-08-30
|
|
9
|
+
|
|
10
|
+
- Add atomic `Rewriter#transaction` and `#merge!` operations
|
|
11
|
+
- Accept same-offset insertions by default, with an opt-in strict policy
|
|
12
|
+
- Add rewrite syntax validation, wrapping, and encoding compatibility checks
|
|
13
|
+
- Add source formatting and location APIs
|
|
14
|
+
- Add opt-in refactoring helpers with `require "astel/refactor"`
|
|
15
|
+
- Add dependency-free unified diffs with `require "astel/diff"`
|
|
16
|
+
- Add weekly rewrite-and-reparse fuzzing against real gem sources
|
|
17
|
+
- Keep wrapping and comment attachment atomic and idempotent across edge cases
|
|
18
|
+
|
|
8
19
|
## [0.1.0] - 2026-07-15
|
|
9
20
|
|
|
10
21
|
- Initial release
|
data/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Astel
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/rb/astel)
|
|
3
4
|
[](https://github.com/ydah/astel/actions/workflows/main.yml)
|
|
4
5
|
[](LICENSE.txt)
|
|
5
6
|
|
|
@@ -116,9 +117,67 @@ rewriter.rewrite # => "new_name\n"
|
|
|
116
117
|
source.source # => "old_name\n"
|
|
117
118
|
```
|
|
118
119
|
|
|
119
|
-
The rewriter supports `replace`, `remove`, `insert_before`,
|
|
120
|
-
Overlapping edits raise `Astel::Rewriter::ConflictError`, and
|
|
120
|
+
The rewriter supports `replace`, `remove`, `insert_before`, `insert_after`, and
|
|
121
|
+
`wrap`. Overlapping edits raise `Astel::Rewriter::ConflictError`, and
|
|
121
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.
|
|
125
|
+
|
|
126
|
+
Group related edits atomically with a transaction:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
rewriter.transaction do |rw|
|
|
130
|
+
rw.replace(first.location, "first")
|
|
131
|
+
rw.replace(second.location, "second")
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
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.
|
|
138
|
+
|
|
139
|
+
### Source formatting
|
|
140
|
+
|
|
141
|
+
`SourceFile` exposes byte-based line and column lookup, line locations, source
|
|
142
|
+
slicing, dominant newline detection, and indentation detection:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
source.line_at(node.location.start_offset)
|
|
146
|
+
source.column_at(node.location.start_offset)
|
|
147
|
+
source.indentation_at(node.location.start_offset)
|
|
148
|
+
source.newline
|
|
149
|
+
source.indent_unit
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Refactoring helpers
|
|
153
|
+
|
|
154
|
+
Load semantic source-editing helpers explicitly:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
require "astel/refactor"
|
|
158
|
+
|
|
159
|
+
rewriter = Astel::Rewriter.new(source)
|
|
160
|
+
rewriter.remove_keyword_argument(call_node, :required)
|
|
161
|
+
rewriter.insert_into_body(class_node, "def added\nend", position: :before_private)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
See [Refactoring recipes](docs/refactor.md) for the complete API and examples.
|
|
165
|
+
|
|
166
|
+
### Unified diffs
|
|
167
|
+
|
|
168
|
+
Diff support is also opt-in and has no additional runtime dependency:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
require "astel/diff"
|
|
172
|
+
|
|
173
|
+
puts rewriter.to_diff(context: 3)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
As with Git-generated zero-context patches, `context: 0` requires
|
|
177
|
+
`git apply --unidiff-zero`.
|
|
178
|
+
|
|
179
|
+
See the [codemod guide](docs/codemod-guide.md) for an end-to-end example using
|
|
180
|
+
the dispatcher, node patterns, transactions, validation, and diffs.
|
|
122
181
|
|
|
123
182
|
## Performance and concurrency
|
|
124
183
|
|
|
@@ -139,6 +198,13 @@ bundle exec ruby benchmark/node_pattern_bench.rb
|
|
|
139
198
|
bundle exec ruby benchmark/rewriter_bench.rb
|
|
140
199
|
```
|
|
141
200
|
|
|
201
|
+
## Scope
|
|
202
|
+
|
|
203
|
+
Astel intentionally does not provide a nested action tree, file discovery,
|
|
204
|
+
parallel execution, a CLI, a rule framework, or a parser compatibility layer.
|
|
205
|
+
Those concerns stay in applications built on Astel. Source edits remain flat;
|
|
206
|
+
overlapping ranges raise instead of being silently reordered or discarded.
|
|
207
|
+
|
|
142
208
|
## Development
|
|
143
209
|
|
|
144
210
|
After checking out the repository, install dependencies and run the test suite:
|
|
@@ -154,6 +220,15 @@ Verify the packaged gem with:
|
|
|
154
220
|
bundle exec ruby script/package_smoke.rb
|
|
155
221
|
```
|
|
156
222
|
|
|
223
|
+
Run the deterministic source-rewrite fuzz check against Ruby source trees with:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
SEED=123 bundle exec ruby script/fuzz.rb path/to/gem/sources
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
CI runs the same check weekly against Rails, RuboCop, Sidekiq, Faraday, and
|
|
230
|
+
Prism source releases.
|
|
231
|
+
|
|
157
232
|
## Contributing
|
|
158
233
|
|
|
159
234
|
Bug reports and pull requests are welcome on
|
|
@@ -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/refactor"
|
|
9
|
+
require "astel/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/refactor` adds them to every
|
|
4
|
+
`Astel::Rewriter` instance.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require "astel"
|
|
8
|
+
require "astel/refactor"
|
|
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,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../astel'
|
|
4
|
+
|
|
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
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Astel::Rewriter.include(Astel::RewriterDiff)
|
data/lib/astel/location.rb
CHANGED
|
@@ -33,5 +33,64 @@ 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
|
+
if adjusted_start.negative? || adjusted_start > adjusted_end ||
|
|
74
|
+
adjusted_start_column.negative? || adjusted_end_column.negative?
|
|
75
|
+
raise RangeError, 'adjusted location is invalid'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
self.class.new(
|
|
79
|
+
start_offset: adjusted_start,
|
|
80
|
+
end_offset: adjusted_end,
|
|
81
|
+
start_line: start_line,
|
|
82
|
+
start_column: adjusted_start_column,
|
|
83
|
+
end_line: end_line,
|
|
84
|
+
end_column: adjusted_end_column
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def begin_point
|
|
89
|
+
self.class.point(offset: start_offset, line: start_line, column: start_column)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def end_point
|
|
93
|
+
self.class.point(offset: end_offset, line: end_line, column: end_column)
|
|
94
|
+
end
|
|
36
95
|
end
|
|
37
96
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
module Refactor
|
|
5
|
+
def leading_comments(node)
|
|
6
|
+
attach_comments
|
|
7
|
+
node.location.leading_comments
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def trailing_comment(node)
|
|
11
|
+
attach_comments
|
|
12
|
+
node.location.trailing_comments.first
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def remove_with_comments(node)
|
|
16
|
+
comments = leading_comments(node)
|
|
17
|
+
trailing = trailing_comment(node)
|
|
18
|
+
start_offset = comments.empty? ? node.location.start_offset : comments.first.location.start_offset
|
|
19
|
+
end_offset = trailing ? trailing.location.end_offset : node.location.end_offset
|
|
20
|
+
first = @source_file.line_location(start_offset)
|
|
21
|
+
last = @source_file.line_location(end_offset - 1)
|
|
22
|
+
before = @source_file.source.byteslice(first.start_offset, start_offset - first.start_offset)
|
|
23
|
+
after = @source_file.source.byteslice(end_offset, last.end_offset - end_offset)
|
|
24
|
+
return remove(start_offset...end_offset) unless before.strip.empty? && after.strip.empty?
|
|
25
|
+
|
|
26
|
+
ending = @source_file.line_location_with_newline(end_offset - 1).end_offset
|
|
27
|
+
remove(first.start_offset...ending)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def attach_comments
|
|
33
|
+
@source_file.attach_comments!
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Astel
|
|
4
|
+
module Refactor
|
|
5
|
+
def indentation_of(node)
|
|
6
|
+
@source_file.indentation_at(node.location.start_offset)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def indent(text, level: 1)
|
|
10
|
+
prefix = @source_file.indent_unit * level
|
|
11
|
+
text.lines(chomp: false).map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def dedent(text)
|
|
15
|
+
widths = text.lines.filter_map do |line|
|
|
16
|
+
next if line.strip.empty?
|
|
17
|
+
|
|
18
|
+
line[/\A[ \t]*/].bytesize
|
|
19
|
+
end
|
|
20
|
+
width = widths.min || 0
|
|
21
|
+
text.lines(chomp: false).map { |line| line.strip.empty? ? line : line.byteslice(width..) }.join
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reindent(text, to:)
|
|
25
|
+
prefix = indentation_of(to)
|
|
26
|
+
dedent(text).lines(chomp: false).map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }.join
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def insert_into_body(node, source_text, position: :end)
|
|
30
|
+
statements = body_statements(node)
|
|
31
|
+
target, after, section_break = body_insertion_target(node, statements, position)
|
|
32
|
+
first_statement = statements&.body&.first
|
|
33
|
+
child_indent = if first_statement &&
|
|
34
|
+
@source_file.line_at(first_statement.location.start_offset) !=
|
|
35
|
+
@source_file.line_at(node.location.start_offset)
|
|
36
|
+
indentation_of(first_statement)
|
|
37
|
+
else
|
|
38
|
+
"#{indentation_of(node)}#{@source_file.indent_unit}"
|
|
39
|
+
end
|
|
40
|
+
content = format_body_source(source_text, child_indent)
|
|
41
|
+
|
|
42
|
+
if after
|
|
43
|
+
line = @source_file.line_location_with_newline(target.location.end_offset - 1)
|
|
44
|
+
suffix = line_terminated?(line) ? '' : @source_file.newline
|
|
45
|
+
insert_after(line, "#{suffix}#{content}#{@source_file.newline}")
|
|
46
|
+
else
|
|
47
|
+
offset = target ? @source_file.line_start(target.location.start_offset) : body_closing_location(node).start_offset
|
|
48
|
+
prefix = needs_leading_newline?(offset) ? @source_file.newline : ''
|
|
49
|
+
suffix = section_break ? @source_file.newline * 2 : @source_file.newline
|
|
50
|
+
insert_before(offset...offset, "#{prefix}#{content}#{suffix}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def remove_line(node)
|
|
55
|
+
location = node.location
|
|
56
|
+
first = @source_file.line_location(location.start_offset)
|
|
57
|
+
last_offset = [location.end_offset - 1, location.start_offset].max
|
|
58
|
+
last = @source_file.line_location(last_offset)
|
|
59
|
+
before = @source_file.source.byteslice(first.start_offset, location.start_offset - first.start_offset)
|
|
60
|
+
after = @source_file.source.byteslice(location.end_offset, last.end_offset - location.end_offset)
|
|
61
|
+
return remove(location) unless before.strip.empty? && after.strip.empty?
|
|
62
|
+
|
|
63
|
+
ending = @source_file.line_location_with_newline(last_offset).end_offset
|
|
64
|
+
remove(first.start_offset...ending)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def insert_line_before(node, text)
|
|
68
|
+
line_start = @source_file.line_start(node.location.start_offset)
|
|
69
|
+
content = format_body_source(text, indentation_of(node))
|
|
70
|
+
insert_before(line_start...line_start, "#{content}#{@source_file.newline}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def insert_line_after(node, text)
|
|
74
|
+
line = @source_file.line_location_with_newline(node.location.end_offset - 1)
|
|
75
|
+
content = format_body_source(text, indentation_of(node))
|
|
76
|
+
prefix = line_terminated?(line) ? '' : @source_file.newline
|
|
77
|
+
insert_after(line, "#{prefix}#{content}#{@source_file.newline}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def comment_out(node)
|
|
81
|
+
first = @source_file.line_start(node.location.start_offset)
|
|
82
|
+
last = @source_file.line_end([node.location.end_offset - 1, node.location.start_offset].max)
|
|
83
|
+
text = @source_file.source.byteslice(first, last - first)
|
|
84
|
+
commented = text.lines(chomp: false).map do |line|
|
|
85
|
+
indentation = line[/\A[ \t]*/]
|
|
86
|
+
"#{indentation}# #{line.delete_prefix(indentation)}"
|
|
87
|
+
end.join
|
|
88
|
+
replace(first...last, commented)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def body_statements(node)
|
|
94
|
+
body = node.respond_to?(:body) ? node.body : nil
|
|
95
|
+
body if body.is_a?(Prism::StatementsNode)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def body_closing_location(node)
|
|
99
|
+
return node.end_keyword_loc if node.respond_to?(:end_keyword_loc) && node.end_keyword_loc
|
|
100
|
+
return node.closing_loc if node.respond_to?(:closing_loc) && node.closing_loc
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, 'node does not have a delimited body'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def body_insertion_target(node, statements, position)
|
|
106
|
+
body = statements&.body || []
|
|
107
|
+
case position
|
|
108
|
+
when :beginning then return [body.first, false, false]
|
|
109
|
+
when :end then return [nil, false, false]
|
|
110
|
+
when :before_private
|
|
111
|
+
marker = body.find { |child| visibility_marker?(child) }
|
|
112
|
+
return [marker, false, !marker.nil?]
|
|
113
|
+
when Hash
|
|
114
|
+
return [position.fetch(:after), true, false] if position.key?(:after)
|
|
115
|
+
return [position.fetch(:before), false, false] if position.key?(:before)
|
|
116
|
+
end
|
|
117
|
+
raise ArgumentError, 'unsupported body insertion position'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def visibility_marker?(node)
|
|
121
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
|
|
122
|
+
%i[private protected].include?(node.name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_body_source(text, indentation)
|
|
126
|
+
normalized = dedent(text.to_s).gsub(/\r?\n/, @source_file.newline).delete_suffix(@source_file.newline)
|
|
127
|
+
normalized.lines(chomp: false).map { |line| line.strip.empty? ? line : "#{indentation}#{line}" }.join
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def needs_leading_newline?(offset)
|
|
131
|
+
offset.positive? && ![10, 13].include?(@source_file.source.getbyte(offset - 1))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def line_terminated?(location)
|
|
135
|
+
location.end_offset.positive? && @source_file.source.getbyte(location.end_offset - 1) == 10
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../astel'
|
|
4
|
+
|
|
5
|
+
module Astel
|
|
6
|
+
module Refactor
|
|
7
|
+
def find_keyword_argument(call_node, name)
|
|
8
|
+
keyword_hashes(call_node).each do |keyword_hash|
|
|
9
|
+
match = keyword_hash.elements.find { |element| keyword_name(element) == name.to_sym }
|
|
10
|
+
return match if match
|
|
11
|
+
end
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def remove_keyword_argument(call_node, name)
|
|
16
|
+
argument = find_keyword_argument(call_node, name)
|
|
17
|
+
return self unless argument
|
|
18
|
+
|
|
19
|
+
keyword_hash = keyword_hashes(call_node).find { |hash| hash.elements.include?(argument) }
|
|
20
|
+
if keyword_hash.elements.one?
|
|
21
|
+
if call_node.arguments.arguments.one? && !call_node.opening_loc
|
|
22
|
+
return remove(call_node.message_loc.end_offset...keyword_hash.location.end_offset)
|
|
23
|
+
end
|
|
24
|
+
remove_list_element(call_node.arguments, keyword_hash)
|
|
25
|
+
else
|
|
26
|
+
remove_list_element(keyword_hash, argument)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def replace_keyword_key(assoc_node, name)
|
|
31
|
+
replacement = assoc_node.operator_loc ? ":#{name}" : "#{name}:"
|
|
32
|
+
replace(assoc_node.key.location, replacement)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def replace_keyword_value(assoc_node, replacement)
|
|
36
|
+
replace(assoc_node.value.location, replacement)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def insert_keyword_argument(call_node, text, position: :last)
|
|
40
|
+
raise ArgumentError, 'position must be :first or :last' unless %i[first last].include?(position)
|
|
41
|
+
|
|
42
|
+
arguments = call_node.arguments
|
|
43
|
+
unless arguments
|
|
44
|
+
location = call_node.opening_loc || call_node.message_loc
|
|
45
|
+
if call_node.opening_loc && call_node.closing_loc
|
|
46
|
+
return insert_into_empty_delimited(call_node, text)
|
|
47
|
+
end
|
|
48
|
+
return insert_after(location, call_node.opening_loc ? text : " #{text}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
keyword_hash = keyword_hashes(call_node).first
|
|
52
|
+
if keyword_hash
|
|
53
|
+
index = position == :first ? 0 : keyword_hash.elements.length
|
|
54
|
+
return insert_list_element(keyword_hash, index, text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
index = arguments.arguments.length
|
|
58
|
+
insert_list_element(arguments, index, text)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remove_list_element(collection_node, element_node)
|
|
62
|
+
remove_list_elements(collection_node, [element_node])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def remove_list_elements(collection_node, element_nodes)
|
|
66
|
+
elements = collection_elements(collection_node)
|
|
67
|
+
indexes = element_nodes.map do |element|
|
|
68
|
+
elements.index { |candidate| candidate.equal?(element) } ||
|
|
69
|
+
raise(ArgumentError, 'element does not belong to collection')
|
|
70
|
+
end.uniq.sort
|
|
71
|
+
return self if indexes.empty?
|
|
72
|
+
|
|
73
|
+
trailing_end = trailing_comma_end(elements.last)
|
|
74
|
+
replacements = deletion_ranges(elements, indexes, trailing_end)
|
|
75
|
+
start_offset = elements.first.location.start_offset
|
|
76
|
+
end_offset = trailing_end
|
|
77
|
+
content = @source_file.source.byteslice(start_offset, end_offset - start_offset).dup
|
|
78
|
+
replacements.reverse_each do |from, to, replacement|
|
|
79
|
+
content[(from - start_offset)...(to - start_offset)] = replacement
|
|
80
|
+
end
|
|
81
|
+
replace(start_offset...end_offset, content)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def insert_list_element(collection_node, index, text)
|
|
85
|
+
elements = collection_elements(collection_node)
|
|
86
|
+
raise IndexError, 'list index is outside the collection' unless index.between?(0, elements.length)
|
|
87
|
+
|
|
88
|
+
if elements.empty?
|
|
89
|
+
opening = collection_node.respond_to?(:opening_loc) && collection_node.opening_loc
|
|
90
|
+
raise ArgumentError, 'cannot locate an empty collection body' unless opening
|
|
91
|
+
|
|
92
|
+
return insert_into_empty_delimited(collection_node, text)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if index < elements.length
|
|
96
|
+
target = elements[index]
|
|
97
|
+
separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(target)}" : ', '
|
|
98
|
+
return insert_before(target.location, "#{text}#{separator}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
last = elements.last
|
|
102
|
+
separator = multiline_collection?(collection_node) ? ",#{@source_file.newline}#{indentation_of(last)}" : ', '
|
|
103
|
+
insert_after(last.location, "#{separator}#{text}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def keyword_hashes(call_node)
|
|
109
|
+
call_node.arguments&.arguments&.grep(Prism::KeywordHashNode) || []
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def keyword_name(assoc_node)
|
|
113
|
+
key = assoc_node.key
|
|
114
|
+
key.unescaped.to_sym if key.is_a?(Prism::SymbolNode)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def collection_elements(collection_node)
|
|
118
|
+
case collection_node
|
|
119
|
+
when Prism::ArgumentsNode then collection_node.arguments
|
|
120
|
+
when Prism::ArrayNode, Prism::KeywordHashNode, Prism::HashNode then collection_node.elements
|
|
121
|
+
else
|
|
122
|
+
raise ArgumentError, 'collection must be an arguments, array, or hash node'
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def deletion_ranges(elements, indexes, trailing_end)
|
|
127
|
+
removed = indexes.to_h { |index| [index, true] }
|
|
128
|
+
groups = indexes.slice_when { |left, right| right != left + 1 }.to_a
|
|
129
|
+
groups.map do |group|
|
|
130
|
+
first = group.first
|
|
131
|
+
last = group.last
|
|
132
|
+
following = ((last + 1)...elements.length).find { |index| !removed[index] }
|
|
133
|
+
preceding = (first - 1).downto(0).find { |index| !removed[index] }
|
|
134
|
+
|
|
135
|
+
if following
|
|
136
|
+
[elements[first].location.start_offset, elements[following].location.start_offset, '']
|
|
137
|
+
elsif preceding
|
|
138
|
+
from = elements[preceding].location.end_offset
|
|
139
|
+
has_trailing_comma = last == elements.length - 1 && trailing_end > elements.last.location.end_offset
|
|
140
|
+
to = has_trailing_comma ? trailing_end : elements[last].location.end_offset
|
|
141
|
+
between = @source_file.source.byteslice(from, elements[first].location.start_offset - from)
|
|
142
|
+
replacement = if has_trailing_comma
|
|
143
|
+
between.include?('#') ? between.rstrip : ','
|
|
144
|
+
elsif between.include?('#')
|
|
145
|
+
between.sub(',', '').rstrip
|
|
146
|
+
else
|
|
147
|
+
''
|
|
148
|
+
end
|
|
149
|
+
[from, to, replacement]
|
|
150
|
+
else
|
|
151
|
+
[elements.first.location.start_offset, trailing_end, '']
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def trailing_comma_end(element)
|
|
157
|
+
offset = element.location.end_offset
|
|
158
|
+
source = @source_file.source
|
|
159
|
+
offset += 1 while [32, 9].include?(source.getbyte(offset))
|
|
160
|
+
source.getbyte(offset) == 44 ? offset + 1 : element.location.end_offset
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def multiline_collection?(collection_node)
|
|
164
|
+
return true if @source_file.slice(collection_node.location).include?("\n")
|
|
165
|
+
|
|
166
|
+
start_offset = collection_node.location.start_offset
|
|
167
|
+
prefix = @source_file.source.byteslice(@source_file.line_start(start_offset),
|
|
168
|
+
start_offset - @source_file.line_start(start_offset))
|
|
169
|
+
collection_node.is_a?(Prism::KeywordHashNode) && !prefix.empty? && prefix.strip.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def insert_into_empty_delimited(node, text)
|
|
173
|
+
opening = node.opening_loc
|
|
174
|
+
closing = node.closing_loc
|
|
175
|
+
return insert_after(opening, text) unless closing
|
|
176
|
+
|
|
177
|
+
source = @source_file.source
|
|
178
|
+
interior = source.byteslice(opening.end_offset, closing.start_offset - opening.end_offset)
|
|
179
|
+
return insert_after(opening, text) unless interior.include?("\n")
|
|
180
|
+
|
|
181
|
+
indentation = @source_file.indentation_at(closing.start_offset)
|
|
182
|
+
replacement = "#{@source_file.newline}#{indentation}#{@source_file.indent_unit}#{text}" \
|
|
183
|
+
"#{@source_file.newline}#{indentation}"
|
|
184
|
+
replace(opening.end_offset...closing.start_offset, replacement)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
require_relative 'refactor/formatting'
|
|
190
|
+
require_relative 'refactor/comments'
|
|
191
|
+
|
|
192
|
+
Astel::Rewriter.include(Astel::Refactor)
|
data/lib/astel/rewriter.rb
CHANGED
|
@@ -13,6 +13,16 @@ module Astel
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
class ValidationError < Error
|
|
17
|
+
attr_reader :result, :parse_errors
|
|
18
|
+
|
|
19
|
+
def initialize(result, parse_errors)
|
|
20
|
+
@result = result
|
|
21
|
+
@parse_errors = parse_errors.freeze
|
|
22
|
+
super("rewritten source contains #{parse_errors.length} syntax error(s)")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
Edit = Data.define(:start_offset, :end_offset, :replacement, :sequence) do
|
|
17
27
|
def insertion?
|
|
18
28
|
start_offset == end_offset
|
|
@@ -34,24 +44,22 @@ module Astel
|
|
|
34
44
|
return
|
|
35
45
|
end
|
|
36
46
|
|
|
37
|
-
block_index =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
41
|
-
return following if following && yield(following)
|
|
42
|
-
|
|
43
|
-
preceding = if edit_index.positive?
|
|
44
|
-
block[edit_index - 1]
|
|
45
|
-
elsif block_index.positive?
|
|
46
|
-
@blocks[block_index - 1].last
|
|
47
|
-
end
|
|
48
|
-
return preceding if preceding && yield(preceding)
|
|
47
|
+
block_index, block, edit_index = insertion_position(edit)
|
|
48
|
+
conflict = conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
49
|
+
return conflict if conflict
|
|
49
50
|
|
|
50
51
|
block.insert(edit_index, edit)
|
|
51
52
|
split(block_index, block) if block.length > SPLIT_THRESHOLD
|
|
52
53
|
nil
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
def find_conflict(edit)
|
|
57
|
+
return if @blocks.empty?
|
|
58
|
+
|
|
59
|
+
block_index, block, edit_index = insertion_position(edit)
|
|
60
|
+
conflict_at(block_index, block, edit_index) { |existing| yield existing }
|
|
61
|
+
end
|
|
62
|
+
|
|
55
63
|
def each
|
|
56
64
|
return enum_for(__method__) unless block_given?
|
|
57
65
|
|
|
@@ -62,6 +70,27 @@ module Astel
|
|
|
62
70
|
|
|
63
71
|
private
|
|
64
72
|
|
|
73
|
+
def insertion_position(edit)
|
|
74
|
+
block_index = block_index_for(edit)
|
|
75
|
+
block = @blocks.fetch(block_index)
|
|
76
|
+
edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
|
|
77
|
+
[block_index, block, edit_index]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def conflict_at(block_index, block, edit_index)
|
|
81
|
+
following = block[edit_index] || @blocks[block_index + 1]&.first
|
|
82
|
+
return following if following && yield(following)
|
|
83
|
+
|
|
84
|
+
preceding = if edit_index.positive?
|
|
85
|
+
block[edit_index - 1]
|
|
86
|
+
elsif block_index.positive?
|
|
87
|
+
@blocks[block_index - 1].last
|
|
88
|
+
end
|
|
89
|
+
return preceding if preceding && yield(preceding)
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
65
94
|
def block_index_for(edit)
|
|
66
95
|
@blocks.bsearch_index { |block| ordered_after?(block.last, edit) } || @blocks.length - 1
|
|
67
96
|
end
|
|
@@ -79,13 +108,22 @@ module Astel
|
|
|
79
108
|
end
|
|
80
109
|
end
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
attr_reader :source_file
|
|
112
|
+
|
|
113
|
+
def initialize(source_file, duplicate_insertions: :accept)
|
|
114
|
+
unless %i[accept raise].include?(duplicate_insertions)
|
|
115
|
+
raise ArgumentError, 'duplicate_insertions must be :accept or :raise'
|
|
116
|
+
end
|
|
117
|
+
|
|
83
118
|
@source_file = source_file
|
|
119
|
+
@duplicate_insertions = duplicate_insertions
|
|
84
120
|
@edits = []
|
|
85
121
|
@edits_snapshot = nil
|
|
86
122
|
@edit_index = EditIndex.new
|
|
87
123
|
@sequence = 0
|
|
88
124
|
@result_size_delta = 0
|
|
125
|
+
@transaction_staging = false
|
|
126
|
+
@transaction_failed = false
|
|
89
127
|
end
|
|
90
128
|
|
|
91
129
|
def edits
|
|
@@ -111,7 +149,47 @@ module Astel
|
|
|
111
149
|
replace(location, '')
|
|
112
150
|
end
|
|
113
151
|
|
|
114
|
-
def
|
|
152
|
+
def wrap(location, before, after)
|
|
153
|
+
transaction(raise_on_conflict: true) do |rewriter|
|
|
154
|
+
rewriter.insert_before(location, before)
|
|
155
|
+
rewriter.insert_after(location, after)
|
|
156
|
+
end
|
|
157
|
+
self
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def merge!(other)
|
|
161
|
+
unless other.is_a?(Rewriter) && other.source_file.equal?(@source_file)
|
|
162
|
+
raise ArgumentError, 'cannot merge edits for a different source'
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
validation_index = EditIndex.new
|
|
166
|
+
@edit_index.each { |edit| validation_index.add(edit) { false } }
|
|
167
|
+
other.edits.each do |edit|
|
|
168
|
+
conflict = validation_index.find_conflict(edit) { |existing| conflict?(edit, existing) }
|
|
169
|
+
raise ConflictError.new(edit, conflict) if conflict
|
|
170
|
+
|
|
171
|
+
validation_index.add(edit) { false }
|
|
172
|
+
end
|
|
173
|
+
other.edits.each { |edit| add_edit(edit.start_offset, edit.end_offset, edit.replacement) }
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def transaction(raise_on_conflict: false)
|
|
178
|
+
staging = Rewriter.new(@source_file, duplicate_insertions: @duplicate_insertions)
|
|
179
|
+
staging.instance_variable_set(:@transaction_staging, true)
|
|
180
|
+
yield staging
|
|
181
|
+
return transaction_failed if staging.instance_variable_get(:@transaction_failed)
|
|
182
|
+
|
|
183
|
+
merge!(staging)
|
|
184
|
+
true
|
|
185
|
+
rescue ConflictError
|
|
186
|
+
@transaction_failed = true if @transaction_staging
|
|
187
|
+
raise if raise_on_conflict
|
|
188
|
+
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def rewrite(validate: false)
|
|
115
193
|
source = @source_file.source
|
|
116
194
|
result = String.new(capacity: source.bytesize + @result_size_delta, encoding: source.encoding)
|
|
117
195
|
cursor = 0
|
|
@@ -123,6 +201,7 @@ module Astel
|
|
|
123
201
|
end
|
|
124
202
|
|
|
125
203
|
result << source.byteslice(cursor, source.bytesize - cursor)
|
|
204
|
+
validate_result(result, validate)
|
|
126
205
|
result
|
|
127
206
|
end
|
|
128
207
|
|
|
@@ -130,10 +209,15 @@ module Astel
|
|
|
130
209
|
|
|
131
210
|
def add_edit(start_offset, end_offset, replacement)
|
|
132
211
|
validate_offsets(start_offset, end_offset)
|
|
212
|
+
replacement = replacement.to_s.dup
|
|
213
|
+
unless Encoding.compatible?(@source_file.source, replacement)
|
|
214
|
+
raise Encoding::CompatibilityError,
|
|
215
|
+
"#{replacement.encoding} replacement is incompatible with #{@source_file.source.encoding} source"
|
|
216
|
+
end
|
|
133
217
|
edit = Edit.new(
|
|
134
218
|
start_offset: start_offset,
|
|
135
219
|
end_offset: end_offset,
|
|
136
|
-
replacement: replacement.
|
|
220
|
+
replacement: replacement.freeze,
|
|
137
221
|
sequence: @sequence
|
|
138
222
|
)
|
|
139
223
|
conflict = @edit_index.add(edit) { |existing| conflict?(edit, existing) }
|
|
@@ -161,11 +245,26 @@ module Astel
|
|
|
161
245
|
end
|
|
162
246
|
|
|
163
247
|
def conflict?(left, right)
|
|
164
|
-
|
|
248
|
+
if @duplicate_insertions == :raise && left.insertion? && right.insertion?
|
|
249
|
+
return left.start_offset == right.start_offset
|
|
250
|
+
end
|
|
165
251
|
return left.start_offset > right.start_offset && left.start_offset < right.end_offset if left.insertion?
|
|
166
252
|
return right.start_offset > left.start_offset && right.start_offset < left.end_offset if right.insertion?
|
|
167
253
|
|
|
168
254
|
left.start_offset < right.end_offset && right.start_offset < left.end_offset
|
|
169
255
|
end
|
|
256
|
+
|
|
257
|
+
def transaction_failed
|
|
258
|
+
@transaction_failed = true if @transaction_staging
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def validate_result(result, validate)
|
|
263
|
+
return unless validate
|
|
264
|
+
raise ArgumentError, 'validate must be false or :parse' unless validate == :parse
|
|
265
|
+
|
|
266
|
+
parsed = SourceFile.from_string(result, path: @source_file.path, version: @source_file.version)
|
|
267
|
+
raise ValidationError.new(result, parsed.errors) unless parsed.valid?
|
|
268
|
+
end
|
|
170
269
|
end
|
|
171
270
|
end
|
data/lib/astel/source_file.rb
CHANGED
|
@@ -4,7 +4,7 @@ require 'prism'
|
|
|
4
4
|
|
|
5
5
|
module Astel
|
|
6
6
|
class SourceFile
|
|
7
|
-
attr_reader :path, :source, :ast, :comments, :errors, :parse_result
|
|
7
|
+
attr_reader :path, :source, :ast, :comments, :errors, :parse_result, :version
|
|
8
8
|
|
|
9
9
|
def self.parse(path:, version: nil)
|
|
10
10
|
from_string(File.binread(path), path: path, version: version)
|
|
@@ -16,9 +16,10 @@ module Astel
|
|
|
16
16
|
|
|
17
17
|
def initialize(code, path:, version: nil)
|
|
18
18
|
@path = path.to_s.freeze
|
|
19
|
+
@version = normalize_version(version).freeze if version
|
|
19
20
|
raw_source = code.dup.freeze
|
|
20
21
|
options = { filepath: @path }
|
|
21
|
-
options[:version] =
|
|
22
|
+
options[:version] = @version if @version
|
|
22
23
|
@parse_result = Prism.parse(raw_source, **options)
|
|
23
24
|
@source = @parse_result.source.source.freeze
|
|
24
25
|
@ast = @parse_result.value
|
|
@@ -47,7 +48,7 @@ module Astel
|
|
|
47
48
|
start_line: 1,
|
|
48
49
|
start_column: 0,
|
|
49
50
|
end_line: 1,
|
|
50
|
-
end_column: line.delete_suffix("\n").delete_suffix("\r").
|
|
51
|
+
end_column: line.delete_suffix("\n").delete_suffix("\r").bytesize
|
|
51
52
|
)
|
|
52
53
|
end
|
|
53
54
|
|
|
@@ -59,8 +60,95 @@ module Astel
|
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
def attach_comments!
|
|
64
|
+
return self if @comments_attached
|
|
65
|
+
|
|
66
|
+
@parse_result.attach_comments!
|
|
67
|
+
@comments_attached = true
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def line_at(byte_offset)
|
|
72
|
+
validate_offset!(byte_offset)
|
|
73
|
+
@parse_result.source.line(byte_offset)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def column_at(byte_offset)
|
|
77
|
+
validate_offset!(byte_offset)
|
|
78
|
+
@parse_result.source.column(byte_offset)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def line_start(byte_offset)
|
|
82
|
+
validate_offset!(byte_offset)
|
|
83
|
+
@parse_result.source.line_start(byte_offset)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def line_end(byte_offset)
|
|
87
|
+
validate_offset!(byte_offset)
|
|
88
|
+
ending = @parse_result.source.line_end(byte_offset)
|
|
89
|
+
ending -= 1 if ending.positive? && source.getbyte(ending - 1) == 10
|
|
90
|
+
ending -= 1 if ending.positive? && source.getbyte(ending - 1) == 13
|
|
91
|
+
ending
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def line_location(byte_offset)
|
|
95
|
+
location(line_start(byte_offset), line_end(byte_offset))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def line_location_with_newline(byte_offset)
|
|
99
|
+
validate_offset!(byte_offset)
|
|
100
|
+
location(line_start(byte_offset), @parse_result.source.line_end(byte_offset))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def slice(location)
|
|
104
|
+
source.byteslice(location.start_offset, location.end_offset - location.start_offset)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def newline
|
|
108
|
+
@newline ||= begin
|
|
109
|
+
crlf = source.b.scan(/\r\n/).length
|
|
110
|
+
crlf > source.count("\n") - crlf ? "\r\n" : "\n"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def indentation_at(byte_offset)
|
|
115
|
+
start_offset = line_start(byte_offset)
|
|
116
|
+
source.byteslice(start_offset, line_end(byte_offset) - start_offset)[/\A[ \t]*/]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def indent_unit
|
|
120
|
+
@indent_unit ||= begin
|
|
121
|
+
indentations = lines.filter_map { |line| line[/\A[ \t]+/] }
|
|
122
|
+
if indentations.any? { |indentation| indentation.include?("\t") }
|
|
123
|
+
"\t"
|
|
124
|
+
else
|
|
125
|
+
widths = [0, *indentations.map(&:bytesize)].uniq.sort
|
|
126
|
+
width = widths.each_cons(2).map { |left, right| right - left }.min
|
|
127
|
+
width ? ' ' * width : ' '
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
62
132
|
private
|
|
63
133
|
|
|
134
|
+
def location(start_offset, end_offset)
|
|
135
|
+
prism_source = @parse_result.source
|
|
136
|
+
Location.new(
|
|
137
|
+
start_offset: start_offset,
|
|
138
|
+
end_offset: end_offset,
|
|
139
|
+
start_line: prism_source.line(start_offset),
|
|
140
|
+
start_column: prism_source.column(start_offset),
|
|
141
|
+
end_line: prism_source.line(end_offset),
|
|
142
|
+
end_column: prism_source.column(end_offset)
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_offset!(byte_offset)
|
|
147
|
+
unless byte_offset.is_a?(Integer) && byte_offset.between?(0, source.bytesize)
|
|
148
|
+
raise RangeError, 'offset is outside the source'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
64
152
|
def normalize_version(version)
|
|
65
153
|
version.match?(/\A\d+\.\d+\z/) ? "#{version}.0" : version
|
|
66
154
|
end
|
data/lib/astel/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: astel
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -43,7 +43,10 @@ files:
|
|
|
43
43
|
- benchmark/dispatch_bench.rb
|
|
44
44
|
- benchmark/node_pattern_bench.rb
|
|
45
45
|
- benchmark/rewriter_bench.rb
|
|
46
|
+
- docs/codemod-guide.md
|
|
47
|
+
- docs/refactor.md
|
|
46
48
|
- lib/astel.rb
|
|
49
|
+
- lib/astel/diff.rb
|
|
47
50
|
- lib/astel/dispatcher.rb
|
|
48
51
|
- lib/astel/location.rb
|
|
49
52
|
- lib/astel/node_ext.rb
|
|
@@ -53,6 +56,9 @@ files:
|
|
|
53
56
|
- lib/astel/node_pattern/parser.rb
|
|
54
57
|
- lib/astel/node_pattern/predicate_compiler.rb
|
|
55
58
|
- lib/astel/node_type.rb
|
|
59
|
+
- lib/astel/refactor.rb
|
|
60
|
+
- lib/astel/refactor/comments.rb
|
|
61
|
+
- lib/astel/refactor/formatting.rb
|
|
56
62
|
- lib/astel/rewriter.rb
|
|
57
63
|
- lib/astel/source_file.rb
|
|
58
64
|
- lib/astel/version.rb
|
|
@@ -75,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
75
81
|
- !ruby/object:Gem::Version
|
|
76
82
|
version: '0'
|
|
77
83
|
requirements: []
|
|
78
|
-
rubygems_version:
|
|
84
|
+
rubygems_version: 3.6.9
|
|
79
85
|
specification_version: 4
|
|
80
86
|
summary: Fast Prism AST traversal, matching, and source rewriting
|
|
81
87
|
test_files: []
|