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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19a38e5beaa849b77b5d60ea2d46b1913e589a1892dd36fc6797e34e5dbd2738
4
- data.tar.gz: 7de828db0d6c9e6ea66179f2f931308b9609ccade0047dc99e0b5f77e61ead5f
3
+ metadata.gz: f3aa35253044b63edd2bcce05297d78c15740ac93627b8d32ec909eba8dd2ce1
4
+ data.tar.gz: 495161fcfbd3b410f7e220002f177afa8a5c9892f97ab6a3a7360b8005e414c6
5
5
  SHA512:
6
- metadata.gz: 8364cddfb8fe41deb8770e0f3ca105467820030d77c3563184aa9d087cbd8de2649d05db67058b18f04476ef410f945ef948dc12f2226bd55d607fa4ba683dfb
7
- data.tar.gz: 4feb4b4cd00035245cf4b992612f0d51c67e2d7fa74decaea77b59912a481e9cc6129938ded3882b3aecd33a186535783ecf00f755e7347b08ca820d14bbd550
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
+ [![Gem Version](https://badge.fury.io/rb/astel.svg)](https://badge.fury.io/rb/astel)
3
4
  [![Ruby](https://github.com/ydah/astel/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/astel/actions/workflows/main.yml)
4
5
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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`, and `insert_after`.
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)
@@ -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)
@@ -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 = block_index_for(edit)
38
- block = @blocks.fetch(block_index)
39
- edit_index = block.bsearch_index { |existing| ordered_after?(existing, edit) } || block.length
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
- def initialize(source_file)
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 rewrite
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.to_s.dup.freeze,
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
- return left.start_offset == right.start_offset if left.insertion? && right.insertion?
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
@@ -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] = normalize_version(version) if 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").length
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Astel
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
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.1.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: 4.0.6
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: []