canon 0.3.35 → 0.3.37

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: 746a0b0b72cc9111986fb3aab9b8c5de994d71b30a011983665b5a24130b092c
4
- data.tar.gz: e9e5a262b99c0e44c2f11385e911faaf5216b52f32bc027d1a0ae82f0bae985e
3
+ metadata.gz: fc8ab08578b3b1ceed40cb8f14ee8e83fdb116d26214a4a546789108950802dc
4
+ data.tar.gz: 435c4f9300160c8b502b8441096b2eea9aec0e182f025e84789f8f0fd492451d
5
5
  SHA512:
6
- metadata.gz: 9edbbe4c9eb6adb6218b2d282f1ac030fa4642ece72cedae34b54ea6cbac987acfd46bebbf1277da017b4bfcf511be98f41c64951fee9d7034d1410f8414a38b
7
- data.tar.gz: 23d088e8fb394d5f36553b64f9e05955f788ca85615147878d6116552ed2ac3b297ebdc739604de009c754a5a8600a57cad97668c6c325a018635d6c6010df04
6
+ metadata.gz: a9c49d99613a801415eec95e663caa577319e9c4ce63546c1708c0599c891289e225c415279131c2652f83e9cec17b225c15f0bbc620da1a1237cc321eb2d381
7
+ data.tar.gz: fa8e1ea33c6527b7a215c0b8e097f4266d3db0cc00e865bc39c883b8b3de8a8683533beb66bccf1538d89059aeb348a716c5c1d9fb2b4eefd14d257b23cc993c
@@ -75,6 +75,17 @@ module Canon
75
75
  # Use ElementMatcher for semantic comparison
76
76
  def use_element_matcher_comparison(children1, children2, parent_node, comparator,
77
77
  opts, child_opts, diff_children, differences)
78
+ # FAST PATH: positionally paired children — compare pairwise
79
+ # with zero MatchResult allocation. Same pairing the matcher
80
+ # would emit; pos1==pos2 so position_changed? never fires.
81
+ if Canon::Xml::ElementMatcher.positionally_paired?(children1,
82
+ children2)
83
+ return compare_positionally_paired(
84
+ children1, children2, comparator, opts, child_opts,
85
+ diff_children, differences
86
+ )
87
+ end
88
+
78
89
  # Single-level matching: the comparator itself descends via
79
90
  # compare_nodes below, so nested matches would be discarded.
80
91
  matches = Canon::Xml::ElementMatcher.new
@@ -92,6 +103,24 @@ module Canon
92
103
  opts, child_opts, diff_children, differences)
93
104
  end
94
105
 
106
+ # Pairwise compare when ElementMatcher.positionally_paired?
107
+ # has already proved the pairing. No MatchResult objects.
108
+ def compare_positionally_paired(children1, children2, comparator,
109
+ _opts, child_opts, diff_children,
110
+ differences)
111
+ all_equivalent = true
112
+ i = 0
113
+ while i < children1.length
114
+ result = comparator.compare_nodes(
115
+ children1[i], children2[i],
116
+ child_opts, child_opts, diff_children, differences
117
+ )
118
+ all_equivalent = false unless result == Comparison::EQUIVALENT
119
+ i += 1
120
+ end
121
+ all_equivalent ? Comparison::EQUIVALENT : Comparison::UNEQUAL_ELEMENTS
122
+ end
123
+
95
124
  # Process ElementMatcher results
96
125
  def process_matches(matches, _children1, _children2, _parent_node, comparator,
97
126
  opts, child_opts, diff_children, differences)
@@ -613,13 +613,17 @@ module Canon
613
613
  # occurrence. Shorter strings match too many places in the document.
614
614
  min_locate_length = 3
615
615
 
616
- # Element added (only in text2)
616
+ # Element added (only in text2). Index-aware first — see the
617
+ # removed branch note (identical-sibling anchor bug).
617
618
  if before.nil?
618
- loc = if after.length < min_locate_length && path
619
- locate_via_parent_element(path, @text2, @line_map2)
620
- else
621
- SourceLocator.locate(after, @text2, @line_map2)
619
+ loc = if path
620
+ locate_element_at_index(after, @text2, @line_map2, path)
622
621
  end
622
+ loc ||= if after.length < min_locate_length && path
623
+ locate_via_parent_element(path, @text2, @line_map2)
624
+ else
625
+ SourceLocator.locate(after, @text2, @line_map2)
626
+ end
623
627
 
624
628
  if loc
625
629
  end_line = find_end_line(loc[:line_number], @line_map2, after)
@@ -643,13 +647,20 @@ module Canon
643
647
  return
644
648
  end
645
649
 
646
- # Element removed (only in text1)
650
+ # Element removed (only in text1). The path-aware locate runs
651
+ # FIRST: an identical sibling earlier in the document would win
652
+ # a plain first-occurrence search, anchoring the deletion to
653
+ # the wrong element and letting the line builder absorb it
654
+ # into a formatting gap (issue #85/#86).
647
655
  if after.nil?
648
- loc = if before.length < min_locate_length && path
649
- locate_via_parent_element(path, @text1, @line_map1)
650
- else
651
- SourceLocator.locate(before, @text1, @line_map1)
656
+ loc = if path
657
+ locate_element_at_index(before, @text1, @line_map1, path)
652
658
  end
659
+ loc ||= if before.length < min_locate_length && path
660
+ locate_via_parent_element(path, @text1, @line_map1)
661
+ else
662
+ SourceLocator.locate(before, @text1, @line_map1)
663
+ end
653
664
 
