canon 0.3.32 → 0.3.34
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 +4 -4
- data/lib/canon/comparison/html_comparator.rb +49 -24
- data/lib/canon/comparison/node_inspector.rb +8 -1
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/element_matcher.rb +57 -0
- data/lib/tasks/benchmark_runner.rb +21 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af3e6b392ac406de109410e886a3bd12371cc9e5038d3c08833b6b3d71b6ac69
|
|
4
|
+
data.tar.gz: 464804eeac7d4390eb1e6dc44b0759bf45c4d2cd78d5e113ecc5459edb753821
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b1556a7e0bce22da9c862785d24e2ad5176d26fd6f82c97640fe30597d4eed18a2b298d771abcd4b81e9b4fee67eaff4f1b423ccfd798a903d77239ee2925e5
|
|
7
|
+
data.tar.gz: ae481888161bbf62db1b014cd3adaa28ef9bfa39aa7112cae2075a850242276d458445e511f84737ba62a20d1a2bb313132d5e962c331d017f66d69d61e238c3
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "set"
|
|
3
4
|
require "nokogiri" unless RUBY_ENGINE == "opal"
|
|
4
5
|
|
|
5
6
|
module Canon
|
|
@@ -596,6 +597,12 @@ compare_profile = nil)
|
|
|
596
597
|
# Use default list from WhitespaceSensitivity (single source of truth)
|
|
597
598
|
WhitespaceSensitivity.format_default_preserve_elements(match_opts).map(&:to_s)
|
|
598
599
|
end
|
|
600
|
+
preserve_set = preserve_whitespace.to_set
|
|
601
|
+
# Element names arrive lowercased from the HTML parser but the
|
|
602
|
+
# ancestor walk would still allocate a downcase copy per visit;
|
|
603
|
+
# memoize per unique name instead (bounded by the tag vocabulary).
|
|
604
|
+
name_cache = {}
|
|
605
|
+
whitespace_result_cache = {}
|
|
599
606
|
|
|
600
607
|
# Walk all text nodes
|
|
601
608
|
doc.xpath(".//text()").each do |text_node|
|
|
@@ -603,11 +610,20 @@ compare_profile = nil)
|
|
|
603
610
|
# Check all ancestors, not just immediate parent
|
|
604
611
|
# Whitespace preservation happens REGARDLESS of text_content setting
|
|
605
612
|
parent = text_node.parent
|
|
606
|
-
next if ancestor_preserves_whitespace?(parent,
|
|
613
|
+
next if ancestor_preserves_whitespace?(parent, preserve_set,
|
|
614
|
+
name_cache,
|
|
615
|
+
whitespace_result_cache)
|
|
607
616
|
|
|
608
617
|
# Collapse whitespace sequences (spaces, tabs, newlines) to single
|
|
609
618
|
# space - use tr/squeeze to avoid ReDoS vulnerability from gsub(/\s+/)
|
|
610
|
-
|
|
619
|
+
# Content with no tab-class char and no space run is already
|
|
620
|
+
# collapsed — skip the copies entirely.
|
|
621
|
+
content = text_node.content
|
|
622
|
+
normalized = if content.match?(/[\t\n\r\f\v]/) || content.include?(" ")
|
|
623
|
+
content.tr("\t\n\r\f\v", " ").squeeze(" ")
|
|
624
|
+
else
|
|
625
|
+
content
|
|
626
|
+
end
|
|
611
627
|
|
|
612
628
|
# Trim leading/trailing whitespace if appropriate
|
|
613
629
|
normalized = normalized.strip if should_trim_text_node?(text_node)
|
|
@@ -616,31 +632,36 @@ compare_profile = nil)
|
|
|
616
632
|
end
|
|
617
633
|
end
|
|
618
634
|
|
|
619
|
-
# Check if any ancestor of the given node preserves whitespace
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
635
|
+
# Check if any ancestor of the given node preserves whitespace.
|
|
636
|
+
# +name_cache+ memoizes downcased names per unique element name
|
|
637
|
+
# (one downcase per tag vocabulary entry), and +result_cache+
|
|
638
|
+
# memoizes the verdict per element — engine node names allocate
|
|
639
|
+
# a fresh String per call, so sibling text nodes must not
|
|
640
|
+
# re-walk shared ancestors.
|
|
641
|
+
def ancestor_preserves_whitespace?(node, preserve_set, name_cache,
|
|
642
|
+
result_cache)
|
|
643
|
+
return false unless node.is_a?(Canon::Xml::Node) ||
|
|
644
|
+
Canon::XmlParsing.xml_node?(node)
|
|
645
|
+
|
|
646
|
+
cached = result_cache[node]
|
|
647
|
+
return cached unless cached.nil?
|
|
648
|
+
|
|
649
|
+
name = node.name
|
|
650
|
+
name = (name_cache[name] ||= name.downcase)
|
|
651
|
+
result_cache[node] = preserve_set.include?(name) ||
|
|
652
|
+
ancestor_preserves_whitespace?(
|
|
653
|
+
node.parent, preserve_set, name_cache,
|
|
654
|
+
result_cache
|
|
655
|
+
)
|
|
630
656
|
end
|
|
631
657
|
|
|
632
658
|
# Determine if a text node should have leading/trailing whitespace
|
|
633
659
|
# trimmed Text nodes at the start or end of their parent element should
|
|
634
|
-
# be trimmed
|
|
660
|
+
# be trimmed. Only/first/last child ⟺ a missing sibling pointer —
|
|
661
|
+
# checked without allocating the parent's children NodeSet.
|
|
662
|
+
# (The walk runs on Nokogiri fragments only — see the callers.)
|
|
635
663
|
def should_trim_text_node?(text_node)
|
|
636
|
-
|
|
637
|
-
siblings = parent.children
|
|
638
|
-
|
|
639
|
-
# Trim if text is the only child
|
|
640
|
-
return true if siblings.length == 1
|
|
641
|
-
|
|
642
|
-
# Trim if text is at the start or end of parent
|
|
643
|
-
text_node == siblings.first || text_node == siblings.last
|
|
664
|
+
text_node.previous_sibling.nil? || text_node.next_sibling.nil?
|
|
644
665
|
end
|
|
645
666
|
|
|
646
667
|
# Remove whitespace-only text nodes from the document
|
|
@@ -654,12 +675,16 @@ compare_profile = nil)
|
|
|
654
675
|
def remove_whitespace_only_text_nodes(doc)
|
|
655
676
|
# Elements where whitespace is significant - don't remove whitespace-only nodes
|
|
656
677
|
# SINGLE SOURCE OF TRUTH: WhitespaceSensitivity.format_default_preserve_elements
|
|
657
|
-
preserve_whitespace = WhitespaceSensitivity.format_default_preserve_elements(format: :html).
|
|
678
|
+
preserve_whitespace = WhitespaceSensitivity.format_default_preserve_elements(format: :html).to_set(&:to_s)
|
|
679
|
+
name_cache = {}
|
|
680
|
+
whitespace_result_cache = {}
|
|
658
681
|
|
|
659
682
|
doc.xpath(".//text()").each do |text_node|
|
|
660
683
|
# CRITICAL: Skip if this text node is inside a whitespace-preserving element
|
|
661
684
|
parent = text_node.parent
|
|
662
|
-
next if ancestor_preserves_whitespace?(parent, preserve_whitespace
|
|
685
|
+
next if ancestor_preserves_whitespace?(parent, preserve_whitespace,
|
|
686
|
+
name_cache,
|
|
687
|
+
whitespace_result_cache)
|
|
663
688
|
|
|
664
689
|
content = text_node.content
|
|
665
690
|
|
|
@@ -40,7 +40,14 @@ module Canon
|
|
|
40
40
|
|
|
41
41
|
# HTML comments are parsed as TEXT nodes by Nokogiri
|
|
42
42
|
if node.text?
|
|
43
|
-
|
|
43
|
+
raw = text_content(node).to_s
|
|
44
|
+
# A comment-shaped text must start (after whitespace and
|
|
45
|
+
# backslash removal) with '<' — anything else is a cheap
|
|
46
|
+
# negative with no strip/gsub copies. ('\' must reach the
|
|
47
|
+
# slow path: backslash removal happens before the check.)
|
|
48
|
+
return false unless raw.match?(/\A[ \t\r\n\f]*[<\\]/)
|
|
49
|
+
|
|
50
|
+
text_stripped = raw.strip.gsub("\\", "")
|
|
44
51
|
return true if text_stripped.start_with?("<!--") && text_stripped.end_with?("-->")
|
|
45
52
|
end
|
|
46
53
|
false
|
data/lib/canon/version.rb
CHANGED
|
@@ -164,6 +164,21 @@ module Canon
|
|
|
164
164
|
elems1 = children1.select { |n| n.node_type == :element }
|
|
165
165
|
elems2 = children2.select { |n| n.node_type == :element }
|
|
166
166
|
|
|
167
|
+
# FAST PATH: pairwise-corresponding children. When both lists
|
|
168
|
+
# are the same length and every position pairs elements with
|
|
169
|
+
# equal name, namespace URI, and identity value, the full
|
|
170
|
+
# matcher's output is exactly the positional pairing: unique
|
|
171
|
+
# identity keys pair same-with-same, duplicate keys pair
|
|
172
|
+
# last-with-last in the identity phase and the rest pair in
|
|
173
|
+
# order in the positional phase. No deleted/inserted results
|
|
174
|
+
# either way. Skip the identity/positional/class machinery.
|
|
175
|
+
if !elems1.empty? && elems1.length == elems2.length &&
|
|
176
|
+
pairwise_corresponding?(elems1, elems2)
|
|
177
|
+
record_positional_matches(elems1, elems2, path,
|
|
178
|
+
recursive: recursive)
|
|
179
|
+
return
|
|
180
|
+
end
|
|
181
|
+
|
|
167
182
|
# Positions by identity: elems.index(elem) was an O(n) scan per
|
|
168
183
|
# recorded match — O(n²) per level on element-heavy parents.
|
|
169
184
|
positions1 = {}
|
|
@@ -263,6 +278,48 @@ module Canon
|
|
|
263
278
|
end
|
|
264
279
|
end
|
|
265
280
|
|
|
281
|
+
# True when every position pairs elements with equal name,
|
|
282
|
+
# namespace URI, and identity value (nil-safe).
|
|
283
|
+
def pairwise_corresponding?(elems1, elems2)
|
|
284
|
+
elems1.each_index.all? do |i|
|
|
285
|
+
e1 = elems1[i]
|
|
286
|
+
e2 = elems2[i]
|
|
287
|
+
e1.name == e2.name && e1.namespace_uri == e2.namespace_uri &&
|
|
288
|
+
extract_identity(e1) == extract_identity(e2)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Record the positional pairing as :matched MatchResults,
|
|
293
|
+
# descending per pair in recursive mode (match_trees' ordering
|
|
294
|
+
# contract).
|
|
295
|
+
def record_positional_matches(elems1, elems2, path, recursive:)
|
|
296
|
+
elems1.each_index do |i|
|
|
297
|
+
elem1 = elems1[i]
|
|
298
|
+
elem_path = element_path(path, elem1)
|
|
299
|
+
@matches << MatchResult.new(
|
|
300
|
+
status: :matched,
|
|
301
|
+
elem1: elem1,
|
|
302
|
+
elem2: elems2[i],
|
|
303
|
+
path: elem_path,
|
|
304
|
+
pos1: i,
|
|
305
|
+
pos2: i,
|
|
306
|
+
)
|
|
307
|
+
if recursive
|
|
308
|
+
match_children(elem1.children, elems2[i].children, elem_path)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Path segment for an element: expanded under a namespace,
|
|
314
|
+
# bare otherwise.
|
|
315
|
+
def element_path(path, elem)
|
|
316
|
+
if elem.namespace_uri && !elem.namespace_uri.empty?
|
|
317
|
+
path + ["{#{elem.namespace_uri}}#{elem.name}"]
|
|
318
|
+
else
|
|
319
|
+
path + [elem.name]
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
266
323
|
# Match remaining elements by name and position
|
|
267
324
|
def match_by_position(elems1, elems2, path, matched1, matched2,
|
|
268
325
|
recursive:)
|
|
@@ -225,6 +225,13 @@ compare_against: nil)
|
|
|
225
225
|
failure_means: "Slow formatting affects serialization performance. C14N is critical for digital signatures and XML canonicalization.",
|
|
226
226
|
compare_against: "Previous branch (main).",
|
|
227
227
|
},
|
|
228
|
+
data_comparison: {
|
|
229
|
+
name: "Data Comparison",
|
|
230
|
+
icon: "🧮",
|
|
231
|
+
description: "JSON and YAML semantic comparison. Both formats load through their engine gateways (yeptris/stdlib), so this referees engine choice.",
|
|
232
|
+
failure_means: "Slow data comparison affects validation pipelines and test suites. A regression here can also signal an engine lane flip gone wrong.",
|
|
233
|
+
compare_against: "Previous branch (main). Inputs are freshly generated (different values), so the comparison does real work.",
|
|
234
|
+
},
|
|
228
235
|
}.freeze
|
|
229
236
|
|
|
230
237
|
# Test definitions
|
|
@@ -265,6 +272,12 @@ compare_against: nil)
|
|
|
265
272
|
{ name: "JSON", method: :json_format, desc: "JSON formatting" },
|
|
266
273
|
{ name: "YAML", method: :yaml_format, desc: "YAML formatting" },
|
|
267
274
|
],
|
|
275
|
+
data_comparison: [
|
|
276
|
+
{ name: "JSON", method: :json_compare_equivalent,
|
|
277
|
+
desc: "JSON equivalence" },
|
|
278
|
+
{ name: "YAML", method: :yaml_compare_equivalent,
|
|
279
|
+
desc: "YAML equivalence" },
|
|
280
|
+
],
|
|
268
281
|
}.freeze
|
|
269
282
|
|
|
270
283
|
# Test data generators
|
|
@@ -555,6 +568,14 @@ compare_against: nil)
|
|
|
555
568
|
yaml = DataGenerator.generate_yaml(items: @items)
|
|
556
569
|
data = YAML.safe_load(yaml, permitted_classes: [Time])
|
|
557
570
|
measure { Canon.format_yaml(data) }
|
|
571
|
+
when :json_compare_equivalent
|
|
572
|
+
json1 = DataGenerator.generate_json(items: @items)
|
|
573
|
+
json2 = DataGenerator.generate_json(items: @items)
|
|
574
|
+
measure { Canon::Comparison.equivalent?(json1, json2, format: :json) }
|
|
575
|
+
when :yaml_compare_equivalent
|
|
576
|
+
yaml1 = DataGenerator.generate_yaml(items: @items)
|
|
577
|
+
yaml2 = DataGenerator.generate_yaml(items: @items)
|
|
578
|
+
measure { Canon::Comparison.equivalent?(yaml1, yaml2, format: :yaml) }
|
|
558
579
|
else
|
|
559
580
|
raise "Unknown benchmark: #{method}"
|
|
560
581
|
end
|
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.
|
|
4
|
+
version: 0.3.34
|
|
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-
|
|
11
|
+
date: 2026-09-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: diff-lcs
|