prism-merge 1.0.3 → 1.1.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.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prism
4
+ module Merge
5
+ # Wrapper to represent freeze blocks as first-class nodes.
6
+ # A freeze block is a section marked with freeze/unfreeze comment markers that
7
+ # should be preserved from the destination during merges.
8
+ #
9
+ # While freeze blocks are delineated by comment markers, they are conceptually
10
+ # different from CommentNode and do not inherit from it because:
11
+ # - FreezeNode is a *structural directive* that contains code and/or comments
12
+ # - CommentNode represents *pure documentation* with no structural significance
13
+ # - FreezeNode can contain Ruby code nodes (methods, constants, etc.)
14
+ # - CommentNode only contains comments
15
+ # - Their signatures need different semantics for merge matching
16
+ #
17
+ # Freeze blocks can contain other nodes (methods, classes, etc.) and those
18
+ # nodes remain as separate entities within the block for analysis purposes,
19
+ # but the entire freeze block is treated as an atomic unit during merging.
20
+ #
21
+ # @example Freeze block with mixed content
22
+ # # prism-merge:freeze
23
+ # # Custom documentation
24
+ # CUSTOM_CONFIG = { key: "secret" }
25
+ # def custom_method
26
+ # # ...
27
+ # end
28
+ # # prism-merge:unfreeze
29
+ class FreezeNode
30
+ # Error raised when a freeze block has invalid structure
31
+ class InvalidStructureError < StandardError
32
+ attr_reader :start_line, :end_line, :unclosed_nodes
33
+
34
+ def initialize(message, start_line: nil, end_line: nil, unclosed_nodes: [])
35
+ super(message)
36
+ @start_line = start_line
37
+ @end_line = end_line
38
+ @unclosed_nodes = unclosed_nodes
39
+ end
40
+ end
41
+
42
+ attr_reader :start_line, :end_line, :content, :nodes, :start_marker, :end_marker
43
+
44
+ # @param start_line [Integer] Line number of freeze marker
45
+ # @param end_line [Integer] Line number of unfreeze marker
46
+ # @param analysis [FileAnalysis] The file analysis containing this block
47
+ # @param nodes [Array<Prism::Node>] Nodes fully contained within the freeze block
48
+ # @param overlapping_nodes [Array<Prism::Node>] All nodes that overlap with freeze block (for validation)
49
+ # @param start_marker [String, nil] The freeze start marker text
50
+ # @param end_marker [String, nil] The freeze end marker text
51
+ def initialize(start_line:, end_line:, analysis:, nodes: [], overlapping_nodes: nil, start_marker: nil, end_marker: nil)
52
+ @start_line = start_line
53
+ @end_line = end_line
54
+ @analysis = analysis
55
+ @nodes = nodes
56
+ @overlapping_nodes = overlapping_nodes || nodes
57
+ @start_marker = start_marker
58
+ @end_marker = end_marker
59
+
60
+ # Extract content for the entire block
61
+ @content = (start_line..end_line).map { |ln| analysis.line_at(ln) }.join
62
+
63
+ # Validate structure
64
+ validate_structure!
65
+ end
66
+
67
+ # Returns a location-like object for compatibility with Prism nodes
68
+ def location
69
+ @location ||= Location.new(@start_line, @end_line)
70
+ end
71
+
72
+ # Returns the freeze block content
73
+ def slice
74
+ @content
75
+ end
76
+
77
+ # Simple location struct for compatibility
78
+ Location = Struct.new(:start_line, :end_line) do
79
+ def cover?(line)
80
+ (start_line..end_line).cover?(line)
81
+ end
82
+ end
83
+
84
+ # Returns a stable signature for this freeze block
85
+ # Signature includes the normalized content to detect changes
86
+ def signature
87
+ normalized = (@start_line..@end_line).map do |ln|
88
+ @analysis.normalized_line(ln)
89
+ end.compact.join("\n")
90
+
91
+ [:FreezeNode, normalized]
92
+ end
93
+
94
+ # Check if this is a freeze node (always true for FreezeNode)
95
+ def freeze_node?
96
+ true
97
+ end
98
+
99
+ # String representation for debugging
100
+ def inspect
101
+ "#<Prism::Merge::FreezeNode lines=#{@start_line}..#{@end_line} nodes=#{@nodes.length}>"
102
+ end
103
+
104
+ private
105
+
106
+ # Validate that the freeze block has proper structure:
107
+ # - All nodes must be either fully contained or fully outside
108
+ # - No partial overlaps allowed (a node cannot start before and end inside, or vice versa)
109
+ def validate_structure!
110
+ unclosed = []
111
+
112
+ # Check all overlapping nodes
113
+ @overlapping_nodes.each do |node|
114
+ node_start = node.location.start_line
115
+ node_end = node.location.end_line
116
+
117
+ # Check if node is fully contained (valid)
118
+ fully_contained = node_start >= @start_line && node_end <= @end_line
119
+
120
+ # Check if node completely encompasses the freeze block
121
+ # This is only valid for ClassNode/ModuleNode (freeze blocks at class/module body level)
122
+ # For other nodes (methods, etc.), this is invalid
123
+ encompasses = node_start < @start_line && node_end > @end_line
124
+ valid_encompass = encompasses && (node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode))
125
+
126
+ # Check if node partially overlaps (invalid - unclosed/incomplete structure)
127
+ partially_overlaps = !fully_contained && !encompasses &&
128
+ ((node_start < @start_line && node_end >= @start_line) ||
129
+ (node_start <= @end_line && node_end > @end_line))
130
+
131
+ # Invalid if: partial overlap OR if a non-class/module node encompasses the freeze block
132
+ if partially_overlaps || (encompasses && !valid_encompass)
133
+ unclosed << node
134
+ end
135
+ end
136
+
137
+ return if unclosed.empty?
138
+
139
+ # Build error message with details about unclosed/overlapping nodes
140
+ node_descriptions = unclosed.map do |node|
141
+ node_start = node.location.start_line
142
+ node_end = node.location.end_line
143
+ overlap_type = if node_start < @start_line
144
+ "starts before freeze block (line #{node_start}) and ends inside (line #{node_end})"
145
+ else
146
+ "starts inside freeze block (line #{node_start}) and ends after (line #{node_end})"
147
+ end
148
+ "#{node.class.name.split("::").last} at lines #{node_start}-#{node_end} (#{overlap_type})"
149
+ end.join(", ")
150
+
151
+ raise InvalidStructureError.new(
152
+ "Freeze block at lines #{@start_line}-#{@end_line} contains incomplete nodes: #{node_descriptions}. " \
153
+ "A freeze block must fully contain all nodes within it, or be placed between nodes.",
154
+ start_line: @start_line,
155
+ end_line: @end_line,
156
+ unclosed_nodes: unclosed,
157
+ )
158
+ end
159
+ end
160
+ end
161
+ end
@@ -5,7 +5,10 @@ module Prism
5
5
  # Orchestrates the smart merge process using FileAnalysis, FileAligner,