654
665
  if loc
655
666
  end_line = find_end_line(loc[:line_number], @line_map1, before)
@@ -982,6 +993,39 @@ module Canon
982
993
  SourceLocator.locate(value, text, line_map)
983
994
  end
984
995
 
996
+ # Index-aware location for element_structure changes: the path's
997
+ # last bracketed segment names the element itself (unlike
998
+ # text_content paths, whose last segment is the text node).
999
+ # Picks the occurrence whose element index matches the path, so
1000
+ # identical siblings anchor at the right one; falls back to the
1001
+ # first occurrence when no indexed match exists.
1002
+ def locate_element_at_index(value, text, line_map, path)
1003
+ return nil if value.nil? || value.length < 3
1004
+
1005
+ segments = path.split("/").reject(&:empty?)
1006
+ element_segment = segments.reverse.find { |seg| seg.include?("[") }
1007
+ return nil unless element_segment
1008
+
1009
+ element_match = element_segment.match(/([a-zA-Z0-9_:-]+)\[(\d+)\]/)
1010
+ return nil unless element_match
1011
+
1012
+ element_name = element_match[1]
1013
+ target_index = element_match[2].to_i
1014
+
1015
+ # Occurrence offsets point AT the element's own opening tag,
1016
+ # so the prefix scan counts exactly the preceding siblings —
1017
+ # no "inside the element" correction (count_elements_before_
1018
+ # position subtracts one for text-node offsets).
1019
+ opener = /<#{element_name}[>\s]/
1020
+ occurrences = SourceLocator.locate_all(value, text, line_map)
1021
+ occurrences.each do |occ|
1022
+ count = text[0...occ[:char_offset]].scan(opener).length
1023
+ return occ if count == target_index
1024
+ end
1025
+
1026
+ SourceLocator.locate(value, text, line_map)
1027
+ end
1028
+
985
1029
  # Fallback location strategy for text_content when locate_at_element_index
986
1030
  # fails (e.g., the text value is too short to locate reliably).
987
1031
  # Walks the full element hierarchy from the path to locate the correct
data/lib/canon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.35"
4
+ VERSION = "0.3.37"
5
5
  end
@@ -26,12 +26,12 @@ module Canon
26
26
  return false unless Canon::XmlBackend.moxml? &&
27
27
  Canon::XmlParsing.moxml_adapter_name == :leptris
28
28
 
29
- # Node#digest is the 1.9.144 surface; feature-detect it on a
30
- # throwaway document rather than probing the class.
29
+ # moxml Node#digest (moxml#173) wraps leptris Node#digest;
30
+ # feature-detect on a throwaway document.
31
31
  doc = Canon::XmlParsing.moxml_context.parse("<r/>", readonly: true,
32
32
  strict: false)
33
33
  root = doc.root
34
- digestable = !root.native.digest(drop_ws: true).nil?
34
+ digestable = !root.digest(drop_ws_text: true).nil?
35
35
  doc.free
36
36
  digestable
37
37
  rescue StandardError
@@ -85,7 +85,13 @@ module Canon
85
85
  when Moxml::Text then child.content.strip.empty? ? nil : child.to_s
86
86
  end
87
87
  end
88
- [root.native.digest(drop_ws: true), skeleton]
88
+ digest = root.digest(drop_ws_text: true)
89
+ # nil digest means the adapter has no Merkle support — never
90
+ # treat two nils as equal (that would false-positive under
91
+ # Opal / non-leptris backends where every tree digests nil).
92
+ return nil if digest.nil?
93
+
94
+ [digest, skeleton]
89
95
  ensure
90
96
  doc&.free
91
97
  end
@@ -54,6 +54,7 @@ module Canon
54
54
  class ElementMatcher
55
55
  # Default attributes used to identify elements
56
56
  DEFAULT_IDENTITY_ATTRS = %w[id ref name key class].freeze
57
+ EMPTY_PATH = [].freeze
57
58
 
58
59
  # Represents the result of matching an element across two DOM trees
59
60
  #
@@ -145,6 +146,21 @@ module Canon
145
146
  @matches
146
147
  end
147
148
 
149
+ # Public: true when both lists pair positionally (equal length,
150
+ # equal name/ns/identity at every index). ChildComparison uses
151
+ # this to skip MatchResult allocation on the common same-structure
152
+ # path.
153
+ def self.positionally_paired?(elems1, elems2)
154
+ return false if elems1.length != elems2.length || elems1.empty?
155
+
156
+ new.paired_positionally?(elems1, elems2)
157
+ end
158
+
159
+ # Instance form of .positionally_paired?
160
+ def paired_positionally?(elems1, elems2)
161
+ pairwise_corresponding?(elems1, elems2)
162
+ end
163
+
148
164
  private
149
165
 
150
166
  # Match children recursively
@@ -295,7 +311,11 @@ module Canon
295
311
  def record_positional_matches(elems1, elems2, path, recursive:)
296
312
  elems1.each_index do |i|
297
313
  elem1 = elems1[i]
298
- elem_path = element_path(path, elem1)
314
+ # match_children_only never reads path; keep a shared empty
315
+ # for the non-recursive entry so we do not allocate one
316
+ # [name] array per element. Recursive match_trees still
317
+ # builds real paths for its public API.
318
+ elem_path = recursive || !path.empty? ? element_path(path, elem1) : EMPTY_PATH
299
319
  @matches << MatchResult.new(
300
320
  status: :matched,
301
321
  elem1: elem1,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.35
4
+ version: 0.3.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs