canon 0.3.30 → 0.3.32

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: 308416a45e76807d58f00f40a88d5d6f53617d2a9dac468da8add04fa8175a2f
4
- data.tar.gz: 2fa30986100df7b8238263546513705513635bd6c39f1552f0e0f8774f510f5f
3
+ metadata.gz: 44f563b9b8ac9fa82cc08c9413ca4a17afd8080c524d6eb92536572356892539
4
+ data.tar.gz: 4a7cbe635232ca07fa8b9924e46c027849145fbc6ac6846df3ec9de4c51edbc4
5
5
  SHA512:
6
- metadata.gz: 0ffdb20ffc91117fdf24afb405f1684aea837316c1481ed5cc7e435bff4bf5d409876d9ca5851e187d3cd128b192ce3516b49114e6ac5d56a327dd87330f0ec9
7
- data.tar.gz: aaeac239ac4707d8034eb92c8205753e3ec94ff203bf0b4023c33d0b7a92d57742e632ee29998de5a2ed041e45ea7bf2453b2479bb24ed9b9164dacba8db4ab1
6
+ metadata.gz: a228ef2b06e3e59545aab338afd72cdcf4943c32b34fa24063e43884f01b41444558966cb121986513d28041a92f0918864106886875ef44da9b3f00e53dcdc2
7
+ data.tar.gz: 9dbddd3a07e0d6076118ed7e2619de864b9bf3c99ff68c109fa31805a912a1839054d4cf843f712faa2df2a2ee619806d256a47bdce9399feb422721d6117e71
data/CLAUDE.md CHANGED
@@ -149,7 +149,7 @@ Engine A/B testing: `CANON_XML_BACKEND=nokogiri bundle exec rspec` (or `=moxml`
149
149
 
150
150
  ### YAML Engines
151
151
 
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.
152
+ `Canon::YamlBackend` selects the YAML engine: `:yeptris` (default Marshal bulk materialization in yeptris 0.1.28, 4.5–5x Psych load) or `:psych` (`CANON_YAML_BACKEND=psych` forces the stdlib engine). The default follows availability: yeptris when loadable, Psych otherwise. `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 parity gate (`spec/canon/yaml_engine_parity_spec.rb`) ran clean after yeptris-ruby #29/#30/#31/#37 all closed (0.1.15.1), so YAML loads flip to yeptris by default; the spec stays as the executable gate for future yeptris releases. Never `require "yeptris/psych"` — it rebinds the global `::Psych` constant for the whole process; only the namespaced `Yeptris::YAML` API is used.
153
153
 
154
154
  ### Format Detection
155
155
 
@@ -199,10 +199,10 @@ module Canon
199
199
  content
200
200
  when :json
201
201
  # Parse JSON to Ruby object
202
- JSON.parse(content)
202
+ Canon::JsonParsing.parse(content)
203
203
  when :yaml
204
204
  # Parse YAML to Ruby object
205
- YAML.safe_load(content)
205
+ Canon::YamlParsing.safe_load(content)
206
206
  else
207
207
  abort "Error: Unsupported format '#{format}'"
208
208
  end
@@ -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
 
@@ -126,7 +126,7 @@ module Canon
126
126
 
127
127
  def load_yaml(path)
128
128
  content = File.read(path)
129
- YAML.safe_load(content, permitted_classes: [Symbol]) || {}
129
+ Canon::YamlParsing.safe_load(content, permitted_classes: [Symbol]) || {}
130
130
  end
131
131
  end
132
132
  end
@@ -17,7 +17,7 @@ module Canon
17
17
  # Return as-is if already parsed
18
18
  return yaml if yaml.is_a?(Hash) || yaml.is_a?(Array)
19
19
 
20
- Canon::YamlParsing.safe_load(yaml)
20
+ Canon::YamlParsing.safe_load(yaml, aliases: true)
21
21
  end
22
22
 
23
23
  def self.sort_yaml_keys(obj)
@@ -22,7 +22,7 @@ module Canon
22
22
  return if input.is_a?(Hash) || input.is_a?(Array) # Already parsed
23
23
  return if input.strip.empty?
24
24
 
25
- Canon::YamlParsing.safe_load(input)
25
+ Canon::YamlParsing.safe_load(input, aliases: true)
26
26
  rescue Psych::SyntaxError => e
27
27
  location = extract_location(e)
28
28
 
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.30"
4
+ VERSION = "0.3.32"
5
5
  end
@@ -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"
@@ -6,12 +6,12 @@ module Canon
6
6
  # stack).
7
7
  #
8
8
  # Mirrors XmlBackend's discipline (MECE — this module owns selection,
9
- # YamlParsing owns the calls). The default stays :psych until the
10
- # yeptris Psych-safe_load parity gaps close (yeptris-ruby#29 empty
11
- # documents crash, #30 sexagesimal scalars, #31 >64-bit integers);
12
- # yeptris is 3.1x faster at loading (measured, 2,000-item document),
13
- # so CANON_YAML_BACKEND=yeptris opts in early and the default flips
14
- # once the parity spec runs clean.
9
+ # YamlParsing owns the calls). yeptris loads 4.5–5x faster than Psych
10
+ # (0.1.28 Marshal materialization, 2,000-item document) and the
11
+ # Psych-safe_load parity spec runs clean the former gate-blockers
12
+ # (yeptris-ruby#29/#30/#31) are all fixed upstream. The default
13
+ # therefore follows availability: yeptris when loadable, Psych
14
+ # otherwise. CANON_YAML_BACKEND=psych forces the stdlib engine.
15
15
  #
16
16
  # Only the namespaced API (Yeptris::YAML) is ever used — requiring
17
17
  # "yeptris/psych" rebinds the global ::Psych constant for the whole
@@ -22,7 +22,7 @@ module Canon
22
22
  class << self
23
23
  def active
24
24
  @active ||= begin
25
- wanted = forced || :psych
25
+ wanted = forced || (yeptris_available? ? :yeptris : :psych)
26
26
  # A forced yeptris without a loadable gem/native lib must
27
27
  # degrade to Psych, not NameError in the gateway.
28
28
  wanted = :psych if wanted == :yeptris && !yeptris_available?
@@ -14,18 +14,17 @@ module Canon
14
14
  # data with Symbol/Date/Time built in (aliases resolve), so the
15
15
  # permitted-class list is inherently satisfied; parse failures are
16
16
  # normalized to Psych::SyntaxError so canon's rescues hold on both
17
- # engines.
17
+ # engines. Canon always resolves anchors (aliases: true at every
18
+ # call site) — a canonicalizer treats anchors as content: yeptris
19
+ # resolves them unconditionally, so :true keeps both engines
20
+ # behavior-identical.
18
21
  def safe_load(yaml, permitted_classes: [Symbol, Date, Time],
19
- aliases: false)
22
+ aliases: true)
20
23
  if YamlBackend.yeptris?
21
24
  begin
22
25
  ::Yeptris::YAML.load(yaml)
23
26
  rescue ::Yeptris::ParseError => e
24
27
  raise Psych::SyntaxError.new(nil, 0, 0, 0, e.message, "")
25
- rescue ::FFI::NullPointerError
26
- # yeptris-ruby#29: empty/comment-only input. Psych returns
27
- # nil; mirror that until the upstream fix lands.
28
- nil
29
28
  end
30
29
  else
31
30
  YAML.safe_load(yaml, permitted_classes: permitted_classes,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.30
4
+ version: 0.3.32
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -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