canon 0.3.54 → 0.3.56

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: c8094be0b06c7cd2a74e5e5fa4085f2a3ac654c54ed9ec106fcfa930490d4d11
4
- data.tar.gz: 71c81085b65c0648b059a9d6a4d12663d71867b2f032f42e6ed95df2c38fcb26
3
+ metadata.gz: 13ed2f639e9860dac532ef7dfde80a21e76306a14282adabdf0923224c7cbacf
4
+ data.tar.gz: ebf4db9c33fce786057bd2cb47698deca9ee3f42e85fa76df5e7e0f9429e251c
5
5
  SHA512:
6
- metadata.gz: 2a2401a1455182a4c4dc9b553fdbc51f6d63b4a9153ecf99f248bbf42fe43ac2f1ee801df0e22e52aebd1d4bfbecd0409e39d35e4fa02f440de7c674ccb44777
7
- data.tar.gz: 894d3d399acd98b30c8dbd6da4fcce1915f12355347e5df6f77373c37fc7a9d7a2cfd21bbafbe53617d4ab6eb0e37a0820091ce61850554da25b5741a25945e1
6
+ metadata.gz: cec261c87059cd1b27874937a2b801c9d77bdcc05eb346c52dcfb0b3199ac4cc501cd46ae19b54133b491feab360b2eeffebfdd4e804eba112d7a250b0959883
7
+ data.tar.gz: 02b82fe957da9ec3b01bf669d28c8be0187adb1ab18ffb7b37f055d223d3bff4b1f14aec19f16e8b509d4cdd07414ecd1d5003916944c3f22186d5a3e17b329c
data/CLAUDE.md CHANGED
@@ -128,7 +128,7 @@ The comparison pipeline flows through four independent layers:
128
128
  - `lib/canon/formatters/` — Pretty-printers for canonicalization/formatting (distinct from diff formatters). `XmlFormatter` supports Canonical XML (C14N).
129
129
  - `lib/canon/config.rb` — Global configuration with per-format settings (profiles, preprocessing, diff options). Read at runtime via `Canon::Config.instance`.
130
130
  - `lib/canon/rspec_matchers.rb` — RSpec matchers (`be_xml_equivalent_to`, `be_json_equivalent_to`, etc.). These delegate to `Canon::Comparison.equivalent?` with the global config. Automatically included in RSpec.
131
- - `lib/canon/xml/sax_builder.rb` — SAX-based canon-tree builder (~6x faster than DOM parsing for large documents). Engine-neutral; `lib/canon/xml/sax.rb` selects the driver (`NokogiriDriver` on CRuby, `MoxmlDriver` under Opal).
131
+ - `lib/canon/xml/sax_builder.rb` — SAX-based canon-tree builder (~6x faster than DOM parsing for large documents). Engine-neutral; `lib/canon/xml/sax.rb` selects the driver following the resolved adapter (`MoxmlDriver` → leptris SAX under the leptris default, `NokogiriDriver` when the adapter is nokogiri, `MoxmlDriver` under Opal).
132
132
 
133
133
  ### XML Engines
134
134
 
@@ -137,9 +137,9 @@ Canon is engine-agnostic across three seams (MECE — one concern per module):
137
137
  - `Canon::XmlBackend` — XML engine selection. The default follows moxml's resolved adapter: **leptris whenever it is installed** (parse ~1.6x, serialize ~3.8x vs Nokogiri), raw Nokogiri otherwise (wrapping Nokogiri in moxml buys nothing — the wrapper adds 2-3x overhead). `:moxml` under Opal. `CANON_XML_BACKEND=nokogiri|moxml` forces either engine.
138
138
  - `Canon::XmlParsing` — the only place that talks to engines for XML parse/serialize (moxml parses pass `readonly: true` — canon never mutates engine documents); node type queries answer for ANY recognized node (Nokogiri or moxml) by type, never by active backend — user-supplied Nokogiri nodes keep working under the moxml engine.
139
139
  - `Canon::Html::NokogiriSupport` — HTML is always Nokogiri on CRuby (moxml has no HTML adapter, leptris no HTML parser); independent of the XML engine.
140
- - `Canon::Xml::Sax` — SAX driver selection: `NokogiriDriver` on CRuby (per-event C callbacks beat FFI SAX today), `MoxmlDriver` under Opal. The builder (`SaxBuilder`) is engine-neutral.
140
+ - `Canon::Xml::Sax` — SAX driver selection follows the resolved adapter: `MoxmlDriver` (leptris SAX — the comparison hot path, CI-gated in the split flip) under the leptris default, `NokogiriDriver` when the resolved adapter is nokogiri, `MoxmlDriver` under Opal. The builder (`SaxBuilder`) is engine-neutral.
141
141
 
