kettle-dev 1.1.59 → 1.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +1 -2
  3. data/.envrc +1 -0
  4. data/.envrc.example +1 -0
  5. data/.envrc.no-osc.example +1 -0
  6. data/.github/workflows/ancient.yml +1 -1
  7. data/.github/workflows/ancient.yml.example +1 -1
  8. data/.github/workflows/codeql-analysis.yml +1 -1
  9. data/.github/workflows/coverage.yml +1 -1
  10. data/.github/workflows/coverage.yml.example +1 -1
  11. data/.github/workflows/current.yml +1 -1
  12. data/.github/workflows/current.yml.example +1 -1
  13. data/.github/workflows/dep-heads.yml +1 -1
  14. data/.github/workflows/dependency-review.yml +1 -1
  15. data/.github/workflows/heads.yml +1 -1
  16. data/.github/workflows/heads.yml.example +1 -1
  17. data/.github/workflows/jruby.yml +1 -1
  18. data/.github/workflows/jruby.yml.example +1 -1
  19. data/.github/workflows/legacy.yml +1 -1
  20. data/.github/workflows/license-eye.yml +1 -1
  21. data/.github/workflows/locked_deps.yml +1 -1
  22. data/.github/workflows/opencollective.yml +1 -1
  23. data/.github/workflows/style.yml +1 -1
  24. data/.github/workflows/supported.yml +1 -1
  25. data/.github/workflows/truffle.yml +1 -1
  26. data/.github/workflows/unlocked_deps.yml +1 -1
  27. data/.github/workflows/unsupported.yml +1 -1
  28. data/CHANGELOG.md +35 -1
  29. data/Gemfile +3 -0
  30. data/Gemfile.example +3 -0
  31. data/README.md +90 -37
  32. data/README.md.example +16 -12
  33. data/README.md.no-osc.example +16 -12
  34. data/Rakefile.example +1 -1
  35. data/gemfiles/modular/style.gemfile.example +1 -1
  36. data/gemfiles/modular/templating.gemfile +3 -0
  37. data/lib/kettle/dev/appraisals_ast_merger.rb +383 -0
  38. data/lib/kettle/dev/changelog_cli.rb +13 -0
  39. data/lib/kettle/dev/modular_gemfiles.rb +11 -3
  40. data/lib/kettle/dev/prism_utils.rb +188 -0
  41. data/lib/kettle/dev/rakelib/spec_test.rake +70 -20
  42. data/lib/kettle/dev/source_merger.rb +345 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +11 -1
  44. data/lib/kettle/dev/template_helpers.rb +70 -226
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev.rb +2 -0
  47. data/sig/kettle/dev/appraisals_ast_merger.rbs +72 -0
  48. data/sig/kettle/dev/changelog_cli.rbs +64 -0
  49. data/sig/kettle/dev/prism_utils.rbs +56 -0
  50. data/sig/kettle/dev/source_merger.rbs +86 -0
  51. data/sig/kettle/dev/versioning.rbs +21 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +14 -5
  54. metadata.gz.sig +0 -0
  55. /data/sig/kettle/dev/{dvcscli.rbs → dvcs_cli.rbs} +0 -0
data/Rakefile.example CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # kettle-dev Rakefile v1.1.59 - 2025-11-13
3
+ # kettle-dev Rakefile v1.2.0 - 2025-11-25
4
4
  # Ruby 2.3 (Safe Navigation) or higher required
5
5
  #
6
6
  # MIT License (see License.txt)
@@ -13,7 +13,7 @@ gem "rubocop-on-rbs", "~> 1.8" # ruby >= 3.1.0
13
13
  gem "benchmark", "~> 0.4", ">= 0.4.1" # Removed from Std Lib in Ruby 3.5
14
14
 
15
15
  if ENV.fetch("RUBOCOP_LTS_LOCAL", "false").casecmp("true").zero?
16
- home = ENV["HOME"]
16
+ home = ENV["HOME"] || Dir.home
17
17
  gem "rubocop-lts", path: "#{home}/src/rubocop-lts/rubocop-lts"
18
18
  gem "rubocop-lts-rspec", path: "#{home}/src/rubocop-lts/rubocop-lts-rspec"
19
19
  gem "{RUBOCOP|RUBY|GEM}", path: "#{home}/src/rubocop-lts/{RUBOCOP|RUBY|GEM}"
@@ -0,0 +1,3 @@
1
+ # Ruby parsing for advanced templating
2
+ gem "prism", "~> 1.6"
3
+ gem "unparser", "~> 0.8", ">= 0.8.1"
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "kettle/dev/prism_utils"
5
+
6
+ module Kettle
7
+ module Dev
8
+ # AST-driven merger for Appraisals files using Prism.
9
+ # Preserves all comments: preamble headers, block headers, and inline comments.
10
+ # Uses PrismUtils for shared Prism AST operations.
11
+ module AppraisalsAstMerger
12
+ TRACKED_METHODS = [:gem, :eval_gemfile, :gemfile].freeze
13
+
14
+ module_function
15
+
16
+ # Merge template and destination Appraisals files preserving comments
17
+ #
18
+ # Merges appraise blocks by name, preserving:
19
+ # - Preamble comments (deduplicated)
20
+ # - Block headers (comments before each appraise block)
21
+ # - Inline comments on gem/eval_gemfile statements
22
+ # - Leading comments before statements
23
+ #
24
+ # @param template_content [String] Template Appraisals content
25
+ # @param dest_content [String] Destination Appraisals content
26
+ # @return [String] Merged Appraisals content with preserved comments
27
+ # @example
28
+ # AppraisalsAstMerger.merge(
29
+ # 'appraise "rails-7" { gem "rails", "~> 7.0" }',
30
+ # 'appraise "custom" { gem "foo" }'
31
+ # )
32
+ def merge(template_content, dest_content)
33
+ template_content ||= ""
34
+ dest_content ||= ""
35
+
36
+ return template_content if dest_content.strip.empty?
37
+ return dest_content if template_content.strip.empty?
38
+
39
+ # Parse with Prism to get AST and comments
40
+ tmpl_result = PrismUtils.parse_with_comments(template_content)
41
+ dest_result = PrismUtils.parse_with_comments(dest_content)
42
+
43
+ # Extract preamble and blocks with their headers
44
+ tmpl_preamble, tmpl_blocks = extract_blocks(tmpl_result, template_content)
45
+ dest_preamble, dest_blocks = extract_blocks(dest_result, dest_content)
46
+
47
+ # Merge preambles
48
+ merged_preamble = merge_preambles(tmpl_preamble, dest_preamble)
49
+
50
+ # Merge blocks
51
+ merged_blocks = merge_blocks(tmpl_blocks, dest_blocks, tmpl_result, dest_result)
52
+
53
+ # Build output
54
+ build_output(merged_preamble, merged_blocks)
55
+ end
56
+
57
+ def extract_blocks(parse_result, source_content)
58
+ root = parse_result.value
59
+ return [[], []] unless root&.statements&.body
60
+
61
+ source_lines = source_content.lines
62
+ blocks = []
63
+ first_appraise_line = nil
64
+
65
+ # Find all appraise blocks
66
+ root.statements.body.each do |node|
67
+ if appraise_call?(node)
68
+ first_appraise_line ||= node.location.start_line
69
+ name = extract_appraise_name(node)
70
+ next unless name
71
+
72
+ # Extract block header (comments immediately before this block)
73
+ block_header = extract_block_header(node, source_lines, blocks)
74
+
75
+ blocks << {
76
+ node: node,
77
+ name: name,
78
+ header: block_header,
79
+ }
80
+ end
81
+ end
82
+
83
+ # Preamble is all comments before first appraise block
84
+ preamble_comments = if first_appraise_line
85
+ parse_result.comments.select { |c| c.location.start_line < first_appraise_line }
86
+ else
87
+ parse_result.comments
88
+ end
89
+
90
+ # Filter out comments that are part of block headers
91
+ block_header_lines = blocks.flat_map { |b| b[:header].lines.map { |l| l.strip } }.to_set
92
+ preamble_comments = preamble_comments.reject { |c| block_header_lines.include?(c.slice.strip) }
93
+
94
+ [preamble_comments, blocks]
95
+ end
96
+
97
+ def appraise_call?(node)
98
+ PrismUtils.block_call_to?(node, :appraise)
99
+ end
100
+
101
+ def extract_appraise_name(node)
102
+ return unless node.is_a?(Prism::CallNode)
103
+ # Use PrismUtils for extracting literal values
104
+ PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
105
+ end
106
+
107
+ def merge_preambles(tmpl_comments, dest_comments)
108
+ tmpl_lines = tmpl_comments.map { |c| c.slice.strip }
109
+ dest_lines = dest_comments.map { |c| c.slice.strip }
110
+
111
+ # Remove magic comments from dest if template has them
112
+ magic_pattern = /^#.*frozen_string_literal/
113
+ if tmpl_lines.any? { |line| line.match?(magic_pattern) }
114
+ dest_lines.reject! { |line| line.match?(magic_pattern) }
115
+ end
116
+
117
+ # Merge unique lines (case-insensitive), template first
118
+ merged = []
119
+ seen = Set.new
120
+
121
+ (tmpl_lines + dest_lines).each do |line|
122
+ normalized = line.downcase
123
+ unless seen.include?(normalized)
124
+ merged << line
125
+ seen << normalized
126
+ end
127
+ end
128
+
129
+ merged
130
+ end
131
+
132
+ def extract_block_header(node, source_lines, previous_blocks)
133
+ # Get the line number where this block starts (1-indexed from Prism)
134
+ begin_line = node.location.start_line
135
+
136
+ # Find the end of the previous block or start of file
137
+ min_line = if previous_blocks.empty?
138
+ 1
139
+ else
140
+ previous_blocks.last[:node].location.end_line + 1
141
+ end
142
+
143
+ # Convert to 0-indexed and look at the line before the block
144
+ check_line = begin_line - 2 # e.g., if block is on line 2 (1-indexed), check line 0 (0-indexed, which is line 1 in file)
145
+
146
+ # Look backwards from the block start to find contiguous comment lines
147
+ header_lines = []
148
+
149
+ while check_line >= 0 && (check_line + 1) >= min_line
150
+ line = source_lines[check_line]
151
+ break unless line
152
+
153
+ # Stop at empty line
154
+ if line.strip.empty?
155
+ break
156
+ elsif line.lstrip.start_with?("#")
157
+ header_lines.unshift(line)
158
+ check_line -= 1
159
+ else
160
+ # Non-comment, non-blank line - stop
161
+ break
162
+ end
163
+ end
164
+
165
+ header_lines.join
166
+ rescue StandardError => e
167
+ Kettle::Dev.debug_error(e, __method__) if defined?(Kettle::Dev.debug_error)
168
+ ""
169
+ end
170
+
171
+ def merge_blocks(template_blocks, dest_blocks, tmpl_result, dest_result)
172
+ merged = []
173
+ dest_by_name = dest_blocks.each_with_object({}) { |b, h| h[b[:name]] = b }
174
+ template_names = template_blocks.map { |b| b[:name] }.to_set
175
+
176
+ # Track which dest blocks we've already placed
177
+ placed_dest = Set.new
178
+
179
+ # For each template block, merge it and insert any dest-only blocks that come before it
180
+ template_blocks.each_with_index do |tmpl_block, idx|
181
+ name = tmpl_block[:name]
182
+
183
+ # Find dest-only blocks that should come before this template block
184
+ # (i.e., blocks that appear in dest before this shared block)
185
+ if idx == 0 || dest_by_name[name]
186
+ # For first template block or when we have a dest version of this block,
187
+ # check if there are dest-only blocks to insert before it
188
+ dest_blocks.each do |db|
189
+ next if template_names.include?(db[:name])
190
+ next if placed_dest.include?(db[:name])
191
+
192
+ # Check if this dest-only block comes before current shared block in dest
193
+ dest_idx_of_shared = dest_blocks.index { |b| b[:name] == name }
194
+ dest_idx_of_only = dest_blocks.index { |b| b[:name] == db[:name] }
195
+
196
+ if dest_idx_of_only && dest_idx_of_shared && dest_idx_of_only < dest_idx_of_shared
197
+ merged << db
198
+ placed_dest << db[:name]
199
+ end
200
+ end
201
+ end
202
+
203
+ # Now add the template block (merged or template-only)
204
+ dest_block = dest_by_name[name]
205
+ if dest_block
206
+ # Merge this block
207
+ merged_header = merge_block_headers(tmpl_block[:header], dest_block[:header])
208
+ merged_statements = merge_block_statements(
209
+ tmpl_block[:node].block.body,
210
+ dest_block[:node].block.body,
211
+ dest_result,
212
+ )
213
+ merged << {
214
+ name: name,
215
+ header: merged_header,
216
+ node: tmpl_block[:node],
217
+ statements: merged_statements,
218
+ }
219
+ placed_dest << name
220
+ else
221
+ # Template-only block
222
+ merged << tmpl_block
223
+ end
224
+ end
225
+
226
+ # Add any remaining destination-only blocks that haven't been placed
227
+ dest_blocks.each do |dest_block|
228
+ next if placed_dest.include?(dest_block[:name])
229
+ next if template_names.include?(dest_block[:name])
230
+ merged << dest_block
231
+ end
232
+
233
+ merged
234
+ end
235
+
236
+ def merge_block_headers(tmpl_header, dest_header)
237
+ tmpl_lines = tmpl_header.to_s.lines.map(&:strip).reject(&:empty?)
238
+ dest_lines = dest_header.to_s.lines.map(&:strip).reject(&:empty?)
239
+
240
+ # Merge without duplicates (case-insensitive comparison), template first
241
+ merged = []
242
+ seen = Set.new
243
+
244
+ (tmpl_lines + dest_lines).each do |line|
245
+ normalized = line.downcase
246
+ unless seen.include?(normalized)
247
+ merged << line
248
+ seen << normalized
249
+ end
250
+ end
251
+
252
+ return "" if merged.empty?
253
+ merged.join("\n") + "\n"
254
+ end
255
+
256
+ def merge_block_statements(tmpl_body, dest_body, dest_result)
257
+ tmpl_stmts = PrismUtils.extract_statements(tmpl_body)
258
+ dest_stmts = PrismUtils.extract_statements(dest_body)
259
+
260
+ # Build statement keys for both
261
+ tmpl_keys = Set.new
262
+ tmpl_key_to_node = {}
263
+ tmpl_stmts.each do |stmt|
264
+ key = statement_key(stmt)
265
+ if key
266
+ tmpl_keys << key
267
+ tmpl_key_to_node[key] = stmt
268
+ end
269
+ end
270
+
271
+ dest_keys = Set.new
272
+ dest_stmts.each do |stmt|
273
+ key = statement_key(stmt)
274
+ dest_keys << key if key
275
+ end
276
+
277
+ # Process dest statements in order, preserving their position and comments
278
+ merged = []
279
+ dest_stmts.each_with_index do |dest_stmt, idx|
280
+ dest_key = statement_key(dest_stmt)
281
+
282
+ if dest_key && tmpl_keys.include?(dest_key)
283
+ # Shared statement - use template version but preserve dest position
284
+ # Add it with no comments (template version is canonical)
285
+ merged << {node: tmpl_key_to_node[dest_key], inline_comments: [], leading_comments: [], shared: true, key: dest_key}
286
+ else
287
+ # Dest-only statement - preserve with all comments
288
+ inline_comments = PrismUtils.inline_comments_for_node(dest_result, dest_stmt)
289
+
290
+ prev_stmt = (idx > 0) ? dest_stmts[idx - 1] : nil
291
+ leading_comments = PrismUtils.find_leading_comments(dest_result, dest_stmt, prev_stmt, dest_body)
292
+
293
+ merged << {node: dest_stmt, inline_comments: inline_comments, leading_comments: leading_comments, shared: false}
294
+ end
295
+ end
296
+
297
+ # Add template-only statements (those not in dest) at the end
298
+ tmpl_stmts.each do |tmpl_stmt|
299
+ tmpl_key = statement_key(tmpl_stmt)
300
+ unless tmpl_key && dest_keys.include?(tmpl_key)
301
+ merged << {node: tmpl_stmt, inline_comments: [], leading_comments: [], shared: false}
302
+ end
303
+ end
304
+
305
+ # Clean up - remove the tracking fields
306
+ merged.each do |item|
307
+ item.delete(:shared)
308
+ item.delete(:key)
309
+ end
310
+
311
+ merged
312
+ end
313
+
314
+ def statement_key(node)
315
+ # Use PrismUtils for statement key generation with Appraisals-specific tracked methods
316
+ PrismUtils.statement_key(node, tracked_methods: TRACKED_METHODS)
317
+ end
318
+
319
+ def build_output(preamble_lines, blocks)
320
+ output = []
321
+
322
+ # Add preamble
323
+ preamble_lines.each { |line| output << line }
324
+ output << "" unless preamble_lines.empty?
325
+
326
+ # Add blocks
327
+ blocks.each do |block|
328
+ # Add block header (no blank line before it)
329
+ header = block[:header]
330
+ if header && !header.strip.empty?
331
+ output << header.rstrip
332
+ end
333
+
334
+ # Add appraise call - using parentheses and curly braces format
335
+ name = block[:name]
336
+ output << "appraise(\"#{name}\") {"
337
+
338
+ # Add statements
339
+ statements = block[:statements] || extract_original_statements(block[:node])
340
+ statements.each do |stmt_info|
341
+ # Add any leading comments before this statement
342
+ leading = stmt_info[:leading_comments] || []
343
+ leading.each do |comment|
344
+ output << " #{comment.slice.strip}"
345
+ end
346
+
347
+ node = stmt_info[:node]
348
+ # Normalize the statement to use parentheses
349
+ line = normalize_statement(node)
350
+
351
+ inline = stmt_info[:inline_comments] || []
352
+ inline_str = inline.map { |c| c.slice.strip }.join(" ")
353
+ output << " #{line}#{" #{inline_str}" unless inline_str.empty?}"
354
+ end
355
+
356
+ output << "}"
357
+ output << ""
358
+ end
359
+
360
+ output.join("\n").strip + "\n"
361
+ end
362
+
363
+ def normalize_statement(node)
364
+ # Use PrismUtils for normalizing call nodes
365
+ return PrismUtils.node_to_source(node) unless node.is_a?(Prism::CallNode)
366
+ PrismUtils.normalize_call_node(node)
367
+ end
368
+
369
+ def normalize_argument(arg)
370
+ # Use PrismUtils for argument normalization
371
+ PrismUtils.normalize_argument(arg)
372
+ end
373
+
374
+ def extract_original_statements(node)
375
+ body = node.block&.body
376
+ return [] unless body
377
+
378
+ statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
379
+ statements.compact.map { |stmt| {node: stmt, inline_comments: [], leading_comments: []} }
380
+ end
381
+ end
382
+ end
383
+ end
@@ -2,14 +2,27 @@
2
2
 
