kettle-dev 1.2.3 → 2.0.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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +176 -3
- data/CITATION.cff +2 -2
- data/CONTRIBUTING.md +11 -17
- data/README.md +390 -319
- data/exe/kettle-dev-setup +12 -63
- data/exe/kettle-gh-release +82 -0
- data/lib/kettle/dev/gem_spec_reader.rb +2 -2
- data/lib/kettle/dev/open_collective_config.rb +12 -0
- data/lib/kettle/dev/rakelib/yard.rake +15 -0
- data/lib/kettle/dev/tasks/ci_task.rb +4 -4
- data/lib/kettle/dev/version.rb +1 -1
- data/lib/kettle/dev.rb +4 -12
- data/sig/kettle/dev/source_merger.rbs +40 -56
- data.tar.gz.sig +0 -0
- metadata +15 -144
- metadata.gz.sig +0 -0
- data/.aiignore.example +0 -19
- data/.devcontainer/apt-install/devcontainer-feature.json +0 -9
- data/.devcontainer/apt-install/install.sh +0 -11
- data/.devcontainer/devcontainer.json +0 -28
- data/.env.local.example +0 -31
- data/.envrc +0 -47
- data/.envrc.example +0 -51
- data/.envrc.no-osc.example +0 -51
- data/.git-hooks/commit-msg +0 -54
- data/.git-hooks/commit-subjects-goalie.txt +0 -8
- data/.git-hooks/footer-template.erb.txt +0 -16
- data/.git-hooks/prepare-commit-msg +0 -8
- data/.github/.codecov.yml.example +0 -14
- data/.github/FUNDING.yml +0 -13
- data/.github/FUNDING.yml.no-osc.example +0 -13
- data/.github/dependabot.yml +0 -13
- data/.github/workflows/ancient.yml +0 -83
- data/.github/workflows/ancient.yml.example +0 -81
- data/.github/workflows/auto-assign.yml +0 -21
- data/.github/workflows/codeql-analysis.yml +0 -70
- data/.github/workflows/coverage.yml +0 -127
- data/.github/workflows/coverage.yml.example +0 -127
- data/.github/workflows/current.yml +0 -116
- data/.github/workflows/current.yml.example +0 -115
- data/.github/workflows/dep-heads.yml +0 -117
- data/.github/workflows/dependency-review.yml +0 -20
- data/.github/workflows/discord-notifier.yml.example +0 -39
- data/.github/workflows/heads.yml +0 -117
- data/.github/workflows/heads.yml.example +0 -116
- data/.github/workflows/jruby.yml +0 -82
- data/.github/workflows/jruby.yml.example +0 -72
- data/.github/workflows/legacy.yml +0 -76
- data/.github/workflows/license-eye.yml +0 -40
- data/.github/workflows/locked_deps.yml +0 -85
- data/.github/workflows/opencollective.yml +0 -40
- data/.github/workflows/style.yml +0 -67
- data/.github/workflows/supported.yml +0 -75
- data/.github/workflows/truffle.yml +0 -99
- data/.github/workflows/unlocked_deps.yml +0 -84
- data/.github/workflows/unsupported.yml +0 -76
- data/.gitignore +0 -50
- data/.gitlab-ci.yml.example +0 -134
- data/.idea/.gitignore +0 -45
- data/.junie/guidelines-rbs.md +0 -49
- data/.junie/guidelines.md +0 -141
- data/.junie/guidelines.md.example +0 -140
- data/.licenserc.yaml +0 -7
- data/.opencollective.yml +0 -3
- data/.opencollective.yml.example +0 -3
- data/.qlty/qlty.toml +0 -79
- data/.rspec +0 -9
- data/.rubocop.yml +0 -13
- data/.rubocop_rspec.yml +0 -33
- data/.simplecov +0 -16
- data/.simplecov.example +0 -11
- data/.tool-versions +0 -1
- data/.yardignore +0 -13
- data/.yardopts +0 -14
- data/Appraisal.root.gemfile +0 -10
- data/Appraisals +0 -151
- data/Appraisals.example +0 -102
- data/CHANGELOG.md.example +0 -47
- data/CONTRIBUTING.md.example +0 -227
- data/FUNDING.md.no-osc.example +0 -63
- data/Gemfile +0 -40
- data/Gemfile.example +0 -34
- data/README.md.example +0 -570
- data/README.md.no-osc.example +0 -536
- data/Rakefile.example +0 -68
- data/gemfiles/modular/coverage.gemfile +0 -6
- data/gemfiles/modular/debug.gemfile +0 -13
- data/gemfiles/modular/documentation.gemfile +0 -14
- data/gemfiles/modular/erb/r2/v3.0.gemfile +0 -1
- data/gemfiles/modular/erb/r2.3/default.gemfile +0 -6
- data/gemfiles/modular/erb/r2.6/v2.2.gemfile +0 -3
- data/gemfiles/modular/erb/r3/v5.0.gemfile +0 -1
- data/gemfiles/modular/erb/r3.1/v4.0.gemfile +0 -2
- data/gemfiles/modular/erb/vHEAD.gemfile +0 -2
- data/gemfiles/modular/injected.gemfile +0 -60
- data/gemfiles/modular/mutex_m/r2/v0.3.gemfile +0 -2
- data/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile +0 -3
- data/gemfiles/modular/mutex_m/r3/v0.3.gemfile +0 -2
- data/gemfiles/modular/mutex_m/vHEAD.gemfile +0 -2
- data/gemfiles/modular/optional.gemfile +0 -8
- data/gemfiles/modular/optional.gemfile.example +0 -5
- data/gemfiles/modular/runtime_heads.gemfile +0 -10
- data/gemfiles/modular/runtime_heads.gemfile.example +0 -8
- data/gemfiles/modular/stringio/r2/v3.0.gemfile +0 -5
- data/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile +0 -4
- data/gemfiles/modular/stringio/r3/v3.0.gemfile +0 -5
- data/gemfiles/modular/stringio/vHEAD.gemfile +0 -2
- data/gemfiles/modular/style.gemfile +0 -25
- data/gemfiles/modular/style.gemfile.example +0 -25
- data/gemfiles/modular/templating.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r2/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r2.3/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r2.4/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r2.6/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r3/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/r3.1/libs.gemfile +0 -3
- data/gemfiles/modular/x_std_libs/vHEAD.gemfile +0 -3
- data/gemfiles/modular/x_std_libs.gemfile +0 -2
- data/kettle-dev.gemspec.example +0 -154
- data/lib/kettle/dev/modular_gemfiles.rb +0 -119
- data/lib/kettle/dev/prism_appraisals.rb +0 -351
- data/lib/kettle/dev/prism_gemfile.rb +0 -177
- data/lib/kettle/dev/prism_gemspec.rb +0 -284
- data/lib/kettle/dev/prism_utils.rb +0 -201
- data/lib/kettle/dev/rakelib/install.rake +0 -10
- data/lib/kettle/dev/rakelib/template.rake +0 -10
- data/lib/kettle/dev/setup_cli.rb +0 -403
- data/lib/kettle/dev/source_merger.rb +0 -622
- data/lib/kettle/dev/tasks/install_task.rb +0 -553
- data/lib/kettle/dev/tasks/template_task.rb +0 -975
- data/lib/kettle/dev/template_helpers.rb +0 -685
|
@@ -1,622 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "set"
|
|
5
|
-
require "prism"
|
|
6
|
-
|
|
7
|
-
module Kettle
|
|
8
|
-
module Dev
|
|
9
|
-
# Prism-based AST merging for templated Ruby files.
|
|
10
|
-
# Handles universal freeze reminders, kettle-dev:freeze blocks, and
|
|
11
|
-
# strategy dispatch (skip/replace/append/merge).
|
|
12
|
-
#
|
|
13
|
-
# Uses Prism for parsing with first-class comment support, enabling
|
|
14
|
-
# preservation of inline and leading comments throughout the merge process.
|
|
15
|
-
module SourceMerger
|
|
16
|
-
FREEZE_START = /#\s*kettle-dev:freeze/i
|
|
17
|
-
FREEZE_END = /#\s*kettle-dev:unfreeze/i
|
|
18
|
-
FREEZE_BLOCK = Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
|
|
19
|
-
FREEZE_REMINDER = <<~RUBY
|
|
20
|
-
# To retain during kettle-dev templating:
|
|
21
|
-
# kettle-dev:freeze
|
|
22
|
-
# # ... your code
|
|
23
|
-
# kettle-dev:unfreeze
|
|
24
|
-
RUBY
|
|
25
|
-
BUG_URL = "https://github.com/kettle-rb/kettle-dev/issues"
|
|
26
|
-
|
|
27
|
-
module_function
|
|
28
|
-
|
|
29
|
-
# Apply a templating strategy to merge source and destination Ruby files
|
|
30
|
-
#
|
|
31
|
-
# @param strategy [Symbol] Merge strategy - :skip, :replace, :append, or :merge
|
|
32
|
-
# @param src [String] Template source content
|
|
33
|
-
# @param dest [String] Destination file content
|
|
34
|
-
# @param path [String] File path (for error messages)
|
|
35
|
-
# @return [String] Merged content with freeze blocks and comments preserved
|
|
36
|
-
# @raise [Kettle::Dev::Error] If strategy is unknown or merge fails
|
|
37
|
-
# @example
|
|
38
|
-
# SourceMerger.apply(
|
|
39
|
-
# strategy: :merge,
|
|
40
|
-
# src: 'gem "foo"',
|
|
41
|
-
# dest: 'gem "bar"',
|
|
42
|
-
# path: "Gemfile"
|
|
43
|
-
# )
|
|
44
|
-
def apply(strategy:, src:, dest:, path:)
|
|
45
|
-
strategy = normalize_strategy(strategy)
|
|
46
|
-
dest ||= ""
|
|
47
|
-
src_with_reminder = ensure_reminder(src)
|
|
48
|
-
content =
|
|
49
|
-
case strategy
|
|
50
|
-
when :skip
|
|
51
|
-
normalize_source(src_with_reminder)
|
|
52
|
-
when :replace
|
|
53
|
-
normalize_source(src_with_reminder)
|
|
54
|
-
when :append
|
|
55
|
-
apply_append(src_with_reminder, dest)
|
|
56
|
-
when :merge
|
|
57
|
-
apply_merge(src_with_reminder, dest)
|
|
58
|
-
else
|
|
59
|
-
raise Kettle::Dev::Error, "Unknown templating strategy '#{strategy}' for #{path}."
|
|
60
|
-
end
|
|
61
|
-
content = merge_freeze_blocks(content, dest)
|
|
62
|
-
content = restore_custom_leading_comments(dest, content)
|
|
63
|
-
ensure_trailing_newline(content)
|
|
64
|
-
rescue StandardError => error
|
|
65
|
-
warn_bug(path, error)
|
|
66
|
-
raise Kettle::Dev::Error, "Template merge failed for #{path}: #{error.message}"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Ensure freeze reminder comment is present at the top of content
|
|
70
|
-
#
|
|
71
|
-
# @param content [String] Ruby source content
|
|
72
|
-
# @return [String] Content with freeze reminder prepended if missing
|
|
73
|
-
# @api private
|
|
74
|
-
def ensure_reminder(content)
|
|
75
|
-
return content if reminder_present?(content)
|
|
76
|
-
insertion_index = reminder_insertion_index(content)
|
|
77
|
-
before = content[0...insertion_index]
|
|
78
|
-
after = content[insertion_index..-1]
|
|
79
|
-
snippet = FREEZE_REMINDER
|
|
80
|
-
snippet += "\n" unless snippet.end_with?("\n\n")
|
|
81
|
-
[before, snippet, after].join
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Normalize source code by parsing and rebuilding to deduplicate comments
|
|
85
|
-
#
|
|
86
|
-
# @param source [String] Ruby source code
|
|
87
|
-
# @return [String] Normalized source with trailing newline and deduplicated comments
|
|
88
|
-
# @api private
|
|
89
|
-
def normalize_source(source)
|
|
90
|
-
parse_result = PrismUtils.parse_with_comments(source)
|
|
91
|
-
return ensure_trailing_newline(source) unless parse_result.success?
|
|
92
|
-
|
|
93
|
-
# Extract and deduplicate comments
|
|
94
|
-
magic_comments = extract_magic_comments(parse_result)
|
|
95
|
-
file_leading_comments = extract_file_leading_comments(parse_result)
|
|
96
|
-
node_infos = extract_nodes_with_comments(parse_result)
|
|
97
|
-
|
|
98
|
-
# Rebuild source with deduplicated comments
|
|
99
|
-
build_source_from_nodes(node_infos, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def reminder_present?(content)
|
|
103
|
-
content.include?(FREEZE_REMINDER.lines.first.strip)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def reminder_insertion_index(content)
|
|
107
|
-
cursor = 0
|
|
108
|
-
lines = content.lines
|
|
109
|
-
lines.each do |line|
|
|
110
|
-
break unless shebang?(line) || frozen_comment?(line)
|
|
111
|
-
cursor += line.length
|
|
112
|
-
end
|
|
113
|
-
cursor
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def shebang?(line)
|
|
117
|
-
line.start_with?("#!")
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def frozen_comment?(line)
|
|
121
|
-
line.match?(/#\s*frozen_string_literal:/)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Merge kettle-dev:freeze blocks from destination into source content
|
|
125
|
-
# Preserves user customizations wrapped in freeze/unfreeze markers
|
|
126
|
-
#
|
|
127
|
-
# @param src_content [String] Template source content
|
|
128
|
-
# @param dest_content [String] Destination file content
|
|
129
|
-
# @return [String] Merged content with freeze blocks from destination
|
|
130
|
-
# @api private
|
|
131
|
-
def merge_freeze_blocks(src_content, dest_content)
|
|
132
|
-
dest_blocks = freeze_blocks(dest_content)
|
|
133
|
-
return src_content if dest_blocks.empty?
|
|
134
|
-
src_blocks = freeze_blocks(src_content)
|
|
135
|
-
updated = src_content.dup
|
|
136
|
-
# Replace matching freeze sections by textual markers rather than index ranges
|
|
137
|
-
dest_blocks.each do |dest_block|
|
|
138
|
-
marker = dest_block[:text]
|
|
139
|
-
next if updated.include?(marker)
|
|
140
|
-
# If the template had a placeholder block, replace the first occurrence of a freeze stub
|
|
141
|
-
placeholder = src_blocks.find { |blk| blk[:start_marker] == dest_block[:start_marker] }
|
|
142
|
-
if placeholder
|
|
143
|
-
updated.sub!(placeholder[:text], marker)
|
|
144
|
-
else
|
|
145
|
-
updated << "\n" unless updated.end_with?("\n")
|
|
146
|
-
updated << marker
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
updated
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def freeze_blocks(text)
|
|
153
|
-
return [] unless text&.match?(FREEZE_START)
|
|
154
|
-
blocks = []
|
|
155
|
-
text.to_enum(:scan, FREEZE_BLOCK).each do
|
|
156
|
-
match = Regexp.last_match
|
|
157
|
-
start_idx = match&.begin(0)
|
|
158
|
-
end_idx = match&.end(0)
|
|
159
|
-
next unless start_idx && end_idx
|
|
160
|
-
segment = match[0]
|
|
161
|
-
start_marker = segment.lines.first&.strip
|
|
162
|
-
blocks << {range: start_idx...end_idx, text: segment, start_marker: start_marker}
|
|
163
|
-
end
|
|
164
|
-
blocks
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def normalize_strategy(strategy)
|
|
168
|
-
return :skip if strategy.nil?
|
|
169
|
-
strategy.to_s.downcase.strip.to_sym
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def warn_bug(path, error)
|
|
173
|
-
puts "ERROR: kettle-dev templating failed for #{path}: #{error.message}"
|
|
174
|
-
puts "Please file a bug at #{BUG_URL} with the file contents so we can improve the AST merger."
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def ensure_trailing_newline(text)
|
|
178
|
-
return "" if text.nil?
|
|
179
|
-
text.end_with?("\n") ? text : text + "\n"
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
def apply_append(src_content, dest_content)
|
|
183
|
-
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
184
|
-
existing = Set.new(dest_nodes.map { |node| node_signature(node[:node]) })
|
|
185
|
-
appended = dest_nodes.dup
|
|
186
|
-
src_nodes.each do |node_info|
|
|
187
|
-
sig = node_signature(node_info[:node])
|
|
188
|
-
next if existing.include?(sig)
|
|
189
|
-
appended << node_info
|
|
190
|
-
existing << sig
|
|
191
|
-
end
|
|
192
|
-
appended
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def apply_merge(src_content, dest_content)
|
|
197
|
-
prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
|
|
198
|
-
src_map = src_nodes.each_with_object({}) do |node_info, memo|
|
|
199
|
-
sig = node_signature(node_info[:node])
|
|
200
|
-
memo[sig] ||= node_info
|
|
201
|
-
end
|
|
202
|
-
merged = dest_nodes.map do |node_info|
|
|
203
|
-
sig = node_signature(node_info[:node])
|
|
204
|
-
if (src_node_info = src_map[sig])
|
|
205
|
-
merge_node_info(sig, node_info, src_node_info)
|
|
206
|
-
else
|
|
207
|
-
node_info
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
existing = merged.map { |ni| node_signature(ni[:node]) }.to_set
|
|
211
|
-
src_nodes.each do |node_info|
|
|
212
|
-
sig = node_signature(node_info[:node])
|
|
213
|
-
next if existing.include?(sig)
|
|
214
|
-
merged << node_info
|
|
215
|
-
existing << sig
|
|
216
|
-
end
|
|
217
|
-
merged
|
|
218
|
-
end
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def merge_node_info(signature, _dest_node_info, src_node_info)
|
|
222
|
-
return src_node_info unless signature.is_a?(Array)
|
|
223
|
-
case signature[1]
|
|
224
|
-
when :gem_specification
|
|
225
|
-
merge_block_node_info(src_node_info)
|
|
226
|
-
else
|
|
227
|
-
src_node_info
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def merge_block_node_info(src_node_info)
|
|
232
|
-
# For block merging, we need to merge the statements within the block
|
|
233
|
-
# This is complex - for now, prefer template version
|
|
234
|
-
# TODO: Implement deep block statement merging with comment preservation
|
|
235
|
-
src_node_info
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def prism_merge(src_content, dest_content)
|
|
239
|
-
src_result = Kettle::Dev::PrismUtils.parse_with_comments(src_content)
|
|
240
|
-
dest_result = Kettle::Dev::PrismUtils.parse_with_comments(dest_content)
|
|
241
|
-
|
|
242
|
-
# If src parsing failed, return src unchanged to avoid losing content
|
|
243
|
-
unless src_result.success?
|
|
244
|
-
puts "WARNING: Source content parse failed, returning unchanged"
|
|
245
|
-
return src_content
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
src_nodes = extract_nodes_with_comments(src_result)
|
|
249
|
-
dest_nodes = extract_nodes_with_comments(dest_result)
|
|
250
|
-
|
|
251
|
-
merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)
|
|
252
|
-
|
|
253
|
-
# Extract and deduplicate comments from src and dest SEPARATELY
|
|
254
|
-
# This allows sequence detection to work within each source
|
|
255
|
-
src_tuples = create_comment_tuples(src_result)
|
|
256
|
-
src_deduplicated = deduplicate_comment_sequences(src_tuples)
|
|
257
|
-
|
|
258
|
-
dest_tuples = dest_result.success? ? create_comment_tuples(dest_result) : []
|
|
259
|
-
dest_deduplicated = deduplicate_comment_sequences(dest_tuples)
|
|
260
|
-
|
|
261
|
-
# Now merge the deduplicated tuples by hash+type only (ignore line numbers)
|
|
262
|
-
seen_hash_type = Set.new
|
|
263
|
-
final_tuples = []
|
|
264
|
-
|
|
265
|
-
# Add all deduplicated src tuples
|
|
266
|
-
src_deduplicated.each do |tuple|
|
|
267
|
-
hash_val = tuple[0]
|
|
268
|
-
type = tuple[1]
|
|
269
|
-
key = [hash_val, type]
|
|
270
|
-
unless seen_hash_type.include?(key)
|
|
271
|
-
final_tuples << tuple
|
|
272
|
-
seen_hash_type << key
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Add deduplicated dest tuples that don't duplicate src (by hash+type)
|
|
277
|
-
dest_deduplicated.each do |tuple|
|
|
278
|
-
hash_val = tuple[0]
|
|
279
|
-
type = tuple[1]
|
|
280
|
-
key = [hash_val, type]
|
|
281
|
-
unless seen_hash_type.include?(key)
|
|
282
|
-
final_tuples << tuple
|
|
283
|
-
seen_hash_type << key
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
# Extract magic and file-level comments from final merged tuples
|
|
288
|
-
magic_comments = final_tuples
|
|
289
|
-
.select { |tuple| tuple[1] == :magic }
|
|
290
|
-
.map { |tuple| tuple[2] }
|
|
291
|
-
|
|
292
|
-
file_leading_comments = final_tuples
|
|
293
|
-
.select { |tuple| tuple[1] == :file_level }
|
|
294
|
-
.map { |tuple| tuple[2] }
|
|
295
|
-
|
|
296
|
-
build_source_from_nodes(merged_nodes, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def extract_magic_comments(parse_result)
|
|
300
|
-
return [] unless parse_result.success?
|
|
301
|
-
|
|
302
|
-
tuples = create_comment_tuples(parse_result)
|
|
303
|
-
deduplicated = deduplicate_comment_sequences(tuples)
|
|
304
|
-
|
|
305
|
-
# Filter to only magic comments and return their text
|
|
306
|
-
deduplicated
|
|
307
|
-
.select { |tuple| tuple[1] == :magic }
|
|
308
|
-
.map { |tuple| tuple[2] }
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# Create a tuple for each comment: [hash, type, text, line_number]
|
|
312
|
-
# where type is one of: :magic, :file_level, :leading
|
|
313
|
-
# (inline comments are handled with their associated statements)
|
|
314
|
-
def create_comment_tuples(parse_result)
|
|
315
|
-
return [] unless parse_result.success?
|
|
316
|
-
|
|
317
|
-
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
318
|
-
first_stmt_line = statements.any? ? statements.first.location.start_line : Float::INFINITY
|
|
319
|
-
|
|
320
|
-
tuples = []
|
|
321
|
-
|
|
322
|
-
parse_result.comments.each do |comment|
|
|
323
|
-
comment_line = comment.location.start_line
|
|
324
|
-
comment_text = comment.slice.strip
|
|
325
|
-
|
|
326
|
-
# Determine comment type - magic comments are identified by content, not line number
|
|
327
|
-
type = if is_magic_comment?(comment_text)
|
|
328
|
-
:magic
|
|
329
|
-
elsif comment_line < first_stmt_line
|
|
330
|
-
:file_level
|
|
331
|
-
else
|
|
332
|
-
# This will be handled as a leading or inline comment for a statement
|
|
333
|
-
:leading
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
# Create hash from normalized comment text (ignoring trailing whitespace)
|
|
337
|
-
comment_hash = comment_text.hash
|
|
338
|
-
|
|
339
|
-
tuples << [comment_hash, type, comment.slice.rstrip, comment_line]
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
tuples
|
|
343
|
-
end
|
|
344
|
-
|
|
345
|
-
def is_magic_comment?(text)
|
|
346
|
-
text.include?("frozen_string_literal:") ||
|
|
347
|
-
text.include?("encoding:") ||
|
|
348
|
-
text.include?("warn_indent:") ||
|
|
349
|
-
text.include?("shareable_constant_value:")
|
|
350
|
-
end
|
|
351
|
-
|
|
352
|
-
# Two-pass deduplication:
|
|
353
|
-
# Pass 1: Deduplicate multi-line sequences
|
|
354
|
-
# Pass 2: Deduplicate single-line duplicates
|
|
355
|
-
def deduplicate_comment_sequences(tuples)
|
|
356
|
-
return [] if tuples.empty?
|
|
357
|
-
|
|
358
|
-
# Group tuples by type
|
|
359
|
-
by_type = tuples.group_by { |tuple| tuple[1] }
|
|
360
|
-
|
|
361
|
-
result = []
|
|
362
|
-
|
|
363
|
-
[:magic, :file_level, :leading].each do |type|
|
|
364
|
-
type_tuples = by_type[type] || []
|
|
365
|
-
next if type_tuples.empty?
|
|
366
|
-
|
|
367
|
-
# Pass 1: Remove duplicate sequences
|
|
368
|
-
after_pass1 = deduplicate_sequences_pass1(type_tuples)
|
|
369
|
-
|
|
370
|
-
# Pass 2: Remove single-line duplicates
|
|
371
|
-
after_pass2 = deduplicate_singles_pass2(after_pass1)
|
|
372
|
-
|
|
373
|
-
result.concat(after_pass2)
|
|
374
|
-
end
|
|
375
|
-
|
|
376
|
-
result
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
# Pass 1: Find and remove duplicate multi-line comment sequences
|
|
380
|
-
# A sequence is defined by consecutive comments (ignoring blank lines in between)
|
|
381
|
-
def deduplicate_sequences_pass1(tuples)
|
|
382
|
-
return tuples if tuples.length <= 1
|
|
383
|
-
|
|
384
|
-
# Group tuples into sequences (consecutive comments, allowing gaps for blank lines)
|
|
385
|
-
sequences = []
|
|
386
|
-
current_seq = []
|
|
387
|
-
prev_line = nil
|
|
388
|
-
|
|
389
|
-
tuples.each do |tuple|
|
|
390
|
-
line_num = tuple[3]
|
|
391
|
-
|
|
392
|
-
# If this is consecutive with previous (allowing reasonable gaps for blank lines)
|
|
393
|
-
if prev_line.nil? || (line_num - prev_line) <= 3
|
|
394
|
-
current_seq << tuple
|
|
395
|
-
else
|
|
396
|
-
# Start new sequence
|
|
397
|
-
sequences << current_seq if current_seq.any?
|
|
398
|
-
current_seq = [tuple]
|
|
399
|
-
end
|
|
400
|
-
|
|
401
|
-
prev_line = line_num
|
|
402
|
-
end
|
|
403
|
-
sequences << current_seq if current_seq.any?
|
|
404
|
-
|
|
405
|
-
# Find duplicate sequences by comparing hash signatures
|
|
406
|
-
seen_seq_signatures = Set.new
|
|
407
|
-
unique_tuples = []
|
|
408
|
-
|
|
409
|
-
sequences.each do |seq|
|
|
410
|
-
# Create signature from hashes and sequence length
|
|
411
|
-
seq_signature = seq.map { |t| t[0] }.join(",")
|
|
412
|
-
|
|
413
|
-
unless seen_seq_signatures.include?(seq_signature)
|
|
414
|
-
seen_seq_signatures << seq_signature
|
|
415
|
-
unique_tuples.concat(seq)
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
unique_tuples
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# Pass 2: Remove single-line duplicates from already sequence-deduplicated tuples
|
|
423
|
-
def deduplicate_singles_pass2(tuples)
|
|
424
|
-
return tuples if tuples.length <= 1
|
|
425
|
-
|
|
426
|
-
seen_hashes = Set.new
|
|
427
|
-
unique_tuples = []
|
|
428
|
-
|
|
429
|
-
tuples.each do |tuple|
|
|
430
|
-
comment_hash = tuple[0]
|
|
431
|
-
|
|
432
|
-
unless seen_hashes.include?(comment_hash)
|
|
433
|
-
seen_hashes << comment_hash
|
|
434
|
-
unique_tuples << tuple
|
|
435
|
-
end
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
unique_tuples
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
def extract_file_leading_comments(parse_result)
|
|
442
|
-
return [] unless parse_result.success?
|
|
443
|
-
|
|
444
|
-
tuples = create_comment_tuples(parse_result)
|
|
445
|
-
deduplicated = deduplicate_comment_sequences(tuples)
|
|
446
|
-
|
|
447
|
-
# Filter to only file-level comments and return their text
|
|
448
|
-
deduplicated
|
|
449
|
-
.select { |tuple| tuple[1] == :file_level }
|
|
450
|
-
.map { |tuple| tuple[2] }
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def extract_nodes_with_comments(parse_result)
|
|
454
|
-
return [] unless parse_result.success?
|
|
455
|
-
|
|
456
|
-
statements = PrismUtils.extract_statements(parse_result.value.statements)
|
|
457
|
-
return [] if statements.empty?
|
|
458
|
-
|
|
459
|
-
source_lines = parse_result.source.lines
|
|
460
|
-
|
|
461
|
-
statements.map.with_index do |stmt, idx|
|
|
462
|
-
prev_stmt = (idx > 0) ? statements[idx - 1] : nil
|
|
463
|
-
body_node = parse_result.value.statements
|
|
464
|
-
|
|
465
|
-
# Count blank lines before this statement
|
|
466
|
-
blank_lines_before = count_blank_lines_before(source_lines, stmt, prev_stmt, body_node)
|
|
467
|
-
|
|
468
|
-
{
|
|
469
|
-
node: stmt,
|
|
470
|
-
leading_comments: PrismUtils.find_leading_comments(parse_result, stmt, prev_stmt, body_node),
|
|
471
|
-
inline_comments: PrismUtils.inline_comments_for_node(parse_result, stmt),
|
|
472
|
-
blank_lines_before: blank_lines_before,
|
|
473
|
-
}
|
|
474
|
-
end
|
|
475
|
-
end
|
|
476
|
-
|
|
477
|
-
def count_blank_lines_before(source_lines, current_stmt, prev_stmt, body_node)
|
|
478
|
-
# Determine the starting line to search from
|
|
479
|
-
start_line = if prev_stmt
|
|
480
|
-
prev_stmt.location.end_line
|
|
481
|
-
else
|
|
482
|
-
# For the first statement, start from the beginning of the body
|
|
483
|
-
body_node.location.start_line
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
end_line = current_stmt.location.start_line
|
|
487
|
-
|
|
488
|
-
# Count consecutive blank lines before the current statement
|
|
489
|
-
# (after any comments and the previous statement)
|
|
490
|
-
blank_count = 0
|
|
491
|
-
(start_line...end_line).each do |line_num|
|
|
492
|
-
line_idx = line_num - 1
|
|
493
|
-
next if line_idx < 0 || line_idx >= source_lines.length
|
|
494
|
-
|
|
495
|
-
line = source_lines[line_idx]
|
|
496
|
-
# Skip comment lines (they're handled separately)
|
|
497
|
-
next if line.strip.start_with?("#")
|
|
498
|
-
|
|
499
|
-
# Count blank lines
|
|
500
|
-
if line.strip.empty?
|
|
501
|
-
blank_count += 1
|
|
502
|
-
else
|
|
503
|
-
# Reset count if we hit a non-blank, non-comment line
|
|
504
|
-
# This ensures we only count consecutive blank lines immediately before the statement
|
|
505
|
-
blank_count = 0
|
|
506
|
-
end
|
|
507
|
-
end
|
|
508
|
-
|
|
509
|
-
blank_count
|
|
510
|
-
end
|
|
511
|
-
|
|
512
|
-
def build_source_from_nodes(node_infos, magic_comments: [], file_leading_comments: [])
|
|
513
|
-
lines = []
|
|
514
|
-
|
|
515
|
-
# Add magic comments at the top (frozen_string_literal, etc.)
|
|
516
|
-
if magic_comments.any?
|
|
517
|
-
lines.concat(magic_comments)
|
|
518
|
-
lines << "" # Add blank line after magic comments
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
# Add file-level leading comments (comments before first statement)
|
|
522
|
-
if file_leading_comments.any?
|
|
523
|
-
lines.concat(file_leading_comments)
|
|
524
|
-
# Only add blank line if there are statements following
|
|
525
|
-
lines << "" if node_infos.any?
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# If there are no statements and no comments, return empty string
|
|
529
|
-
return "" if node_infos.empty? && lines.empty?
|
|
530
|
-
|
|
531
|
-
# If there are only comments and no statements, return the comments
|
|
532
|
-
return lines.join("\n") if node_infos.empty?
|
|
533
|
-
|
|
534
|
-
node_infos.each do |node_info|
|
|
535
|
-
# Add blank lines before this statement (for visual grouping)
|
|
536
|
-
blank_lines = node_info[:blank_lines_before] || 0
|
|
537
|
-
blank_lines.times { lines << "" }
|
|
538
|
-
|
|
539
|
-
# Add leading comments
|
|
540
|
-
node_info[:leading_comments].each do |comment|
|
|
541
|
-
lines << comment.slice.rstrip
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
# Add the node's source
|
|
545
|
-
node_source = PrismUtils.node_to_source(node_info[:node])
|
|
546
|
-
|
|
547
|
-
# Add inline comments on the same line
|
|
548
|
-
if node_info[:inline_comments].any?
|
|
549
|
-
inline = node_info[:inline_comments].map { |c| c.slice.strip }.join(" ")
|
|
550
|
-
node_source = node_source.rstrip + " " + inline
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
lines << node_source
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
lines.join("\n")
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def node_signature(node)
|
|
560
|
-
return [:nil] unless node
|
|
561
|
-
|
|
562
|
-
case node
|
|
563
|
-
when Prism::CallNode
|
|
564
|
-
method_name = node.name
|
|
565
|
-
if node.block
|
|
566
|
-
# Block call
|
|
567
|
-
first_arg = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
568
|
-
receiver_name = PrismUtils.extract_const_name(node.receiver)
|
|
569
|
-
|
|
570
|
-
if receiver_name == "Gem::Specification" && method_name == :new
|
|
571
|
-
[:block, :gem_specification]
|
|
572
|
-
elsif method_name == :task
|
|
573
|
-
[:block, :task, first_arg]
|
|
574
|
-
elsif method_name == :git_source
|
|
575
|
-
[:block, :git_source, first_arg]
|
|
576
|
-
else
|
|
577
|
-
[:block, method_name, first_arg, node.slice]
|
|
578
|
-
end
|
|
579
|
-
elsif [:source, :git_source, :gem, :eval_gemfile].include?(method_name)
|
|
580
|
-
# Simple call
|
|
581
|
-
first_literal = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
|
|
582
|
-
[:send, method_name, first_literal]
|
|
583
|
-
else
|
|
584
|
-
[:send, method_name, node.slice]
|
|
585
|
-
end
|
|
586
|
-
else
|
|
587
|
-
# Other node types
|
|
588
|
-
[node.class.name.split("::").last.to_sym, node.slice]
|
|
589
|
-
end
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
def restore_custom_leading_comments(dest_content, merged_content)
|
|
593
|
-
block = leading_comment_block(dest_content)
|
|
594
|
-
return merged_content if block.strip.empty?
|
|
595
|
-
|
|
596
|
-
# Check if the merged content already starts with this block
|
|
597
|
-
# Use normalized comparison to handle whitespace differences
|
|
598
|
-
merged_leading = leading_comment_block(merged_content)
|
|
599
|
-
|
|
600
|
-
# If merged already has the same or more comprehensive leading comments, don't add
|
|
601
|
-
return merged_content if merged_leading.strip == block.strip
|
|
602
|
-
return merged_content if merged_content.include?(block.strip)
|
|
603
|
-
|
|
604
|
-
# Insert after shebang / frozen string literal comments (same place reminder goes)
|
|
605
|
-
insertion_index = reminder_insertion_index(merged_content)
|
|
606
|
-
block = ensure_trailing_newline(block)
|
|
607
|
-
merged_content.dup.insert(insertion_index, block)
|
|
608
|
-
end
|
|
609
|
-
|
|
610
|
-
def leading_comment_block(content)
|
|
611
|
-
lines = content.to_s.lines
|
|
612
|
-
collected = []
|
|
613
|
-
lines.each do |line|
|
|
614
|
-
stripped = line.strip
|
|
615
|
-
break unless stripped.empty? || stripped.start_with?("#")
|
|
616
|
-
collected << line
|
|
617
|
-
end
|
|
618
|
-
collected.join
|
|
619
|
-
end
|
|
620
|
-
end
|
|
621
|
-
end
|
|
622
|
-
end
|