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 +4 -4
- data/CLAUDE.md +3 -3
- data/lib/canon/comparison/xml_comparator.rb +85 -19
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/digest_gate.rb +32 -1
- data/lib/canon/xml/sax/probe.rb +61 -0
- data/lib/canon/xml/sax.rb +14 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13ed2f639e9860dac532ef7dfde80a21e76306a14282adabdf0923224c7cbacf
|
|
4
|
+
data.tar.gz: ebf4db9c33fce786057bd2cb47698deca9ee3f42e85fa76df5e7e0f9429e251c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`
|
|
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: `
|
|
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.
|
|
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)
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
]
|
|
141
|
-
|
|
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 =
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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
|