prism-merge 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '058627bf3c8cc38b58815faddda8774645a25a06f8dd00116fac3c5368cad1f4'
4
- data.tar.gz: 75eb6d7b4db6fe1774e45964bada3c2b3396d4d4efb8b5c8813d4f52b07ce120
3
+ metadata.gz: 025344bcf44324e19df2d0519fc8f935a0c51c1236d2299407ca52ec406e6a13
4
+ data.tar.gz: a3f1a6fe2a2ede10fa8c5072268bd384a850d9cbd8e2e95a206e39158e426ec4
5
5
  SHA512:
6
- metadata.gz: 84b86d2c1052489cb9deef903966a1f2c5bdb3523efa93e88e5cd3cc7df5bc52838910b328790e6106e6904ae0df9356d3c54b28aa8e724a95f8210ff014d228
7
- data.tar.gz: f67161e7e0500d7f1c819a0ba9ec6e459fb46cd3da8a05dfe386c245f0fcfaf56912cb22675d3c4547cd0cc98a66aae26023939f7453c13df16202acd0000a3b
6
+ metadata.gz: e2e182168a6014be8e7d3c8ad2130886e63938ac2e7543675a323284cd7cae73667d4b0309b3faa4ae778c241c0b22dc58f03a50d37431f71e0ed9381fdbdd31
7
+ data.tar.gz: 5ec2df827e3bd5f749fdd26576f145f0244f37fa167aa85d101a2fd538fc4b88683c74497bb43e7ae366287b44ca1a39c8afc2ca7fc35bbb219c92c17efe5b20
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,33 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [2.0.4] - 2026-02-22
34
+
35
+ - TAG: [v2.0.4][2.0.4t]
36
+ - COVERAGE: 98.34% -- 891/906 lines in 12 files
37
+ - BRANCH COVERAGE: 84.34% -- 501/594 branches in 12 files
38
+ - 93.51% documented
39
+
40
+ ### Fixed
41
+
42
+ - Always preserve destination magic comments (`# frozen_string_literal: true`,
43
+ `# encoding: UTF-8`, etc.) at the top of merged output, regardless of merge
44
+ preference. Magic comments are file-level metadata managed by Prism and must
45
+ not be lost when the template side lacks them (e.g. after filtering).
46
+ `emit_dest_prefix_lines` now detects contiguous magic comments from the first
47
+ destination node's leading comments, emits them before any template-only nodes
48
+ (Phase 1), and records the emitted line numbers so `add_node_to_result` and
49
+ `merge_node_body_recursively` skip them to prevent duplication.
50
+ - Non-top-of-file magic comments (e.g. used as documentation) are left alone and
51
+ treated as regular comments.
52
+ - Fix blank line preservation between magic comments and subsequent comments when
53
+ template preference is used. Gap lines between a stripped magic comment and the
54
+ next remaining comment are now correctly emitted from the template source.
55
+ - Fix inter-node gap line preservation when a matched node is output from the
56
+ template source. `perform_merge` now checks whether the output source's
57
+ analysis had a trailing blank before advancing `last_output_dest_line`, so
58
+ `emit_dest_gap_lines` correctly emits dest gap lines that the template lacked.
59
+
33
60
  ## [2.0.3] - 2026-02-22
34
61
 
35
62
  - TAG: [v2.0.3][2.0.3t]
@@ -354,7 +381,9 @@ Please file a bug if you notice a violation of semantic versioning.
354
381
 
355
382
  - Initial release
356
383
 
357
- [Unreleased]: https://github.com/kettle-rb/prism-merge/compare/v2.0.3...HEAD
384
+ [Unreleased]: https://github.com/kettle-rb/prism-merge/compare/v2.0.4...HEAD
385
+ [2.0.4]: https://github.com/kettle-rb/prism-merge/compare/v2.0.3...v2.0.4
386
+ [2.0.4t]: https://github.com/kettle-rb/prism-merge/releases/tag/v2.0.4
358
387
  [2.0.3]: https://github.com/kettle-rb/prism-merge/compare/v2.0.2...v2.0.3
359
388
  [2.0.3t]: https://github.com/kettle-rb/prism-merge/releases/tag/v2.0.3
360
389
  [2.0.2]: https://github.com/kettle-rb/prism-merge/compare/v2.0.1...v2.0.2
data/README.md CHANGED
@@ -1564,7 +1564,7 @@ Thanks for RTFM. ☺️
1564
1564
  [📌gitmoji]: https://gitmoji.dev