6
6
  # ConflictResolver, and MergeResult to merge two Ruby files intelligently.
7
7
  #
8
- # SmartMerger provides flexible configuration for different merge scenarios:
8
+ # SmartMerger provides flexible configuration for different merge scenarios.
9
+ # When matching class or module definitions are found in both files, the merger
10
+ # automatically performs recursive merging of their bodies, intelligently combining
11
+ # nested methods, constants, and other definitions.
9
12
  #
10
13
  # @example Basic merge (destination customizations preserved)
11
14
  # merger = SmartMerger.new(template_content, dest_content)
@@ -86,6 +89,10 @@ module Prism
86
89
  # - `true` - Add template-only nodes to result.
87
90
  # Use when template has new required constants/methods to add.
88
91
  #
92
+ # @param freeze_token [String] Token to use for freeze block markers.
93
+ # Default: "prism-merge" (looks for # prism-merge:freeze / # prism-merge:unfreeze)
94
+ # Freeze blocks preserve destination content unchanged during merge.
95
+ #
89
96
  # @raise [TemplateParseError] If template has syntax errors
90
97
  # @raise [DestinationParseError] If destination has syntax errors
91
98
  #
@@ -124,13 +131,14 @@ module Prism
124
131
  # destination,
125
132
  # signature_generator: sig_gen
126
133
  # )
127
- def initialize(template_content, dest_content, signature_generator: nil, signature_match_preference: :destination, add_template_only_nodes: false)
134
+ def initialize(template_content, dest_content, signature_generator: nil, signature_match_preference: :destination, add_template_only_nodes: false, freeze_token: FileAnalysis::DEFAULT_FREEZE_TOKEN)
128
135
  @template_content = template_content
129
136
  @dest_content = dest_content
130
137
  @signature_match_preference = signature_match_preference
131
138
  @add_template_only_nodes = add_template_only_nodes