142
- Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml 0.5.12: attribute-value normalization, prolog/epilog PIs and comments, DTD ATTLIST defaults off by default (opt-in via `ParseOptions::DTDATTR`), PI-data whitespace matching libxml2, materialize with namespace declarations and a root-subtree-only record contract (moxml#140), readonly parse, deterministic `Document#free` (wired into `from_moxml_xml` — moxml#134). Canon's moxml conversion is record-based (`build_from_moxml` consumes `materialize` records — no per-node wrapper allocation; ~0.55x the Nokogiri conversion). `spec/canon/xml/engine_parity_spec.rb` is fully green. Remaining product gaps: moxml#129 (serializer byte parity — last blocker for pretty-printing on leptris) and leptris#594 (SAX at parity, not faster the SAX driver stays Nokogiri). Pretty-printers (`PrettyPrinter::Xml`, `XmlNormalized`) deliberately stay on the Nokogiri pipeline — pretty-printed bytes are canon's product.
142
+ Engine parity is complete through libleptris 1.9.8 / leptris-ruby 1.9.33 / moxml 0.5.12: attribute-value normalization, prolog/epilog PIs and comments, DTD ATTLIST defaults off by default (opt-in via `ParseOptions::DTDATTR`), PI-data whitespace matching libxml2, materialize with namespace declarations and a root-subtree-only record contract (moxml#140), readonly parse, deterministic `Document#free` (wired into `from_moxml_xml` — moxml#134). Canon's moxml conversion is record-based (`build_from_moxml` consumes `materialize` records — no per-node wrapper allocation; ~0.55x the Nokogiri conversion). `spec/canon/xml/engine_parity_spec.rb` is fully green. Pretty-printing runs the leptris serializer through `PrettyPrinter::Xml#moxml_format` (byte parity via moxml#153/#155/#156; the #167 escaping race fixed in 0.5.28), with Nokogiri serving the fallback engine and Opal — pretty-printed bytes are canon's product, gated by the fixture-integrity suite and the CI performance gate.
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
 
@@ -82,14 +82,29 @@ module Canon
82
82
  # parse-time strip. Callers whose whitespace semantics
83
83
  # differ from that skip the gate: strict attribute order
84
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 ||
85
+ # element lists, and xml:space documents (checked inside the
86
+ # gate). Attribute order is invisible to the digest, so
87
+ # :strict always falls through for the verdict itself.
88
+ # Verbose reports additionally carry two digest-invisible
89
+ # entries: informative attribute-order DiffNodes (emitted
90
+ # even under :ignore for order-differing documents) and the
91
+ # SAX lane's parse-error banner (issue #130). The verbose
92
+ # lane therefore re-scans both sides through the SAX probe —
93
+ # identical attribute-order signatures and no recover errors
94
+ # prove both entries empty, and a hit returns the same shape
95
+ # the pipeline builds for equivalent documents (empty
96
+ # differences, deferred display strings) without the two
97
+ # full parses and the walk.
98
+ if !(match_opts_hash[:attribute_order] == :strict ||
89
99
  match_opts_hash[:preserve_whitespace_elements] ||
90
100
  match_opts_hash[:collapse_whitespace_elements] ||
91
101
  match_opts_hash[:strip_whitespace_elements]) &&
92
- Xml::DigestGate.equal?(n1, n2)
102
+ Xml::DigestGate.certify(n1, n2) &&
103
+ verbose_report_proven_empty?(n1, n2, opts)
104
+ if opts[:verbose]
105
+ return build_digest_equivalent_result(n1, n2, match_opts_hash)
106
+ end
107
+
93
108
  return true
94
109
  end
95
110
 
@@ -133,12 +148,12 @@ module Canon
133
148
  # Serialize parsed nodes for consistent formatting
134
149
  # This ensures both sides formatted identically, showing only real
135
150
  # differences. Deferred: by_object consumers never read these.
136
- preprocessed = lambda do
137
- [
138
- serialize_node(node1).gsub("><", ">\n<"),
139
- serialize_node(node2).gsub("><", ">\n<"),
140
- ]
141
- end
151
+ # node1/node2 are parsed Canon nodes, so the deferred
152
+ # re-parse inside is a pass-through.
153
+ preprocessed = deferred_preprocessed(
154
+ node1, node2,
155
+ match_opts_hash[:preprocessing], preserve_whitespace
156
+ )
142
157
 
143
158
  ComparisonResult.new(
144
159
  differences: differences,
@@ -180,20 +195,71 @@ module Canon
180
195
  # @param n1 [Object] First input
181
196
  # @param n2 [Object] Second input
182
197
  # @param opts [Hash] Raw options (before merge with DEFAULT_OPTS)
198
+ # The verbose-report half of the gate: the digest cannot see
199
+ # attribute order (informative DiffNodes) or SAX recover
200
+ # errors (parse-error banner). One tree-free probe scan per
201
+ # side answers both; any hit falls through to the pipeline.
202
+ def verbose_report_proven_empty?(n1, n2, opts)
203
+ return true unless opts[:verbose]
204
+ # Identical input parses identically — differences and
205
+ # attribute order coincide by construction; only the SAX
206
+ # recover-error surface (parse-error banner, issue #130)
207
+ # needs its one scan.
208
+ return !Xml::Sax.probe(n1).saw_error? if n1 == n2
209
+
210
+ left = Xml::Sax.probe(n1)
211
+ return false if left.saw_error?
212
+
213
+ right = Xml::Sax.probe(n2)
214
+ !right.saw_error? && left.signature == right.signature
215
+ end
216
+
217
+ # Digest-proven equivalence for verbose callers. The report is
218
+ # indistinguishable from the full pipeline's for such inputs —
219
+ # certify guarantees no recover errors, the digest guarantees
220
+ # no differences — with display strings deferred exactly like
221
+ # the pipeline's lazy lane.
222
+ #
223
+ # @return [ComparisonResult]
224
+ def build_digest_equivalent_result(n1, n2, match_opts_hash)
225
+ preserve_whitespace = match_opts_hash[:structural_whitespace] == :strict
226
+ ComparisonResult.new(
227
+ differences: [],
228
+ preprocessed_strings: deferred_preprocessed(
229
+ n1, n2,
230
+ match_opts_hash[:preprocessing], preserve_whitespace
231
+ ),
232
+ original_strings: [n1, n2],
233
+ format: :xml,
234
+ match_options: match_opts_hash,
235
+ algorithm: :dom,
236
+ )
237
+ end
238
+
239
+ # Deferred display-string pair. Inputs may be raw strings
240
+ # (trivial/digest paths — parsed on materialization with the
241
+ # caller's preprocessing) or already-parsed Canon nodes
242
+ # (compare path — NodeParser passes them through unchanged).
243
+ #
244
+ # @return [Proc] Array<String, String> on call
245
+ def deferred_preprocessed(n1, n2, preprocessing, preserve_whitespace)
246
+ lambda do
247
+ [
248
+ serialize_node(parse_node(n1, preprocessing,
249
+ preserve_whitespace: preserve_whitespace)).gsub("><", ">\n<"),
250
+ serialize_node(parse_node(n2, preprocessing,
251
+ preserve_whitespace: preserve_whitespace)).gsub("><", ">\n<"),
252
+ ]
253
+ end
254
+ end
255
+
183
256
  # @return [Boolean, ComparisonResult]
184
257
  def build_trivial_equivalent_result(n1, n2, opts)
185
258
  return true unless opts[:verbose]
186
259
 
187
260
  # Parse nodes for verbose display — deferred with the strings:
188
261
  # nothing but the display pair ever reads them.
189
- preprocessed = lambda do
190
- [
191
- serialize_node(parse_node(n1, :none,
192
- preserve_whitespace: true)).gsub("><", ">\n<"),
193
- serialize_node(parse_node(n2, :none,
194
- preserve_whitespace: true)).gsub("><", ">\n<"),
195
- ]
196
- end
262
+ preprocessed = deferred_preprocessed(n1, n2, :none, true)
197
263
  original1 = n1.is_a?(String) ? n1 : serialize_node(n1)
198
264
  original2 = n2.is_a?(String) ? n2 : serialize_node(n2)
199
265
 
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.54"
4
+ VERSION = "0.3.56"
5
5
  end
@@ -71,11 +71,42 @@ module Canon
71
71
  end
72
72
  end
73
73
 
74
+ # The shared fingerprint ([root digest, doc-level skeleton])
75
+ # when both documents digest identically AND both parse clean of
76
+ # recover errors; nil otherwise. The verbose lane certifies
77
+ # before skipping the pipeline: error-bearing documents decline
78
+ # so the pipeline can produce the identical verbose report
79
+ # (parse-error banner included) it always has.
80
+ def certify(xml1, xml2)
81
+ return nil unless xml1.is_a?(String) && xml2.is_a?(String)
82
+
83
+ begin
84
+ return nil if xml1.include?("xml:space") || xml2.include?("xml:space")
85
+ rescue Encoding::CompatibilityError
86
+ return nil
87
+ end
88
+
89
+ context = Canon::XmlParsing.moxml_context
90
+ begin
91
+ left = fingerprint(context, xml1, clean: true)
92
+ right = fingerprint(context, xml2, clean: true)
93
+ return nil if left.nil? || left != right
94
+
95
+ left
96
+ rescue StandardError
97
+ nil
98
+ end
99
+ end
100
+
74
101
  # [root digest, doc-level skeleton] or nil when unparseable.
75
- def fingerprint(context, xml)
102
+ # With `clean:` a document carrying recover errors also answers
103
+ # nil — only certify uses that; the boolean lane keeps the
104
+ # historical verdict for recovered-equal pairs.
105
+ def fingerprint(context, xml, clean: false)
76
106
  doc = context.parse(xml, readonly: true, strict: false)
77
107
  root = doc.root
78
108
  return nil unless root
109
+ return nil if clean && doc.parse_errors.any?
79
110
 
80
111
  skeleton = doc.children.filter_map do |child|
81
112
  next if Canon::XmlParsing.same_engine_node?(child, root)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ module Sax
6
+ # Tree-free scan stand-in for the builder protocol. Records
7
+ # recover errors and accumulates an attribute-name-order
8
+ # signature (element open/close tokens plus each non-xmlns
9
+ # attribute name in document order); content itself is ignored —
10
+ # the digest gate covers it. The verbose lane compares the two
11
+ # sides' signatures to prove the absence of the two
12
+ # digest-invisible report entries: informative attribute-order
13
+ # DiffNodes and the SAX-lane parse-error banner (issue #130).
14
+ class Probe
15
+ attr_reader :signature
16
+
17
+ def initialize
18
+ @saw_error = false
19
+ @signature = +""
20
+ end
21
+
22
+ def saw_error?
23
+ @saw_error
24
+ end
25
+
26
+ def error(_string)
27
+ @saw_error = true
28
+ end
29
+
30
+ def warning(_string); end
31
+
32
+ def start_element(name, attrs = [])
33
+ @signature << "/" << name << "\0"
34
+ attrs.each do |attr|
35
+ attr_name = attr[0].to_s
36
+ next if attr_name == "xmlns" || attr_name.start_with?("xmlns:")
37
+
38
+ @signature << "@" << attr_name << "\0"
39
+ end
40
+ end
41
+
42
+ def end_element(_name)
43
+ @signature << "\\"
44
+ end
45
+
46
+ def characters(_string); end
47
+
48
+ # The builder protocol the SAX drivers forward to is `cdata`
49
+ # (MoxmlDriver#on_cdata and NokogiriDriver#cdata_block both call
50
+ # builder.cdata); the nokogiri-style name here made every
51
+ # CDATA-bearing probe raise NoMethodError under the flipped
52
+ # leptris SAX engine.
53
+ def cdata(_string); end
54
+
55
+ def comment(_string); end
56
+
57
+ def processing_instruction(_name, _content); end
58
+ end
59
+ end
60
+ end
61
+ end
data/lib/canon/xml/sax.rb CHANGED
@@ -20,6 +20,7 @@ module Canon
20
20
  module Sax
21
21
  autoload :NokogiriDriver, "canon/xml/sax/nokogiri_driver"
22
22
  autoload :MoxmlDriver, "canon/xml/sax/moxml_driver"
23
+ autoload :Probe, "canon/xml/sax/probe"
23
24
 
24
25
  class << self
25
26
  # Drive `builder` with the runtime's SAX engine.
@@ -31,6 +32,19 @@ module Canon
31
32
  end
32
33
  nil
33
34
  end
35
+
36
+ # Tree-free scan through the runtime's SAX engine: answers
37
+ # recover errors and an attribute-name-order signature for
38
+ # `xml_string` without building anything.
39
+ def probe(xml_string)
40
+ probe = Probe.new
41
+ if RUBY_ENGINE == "opal" || Canon::XmlParsing.moxml_adapter_name != :nokogiri
42
+ MoxmlDriver.new(probe).parse(xml_string)
43
+ else
44
+ NokogiriDriver.new(probe).parse(xml_string)
45
+ end
46
+ probe
47
+ end
34
48
  end
35
49
  end
36
50
  end
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.54
4
+ version: 0.3.56
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -437,6 +437,7 @@ files:
437
437
  - lib/canon/xml/sax.rb
438
438
  - lib/canon/xml/sax/moxml_driver.rb
439
439
  - lib/canon/xml/sax/nokogiri_driver.rb
440
+ - lib/canon/xml/sax/probe.rb
440
441
  - lib/canon/xml/sax_builder.rb
441
442
  - lib/canon/xml/tree_builder.rb
442
443
  - lib/canon/xml/whitespace_normalizer.rb