canon 0.3.53 → 0.3.55

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: 6f9c6ba9a065bf2695019dc0a43595144a12233a9e37441ae3509a937fb43d2c
4
- data.tar.gz: c7a8258f5f73a778c0f7b7d4104e02b0f7848e27892f1f63897b237675013d07
3
+ metadata.gz: 50a34a46b082f7aa0555199b09780ec1e8c5f38f76656e2a6899a54b3b96a90f
4
+ data.tar.gz: f2b7cbf27874cc6bbd6ab5615a14ad43a2ea6cb7e8ebdd9a3894efcc112c103c
5
5
  SHA512:
6
- metadata.gz: 36afd9035a4078563855537f9af8aab3e0e53e22515a9d5f85ca47ffc5f3e564ab0507bd0013e45c234b5fe02d589d50f2e0a6e2a76df86b0d8fbeb33a37e105
7
- data.tar.gz: 6341a23681ca3afc20ea888b440e0ccd01037979d6815dbdd2b010ed856bb03891c79c494698a5ddef7512da9613e5f480d3028dc9a9669fa356c23650f2600d
6
+ metadata.gz: 5474c6581c262c497d51cebfe91b0882a096a3340acfd7f7c77d67ca064d12164cbc423b6c4865138a80a660115755597672c3e7a1dbe380ddf24f94e8c55d0d
7
+ data.tar.gz: 8dac986a479c7820ec6d780c116d906ef34489cbfdf15f19ebad9f013c0ec90070ad75801471e4cd8e4be4f1e9c62faaeaa8b97d42a296286f1c0842ce9d8d9b
@@ -5,12 +5,14 @@ module Canon
5
5
  # Encapsulates the result of a comparison operation
6
6
  # Provides methods to query equivalence based on normative diffs
7
7
  class ComparisonResult
8
- attr_reader :differences, :preprocessed_strings, :format, :html_version,
9
- :match_options, :algorithm, :original_strings,
8
+ attr_reader :differences, :format, :html_version,
9
+ :match_options, :algorithm,
10
10
  :parse_errors_expected, :parse_errors_received
11
11
 
12
12
  # @param differences [Array<DiffNode>] Array of difference nodes
13
- # @param preprocessed_strings [Array<String, String>] Pre-processed content for display
13
+ # @param preprocessed_strings [Array<String, String>, Proc] Pre-processed
14
+ # content for display, or a Proc returning the Array (materialized on
15
+ # first read)
14
16
  # @param format [Symbol] Format type (:xml, :html, :json, :yaml)
15
17
  # @param html_version [Symbol, nil] HTML version (:html4 or :html5) for HTML format only
16
18
  # @param match_options [Hash, nil] Resolved match options used for comparison
@@ -23,7 +25,7 @@ html_version: nil, match_options: nil, algorithm: :dom, original_strings: nil,
23
25
  parse_errors_expected: nil, parse_errors_received: nil)
24
26
  @differences = differences
25
27
  @preprocessed_strings = preprocessed_strings
26
- @original_strings = original_strings || preprocessed_strings
28
+ @original_strings = original_strings
27
29
  @format = format
28
30
  @html_version = html_version
29
31
  @match_options = match_options
@@ -32,6 +34,28 @@ parse_errors_expected: nil, parse_errors_received: nil)
32
34
  @parse_errors_received = Array(parse_errors_received)
33
35
  end
34
36
 
37
+ # Display strings for the compared inputs. When the comparator
38
+ # supplied a builder Proc (the display strings require serializing
39
+ # both parsed documents), materialization is deferred to the first
40
+ # read: by_object consumers never pay it, by_line and preprocessed
41
+ # display trigger it exactly once.
42
+ #
43
+ # @return [Array<String, String>]
44
+ def preprocessed_strings
45
+ unless @preprocessed_strings.is_a?(Array)
46
+ @preprocessed_strings = @preprocessed_strings.call
47
+ end
48
+ @preprocessed_strings
49
+ end
50
+
51
+ # Unprocessed input strings; falls back to the preprocessed pair
52
+ # when the comparator did not provide originals.
53
+ #
54
+ # @return [Array<String, String>]
55
+ def original_strings
56
+ @original_strings || preprocessed_strings
57
+ end
58
+
35
59
  # Whether either side reported parse errors. Used by the diff
36
60
  # formatter to decide whether to render the parse-error banner.
37
61
  #
@@ -147,8 +171,8 @@ show_diffs: :all, diff_mode: :separate, legacy_terminal: false)
147
171
  formatter.format(
148
172
  self,
149
173
  @format,
150
- doc1: @preprocessed_strings[0],
151
- doc2: @preprocessed_strings[1],
174
+ doc1: preprocessed_strings[0],
175
+ doc2: preprocessed_strings[1],
152
176
  html_version: @html_version,
153
177
  )
154
178
  end
@@ -131,8 +131,10 @@ module Canon
131
131
  if opts[:verbose]
132
132
  ComparisonResult.new(
133
133
  differences: differences,
134
- preprocessed_strings: [serialize_for_display(node1),
135
- serialize_for_display(node2)],
134
+ preprocessed_strings: lambda do
135
+ [serialize_for_display(node1),
136
+ serialize_for_display(node2)]
137
+ end,
136
138
  original_strings: [original_str1, original_str2],
137
139
  format: :html,
138
140
  html_version: html_version,
@@ -44,12 +44,16 @@ module Canon
44
44
  differences, "")
45
45
 
46
46
  if opts[:verbose]
47
- json_str1 = obj1.is_a?(String) ? obj1 : JSON.pretty_generate(obj1)
48
- json_str2 = obj2.is_a?(String) ? obj2 : JSON.pretty_generate(obj2)
47
+ preprocessed = lambda do
48
+ [
49
+ obj1.is_a?(String) ? obj1 : JSON.pretty_generate(obj1),
50
+ obj2.is_a?(String) ? obj2 : JSON.pretty_generate(obj2),
51
+ ]
52
+ end
49
53
 
50
54
  ComparisonResult.new(
51
55
  differences: differences,
52
- preprocessed_strings: [json_str1, json_str2],
56
+ preprocessed_strings: preprocessed,
53
57
  format: :json,
54
58
  match_options: match_opts_hash,
55
59
  )
@@ -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
 
@@ -131,11 +146,14 @@ module Canon
131
146
 
132
147
  if opts[:verbose]
133
148
  # Serialize parsed nodes for consistent formatting
134
- # This ensures both sides formatted identically, showing only real differences
135
- preprocessed = [
136
- serialize_node(node1).gsub("><", ">\n<"),
137
- serialize_node(node2).gsub("><", ">\n<"),
138
- ]
149
+ # This ensures both sides formatted identically, showing only real
150
+ # differences. Deferred: by_object consumers never read these.
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
+ )
139
157
 
140
158
  ComparisonResult.new(
141
159
  differences: differences,
@@ -177,20 +195,71 @@ module Canon
177
195
  # @param n1 [Object] First input
178
196
  # @param n2 [Object] Second input
179
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
+
180
256
  # @return [Boolean, ComparisonResult]
181
257
  def build_trivial_equivalent_result(n1, n2, opts)
182
258
  return true unless opts[:verbose]
183
259
 
184
- # Parse nodes for verbose display
185
- preserve_whitespace = true
186
- node1 = parse_node(n1, :none,
187
- preserve_whitespace: preserve_whitespace)
188
- node2 = parse_node(n2, :none,
189
- preserve_whitespace: preserve_whitespace)
190
- preprocessed = [
191
- serialize_node(node1).gsub("><", ">\n<"),
192
- serialize_node(node2).gsub("><", ">\n<"),
193
- ]
260
+ # Parse nodes for verbose display — deferred with the strings:
261
+ # nothing but the display pair ever reads them.
262
+ preprocessed = deferred_preprocessed(n1, n2, :none, true)
194
263
  original1 = n1.is_a?(String) ? n1 : serialize_node(n1)
195
264
  original2 = n2.is_a?(String) ? n2 : serialize_node(n2)
196
265
 
@@ -69,13 +69,17 @@ module Canon
69
69
  differences, "")
70
70
 
71
71
  if opts[:verbose]
72
- # Format YAML for display
73
- yaml_str1 = obj1.is_a?(String) ? obj1 : Canon::YamlParsing.dump(obj1)
74
- yaml_str2 = obj2.is_a?(String) ? obj2 : Canon::YamlParsing.dump(obj2)
72
+ # Format YAML for display — deferred until first read
73
+ preprocessed = lambda do
74
+ [
75
+ obj1.is_a?(String) ? obj1 : Canon::YamlParsing.dump(obj1),
76
+ obj2.is_a?(String) ? obj2 : Canon::YamlParsing.dump(obj2),
77
+ ]
78
+ end
75
79
 
76
80
  ComparisonResult.new(
77
81
  differences: differences,
78
- preprocessed_strings: [yaml_str1, yaml_str2],
82
+ preprocessed_strings: preprocessed,
79
83
  format: :yaml,
80
84
  match_options: match_opts_hash,
81
85
  )
@@ -407,11 +407,11 @@ module Canon
407
407
 
408
408
  # CRITICAL: Use strategy's preprocess_for_display to ensure proper
409
409
  # line-breaking. This matches DOM diff preprocessing pattern
410
- # (xml_comparator.rb:106-109).
410
+ # (xml_comparator.rb). Deferred via the lazy ComparisonResult seam:
411
+ # by_object consumers never pay the serialization.
411
412
  strategy = Comparison::Strategies::SemanticTreeMatchStrategy.new(
412
413
  format: format1, match_options: match_options_only,
413
414
  )
414
- str1, str2 = strategy.preprocess_for_display(doc1, doc2)
415
415
 
416
416
  # Store tree diff data in match_options for access via result.
417
417
  enhanced_match_options = match_opts_hash.merge(
@@ -423,7 +423,7 @@ module Canon
423
423
  # Create ComparisonResult for unified handling.
424
424
  result = Canon::Comparison::ComparisonResult.new(
425
425
  differences: diff_nodes,
426
- preprocessed_strings: [str1, str2],
426
+ preprocessed_strings: -> { strategy.preprocess_for_display(doc1, doc2) },
427
427
  original_strings: [original_str1, original_str2],
428
428
  format: format1,
429
429
  html_version: %i[html4 html5].include?(format1) ? format1 : nil,
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.53"
4
+ VERSION = "0.3.55"
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,56 @@
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
+ def cdata_block(_string); end
49
+
50
+ def comment(_string); end
51
+
52
+ def processing_instruction(_name, _content); end
53
+ end
54
+ end
55
+ end
56
+ 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.53
4
+ version: 0.3.55
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