canon 0.3.36 → 0.3.38

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: 53129bc040b25503b792d4c1afa96498715cd05363d462afb87aa004dbe37004
4
- data.tar.gz: de9d2c032c3b20e73f6737bcd0d092130dd9622851cca6c9353d6c10ea7da0f8
3
+ metadata.gz: dc55555287eb70fb4fc5201029aea89c2af55d8a9df26549d1d17b639a6de5de
4
+ data.tar.gz: 59ac3ba881495f29ee1355eda305da05d03d62a5611aa738404bf029f66d1721
5
5
  SHA512:
6
- metadata.gz: 2471eebf546ec921fa8d307a02363b9d1112ef5111fd0f0edd57d1894a3e04e7039965fede0a4b4f8e02fa05540dad05324e6dc1e052676512407a7965c0ea83
7
- data.tar.gz: a58130908f35634360d689eba9e27d48845f955c9cd44eae6eab771e0bfb0b84af35cd97db05137eb1563baa3449a1591237fc3eac01a74ef839df5c3811e386
6
+ metadata.gz: 6b2d535b82b40ece128bea902b603b1b43c06c7bf4f40535f651e3b43c24674345de4e0c0767380fd6fc50d2986dc80ecf0e2c1b2d5286a7dd02524d65641920
7
+ data.tar.gz: '09af782d9c914502a54ca9b961448c89502bb20c3cffa93ec9268b7afc72099bcea2dcc2f7f8e36ccbf7fd6a6f8e3320ced5b7edfd67f7d70d0f5f95e7cf8d85'
@@ -29,9 +29,7 @@ module Canon
29
29
  # difference exists (strict key_order compares insertion
30
30
  # order, which keys == keys preserves). Most object pairs in
31
31
  # similar documents hit this and skip the path-building walk.
32
- if obj1 == obj2 && (!obj1.is_a?(Hash) || obj1.keys == obj2.keys)
33
- return Comparison::EQUIVALENT
34
- end
32
+ return Comparison::EQUIVALENT if deep_equal?(obj1, obj2)
35
33
 
36
34
  case obj1
37
35
  when Hash
@@ -68,8 +66,16 @@ module Canon
68
66
  # Sort keys if order should be ignored (based on match options)
69
67
  match_opts = opts[:match_opts]
70
68
  if match_opts && match_opts[:key_order] != :strict
71
- keys1 = keys1.sort_by(&:to_s)
72
- keys2 = keys2.sort_by(&:to_s)
69
+ # Identical key arrays: one sort replaces two (the sorted
70
+ # forms are trivially equal, and the set algebra below
71
+ # degenerates — missing/extra empty, common = the sorted
72
+ # keys themselves).
73
+ if keys1 == keys2
74
+ keys1 = keys2 = keys1.sort_by(&:to_s)
75
+ else
76
+ keys1 = keys1.sort_by(&:to_s)
77
+ keys2 = keys2.sort_by(&:to_s)
78
+ end
73
79
  elsif match_opts && match_opts[:key_order] == :strict
74
80
  # Strict mode: key order matters
75
81
  # Check if keys are in same order
@@ -85,8 +91,8 @@ module Canon
85
91
  end
86
92
 
87
93
  # Check for missing keys
88
- missing_in_second = keys1 - keys2
89
- missing_in_first = keys2 - keys1
94
+ missing_in_second = keys1 == keys2 ? EMPTY_KEYS : keys1 - keys2
95
+ missing_in_first = keys1 == keys2 ? EMPTY_KEYS : keys2 - keys1
90
96
 
91
97
  missing_in_second.each do |key|
92
98
  key_path = path.empty? ? key.to_s : "#{path}.#{key}"
@@ -102,13 +108,19 @@ module Canon
102
108
 
103
109
  has_missing_keys = !missing_in_first.empty? || !missing_in_second.empty?
104
110
 
105
- # Compare common keys
106
- common_keys = keys1 & keys2
111
+ # Compare common keys. The path string is built lazily: deep-equal
112
+ # pairs (the overwhelming majority in similar documents) answer
113
+ # equivalent without ever needing it.
114
+ common_keys = keys1 == keys2 ? keys1 : keys1 & keys2
107
115
  all_equivalent = true
108
116
  common_keys.each do |key|
117
+ value1 = hash1[key]
118
+ value2 = hash2[key]
119
+ next if deep_equal?(value1, value2)
120
+
109
121
  key_path = path.empty? ? key.to_s : "#{path}.#{key}"
110
- result = compare_objects(hash1[key], hash2[key], opts,
111
- differences, key_path)
122
+ result = compare_objects(value1, value2, opts, differences,
123
+ key_path)
112
124
  all_equivalent = false unless result == Comparison::EQUIVALENT
113
125
  end
114
126
 
@@ -138,6 +150,8 @@ module Canon
138
150
  all_equivalent = true
139
151
  arr1.each_with_index do |elem1, index|
140
152
  elem2 = arr2[index]
153
+ next if deep_equal?(elem1, elem2)
154
+
141
155
  elem_path = "#{path}[#{index}]"
142
156
  result = compare_objects(elem1, elem2, opts, differences,
143
157
  elem_path)
@@ -166,6 +180,16 @@ module Canon
166
180
  end
167
181
  end
168
182
 
183
+ # Deep-equal with identical key order — compare_objects returns
184
+ # EQUIVALENT without consulting the path for such pairs, which
185
+ # is what makes lazy path construction in the hash/array walks
186
+ # behavior-identical.
187
+ def self.deep_equal?(obj1, obj2)
188
+ obj1 == obj2 && (!obj1.is_a?(Hash) || obj1.keys == obj2.keys)
189
+ end
190
+
191
+ EMPTY_KEYS = [].freeze
192
+
169
193
  # Add a Ruby object difference
170
194
  #
171
195
  # @param path [String] Path to the difference
@@ -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)
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.36"
4
+ VERSION = "0.3.38"
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.36
4
+ version: 0.3.38
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