Module: Kettle::Dev::SourceMerger

Defined in:
lib/kettle/dev/source_merger.rb

Overview

Prism-based AST merging for templated Ruby files.
Handles universal freeze reminders, kettle-dev:freeze blocks, and
strategy dispatch (skip/replace/append/merge).

Uses Prism for parsing with first-class comment support, enabling
preservation of inline and leading comments throughout the merge process.

Constant Summary collapse

FREEZE_START =
/#\s*kettle-dev:freeze/i
FREEZE_END =
/#\s*kettle-dev:unfreeze/i
FREEZE_BLOCK =
Regexp.new("(#{FREEZE_START.source}).*?(#{FREEZE_END.source})", Regexp::IGNORECASE | Regexp::MULTILINE)
FREEZE_REMINDER =
<<~RUBY
  # To retain during kettle-dev templating:
  #     kettle-dev:freeze
  #     # ... your code
  #     kettle-dev:unfreeze
RUBY
BUG_URL =
"https://github.com/kettle-rb/kettle-dev/issues"

Class Method Summary collapse

Class Method Details

.apply(strategy:, src:, dest:, path:) ⇒ String

Apply a templating strategy to merge source and destination Ruby files

Examples:

SourceMerger.apply(
  strategy: :merge,
  src: 'gem "foo"',
  dest: 'gem "bar"',
  path: "Gemfile"
)

Parameters:

  • strategy (Symbol)

    Merge strategy - :skip, :replace, :append, or :merge

  • src (String)

    Template source content

  • dest (String)

    Destination file content

  • path (String)

    File path (for error messages)

Returns:

  • (String)

    Merged content with freeze blocks and comments preserved

Raises:



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/kettle/dev/source_merger.rb', line 44

def apply(strategy:, src:, dest:, path:)
  strategy = normalize_strategy(strategy)
  dest ||= ""
  src_with_reminder = ensure_reminder(src)
  content =
    case strategy
    when :skip
      src_with_reminder
    when :replace
      normalize_source(src_with_reminder)
    when :append
      apply_append(src_with_reminder, dest)
    when :merge
      apply_merge(src_with_reminder, dest)
    else
      raise Kettle::Dev::Error, "Unknown templating strategy '#{strategy}' for #{path}."
    end
  content = merge_freeze_blocks(content, dest)
  content = restore_custom_leading_comments(dest, content)
  ensure_trailing_newline(content)
rescue StandardError => error
  warn_bug(path, error)
  raise Kettle::Dev::Error, "Template merge failed for #{path}: #{error.message}"
end

.apply_append(src_content, dest_content) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/kettle/dev/source_merger.rb', line 177

def apply_append(src_content, dest_content)
  prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
    existing = Set.new(dest_nodes.map { |node| node_signature(node[:node]) })
    appended = dest_nodes.dup
    src_nodes.each do |node_info|
      sig = node_signature(node_info[:node])
      next if existing.include?(sig)
      appended << node_info
      existing << sig
    end
    appended
  end
end

.apply_merge(src_content, dest_content) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/kettle/dev/source_merger.rb', line 191

def apply_merge(src_content, dest_content)
  prism_merge(src_content, dest_content) do |src_nodes, dest_nodes, _src_result, _dest_result|
    src_map = src_nodes.each_with_object({}) do |node_info, memo|
      sig = node_signature(node_info[:node])
      memo[sig] ||= node_info
    end
    merged = dest_nodes.map do |node_info|
      sig = node_signature(node_info[:node])
      if (src_node_info = src_map[sig])
        merge_node_info(sig, node_info, src_node_info)
      else
        node_info
      end
    end
    existing = merged.map { |ni| node_signature(ni[:node]) }.to_set
    src_nodes.each do |node_info|
      sig = node_signature(node_info[:node])
      next if existing.include?(sig)
      merged << node_info
      existing << sig
    end
    merged
  end
end

.build_source_from_nodes(node_infos, magic_comments: [], file_leading_comments: []) ⇒ Object



360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
# File 'lib/kettle/dev/source_merger.rb', line 360