3
3
  module Kettle
4
4
  module Dev
5
+ # CLI for updating CHANGELOG.md with new version sections
6
+ #
7
+ # Automatically extracts unreleased changes, formats them into a new version section,
8
+ # includes coverage and YARD stats, and updates link references.
5
9
  class ChangelogCLI
6
10
  UNRELEASED_SECTION_HEADING = "[Unreleased]:"
11
+
12
+ # Initialize the changelog CLI
13
+ # Sets up paths for CHANGELOG.md and coverage.json
7
14
  def initialize
8
15
  @root = Kettle::Dev::CIHelpers.project_root
9
16
  @changelog_path = File.join(@root, "CHANGELOG.md")
10
17
  @coverage_path = File.join(@root, "coverage", "coverage.json")
11
18
  end
12
19
 
20
+ # Main entry point to update CHANGELOG.md
21
+ #
22
+ # Detects current version, extracts unreleased changes, formats them into
23
+ # a new version section with coverage/YARD stats, and updates all link references.
24
+ #
25
+ # @return [void]
13
26
  def run
14
27
  version = Kettle::Dev::Versioning.detect_version(@root)
15
28
  today = Time.now.strftime("%Y-%m-%d")
@@ -22,25 +22,31 @@ module Kettle
22
22
  def sync!(helpers:, project_root:, gem_checkout_root:, min_ruby: nil)