132
- @template_analysis = FileAnalysis.new(template_content, signature_generator: signature_generator)
133
- @dest_analysis = FileAnalysis.new(dest_content, signature_generator: signature_generator)
139
+ @freeze_token = freeze_token
140
+ @template_analysis = FileAnalysis.new(template_content, signature_generator: signature_generator, freeze_token: freeze_token)
141
+ @dest_analysis = FileAnalysis.new(dest_content, signature_generator: signature_generator, freeze_token: freeze_token)
134
142
  @aligner = FileAligner.new(@template_analysis, @dest_analysis)
135
143
  @resolver = ConflictResolver.new(
136
144
  @template_analysis,
@@ -304,8 +312,15 @@ module Prism
304
312
  end
305
313
 
306
314
  def add_signature_match_from_dest(anchor)
307
- # For signature matches, use the configured preference
308
- if @signature_match_preference == :template
315
+ # Find the nodes corresponding to this anchor
316
+ # Look for nodes that overlap with the anchor range (not just at start line)
317
+ template_node = find_node_in_range(@template_analysis, anchor.template_start, anchor.template_end)
318
+ dest_node = find_node_in_range(@dest_analysis, anchor.dest_start, anchor.dest_end)
319
+
320
+ # Check if this is a class or module that should be recursively merged
321
+ if should_merge_recursively?(template_node, dest_node)
322
+ merge_node_body_recursively(template_node, dest_node, anchor)
323
+ elsif @signature_match_preference == :template
309
324
  # Use template version (for updates/canonical values)
310
325
  anchor.template_range.each do |line_num|
311
326
  line = @template_analysis.line_at(line_num)
@@ -342,6 +357,201 @@ module Prism
342
357
  def process_boundary(boundary)
343
358
  @resolver.resolve(boundary, @result)
344
359
  end
360
+
361
+ # Find a node that overlaps with the given line range.
362
+ # This handles cases where anchors include leading comments (e.g., magic comments).
363
+ #
364
+ # @param analysis [FileAnalysis] The file analysis to search
365
+ # @param start_line [Integer] Start line of the range
366
+ # @param end_line [Integer] End line of the range
367
+ # @return [Prism::Node, nil] The first node that overlaps with the range, or nil if none found
368
+ def find_node_in_range(analysis, start_line, end_line)
369
+ # Find a node that overlaps with the range
370
+ analysis.statements.find do |stmt|
371
+ # Check if node overlaps with the range
372
+ stmt.location.start_line <= end_line && stmt.location.end_line >= start_line
373
+ end
374
+ end
375
+
376
+ # Find the node at a specific line in the analysis (deprecated - use find_node_in_range)
377
+ # @deprecated Use {#find_node_in_range} instead for better handling of leading comments
378
+ # @param analysis [FileAnalysis] The file analysis to search
379
+ # @param line_num [Integer] The line number to find a node at
380
+ # @return [Prism::Node, nil] The node at that line, or nil if none found
381
+ def find_node_at_line(analysis, line_num)
382
+ analysis.statements.find do |stmt|
383
+ line_num.between?(stmt.location.start_line, stmt.location.end_line)
384
+ end
385
+ end
386
+
387
+ # Determines if two matching nodes should be recursively merged.
388
+ #
389
+ # Recursive merge is performed for matching class/module definitions to intelligently
390
+ # combine their body contents (nested methods, constants, etc.). This allows template
391
+ # updates to class internals to be merged with destination customizations.
392
+ #
393
+ # @param template_node [Prism::Node, nil] Node from template file
394
+ # @param dest_node [Prism::Node, nil] Node from destination file
395
+ # @return [Boolean] true if nodes should be recursively merged
396
+ #
397
+ # @note Recursive merge is NOT performed for:
398
+ # - Conditional nodes (if/unless) - treated as atomic units
399
+ # - Classes/modules containing freeze blocks - frozen content would be lost
400
+ # - Nodes of different types
401
+ def should_merge_recursively?(template_node, dest_node)
402
+ return false unless template_node && dest_node
403
+
404
+ is_class_or_module = (template_node.is_a?(Prism::ClassNode) && dest_node.is_a?(Prism::ClassNode)) ||
405
+ (template_node.is_a?(Prism::ModuleNode) && dest_node.is_a?(Prism::ModuleNode))
406
+
407
+ return false unless is_class_or_module
408
+
409
+ # Don't recursively merge if either node contains freeze blocks
410
+ # (they would be lost in the nested merge since we pass freeze_token: nil)
411
+ return false if node_contains_freeze_blocks?(template_node)
412
+ return false if node_contains_freeze_blocks?(dest_node)
413
+
414
+ true
415
+ end
416
+
417
+ # Check if a node's body contains freeze block markers.
418
+ #
419
+ # @param node [Prism::Node] The node to check
420
+ # @return [Boolean] true if the node's body contains freeze block comments
421
+ # @api private
422
+ def node_contains_freeze_blocks?(node)
423
+ return false unless @freeze_token
424
+ return false unless node.body
425
+
426
+ # Check if any comments in the node's range contain freeze markers
427
+ freeze_pattern = /#\s*#{Regexp.escape(@freeze_token)}:(freeze|unfreeze)/i
428
+
429
+ node_start = node.location.start_line
430
+ node_end = node.location.end_line
431
+
432
+ @template_analysis.parse_result.comments.any? do |comment|
433
+ comment_line = comment.location.start_line
434
+ comment_line > node_start && comment_line < node_end && comment.slice.match?(freeze_pattern)
435
+ end || @dest_analysis.parse_result.comments.any? do |comment|
436
+ comment_line = comment.location.start_line
437
+ comment_line > node_start && comment_line < node_end && comment.slice.match?(freeze_pattern)
438
+ end
439
+ end
440
+
441
+ # Recursively merges the body of matching class or module nodes.
442
+ #
443
+ # This method extracts the body content (everything between the class/module
444
+ # declaration and the closing 'end'), creates a new nested SmartMerger to merge
445
+ # those bodies, and then reassembles the complete class/module with the merged body.
446
+ #
447
+ # @param template_node [Prism::ClassNode, Prism::ModuleNode] Class/module from template
448
+ # @param dest_node [Prism::ClassNode, Prism::ModuleNode] Class/module from destination
449
+ # @param anchor [FileAligner::Anchor] The anchor representing this match
450
+ #
451
+ # @note The nested merger is configured with:
452
+ # - Same signature_generator, signature_match_preference, and add_template_only_nodes
453
+ # - freeze_token: nil (freeze blocks not processed in nested context)
454
+ #
455
+ # @api private
456
+ def merge_node_body_recursively(template_node, dest_node, anchor)
457
+ # Extract the body source for both nodes
458
+ template_body = extract_node_body(template_node, @template_analysis)
459
+ dest_body = extract_node_body(dest_node, @dest_analysis)
460
+
461
+ # Recursively merge the bodies
462
+ body_merger = SmartMerger.new(
463
+ template_body,
464
+ dest_body,
465
+ signature_generator: @template_analysis.instance_variable_get(:@signature_generator),
466
+ signature_match_preference: @signature_match_preference,
467
+ add_template_only_nodes: @add_template_only_nodes,
468
+ freeze_token: nil, # Don't process freeze blocks in nested context
469
+ )
470
+ merged_body = body_merger.merge.rstrip
471
+
472
+ # Add the opening line (class/module declaration) with leading comments
473
+ source_analysis = (@signature_match_preference == :template) ? @template_analysis : @dest_analysis
474
+ source_node = (@signature_match_preference == :template) ? template_node : dest_node
475
+ source_anchor_start = (@signature_match_preference == :template) ? anchor.template_start : anchor.dest_start
476
+
477
+ # Add leading comments
478
+ (source_anchor_start...source_node.location.start_line).each do |line_num|
479
+ line = source_analysis.line_at(line_num)
480
+ @result.add_line(
481
+ line.chomp,
482
+ decision: MergeResult::DECISION_REPLACED,
483
+ template_line: ((@signature_match_preference == :template) ? line_num : nil),
484
+ dest_line: ((@signature_match_preference == :destination) ? line_num : nil),
485
+ )
486
+ end
487
+
488
+ # Add the class/module opening line
489
+ opening_line = source_analysis.line_at(source_node.location.start_line)
490
+ @result.add_line(
491
+ opening_line.chomp,
492
+ decision: MergeResult::DECISION_REPLACED,
493
+ template_line: ((@signature_match_preference == :template) ? source_node.location.start_line : nil),
494
+ dest_line: ((@signature_match_preference == :destination) ? source_node.location.start_line : nil),
495
+ )
496
+
497
+ # Add the merged body (indented appropriately)
498
+ merged_body.lines.each do |line|
499
+ @result.add_line(
500
+ line.chomp,
501
+ decision: MergeResult::DECISION_REPLACED,
502
+ template_line: nil,
503
+ dest_line: nil,
504
+ )
505
+ end
506
+
507
+ # Add the closing 'end'
508
+ end_line = source_analysis.line_at(source_node.location.end_line)
509
+ @result.add_line(
510
+ end_line.chomp,
511
+ decision: MergeResult::DECISION_REPLACED,
512
+ template_line: ((@signature_match_preference == :template) ? source_node.location.end_line : nil),
513
+ dest_line: ((@signature_match_preference == :destination) ? source_node.location.end_line : nil),
514
+ )
515
+ end
516
+
517
+ # Extracts the body content of a node (without declaration and closing 'end').
518
+ #
519
+ # For class/module nodes, extracts content between the declaration line and the
520
+ # closing 'end'. For conditional nodes, extracts the statements within the condition.
521
+ #
522
+ # @param node [Prism::Node] The node to extract body from
523
+ # @param analysis [FileAnalysis] The file analysis containing the node
524
+ # @return [String] The extracted body content
525
+ #
526
+ # @note Handles different node types:
527
+ # - ClassNode/ModuleNode: Uses node.body (StatementsNode)
528
+ # - IfNode/UnlessNode: Uses node.statements (StatementsNode)
529
+ #
530
+ # @api private
531
+ def extract_node_body(node, analysis)
532
+ # Get the statements node based on node type
533
+ statements_node = if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
534
+ node.body
535
+ elsif node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
536
+ node.statements
537
+ end
538
+
539
+ return "" unless statements_node&.is_a?(Prism::StatementsNode)
540
+
541
+ body_statements = statements_node.body
542
+ return "" if body_statements.empty?
543
+
544
+ # Get the line range of the body (between opening line and end)
545
+ first_stmt_line = body_statements.first.location.start_line
546
+ last_stmt_line = body_statements.last.location.end_line
547
+
548
+ # Extract the source lines for the body
549
+ lines = []
550
+ (first_stmt_line..last_stmt_line).each do |line_num|
551
+ lines << analysis.line_at(line_num).chomp
552
+ end
553
+ lines.join("\n") + "\n"
554
+ end
345
555
  end
346
556
  end
347
557
  end
@@ -5,7 +5,7 @@ module Prism
5
5
  # Version information for Prism::Merge
6
6
  module Version
7
7
  # Current version of the prism-merge gem
8
- VERSION = "1.0.3"
8
+ VERSION = "1.1.0"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
data/lib/prism/merge.rb CHANGED
@@ -80,6 +80,8 @@ module Prism
80
80
  # end
81
81
  class DestinationParseError < ParseError; end
82
82
 
83
+ autoload :DebugLogger, "prism/merge/debug_logger"
84
+ autoload :FreezeNode, "prism/merge/freeze_node"
83
85
  autoload :FileAnalysis, "prism/merge/file_analysis"
84
86
  autoload :MergeResult, "prism/merge/merge_result"
85
87
  autoload :FileAligner, "prism/merge/file_aligner"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prism-merge
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -252,8 +252,10 @@ files:
252
252
  - lib/prism-merge.rb
253
253
  - lib/prism/merge.rb
254
254
  - lib/prism/merge/conflict_resolver.rb
255
+ - lib/prism/merge/debug_logger.rb
255
256
  - lib/prism/merge/file_aligner.rb
256
257
  - lib/prism/merge/file_analysis.rb
258
+ - lib/prism/merge/freeze_node.rb
257
259
  - lib/prism/merge/merge_result.rb
258
260
  - lib/prism/merge/smart_merger.rb
259
261
  - lib/prism/merge/version.rb
@@ -263,10 +265,10 @@ licenses:
263
265
  - MIT
264
266
  metadata:
265
267
  homepage_uri: https://prism-merge.galtzo.com/
266
- source_code_uri: https://github.com/kettle-rb/prism-merge/tree/v1.0.3
267
- changelog_uri: https://github.com/kettle-rb/prism-merge/blob/v1.0.3/CHANGELOG.md
268
+ source_code_uri: https://github.com/kettle-rb/prism-merge/tree/v1.1.0
269
+ changelog_uri: https://github.com/kettle-rb/prism-merge/blob/v1.1.0/CHANGELOG.md
268
270
  bug_tracker_uri: https://github.com/kettle-rb/prism-merge/issues
269
- documentation_uri: https://www.rubydoc.info/gems/prism-merge/1.0.3
271
+ documentation_uri: https://www.rubydoc.info/gems/prism-merge/1.1.0
270
272
  funding_uri: https://github.com/sponsors/pboling
271
273
  wiki_uri: https://github.com/kettle-rb/prism-merge/wiki
272
274
  news_uri: https://www.railsbling.com/tags/prism-merge
metadata.gz.sig CHANGED
Binary file