def build_source_from_nodes(node_infos, magic_comments: [], file_leading_comments: [])
  return "" if node_infos.empty?

  lines = []

  # Add magic comments at the top (frozen_string_literal, etc.)
  if magic_comments.any?
    lines.concat(magic_comments)
    lines << "" # Add blank line after magic comments
  end

  # Add file-level leading comments (comments before first statement)
  if file_leading_comments.any?
    lines.concat(file_leading_comments)
    lines << "" # Add blank line after file-level comments
  end

  node_infos.each do |node_info|
    # Add blank lines before this statement (for visual grouping)
    blank_lines = node_info[:blank_lines_before] || 0
    blank_lines.times { lines << "" }

    # Add leading comments
    node_info[:leading_comments].each do |comment|
      lines << comment.slice.rstrip
    end

    # Add the node's source
    node_source = PrismUtils.node_to_source(node_info[:node])

    # Add inline comments on the same line
    if node_info[:inline_comments].any?
      inline = node_info[:inline_comments].map { |c| c.slice.strip }.join(" ")
      node_source = node_source.rstrip + " " + inline
    end

    lines << node_source
  end

  lines.join("\n")
end

.count_blank_lines_before(source_lines, current_stmt, prev_stmt, body_node) ⇒ Object



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/kettle/dev/source_merger.rb', line 325

def count_blank_lines_before(source_lines, current_stmt, prev_stmt, body_node)
  # Determine the starting line to search from
  start_line = if prev_stmt
    prev_stmt.location.end_line
  else
    # For the first statement, start from the beginning of the body
    body_node.location.start_line
  end

  end_line = current_stmt.location.start_line

  # Count consecutive blank lines before the current statement
  # (after any comments and the previous statement)
  blank_count = 0
  (start_line...end_line).each do |line_num|
    line_idx = line_num - 1
    next if line_idx < 0 || line_idx >= source_lines.length

    line = source_lines[line_idx]
    # Skip comment lines (they're handled separately)
    next if line.strip.start_with?("#")

    # Count blank lines
    if line.strip.empty?
      blank_count += 1
    else
      # Reset count if we hit a non-blank, non-comment line
      # This ensures we only count consecutive blank lines immediately before the statement
      blank_count = 0
    end
  end

  blank_count
end

.ensure_reminder(content) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Ensure freeze reminder comment is present at the top of content

Parameters:

  • content (String)

    Ruby source content

Returns:

  • (String)

    Content with freeze reminder prepended if missing



74
75
76
77
78
79
80
81
82
# File 'lib/kettle/dev/source_merger.rb', line 74

def ensure_reminder(content)
  return content if reminder_present?(content)
  insertion_index = reminder_insertion_index(content)
  before = content[0...insertion_index]
  after = content[insertion_index..-1]
  snippet = FREEZE_REMINDER
  snippet += "\n" unless snippet.end_with?("\n\n")
  [before, snippet, after].join
end

.ensure_trailing_newline(text) ⇒ Object



172
173
174
175
# File 'lib/kettle/dev/source_merger.rb', line 172

def ensure_trailing_newline(text)
  return "" if text.nil?
  text.end_with?("\n") ? text : text + "\n"
end

.extract_file_leading_comments(parse_result) ⇒ Object



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/kettle/dev/source_merger.rb', line 283

def extract_file_leading_comments(parse_result)
  return [] unless parse_result.success?

  statements = PrismUtils.extract_statements(parse_result.value.statements)
  return [] if statements.empty?

  first_stmt = statements.first
  first_stmt_line = first_stmt.location.start_line

  # Extract file-level comments that appear after magic comments (line 1-2)
  # but before the first executable statement. These are typically documentation
  # comments describing the file's purpose.
  parse_result.comments.select do |comment|
    comment.location.start_line > 2 &&
      comment.location.start_line < first_stmt_line
  end.map { |comment| comment.slice.rstrip }
end

.extract_magic_comments(parse_result) ⇒ Object



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/kettle/dev/source_merger.rb', line 257

def extract_magic_comments(parse_result)
  return [] unless parse_result.success?

  magic_comments = []
  source_lines = parse_result.source.lines

  # Magic comments appear at the very top of the file (possibly after shebang)
  # They must be on the first or second line
  source_lines.first(2).each do |line|
    stripped = line.strip
    # Check for shebang
    if stripped.start_with?("#!")
      magic_comments << line.rstrip
    # Check for magic comments like frozen_string_literal, encoding, etc.
    elsif stripped.start_with?("#") &&
        (stripped.include?("frozen_string_literal:") ||
         stripped.include?("encoding:") ||
         stripped.include?("warn_indent:") ||
         stripped.include?("shareable_constant_value:"))
      magic_comments << line.rstrip
    end
  end

  magic_comments