1565
1565
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1566
1566
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1567
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.839-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1567
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.906-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1568
1568
  [🔐security]: SECURITY.md
1569
1569
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1570
1570
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -93,6 +93,7 @@ module Prism
93
93
  @max_recursion_depth = max_recursion_depth
94
94
  @current_depth = current_depth
95
95
  @text_merger_options = text_merger_options
96
+ @dest_prefix_comment_lines = nil
96
97
 
97
98
  # Store the raw (unwrapped) signature_generator so that
98
99
  # merge_node_body_recursively can pass it to inner SmartMergers
@@ -228,6 +229,10 @@ module Prism
228
229
  # Track which dest line ranges have been output (to avoid duplicating nested content)
229
230
  output_dest_line_ranges = []
230
231
 
232
+ # Emit dest magic comments first — they must always be at the very
233
+ # top of the output, before any template-only nodes from Phase 1.
234
+ last_output_dest_line = emit_dest_prefix_lines(@result, @dest_analysis)
235
+
231
236
  # Phase 1: Output template-only nodes first (nodes in template but not in dest)
232
237
  # This ensures template-only nodes (like `source` in Gemfiles) appear at the top
233
238
  if @add_template_only_nodes
@@ -246,9 +251,6 @@ module Prism
246
251
  # Phase 2: Process dest nodes in their original order
247
252
  # This preserves dest-only nodes in their original position relative to matched nodes
248
253
 
249
- # Emit prefix lines from the dest source (magic comments, blank lines before first node)
250
- last_output_dest_line = emit_dest_prefix_lines(@result, @dest_analysis)
251
-
252
254
  @dest_analysis.statements.each do |dest_node|
253
255
  dest_signature = @dest_analysis.generate_signature(dest_node)
254
256
 
@@ -262,6 +264,11 @@ module Prism
262
264
  # Emit inter-node gap lines from the dest source (blank lines between blocks)
263
265
  last_output_dest_line = emit_dest_gap_lines(@result, @dest_analysis, last_output_dest_line, dest_node)
264
266
 
267
+ # Track which source/analysis was used for output so we can check
268
+ # whether a trailing blank was emitted from that source's analysis.
269
+ output_node = dest_node
270
+ output_analysis = @dest_analysis
271
+
265
272
  if dest_signature && template_by_signature.key?(dest_signature)
266
273
  # Matched node - merge with template version
267
274
  template_node = template_by_signature[dest_signature]
@@ -273,12 +280,19 @@ module Prism
273
280
  if should_merge_recursively?(template_node, dest_node)
274
281
  # Recursively merge class/module/block bodies
275
282
  merge_node_body_recursively(template_node, dest_node)
283
+ node_pref = preference_for_node(template_node, dest_node)
284
+ if node_pref == :template
285
+ output_node = template_node.respond_to?(:unwrap) ? template_node.unwrap : template_node
286
+ output_analysis = @template_analysis
287
+ end
276
288
  else
277
289
  # Output based on preference
278
290
  node_preference = preference_for_node(template_node, dest_node)
279
291
 
280
292
  if node_preference == :template
281
293
  add_node_to_result(@result, template_node, @template_analysis, :template)
294
+ output_node = template_node
295
+ output_analysis = @template_analysis
282
296
  else
283
297
  add_node_to_result(@result, dest_node, @dest_analysis, :destination)
284
298
  end
@@ -290,11 +304,20 @@ module Prism
290
304
  output_signatures << dest_signature if dest_signature
291
305
  end
292
306
 
293
- # Update last_output_dest_line to track trailing blank line from add_node_to_result
307
+ # Update last_output_dest_line. Advance past the trailing blank
308
+ # only if the output source actually has a trailing blank (meaning
309
+ # add_node_to_result / merge_node_body_recursively emitted it).
294
310
  last_output_dest_line = dest_node.location.end_line
295
- trailing_line = last_output_dest_line + 1
296
- trailing_content = @dest_analysis.line_at(trailing_line)
297
- last_output_dest_line = trailing_line if trailing_content && trailing_content.strip.empty?
311
+ actual_output_end = output_node.respond_to?(:unwrap) ? output_node.unwrap.location.end_line : output_node.location.end_line
312
+ trailing_line_num = actual_output_end + 1
313
+ trailing_content = output_analysis.line_at(trailing_line_num)
314
+ if trailing_content && trailing_content.strip.empty?
315
+ # The output source had a trailing blank that was emitted.
316
+ # Advance last_output_dest_line so emit_dest_gap_lines doesn't re-emit it.
317
+ trailing_dest_line = dest_node.location.end_line + 1
318
+ dest_trailing = @dest_analysis.line_at(trailing_dest_line)
319
+ last_output_dest_line = trailing_dest_line if dest_trailing && dest_trailing.strip.empty?
320
+ end
298
321
  end