23
23
  # 4a) gemfiles/modular/*.gemfile except style.gemfile (handled below)
24
24
  # Note: `injected.gemfile` is only intended for testing this gem, and isn't even actively used there. It is not part of the template.
25
+ # Note: `style.gemfile` is handled separately below.
25
26
  modular_gemfiles = %w[
26
27
  coverage
27
28
  debug
28
29
  documentation
29
30
  optional
30
31
  runtime_heads
32
+ templating
31
33
  x_std_libs
32
34
  ]
33
35
  modular_gemfiles.each do |base|
34
36
  modular_gemfile = "#{base}.gemfile"
35
37
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
36
38
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
37
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
39
+ existing = File.exist?(dest) ? File.read(dest) : nil
40
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
41
+ existing ? helpers.merge_gemfile_dependencies(content, existing) : content
42
+ end
38
43
  end
39
44
 
40
45
  # 4b) gemfiles/modular/style.gemfile with dynamic rubocop constraints
41
46
  modular_gemfile = "style.gemfile"
42
47
  src = helpers.prefer_example(File.join(gem_checkout_root, MODULAR_GEMFILE_DIR, modular_gemfile))
43
48
  dest = File.join(project_root, MODULAR_GEMFILE_DIR, modular_gemfile)
49
+ existing_style = File.exist?(dest) ? File.read(dest) : nil
44
50
  if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
