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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +176 -3
  4. data/CITATION.cff +2 -2
  5. data/CONTRIBUTING.md +11 -17
  6. data/README.md +390 -319
  7. data/exe/kettle-dev-setup +12 -63
  8. data/exe/kettle-gh-release +82 -0
  9. data/lib/kettle/dev/gem_spec_reader.rb +2 -2
  10. data/lib/kettle/dev/open_collective_config.rb +12 -0
  11. data/lib/kettle/dev/rakelib/yard.rake +15 -0
  12. data/lib/kettle/dev/tasks/ci_task.rb +4 -4
  13. data/lib/kettle/dev/version.rb +1 -1
  14. data/lib/kettle/dev.rb +4 -12
  15. data/sig/kettle/dev/source_merger.rbs +40 -56
  16. data.tar.gz.sig +0 -0
  17. metadata +15 -144
  18. metadata.gz.sig +0 -0
  19. data/.aiignore.example +0 -19
  20. data/.devcontainer/apt-install/devcontainer-feature.json +0 -9
  21. data/.devcontainer/apt-install/install.sh +0 -11
  22. data/.devcontainer/devcontainer.json +0 -28
  23. data/.env.local.example +0 -31
  24. data/.envrc +0 -47
  25. data/.envrc.example +0 -51
  26. data/.envrc.no-osc.example +0 -51
  27. data/.git-hooks/commit-msg +0 -54
  28. data/.git-hooks/commit-subjects-goalie.txt +0 -8
  29. data/.git-hooks/footer-template.erb.txt +0 -16
  30. data/.git-hooks/prepare-commit-msg +0 -8
  31. data/.github/.codecov.yml.example +0 -14
  32. data/.github/FUNDING.yml +0 -13
  33. data/.github/FUNDING.yml.no-osc.example +0 -13
  34. data/.github/dependabot.yml +0 -13
  35. data/.github/workflows/ancient.yml +0 -83
  36. data/.github/workflows/ancient.yml.example +0 -81
  37. data/.github/workflows/auto-assign.yml +0 -21
  38. data/.github/workflows/codeql-analysis.yml +0 -70
  39. data/.github/workflows/coverage.yml +0 -127
  40. data/.github/workflows/coverage.yml.example +0 -127
  41. data/.github/workflows/current.yml +0 -116
  42. data/.github/workflows/current.yml.example +0 -115
  43. data/.github/workflows/dep-heads.yml +0 -117
  44. data/.github/workflows/dependency-review.yml +0 -20
  45. data/.github/workflows/discord-notifier.yml.example +0 -39
  46. data/.github/workflows/heads.yml +0 -117
  47. data/.github/workflows/heads.yml.example +0 -116
  48. data/.github/workflows/jruby.yml +0 -82
  49. data/.github/workflows/jruby.yml.example +0 -72
  50. data/.github/workflows/legacy.yml +0 -76
  51. data/.github/workflows/license-eye.yml +0 -40
  52. data/.github/workflows/locked_deps.yml +0 -85
  53. data/.github/workflows/opencollective.yml +0 -40
  54. data/.github/workflows/style.yml +0 -67
  55. data/.github/workflows/supported.yml +0 -75
  56. data/.github/workflows/truffle.yml +0 -99
  57. data/.github/workflows/unlocked_deps.yml +0 -84
  58. data/.github/workflows/unsupported.yml +0 -76
  59. data/.gitignore +0 -50
  60. data/.gitlab-ci.yml.example +0 -134
  61. data/.idea/.gitignore +0 -45
  62. data/.junie/guidelines-rbs.md +0 -49
  63. data/.junie/guidelines.md +0 -141
  64. data/.junie/guidelines.md.example +0 -140
  65. data/.licenserc.yaml +0 -7
  66. data/.opencollective.yml +0 -3
  67. data/.opencollective.yml.example +0 -3
  68. data/.qlty/qlty.toml +0 -79
  69. data/.rspec +0 -9
  70. data/.rubocop.yml +0 -13
  71. data/.rubocop_rspec.yml +0 -33
  72. data/.simplecov +0 -16
  73. data/.simplecov.example +0 -11
  74. data/.tool-versions +0 -1
  75. data/.yardignore +0 -13
  76. data/.yardopts +0 -14
  77. data/Appraisal.root.gemfile +0 -10
  78. data/Appraisals +0 -151
  79. data/Appraisals.example +0 -102
  80. data/CHANGELOG.md.example +0 -47
  81. data/CONTRIBUTING.md.example +0 -227
  82. data/FUNDING.md.no-osc.example +0 -63
  83. data/Gemfile +0 -40
  84. data/Gemfile.example +0 -34
  85. data/README.md.example +0 -570
  86. data/README.md.no-osc.example +0 -536
  87. data/Rakefile.example +0 -68
  88. data/gemfiles/modular/coverage.gemfile +0 -6
  89. data/gemfiles/modular/debug.gemfile +0 -13
  90. data/gemfiles/modular/documentation.gemfile +0 -14
  91. data/gemfiles/modular/erb/r2/v3.0.gemfile +0 -1
  92. data/gemfiles/modular/erb/r2.3/default.gemfile +0 -6
  93. data/gemfiles/modular/erb/r2.6/v2.2.gemfile +0 -3
  94. data/gemfiles/modular/erb/r3/v5.0.gemfile +0 -1
  95. data/gemfiles/modular/erb/r3.1/v4.0.gemfile +0 -2
  96. data/gemfiles/modular/erb/vHEAD.gemfile +0 -2
  97. data/gemfiles/modular/injected.gemfile +0 -60
  98. data/gemfiles/modular/mutex_m/r2/v0.3.gemfile +0 -2
  99. data/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile +0 -3
  100. data/gemfiles/modular/mutex_m/r3/v0.3.gemfile +0 -2
  101. data/gemfiles/modular/mutex_m/vHEAD.gemfile +0 -2
  102. data/gemfiles/modular/optional.gemfile +0 -8
  103. data/gemfiles/modular/optional.gemfile.example +0 -5
  104. data/gemfiles/modular/runtime_heads.gemfile +0 -10
  105. data/gemfiles/modular/runtime_heads.gemfile.example +0 -8
  106. data/gemfiles/modular/stringio/r2/v3.0.gemfile +0 -5
  107. data/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile +0 -4
  108. data/gemfiles/modular/stringio/r3/v3.0.gemfile +0 -5
  109. data/gemfiles/modular/stringio/vHEAD.gemfile +0 -2
  110. data/gemfiles/modular/style.gemfile +0 -25
  111. data/gemfiles/modular/style.gemfile.example +0 -25
  112. data/gemfiles/modular/templating.gemfile +0 -3
  113. data/gemfiles/modular/x_std_libs/r2/libs.gemfile +0 -3
  114. data/gemfiles/modular/x_std_libs/r2.3/libs.gemfile +0 -3
  115. data/gemfiles/modular/x_std_libs/r2.4/libs.gemfile +0 -3
  116. data/gemfiles/modular/x_std_libs/r2.6/libs.gemfile +0 -3
  117. data/gemfiles/modular/x_std_libs/r3/libs.gemfile +0 -3
  118. data/gemfiles/modular/x_std_libs/r3.1/libs.gemfile +0 -3
  119. data/gemfiles/modular/x_std_libs/vHEAD.gemfile +0 -3
  120. data/gemfiles/modular/x_std_libs.gemfile +0 -2
  121. data/kettle-dev.gemspec.example +0 -154
  122. data/lib/kettle/dev/modular_gemfiles.rb +0 -119
  123. data/lib/kettle/dev/prism_appraisals.rb +0 -351
  124. data/lib/kettle/dev/prism_gemfile.rb +0 -177
  125. data/lib/kettle/dev/prism_gemspec.rb +0 -284
  126. data/lib/kettle/dev/prism_utils.rb +0 -201
  127. data/lib/kettle/dev/rakelib/install.rake +0 -10
  128. data/lib/kettle/dev/rakelib/template.rake +0 -10
  129. data/lib/kettle/dev/setup_cli.rb +0 -403
  130. data/lib/kettle/dev/source_merger.rb +0 -622
  131. data/lib/kettle/dev/tasks/install_task.rb +0 -553
  132. data/lib/kettle/dev/tasks/template_task.rb +0 -975
  133. 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