end

.extract_nodes_with_comments(parse_result) ⇒ Object



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/kettle/dev/source_merger.rb', line 301

def extract_nodes_with_comments(parse_result)
  return [] unless parse_result.success?

  statements = PrismUtils.extract_statements(parse_result.value.statements)
  return [] if statements.empty?

  source_lines = parse_result.source.lines

  statements.map.with_index do |stmt, idx|
    prev_stmt = (idx > 0) ? statements[idx - 1] : nil
    body_node = parse_result.value.statements

    # Count blank lines before this statement
    blank_lines_before = count_blank_lines_before(source_lines, stmt, prev_stmt, body_node)

    {
      node: stmt,
      leading_comments: PrismUtils.find_leading_comments(parse_result, stmt, prev_stmt, body_node),
      inline_comments: PrismUtils.inline_comments_for_node(parse_result, stmt),
      blank_lines_before: blank_lines_before,
    }
  end
end

.freeze_blocks(text) ⇒ Object



147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/kettle/dev/source_merger.rb', line 147

def freeze_blocks(text)
  return [] unless text&.match?(FREEZE_START)
  blocks = []
  text.to_enum(:scan, FREEZE_BLOCK).each do
    match = Regexp.last_match
    start_idx = match&.begin(0)
    end_idx = match&.end(0)
    next unless start_idx && end_idx
    segment = match[0]
    start_marker = segment.lines.first&.strip
    blocks << {range: start_idx...end_idx, text: segment, start_marker: start_marker}
  end
  blocks
end

.frozen_comment?(line) ⇒ Boolean

Returns:

  • (Boolean)


115
116
117
# File 'lib/kettle/dev/source_merger.rb', line 115

