canon 0.3.29 → 0.3.31

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: ff4b9d6b5a57176d2d382f7b0d730047161920dfb4cb4004b58d60bba9c0edab
4
- data.tar.gz: ed8dfddca4d957fbc3fbb1e62dbd1b03032c661e82c06513304d2abcf4e288cc
3
+ metadata.gz: 7c7f349890b131b4280956b65c069369fee17b526db3ffacaa4560c3b3beb485
4
+ data.tar.gz: dc2179259f0a7212de6b6aaddd50e61a32fba4627d4b853d60e00172cb819c10
5
5
  SHA512:
6
- metadata.gz: 778faacee321541b7d8ceef7c3497969080243acd01fc01bde3902deb68b7664e5ed966a230fc969766cfb32aefe76c81be9d7ae8a86d6b0f0ea870a6ed32cf1
7
- data.tar.gz: a3a42291c8100b20362c273c34778abdb4f22eec057c616e54daa60b77e4272c9fe5742fa352874ffc1145c0800e89f6c00800d8938bfcf2af364cff9c71ba9f
6
+ metadata.gz: 6e3085a46f5ae85c542a419ae35b961529fc76d1f232d72f3599eb54b1f6588e0bbc9d8f1ffcdb6e31e61a5dcf4a3d8c884ddf0999f9a1f2062e22997640052a
7
+ data.tar.gz: 3573d0da07ad60bd4aac8a3e3ffb149d61c400a2708c10d8dcca9280abae0dc12ac7d442786fd12be62ea584345e67dd2807ca63bc598578a4013e9159cd0ad7
data/CLAUDE.md CHANGED
@@ -143,6 +143,10 @@ Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml
143
143
 
144
144
  Engine A/B testing: `CANON_XML_BACKEND=nokogiri bundle exec rspec` (or `=moxml` to force leptris when it isn't the resolved default). The default suite must stay green under BOTH values; the only expected pendings are the upstream-tracked ones. The benchmark header (`rake performance:quick`) reports the active engine.
145
145
 
146
+ ### C14N Engines
147
+
148
+ `Canon::Xml::C14n.canonicalize` stays on canon's Ruby C14N 1.1 processor by default. leptris' native C-side C14N 1.1 is ~50x faster on large documents and byte-identical on the main corpus, but diverges from the spec on edge cases (attribute ordering, prefix preservation, `>`/tab escaping, document-level PIs — leptris#1015, all pinned in `spec/canon/xml/c14n_engine_parity_spec.rb`). `CANON_C14N_BACKEND=leptris` opts in; when the parity gate runs clean the default flips. `canonicalize_subset` and `with_comments: true` always use the Ruby processor.
149
+
146
150
  ### YAML Engines
147
151
 
148
152
  `Canon::YamlBackend` selects the YAML engine: `:psych` (default) or `:yeptris` (FFI over libyeptris, the YAML counterpart of the leptris XML stack — `CANON_YAML_BACKEND=yeptris` opts in; the optional `yeptris` Gemfile group must be enabled). `Canon::YamlParsing` is the single gateway for string loads; `Canon::JsonParsing` mirrors it for JSON (the same yeptris engine serves both — `Yeptris::YAML.load` auto-detects JSON and routes to the native C-API materializer; JSON falls back to stdlib unless `YamlBackend.yeptris_native?`, since the FFI ladder is ~29x slower than the stdlib C extension). `YAML.dump` stays on Psych everywhere — canonical output bytes are canon's product and the writers differ. **JSON defaults to the strict yeptris surface whenever the native materializer is installed** (yeptris 0.1.13.4 ships platform gems — zero compilation, zero env; parity spec-pinned to `JSON.parse` upstream). The YAML default stays `:psych` until the yeptris Psych-parity gaps close (yeptris-ruby#30 sexagesimal scalars, #31 >64-bit integers — #29 empty documents was fixed in 0.1.12); `spec/canon/yaml_engine_parity_spec.rb` is the executable gate, with upstream-tracked cases pending. Never `require "yeptris/psych"` — it rebinds the global `::Psych` constant for the whole process; only the namespaced `Yeptris::YAML` API is used.
@@ -74,6 +74,25 @@ module Canon
74
74
  # Store resolved match options hash for use in comparison logic
75
75
  opts[:match_opts] = match_opts_hash
76
76
 
77
+ # FAST PATH: leptris Merkle digest — equal root-subtree
78
+ # digests plus identical document-level skeletons prove
79
+ # content identity (modulo whitespace-only text nodes and
80
+ # attribute order), which implies equivalence wherever
81
+ # canon's whitespace handling is the same symmetric
82
+ # parse-time strip. Callers whose whitespace semantics
83
+ # differ from that skip the gate: strict attribute order
84
+ # (invisible to the digest), user-configured whitespace
85
+ # element lists, xml:space documents (checked inside the
86
+ # gate), and verbose callers who need the report.
87
+ if !(opts[:verbose] ||
88
+ match_opts_hash[:attribute_order] == :strict ||
89
+ match_opts_hash[:preserve_whitespace_elements] ||
90
+ match_opts_hash[:collapse_whitespace_elements] ||
91
+ match_opts_hash[:strip_whitespace_elements]) &&
92
+ Xml::DigestGate.equal?(n1, n2)
93
+ return true
94
+ end
95
+
77
96
  # Create child_opts with resolved options
78
97
  child_opts = opts.merge(child_opts)
79
98
 
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.29"
4
+ VERSION = "0.3.31"
5
5
  end
@@ -10,6 +10,10 @@ module Canon
10
10
  # @param with_comments [Boolean] Include comments in canonical form
11
11
  # @return [String] Canonical form in UTF-8
12
12
  def self.canonicalize(xml, with_comments: false)
13
+ if (native = native_canonicalize(xml, with_comments))
14
+ return native
15
+ end
16
+
13
17
  # Build XPath data model
14
18
  root_node = DataModel.from_xml(xml)
15
19
 
@@ -18,6 +22,46 @@ module Canon
18
22
  processor.process(root_node)
19
23
  end
20
24
 
25
+ # leptris' C-side C14N 1.1 — ~50x faster than the Ruby processor
26
+ # but NOT yet byte-identical on canon's edge-case corpus
27
+ # (attribute ordering, `>` escaping, document-level PIs, one
28
+ # prefix case — leptris#1015; the parity spec pins
29
+ # each). Opt-in via CANON_C14N_BACKEND=leptris until those
30
+ # close; comments mode keeps the Ruby path regardless (the
31
+ # native seam exposes no with-comments form).
32
+ def self.native_canonicalize(xml, with_comments)
33
+ return nil if with_comments
34
+ return nil if RUBY_ENGINE == "opal"
35
+ return nil unless ENV["CANON_C14N_BACKEND"].to_s.casecmp("leptris").zero?
36
+ return nil unless Canon::XmlBackend.moxml? &&
37
+ Canon::XmlParsing.moxml_adapter_name == :leptris
38
+ return nil unless native_c14n_available?
39
+
40
+ doc = Canon::XmlParsing.moxml_context.parse(xml, readonly: true,
41
+ strict: false)
42
+ begin
43
+ doc.native.canonicalize(::Leptris::XML::FFI::C14N_1_1, nil,
44
+ mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL)
45
+ ensure
46
+ doc.free
47
+ end
48
+ rescue StandardError
49
+ # Malformed inputs are the Ruby path's domain (recovery parse
50
+ # + parse_errors surfacing), not the native lane's.
51
+ nil
52
+ end
53
+
54
+ def self.native_c14n_available?
55
+ return @native_c14n_available unless @native_c14n_available.nil?
56
+
57
+ @native_c14n_available = defined?(::Leptris::XML::FFI::C14N_1_1) &&
58
+ ::Leptris::XML::FFI.constants.include?(:C14N_MODE_CANONICAL)
59
+ end
60
+
61
+ def self.reset_native_probe!
62
+ @native_c14n_available = nil
63
+ end
64
+
21
65
  # Canonicalize a document subset selected by XPath expression.
22
66
  #
23
67
  # Implements W3C C14N 1.1 subset canonicalization:
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ # Document-level Merkle-digest equivalence gate over leptris'
6
+ # content-defined subtree digests (libleptris #869, leptris-ruby
7
+ # Node#digest since 1.9.144).
8
+ #
9
+ # Equal root digests prove the root subtrees carry identical
10
+ # content (whitespace-only nodes dropped), and every canon match
11
+ # behavior is a relaxation of content identity — so equal digests
12
+ # imply equivalence under any option combination EXCEPT the two
13
+ # restrictions that compare things the digest deliberately
14
+ # ignores (attribute order, comments). The caller excludes those;
15
+ # this module answers the digest question only.
16
+ #
17
+ # A digest miss costs two readonly engine parses (no field
18
+ # materialization, no canon tree) — a fraction of the comparison
19
+ # it precedes. Any parse failure answers false and lets the full
20
+ # pipeline surface the error.
21
+ module DigestGate
22
+ module_function
23
+
24
+ def available?
25
+ return false if RUBY_ENGINE == "opal"
26
+ return false unless Canon::XmlBackend.moxml? &&
27
+ Canon::XmlParsing.moxml_adapter_name == :leptris
28
+
29
+ # Node#digest is the 1.9.144 surface; feature-detect it on a
30
+ # throwaway document rather than probing the class.
31
+ doc = Canon::XmlParsing.moxml_context.parse("<r/>", readonly: true,
32
+ strict: false)
33
+ root = doc.root
34
+ digestable = !root.native.digest(drop_ws: true).nil?
35
+ doc.free
36
+ digestable
37
+ rescue StandardError
38
+ false
39
+ end
40
+
41
+ # True when both documents' root subtrees digest identically
42
+ # AND their document-level skeletons (prolog/epilog comments and
43
+ # PIs — the only doc-level nodes canon compares; doctype and
44
+ # whitespace-only text are excluded) serialize identically.
45
+ # Anything else — parse failure, missing root, digest
46
+ # unavailable — is false: the caller falls through to the full
47
+ # pipeline, which surfaces the real parse errors.
48
+ def equal?(xml1, xml2)
49
+ return false unless xml1.is_a?(String) && xml2.is_a?(String)
50
+
51
+ begin
52
+ # xml:space documents carry attribute-scoped whitespace canon
53
+ # makes normative and the digest cannot see — decline them.
54
+ # The scan also declines non-ASCII-compatible encodings
55
+ # (include? raises) — those are the full pipeline's to
56
+ # normalize.
57
+ return false if xml1.include?("xml:space") || xml2.include?("xml:space")
58
+ rescue Encoding::CompatibilityError
59
+ return false
60
+ end
61
+
62
+ context = Canon::XmlParsing.moxml_context
63
+ begin
64
+ left = fingerprint(context, xml1)
65
+ right = fingerprint(context, xml2)
66
+ !left.nil? && left == right
67
+ rescue StandardError
68
+ # Any engine-level surprise (encodings, broken input) is the
69
+ # full pipeline's domain — it normalizes and surfaces errors.
70
+ false
71
+ end
72
+ end
73
+
74
+ # [root digest, doc-level skeleton] or nil when unparseable.
75
+ def fingerprint(context, xml)
76
+ doc = context.parse(xml, readonly: true, strict: false)
77
+ root = doc.root
78
+ return nil unless root
79
+
80
+ skeleton = doc.children.filter_map do |child|
81
+ next if child.equal?(root)
82
+
83
+ case child
84
+ when Moxml::Comment, Moxml::ProcessingInstruction then child.to_s
85
+ when Moxml::Text then child.content.strip.empty? ? nil : child.to_s
86
+ end
87
+ end
88
+ [root.native.digest(drop_ws: true), skeleton]
89
+ ensure
90
+ doc&.free
91
+ end
92
+ end
93
+ end
94
+ end
data/lib/canon/xml.rb CHANGED
@@ -16,6 +16,7 @@ module Canon
16
16
  module Xml
17
17
  autoload :AttributeHandler, "canon/xml/attribute_handler"
18
18
  autoload :C14n, "canon/xml/c14n"
19
+ autoload :DigestGate, "canon/xml/digest_gate"
19
20
  autoload :CharacterEncoder, "canon/xml/character_encoder"
20
21
  autoload :DataModel, "canon/xml/data_model"
21
22
  autoload :ElementMatcher, "canon/xml/element_matcher"
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.29
4
+ version: 0.3.31
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-09 00:00:00.000000000 Z
11
+ date: 2026-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs
@@ -391,6 +391,7 @@ files:
391
391
  - lib/canon/xml/c14n.rb
392
392
  - lib/canon/xml/character_encoder.rb
393
393
  - lib/canon/xml/data_model.rb
394
+ - lib/canon/xml/digest_gate.rb
394
395
  - lib/canon/xml/element_matcher.rb
395
396
  - lib/canon/xml/line_range_mapper.rb
396
397
  - lib/canon/xml/namespace_handler.rb