45
51
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
46
52
  # Adjust rubocop-lts constraint based on min_ruby
@@ -92,10 +98,12 @@ module Kettle
92
98
  token = "{RUBOCOP|RUBY|GEM}"
93
99
  content.gsub!(token, "rubocop-ruby#{rubocop_ruby_gem_version}") if content.include?(token)
94
100
  end
95
- content
101
+ existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
96
102
  end
97
103
  else
98
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
104
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
105
+ existing_style ? helpers.merge_gemfile_dependencies(content, existing_style) : content
106
+ end
99
107
  end
100
108
 
101
109
  # 4c) Copy modular directories with nested/versioned files
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Kettle
6
+ module Dev
7
+ # Shared utilities for working with Prism AST nodes.
8
+ # Provides parsing, node inspection, and source generation helpers
9
+ # used by both PrismMerger and AppraisalsAstMerger.
10
+ #
11
+ # Uses Prism's native methods for source generation (via .slice) to preserve
12
+ # original formatting and comments. For normalized output (e.g., adding parentheses),
13
+ # use normalize_call_node instead.
14
+ module PrismUtils
15
+ module_function
16
+
17
+ # Parse Ruby source code and return Prism parse result with comments
18
+ # @param source [String] Ruby source code
19
+ # @return [Prism::ParseResult] Parse result containing AST and comments
20
+ def parse_with_comments(source)
21
+ Prism.parse(source)
22
+ end
23
+
24
+ # Extract statements from a Prism body node
25
+ # @param body_node [Prism::Node, nil] Body node (typically StatementsNode)
26
+ # @return [Array<Prism::Node>] Array of statement nodes
27
+ def extract_statements(body_node)
28
+ return [] unless body_node
29
+
30
+ if body_node.is_a?(Prism::StatementsNode)
31
+ body_node.body.compact
32
+ else
33
+ [body_node].compact
34
+ end
35
+ end
36
+
37
+ # Generate a unique key for a statement node to identify equivalent statements
38
+ # Used for merge/append operations to detect duplicates
39
+ # @param node [Prism::Node] Statement node
40
+ # @param tracked_methods [Array<Symbol>] Methods to track (default: gem, source, eval_gemfile, git_source)
41
+ # @return [Array, nil] Key array like [:gem, "foo"] or nil if not trackable
42
+ def statement_key(node, tracked_methods: %i[gem source eval_gemfile git_source])
43
+ return unless node.is_a?(Prism::CallNode)
44
+ return unless tracked_methods.include?(node.name)
45
+
46
+ first_arg = node.arguments&.arguments&.first
47
+ arg_value = extract_literal_value(first_arg)
48
+
49
+ [node.name, arg_value] if arg_value
50
+ end
51
+
52
+ # Extract literal value from string or symbol nodes
53
+ # @param node [Prism::Node, nil] Node to extract from
54
+ # @return [String, Symbol, nil] Literal value or nil
55
+ def extract_literal_value(node)
56
+ case node
57
+ when Prism::StringNode then node.unescaped
58
+ when Prism::SymbolNode then node.unescaped
59
+ end
60
+ end
61
+
62
+ # Extract qualified constant name from a constant node
63
+ # @param node [Prism::Node, nil] Constant node
64
+ # @return [String, nil] Qualified name like "Gem::Specification" or nil
65
+ def extract_const_name(node)
66
+ case node
67
+ when Prism::ConstantReadNode
68
+ node.name.to_s
69
+ when Prism::ConstantPathNode
70
+ parent = extract_const_name(node.parent)
71
+ child = node.name || node.child&.name
72
+ (parent && child) ? "#{parent}::#{child}" : child.to_s
73
+ end
74
+ end
75
+
76
+ # Find leading comments for a statement node
77
+ # Leading comments are those that appear after the previous statement
78
+ # and before the current statement
79
+ # @param parse_result [Prism::ParseResult] Parse result with comments
80
+ # @param current_stmt [Prism::Node] Current statement node
81
+ # @param prev_stmt [Prism::Node, nil] Previous statement node
82
+ # @param body_node [Prism::Node] Body containing the statements
83
+ # @return [Array<Prism::Comment>] Leading comments
84
+ def find_leading_comments(parse_result, current_stmt, prev_stmt, body_node)
85
+ start_line = prev_stmt ? prev_stmt.location.end_line : body_node.location.start_line
86
+ end_line = current_stmt.location.start_line
87
+
88
+ parse_result.comments.select do |comment|
89
+ comment.location.start_line > start_line &&
90
+ comment.location.start_line < end_line
91
+ end
92
+ end
93
+
94
+ # Find inline comments for a statement node
95
+ # Inline comments are those that appear on the same line as the statement's end
96
+ # @param parse_result [Prism::ParseResult] Parse result with comments
97
+ # @param stmt [Prism::Node] Statement node
98
+ # @return [Array<Prism::Comment>] Inline comments
99
+ def inline_comments_for_node(parse_result, stmt)
100
+ parse_result.comments.select do |comment|
101
+ comment.location.start_line == stmt.location.end_line &&
102
+ comment.location.start_offset > stmt.location.end_offset
103
+ end
104
+ end
105
+
106
+ # Convert a Prism AST node to Ruby source code
107
+ # Uses Prism's native slice method which preserves the original source exactly.
108
+ # This is preferable to Unparser for Prism nodes as it maintains original formatting
109
+ # and comments without requiring transformation.
110
+ # @param node [Prism::Node] AST node
111
+ # @return [String] Ruby source code
112
+ def node_to_source(node)
113
+ return "" unless node
114
+ # Prism nodes have a slice method that returns the original source
115
+ node.slice
116
+ end
117
+
118
+ # Normalize a call node to use parentheses format
119
+ # Converts `gem "foo"` to `gem("foo")` style
120
+ # @param node [Prism::CallNode] Call node
121
+ # @return [String] Normalized source code
122
+ def normalize_call_node(node)
123
+ return node.slice.strip unless node.is_a?(Prism::CallNode)
124
+
125
+ method_name = node.name
126
+ args = node.arguments&.arguments || []
127
+
128
+ if args.empty?
129
+ "#{method_name}()"
130
+ else
131
+ arg_strings = args.map { |arg| normalize_argument(arg) }
132
+ "#{method_name}(#{arg_strings.join(", ")})"
133
+ end
134
+ end
135
+
136
+ # Normalize an argument node to canonical format
137
+ # @param arg [Prism::Node] Argument node
138
+ # @return [String] Normalized argument source
139
+ def normalize_argument(arg)
140
+ case arg
141
+ when Prism::StringNode
142
+ "\"#{arg.unescaped}\""
143
+ when Prism::SymbolNode
144
+ ":#{arg.unescaped}"
145
+ when Prism::KeywordHashNode
146
+ # Handle hash arguments like {key: value}
147
+ pairs = arg.elements.map do |assoc|
148
+ key = case assoc.key
149
+ when Prism::SymbolNode then "#{assoc.key.unescaped}:"
150
+ when Prism::StringNode then "\"#{assoc.key.unescaped}\" =>"
151
+ else "#{assoc.key.slice} =>"
152
+ end
153
+ value = normalize_argument(assoc.value)
154
+ "#{key} #{value}"
155
+ end.join(", ")
156
+ pairs
157
+ when Prism::HashNode
158
+ # Handle explicit hash syntax
159
+ pairs = arg.elements.map do |assoc|
160
+ key_part = normalize_argument(assoc.key)
161
+ value_part = normalize_argument(assoc.value)
162
+ "#{key_part} => #{value_part}"
163
+ end.join(", ")
164
+ "{#{pairs}}"
165
+ else
166
+ # For other types (numbers, arrays, etc.), use the original source
167
+ arg.slice.strip
168
+ end
169
+ end
170
+
171
+ # Check if a node is a specific method call
172
+ # @param node [Prism::Node] Node to check
173
+ # @param method_name [Symbol] Method name to check for
174
+ # @return [Boolean] True if node is a call to the specified method
175
+ def call_to?(node, method_name)
176
+ node.is_a?(Prism::CallNode) && node.name == method_name
177
+ end
178
+
179
+ # Check if a node is a block call to a specific method
180
+ # @param node [Prism::Node] Node to check
181
+ # @param method_name [Symbol] Method name to check for
182
+ # @return [Boolean] True if node is a block call to the specified method
183
+ def block_call_to?(node, method_name)
184
+ node.is_a?(Prism::CallNode) && node.name == method_name && !node.block.nil?
185
+ end
186
+ end
187
+ end
188
+ end