299
322
 
300
323
  @result
@@ -614,33 +637,105 @@ module Prism
614
637
  }
615
638
  end
616
639
 
617
- # Emit prefix lines from the destination source that appear before the first node.
618
- # This preserves magic comments (e.g., `# frozen_string_literal: true`) and blank
619
- # lines that precede any AST statements.
640
+ # Emit destination magic comments and any lines that precede the first
641
+ # AST node's leading comments.
642
+ #
643
+ # Magic comments (frozen_string_literal, encoding, etc.) are file-level
644
+ # metadata managed by Prism. The destination is the real file on disk,
645
+ # so its magic comments must always be preserved — regardless of merge
646
+ # preference. This method:
647
+ #
648
+ # 1. Emits any lines before the first leading comment (shebangs, etc.)
649
+ # 2. Emits magic comments from the first dest node's leading comments
650
+ # 3. Emits blank lines between the last magic comment and the first
651
+ # non-magic leading comment (or the node itself)
652
+ # 4. Records which dest line numbers were emitted so that
653
+ # add_node_to_result can skip them (preventing duplication)
620
654
  #
621
655
  # @param result [MergeResult] The merge result
622
656
  # @param analysis [FileAnalysis] The destination file analysis
623
657
  # @return [Integer] The last line number emitted (0 if none)
624
658
  def emit_dest_prefix_lines(result, analysis)
659
+ @dest_prefix_comment_lines = Set.new
625
660
  return 0 if analysis.statements.empty?
626
661
 
627
662
  first_node = analysis.statements.first
628
- # Find the first line of content: either leading comment or node start
629
663
  leading_comments = first_node.location.respond_to?(:leading_comments) ? first_node.location.leading_comments : []
630
664
  first_content_line = leading_comments.any? ? leading_comments.first.location.start_line : first_node.location.start_line
631
665
 
632
- return 0 if first_content_line <= 1
633
-
634
- # Emit lines before the first node (magic comments, blank lines)
635
666
  last_emitted = 0
636
- (1...first_content_line).each do |line_num|
637
- line = analysis.line_at(line_num)&.chomp || ""
667
+
668
+ # Step 1: Emit lines before first leading comment (shebangs, etc.)
669
+ if first_content_line > 1
670
+ (1...first_content_line).each do |line_num|
671
+ line = analysis.line_at(line_num)&.chomp || ""
672
+ result.add_line(line, decision: MergeResult::DECISION_KEPT_DEST, dest_line: line_num)
673
+ @dest_prefix_comment_lines << line_num
674
+ last_emitted = line_num
675
+ end
676
+ end
677
+
678
+ # Step 2: Emit contiguous magic comments from the top of the
679
+ # leading comments list, plus blank lines between them and the
680
+ # next non-magic content.
681
+ magic_end_index = -1
682
+ leading_comments.each_with_index do |comment, idx|
683
+ break unless prism_magic_comment?(comment)
684
+ magic_end_index = idx
685
+ end
686
+
687
+ return last_emitted if magic_end_index < 0
688
+
689
+ # Emit magic comment lines
690
+ (0..magic_end_index).each do |idx|
691
+ comment = leading_comments[idx]
692
+ line_num = comment.location.start_line
693
+ line = analysis.line_at(line_num)&.chomp || comment.slice.rstrip
694
+
695
+ # Emit gap lines between consecutive magic comments
696
+ if last_emitted > 0 && line_num > last_emitted + 1
697
+ ((last_emitted + 1)...line_num).each do |gap_num|
698
+ gap = analysis.line_at(gap_num)&.chomp || ""
699
+ result.add_line(gap, decision: MergeResult::DECISION_KEPT_DEST, dest_line: gap_num)
700
+ @dest_prefix_comment_lines << gap_num
701
+ end
702
+ end
703
+
638
704
  result.add_line(line, decision: MergeResult::DECISION_KEPT_DEST, dest_line: line_num)
705
+ @dest_prefix_comment_lines << line_num
639
706
  last_emitted = line_num