def frozen_comment?(line)
  line.match?(/#\s*frozen_string_literal:/)
end

.leading_comment_block(content) ⇒ Object



446
447
448
449
450
451
452
453
454
455
# File 'lib/kettle/dev/source_merger.rb', line 446

def leading_comment_block(content)
  lines = content.to_s.lines
  collected = []
  lines.each do |line|
    stripped = line.strip
    break unless stripped.empty? || stripped.start_with?("#")
    collected << line
  end
  collected.join
end

.merge_block_node_info(src_node_info) ⇒ Object



226
227
228
229
230
231
# File 'lib/kettle/dev/source_merger.rb', line 226

def merge_block_node_info(src_node_info)
  # For block merging, we need to merge the statements within the block
  # This is complex - for now, prefer template version
  # TODO: Implement deep block statement merging with comment preservation
  src_node_info
end

.merge_freeze_blocks(src_content, dest_content) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Merge kettle-dev:freeze blocks from destination into source content
Preserves user customizations wrapped in freeze/unfreeze markers

Parameters:

  • src_content (String)

    Template source content

  • dest_content (String)

    Destination file content

Returns:

  • (String)

    Merged content with freeze blocks from destination



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
# File 'lib/kettle/dev/source_merger.rb', line 126

def merge_freeze_blocks(src_content, dest_content)
  dest_blocks = freeze_blocks(dest_content)
  return src_content if dest_blocks.empty?
  src_blocks = freeze_blocks(src_content)
  updated = src_content.dup
  # Replace matching freeze sections by textual markers rather than index ranges
  dest_blocks.each do |dest_block|
    marker = dest_block[:text]
    next if updated.include?(marker)
    # If the template had a placeholder block, replace the first occurrence of a freeze stub
    placeholder = src_blocks.find { |blk| blk[:start_marker] == dest_block[:start_marker] }
    if placeholder
      updated.sub!(placeholder[:text], marker)
    else
      updated << "\n" unless updated.end_with?("\n")
      updated << marker
    end
  end
  updated
end

.merge_node_info(signature, _dest_node_info, src_node_info) ⇒ Object



216
217
218
219
220
221
222
223
224
# File 'lib/kettle/dev/source_merger.rb', line 216

def merge_node_info(signature, _dest_node_info, src_node_info)
  return src_node_info unless signature.is_a?(Array)
  case signature[1]
  when :gem_specification
    merge_block_node_info(src_node_info)
  else
    src_node_info
  end
end

.node_signature(node) ⇒ Object



402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
# File 'lib/kettle/dev/source_merger.rb', line 402

def node_signature(node)
  return [:nil] unless node

  case node
  when Prism::CallNode
    method_name = node.name
    if node.block
      # Block call
      first_arg = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
      receiver_name = PrismUtils.extract_const_name(node.receiver)

      if receiver_name == "Gem::Specification" && method_name == :new
        [:block, :gem_specification]
      elsif method_name == :task
        [:block, :task, first_arg]
      elsif method_name == :git_source
        [:block, :git_source, first_arg]
      else
        [:block, method_name, first_arg, node.slice]
      end
    elsif [:source, :git_source, :gem, :eval_gemfile].include?(method_name)
      # Simple call
      first_literal = PrismUtils.extract_literal_value(node.arguments&.arguments&.first)
      [:send, method_name, first_literal]
    else
      [:send, method_name, node.slice]
    end
  else
    # Other node types
    [node.class.name.split("::").last.to_sym, node.slice]
  end
end

.normalize_source(source) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Normalize source code while preserving formatting

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (String)

    Normalized source with trailing newline



89
90
91
92
93
94
95
# File 'lib/kettle/dev/source_merger.rb', line 89

def normalize_source(source)
  parse_result = PrismUtils.parse_with_comments(source)
  return ensure_trailing_newline(source) unless parse_result.success?

  # Use Prism's slice to preserve original formatting
  ensure_trailing_newline(source)
end

.normalize_strategy(strategy) ⇒ Object



162
163
164
165
# File 'lib/kettle/dev/source_merger.rb', line 162

def normalize_strategy(strategy)
  return :skip if strategy.nil?
  strategy.to_s.downcase.strip.to_sym
end

.prism_merge(src_content, dest_content) ⇒ Object



233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/kettle/dev/source_merger.rb', line 233

def prism_merge(src_content, dest_content)
  src_result = PrismUtils.parse_with_comments(src_content)
  dest_result = PrismUtils.parse_with_comments(dest_content)

  # If src parsing failed, return src unchanged to avoid losing content
  unless src_result.success?
    puts "WARNING: Source content parse failed, returning unchanged"
    return src_content
  end

  src_nodes = extract_nodes_with_comments(src_result)
  dest_nodes = extract_nodes_with_comments(dest_result)

  merged_nodes = yield(src_nodes, dest_nodes, src_result, dest_result)

  # Extract magic comments from source (frozen_string_literal, etc.)
  magic_comments = extract_magic_comments(src_result)

  # Extract file-level leading comments (comments before first statement)
  file_leading_comments = extract_file_leading_comments(src_result)

  build_source_from_nodes(merged_nodes, magic_comments: magic_comments, file_leading_comments: file_leading_comments)
end

.reminder_insertion_index(content) ⇒ Object



101
102
103
104
105
106
107
108
109
# File 'lib/kettle/dev/source_merger.rb', line 101

def reminder_insertion_index(content)
  cursor = 0
  lines = content.lines
  lines.each do |line|
    break unless shebang?(line) || frozen_comment?(line)
    cursor += line.length
  end
  cursor
end

.reminder_present?(content) ⇒ Boolean

Returns:

  • (Boolean)


97
98
99
# File 'lib/kettle/dev/source_merger.rb', line 97

def reminder_present?(content)
  content.include?(FREEZE_REMINDER.lines.first.strip)
end

.restore_custom_leading_comments(dest_content, merged_content) ⇒ Object



435
436
437
438
439
440
441
442
443
444
# File 'lib/kettle/dev/source_merger.rb', line 435

def restore_custom_leading_comments(dest_content, merged_content)
  block = leading_comment_block(dest_content)
  return merged_content if block.strip.empty?
  return merged_content if merged_content.start_with?(block)

  # Insert after shebang / frozen string literal comments (same place reminder goes)
  insertion_index = reminder_insertion_index(merged_content)
  block = ensure_trailing_newline(block)
  merged_content.dup.insert(insertion_index, block)
end

.shebang?(line) ⇒ Boolean

Returns:

  • (Boolean)


111
112
113
# File 'lib/kettle/dev/source_merger.rb', line 111

def shebang?(line)
  line.start_with?("#!")
end

.warn_bug(path, error) ⇒ Object



167
168
169
170
# File 'lib/kettle/dev/source_merger.rb', line 167

def warn_bug(path, error)
  puts "ERROR: kettle-dev templating failed for #{path}: #{error.message}"
  puts "Please file a bug at #{BUG_URL} with the file contents so we can improve the AST merger."
end