640
707
  end
708
+
709
+ # Emit blank lines between last magic comment and next content
710
+ next_content_line = if magic_end_index + 1 < leading_comments.size
711
+ leading_comments[magic_end_index + 1].location.start_line
712
+ else
713
+ first_node.location.start_line
714
+ end
715
+
716
+ if next_content_line > last_emitted + 1
717
+ ((last_emitted + 1)...next_content_line).each do |gap_num|
718
+ gap_line = analysis.line_at(gap_num)&.chomp || ""
719
+ next unless gap_line.strip.empty?
720
+
721
+ result.add_line(gap_line, decision: MergeResult::DECISION_KEPT_DEST, dest_line: gap_num)
722
+ @dest_prefix_comment_lines << gap_num
723
+ last_emitted = gap_num
724
+ end
725
+ end
726
+
641
727
  last_emitted
642
728
  end
643
729
 
730
+ # Check if a Prism comment object is a Ruby magic comment.
731
+ #
732
+ # @param comment [Prism::Comment] A Prism comment object
733
+ # @return [Boolean]
734
+ def prism_magic_comment?(comment)
735
+ text = comment.slice.sub(/\A#\s*/, "").strip
736
+ Comment::Line::MAGIC_COMMENT_PATTERNS.any? { |_, pat| text.match?(pat) }
737
+ end
738
+
644
739
  # Emit blank/gap lines from the destination source between the last output line
645
740
  # and the next node (including its leading comments). This preserves blank lines
646
741
  # that separate top-level blocks.
@@ -686,18 +781,49 @@ module Prism
686
781
  MergeResult::DECISION_KEPT_DEST
687
782
  end
688
783
 
689
- # Get leading comments attached to the node
690
- leading_comments = node.location.respond_to?(:leading_comments) ? node.location.leading_comments : []
784
+ # Get leading comments attached to the node, skipping any that were
785
+ # already emitted by emit_dest_prefix_lines (dest magic comments at
786
+ # the top of the file). Non-top-of-file magic comments are left alone
787
+ # — they may be documentation or intentional repetition.
788
+ #
789
+ # For dest nodes: skip by exact line number match.
790
+ # For template nodes: skip magic comments if dest prefix already
791
+ # emitted magic comments (to avoid duplication).
792
+ all_leading_comments = node.location.respond_to?(:leading_comments) ? node.location.leading_comments : []
793
+ last_skipped_line = nil
794
+ leading_comments = if source == :destination
795
+ all_leading_comments.reject do |c|
796
+ if @dest_prefix_comment_lines&.include?(c.location.start_line)
797
+ last_skipped_line = c.location.start_line
798
+ true
799
+ end
800
+ end
801
+ elsif @dest_prefix_comment_lines&.any?
802
+ all_leading_comments.reject do |c|
803
+ if prism_magic_comment?(c)
804
+ last_skipped_line = c.location.start_line
805
+ true
806
+ end
807
+ end
808
+ else
809
+ all_leading_comments
810
+ end
691
811
 
692
812
  # Add leading comments first (includes freeze markers if present)
693
- # Also add any blank lines between consecutive comments
694
- prev_comment_line = nil
813
+ # Also add any blank lines between consecutive comments.
814
+ # For template nodes where magic comments were skipped, seed
815
+ # prev_comment_line so gap lines between the stripped comment and
816
+ # the next remaining comment are properly emitted.
817
+ prev_comment_line = (source == :template) ? last_skipped_line : nil
695
818
  leading_comments.each do |comment|
696
819
  line_num = comment.location.start_line
697
820
 
698
821
  # Add blank lines between this comment and the previous one
699
822
  if prev_comment_line && line_num > prev_comment_line + 1
700
823
  ((prev_comment_line + 1)...line_num).each do |blank_line_num|
824
+ # Skip lines already emitted by emit_dest_prefix_lines
825
+ next if @dest_prefix_comment_lines&.include?(blank_line_num)
826
+
701
827
  line = analysis.line_at(blank_line_num)&.chomp || ""
702
828
  if source == :template
703
829
  result.add_line(line, decision: decision, template_line: blank_line_num)
@@ -724,6 +850,9 @@ module Prism
724
850
  if node.location.start_line > last_comment_line + 1
725
851
  # There's a gap - add blank lines
726
852
  ((last_comment_line + 1)...node.location.start_line).each do |line_num|
853
+ # Skip lines already emitted by emit_dest_prefix_lines
854
+ next if @dest_prefix_comment_lines&.include?(line_num)
855
+
727
856
  line = analysis.line_at(line_num)&.chomp || ""
728
857
  if source == :template
729
858
  result.add_line(line, decision: decision, template_line: line_num)
@@ -906,12 +1035,23 @@ module Prism
906
1035
  # Get preference for this specific node pair
907
1036
  node_preference = preference_for_node(template_node, dest_node)
908
1037
 
909
- # Determine which comments to use:
1038
+ # Determine which comments to use (skipping any already emitted
1039
+ # by emit_dest_prefix_lines):
910
1040
  # - If template preference and template has comments, use template's
911
1041
  # - If template preference but template has NO comments, preserve dest's comments
912
1042
  # - If dest preference, use dest's comments
913
1043
  template_comments = actual_template.location.respond_to?(:leading_comments) ? actual_template.location.leading_comments : []
914
1044
  dest_comments = actual_dest.location.respond_to?(:leading_comments) ? actual_dest.location.leading_comments : []
1045
+ dest_comments = dest_comments.reject { |c| @dest_prefix_comment_lines&.include?(c.location.start_line) }
1046
+ last_skipped_template_line = nil
1047
+ if @dest_prefix_comment_lines&.any?
1048
+ template_comments = template_comments.reject do |c|
1049
+ if prism_magic_comment?(c)
1050
+ last_skipped_template_line = c.location.start_line
1051
+ true
1052
+ end
1053
+ end
1054
+ end
915
1055
 
916
1056
  # Choose comment source: prefer dest comments if template has none (to preserve existing headers)
917
1057
  if node_preference == :template && template_comments.empty? && dest_comments.any?
@@ -933,14 +1073,19 @@ module Prism
933
1073
  source_node = (node_preference == :template) ? actual_template : actual_dest
934
1074
  decision = MergeResult::DECISION_REPLACED
935
1075
 
936
- # Add leading comments with blank lines between them preserved
937
- prev_comment_line = nil
1076
+ # Add leading comments with blank lines between them preserved.
1077
+ # Seed prev_comment_line for template source so gap lines between
1078
+ # stripped magic comments and remaining comments are preserved.
1079
+ prev_comment_line = (comment_source == :template) ? last_skipped_template_line : nil
938
1080
  leading_comments.each do |comment|
939
1081
  line_num = comment.location.start_line
940
1082
 
941
1083
  # Add blank lines between this comment and the previous one
942
1084
  if prev_comment_line && line_num > prev_comment_line + 1
943
1085
  ((prev_comment_line + 1)...line_num).each do |blank_line_num|
1086
+ # Skip lines already emitted by emit_dest_prefix_lines
1087
+ next if @dest_prefix_comment_lines&.include?(blank_line_num)
1088
+
944
1089
  line = comment_analysis.line_at(blank_line_num)&.chomp || ""
945
1090
  if comment_source == :template
946
1091
  @result.add_line(line, decision: decision, template_line: blank_line_num)
@@ -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 = "2.0.3"
8
+ VERSION = "2.0.4"
9
9
  end
10
10
  VERSION = Version::VERSION # traditional location
11
11
  end
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: 2.0.3
4
+ version: 2.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -314,10 +314,10 @@ licenses:
314
314
  - MIT
315
315
  metadata:
316
316
  homepage_uri: https://prism-merge.galtzo.com/
317
- source_code_uri: https://github.com/kettle-rb/prism-merge/tree/v2.0.3
318
- changelog_uri: https://github.com/kettle-rb/prism-merge/blob/v2.0.3/CHANGELOG.md
317
+ source_code_uri: https://github.com/kettle-rb/prism-merge/tree/v2.0.4
318
+ changelog_uri: https://github.com/kettle-rb/prism-merge/blob/v2.0.4/CHANGELOG.md
319
319
  bug_tracker_uri: https://github.com/kettle-rb/prism-merge/issues
320
- documentation_uri: https://www.rubydoc.info/gems/prism-merge/2.0.3
320
+ documentation_uri: https://www.rubydoc.info/gems/prism-merge/2.0.4
321
321
  funding_uri: https://github.com/sponsors/pboling
322
322
  wiki_uri: https://github.com/kettle-rb/prism-merge/wiki
323
323
  news_uri: https://www.railsbling.com/tags/prism-merge
metadata.gz.sig CHANGED
Binary file