canon 0.3.26 → 0.3.27

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: '05685ad043ad0a5465f4e065c26cf35ad1b453511ce347a56e60d5a5153e731f'
4
- data.tar.gz: 864587a2ddf54edd3f40d71676882b255904dfe6f46ee41a330ef8117ceddfed
3
+ metadata.gz: d8de5d7008a348037ef44fdd8bd5ee722e8077e058ab1e53cd2995b0dd5f0e2c
4
+ data.tar.gz: 930b0efd7c7302968a6ff2f4fd9af8a6261a1a57fc71707d9206333a380f47a8
5
5
  SHA512:
6
- metadata.gz: 6e568174aeaf605650e7eb64267afddbe30fd4399b82c032757e60177631b75663b0cf388d16e2fbaddea774ea703cb4c88f3e11ac8307ee1a75a3ea6f6530cc
7
- data.tar.gz: 5af9aded692f6b909ffd1eb50bb9e96c851b8ca98844a397ceec11f8a2eede33437f99d4df15a2870beee56954924d2fd1ec061b6cb4523d2204fa07524f0710
6
+ metadata.gz: 570bd7072c65d9c87316becd43334255bb176ee4260c1d40738cd51ee3d030c4ea3df9d93e923a2f6062c493271fb122bb252e1f41c6c0e40836d7fbe675fd5b
7
+ data.tar.gz: a7d60c235f74cdf466a35b4ef6204c9943d306a1167dead26123314326936f90f7d96a5d2ee3bd2ace308556655c2e6492d2beb2ebab25933d4ce796dfd84e9f
data/README.adoc CHANGED
@@ -12,9 +12,19 @@ Key features:
12
12
 
13
13
  * **Format support**: XML, HTML, JSON, YAML
14
14
  * **Canonicalization**: W3C XML C14N 1.1, sorted JSON/YAML keys
15
- * **Semantic comparison**: Compare meaning, not formatting
15
+ * **Semantic comparison**: Compare meaning, not formatting — attributes by
16
+ expanded name (namespace URI + local name), prefixes never affect results
16
17
  * **Multiple interfaces**: Ruby API, CLI, RSpec matchers
17
18
  * **Smart diff output**: By-line or by-object modes with syntax highlighting
19
+ * **Fast native engines, zero mandatory C deps**:
20
+ ** XML on https://github.com/leptris/leptris[libleptris] (via moxml) whenever
21
+ installed — Nokogiri otherwise and for HTML; both byte-compatible, engine
22
+ parity is spec-gated
23
+ ** JSON on https://github.com/leptris/yeptris[libyeptris]' strict JSON surface
24
+ whenever the native materializer is installed (`gem install yeptris` —
25
+ platform gems, zero compilation), the stdlib JSON extension otherwise
26
+ ** YAML on yeptris opt-in (`CANON_YAML_BACKEND=yeptris`, ~3x Psych loads);
27
+ Psych otherwise
18
28
 
19
29
  == When to use formatting vs comparison
20
30
 
@@ -957,10 +967,25 @@ bundle exec rake performance:yaml
957
967
  * **XML/HTML Comparison**: Identical, similar, different documents
958
968
  * **Format Canonicalization**: XML C14N, JSON, YAML
959
969
 
970
+ === Engines
971
+
972
+ Canon is engine-agnostic across its formats:
973
+
974
+ * **XML**: libleptris via moxml whenever installed (parse, comparison, C14N,
975
+ pretty-printing); raw Nokogiri otherwise, and always Nokogiri for HTML.
976
+ `CANON_XML_BACKEND=nokogiri|moxml` forces either engine; parity between the
977
+ two is spec-gated (`spec/canon/xml/engine_parity_spec.rb`).
978
+ * **JSON**: yeptris' strict JSON surface when the native materializer is
979
+ installed (platform gems — zero compilation), the stdlib JSON extension
980
+ otherwise. `CANON_YAML_BACKEND=psych` forces stdlib.
981
+ * **YAML**: Psych by default; yeptris (≈3x loads) behind
982
+ `CANON_YAML_BACKEND=yeptris`. Parity is spec-gated
983
+ (`spec/canon/yaml_engine_parity_spec.rb`).
984
+
960
985
  === SAX Parser
961
986
 
962
987
  Canon includes a SAX-based XML parser (`Canon::Xml::SaxBuilder`) that provides
963
- significantly faster XML parsing by avoiding intermediate Nokogiri DOM trees.
988
+ significantly faster XML parsing by avoiding intermediate engine DOM trees.
964
989
 
965
990
  [source,ruby]
966
991
  ----
@@ -34,14 +34,24 @@ module Canon
34
34
  # @param filtered [Hash] Output hash to populate
35
35
  def self.filter_array_attributes(attributes, opts, match_opts, filtered)
36
36
  attributes.each do |attr|
37
- name = attr.name
37
+ # Expanded name (XML Namespaces 1.0 §5.2/§5.3): an
38
+ # unprefixed attribute is in NO namespace — {no-ns}srsName
39
+ # and {uri}srsName are different attributes, and two
40
+ # prefixes bound to the same URI are the same attribute.
41
+ # Local-name keys conflated qualified with unqualified
42
+ # attributes whenever the prefix matched the element's
43
+ # (issue #155).
44
+ name = expanded_attribute_name(attr)
38
45
  value = attr.value
39
46
 
40
47
  # Skip namespace declarations - they're handled separately
41
- next if namespace_declaration?(name)
48
+ next if namespace_declaration?(attr.name)
42
49
 
43
- # Skip if attribute name should be ignored
44
- next if ignore_by_name?(name, opts)
50
+ # Skip if attribute name should be ignored — by local name
51
+ # (user-facing list) or expanded key, so ignore lists keep
52
+ # working against both forms.
53
+ next if ignore_by_name?(attr.name, opts) ||
54
+ ignore_by_name?(name, opts)
45
55
 
46
56
  # Skip if attribute content should be ignored
47
57
  next if ignore_by_content?(value, opts)
@@ -54,6 +64,26 @@ module Canon
54
64
  end
55
65
  end
56
66
 
67
+ # Expanded attribute key: "{namespace-uri}local-name", or the
68
+ # bare local name when the attribute is in no namespace.
69
+ # Prefixed-but-unresolvable attributes (namespace-invalid
70
+ # documents — an undeclared prefix has no expanded name) fall
71
+ # back to the local name: recovery comparison cannot do better.
72
+ def self.expanded_attribute_name(attr)
73
+ case attr
74
+ when Canon::Xml::Nodes::AttributeNode
75
+ uri = attr.namespace_uri
76
+ attr.prefix
77
+ when defined?(Nokogiri) && Nokogiri::XML::Attr
78
+ uri = attr.namespace&.href
79
+ attr.namespace&.prefix
80
+ else
81
+ uri = attr.namespace_uri
82
+ nil
83
+ end
84
+ uri && !uri.empty? ? "{#{uri}}#{attr.name}" : attr.name
85
+ end
86
+
57
87
  # Filter hash-format attributes (Nokogiri/Moxml)
58
88
  #
59
89
  # @param attributes [Hash] Hash-like attributes
@@ -33,82 +33,11 @@ module Canon
33
33
  # @return [String] Formatted diff
34
34
  def format(doc1, doc2)
35
35
  compute_line_num_width(doc1, doc2)
36
- # If we have DiffNodes from comparison, use the new pipeline
37
- if @differences&.any?(Canon::Diff::DiffNode)
38
- # Check if we should skip based on show_diffs setting
39
- if should_skip_diff_display?
40
- return ""
41
- end
42
-
43
- # Use new pipeline when DiffNodes available
44
- return format_with_pipeline(doc1, doc2)
45
- end
46
-
47
- # LEGACY: Fall back to old DOM-based behavior
48
- # Check if we should show any diffs based on differences array
49
- if should_skip_diff_display?
50
- return ""
51
- end
52
-
53
- output = []
54
-
55
- begin
56
- # Parse to DOM using HTML parser
57
- root1 = Canon::Html::DataModel.from_html(doc1,
58
- version: @html_version)
59
- root2 = Canon::Html::DataModel.from_html(doc2,
60
- version: @html_version)
61
-
62
- # Match elements semantically
63
- matcher = Canon::Xml::ElementMatcher.new
64
- matches = matcher.match_trees(root1, root2)
65
-
66
- # Pretty-print HTML for line mapping
67
- pretty_printer = Canon::PrettyPrinter::Html.new(indent: 2)
68
- pretty1 = pretty_printer.format(doc1)
69
- pretty2 = pretty_printer.format(doc2)
70
-
71
- # Build line range maps using pretty-printed documents
72
- mapper1 = Canon::Xml::LineRangeMapper.new(indent: 2)
73
- mapper2 = Canon::Xml::LineRangeMapper.new(indent: 2)
74
- map1 = mapper1.build_map(root1, pretty1)
75
- map2 = mapper2.build_map(root2, pretty2)
76
-
77
- # Use pretty-printed document lines for display
78
- lines1 = pretty1.split("\n")
79
- lines2 = pretty2.split("\n")
80
-
81
- # Display diffs based on element matches
82
- result = format_element_matches(matches, map1, map2, lines1, lines2)
83
- output << result
84
- rescue StandardError => e
85
- # Fall back to simple diff on error
86
- output << colorize("Warning: DOM parsing failed, using simple diff",
87
- :yellow)
88
- output << colorize("Error: #{e.class}: #{e.message}", :red)
36
+ return "" if should_skip_diff_display?
89
37
 
90
- # Include relevant backtrace lines
91
- relevant_trace = e.backtrace.select do |line|
92
- line.include?("canon")
93
- end.take(3)
94
- unless relevant_trace.empty?
95
- output << colorize("Backtrace:", :yellow)
96
- relevant_trace.each do |line|
97
- output << colorize(" #{line}", :yellow)
98
- end
99
- end
100
-
101
- output << ""
102
- simple = SimpleFormatter.new(
103
- use_color: @use_color,
104
- context_lines: @context_lines,
105
- diff_grouping_lines: @diff_grouping_lines,
106
- visualization_map: @visualization_map,
107
- )
108
- output << simple.format(doc1, doc2)
109
- end
110
-
111
- output.join("\n")
38
+ # The DiffNode pipeline is the only renderer: without
39
+ # DiffNodes there is nothing to report (#84).
40
+ format_with_pipeline(doc1, doc2)
112
41
  end
113
42
 
114
43
  # Format using new DiffReportBuilder pipeline
@@ -260,227 +189,6 @@ module Canon
260
189
 
261
190
  output.join("\n")
262
191
  end
263
-
264
- private
265
-
266
- # Format element matches for display
267
- def format_element_matches(matches, map1, map2, lines1, lines2)
268
- output = []
269
-
270
- # Detect non-ASCII characters in the diff
271
- all_text = (lines1 + lines2).join
272
- non_ascii = Legend.detect_non_ascii(all_text, @visualization_map)
273
-
274
- # Add Unicode legend if any non-ASCII characters detected
275
- unless non_ascii.empty?
276
- output << Legend.build_legend(non_ascii, use_color: @use_color)
277
- output << ""
278
- end
279
-
280
- # Build a set of elements to skip (children of parents showing diffs)
281
- elements_to_skip = build_skip_set(matches, map1, map2, lines1,
282
- lines2)
283
-
284
- # Build a set of children of matched parents
285
- children_of_matched_parents = build_children_set(matches)
286
-
287
- # Collect diff sections with metadata
288
- diff_sections = collect_diff_sections(matches, map1, map2, lines1,
289
- lines2, elements_to_skip,
290
- children_of_matched_parents)
291
-
292
- # Sort by line number
293
- diff_sections.sort_by! do |section|
294
- section[:start_line1] || section[:start_line2] || 0
295
- end
296
-
297
- # Group diffs by proximity if diff_grouping_lines is set
298
- formatted_diffs = if @diff_grouping_lines
299
- groups = group_diff_sections(diff_sections,
300
- @diff_grouping_lines)
301
- format_diff_groups(groups)
302
- else
303
- diff_sections.filter_map do |s|
304
- s[:formatted]
305
- end.join("\n\n")
306
- end
307
-
308
- output << formatted_diffs
309
- output.join("\n")
310
- end
311
-
312
- # Build set of elements to skip (children with parents showing diffs)
313
- def build_skip_set(matches, map1, map2, lines1, lines2)
314
- elements_to_skip = Set.new
315
- elements_with_diffs = Set.new
316
-
317
- # Build set of element pairs that have semantic diffs
318
- build_elements_with_semantic_diffs_set
319
-
320
- # First pass: identify elements with line differences
321
- # (semantic filtering happens in collect_diff_sections)
322
- matches.each do |match|
323
- next unless match.status == :matched
324
-
325
- range1 = map1[match.elem1]
326
- range2 = map2[match.elem2]
327
- next unless range1 && range2
328
-
329
- elem_lines1 = lines1[range1.start_line..range1.end_line]
330
- elem_lines2 = lines2[range2.start_line..range2.end_line]
331
-
332
- # Add if there are line diffs
333
- # Semantic filtering is done in collect_diff_sections
334
- if elem_lines1 != elem_lines2
335
- elements_with_diffs.add(match.elem1)
336
- end
337
- end
338
-
339
- # Second pass: skip children of elements with diffs
340
- elements_with_diffs.each do |elem|
341
- current = Canon::Comparison::NodeInspector.parent(elem)
342
- while current
343
- if Canon::Comparison::NodeInspector.element_node?(current) &&
344
- elements_with_diffs.include?(current)
345
- elements_to_skip.add(elem)
346
- break
347
- end
348
- current = Canon::Comparison::NodeInspector.parent(current)
349
- end
350
- end
351
-
352
- elements_to_skip
353
- end
354
-
355
- # Collect diff sections with metadata
356
- def collect_diff_sections(matches, map1, map2, lines1, lines2,
357
- elements_to_skip, _children_of_matched_parents)
358
- diff_sections = []
359
- no_range_count = 0
360
- no_diff_count = 0
361
-
362
- # If there are NO semantic diffs, don't show any matched elements
363
- # (all text diffs were normalized away)
364
- elements_with_semantic_diffs = build_elements_with_semantic_diffs_set
365
-
366
- matches.each do |match|
367
- case match.status
368
- when :matched
369
- next if elements_to_skip.include?(match.elem1)
370
-
371
- # Only apply semantic filtering if we have DiffNode objects
372
- # (when called standalone or without DiffNodes, show all diffs)
373
- if !@differences.nil? && !@differences.empty? && @differences.any?(Canon::Diff::DiffNode)
374
- # Skip if no semantic diffs exist (all diffs were normalized)
375
- next if elements_with_semantic_diffs.empty?
376
-
377
- # Skip if this element has no semantic diffs in its subtree
378
- next unless has_semantic_diff_in_subtree?(match.elem1,
379
- elements_with_semantic_diffs)
380
- end
381
-
382
- range1 = map1[match.elem1]
383
- range2 = map2[match.elem2]
384
- if !range1 || !range2
385
- no_range_count += 1
386
- end
387
-
388
- section = format_matched_element_with_metadata(match, map1,
389
- map2, lines1,
390
- lines2)
391
- if range1 && range2 && !section
392
- no_diff_count += 1
393
- end
394
- diff_sections << section if section
395
- when :deleted
396
- # Don't skip deleted elements - they should always be shown
397
- section = format_deleted_element_with_metadata(match, map1,
398
- lines1)
399
- diff_sections << section if section
400
- when :inserted
401
- # Don't skip inserted elements - they should always be shown
402
- section = format_inserted_element_with_metadata(match, map2,
403
- lines2)
404
- diff_sections << section if section
405
- end
406
- end
407
-
408
- diff_sections
409
- end
410
-
411
- # Format a matched element showing differences
412
- def format_matched_element(match, map1, map2, lines1, lines2)
413
- range1 = map1[match.elem1]
414
- range2 = map2[match.elem2]
415
- return nil unless range1 && range2
416
-
417
- # Extract line ranges
418
- elem_lines1 = lines1[range1.start_line..range1.end_line]
419
- elem_lines2 = lines2[range2.start_line..range2.end_line]
420
-
421
- # Skip if identical
422
- return nil if elem_lines1 == elem_lines2
423
-
424
- # Run line diff
425
- diffs = ::Diff::LCS.sdiff(elem_lines1, elem_lines2)
426
-
427
- # Identify diff blocks
428
- diff_blocks = identify_diff_blocks(diffs)
429
- return nil if diff_blocks.empty?
430
-
431
- # Group into contexts
432
- contexts = group_diff_blocks_into_contexts(diff_blocks,
433
- @diff_grouping_lines || 0)
434
-
435
- # Expand with context lines
436
- expanded_contexts = expand_contexts_with_context_lines(contexts,
437
- @context_lines,
438
- diffs.length)
439
-
440
- # Format contexts
441
- output = []
442
- expanded_contexts.each_with_index do |context, idx|
443
- output << "" if idx.positive?
444
- output << format_context(context, diffs, range1.start_line,
445
- range2.start_line)
446
- end
447
-
448
- output.join("\n")
449
- end
450
-
451
- # Format a deleted element
452
- def format_deleted_element(match, map1, lines1)
453
- range1 = map1[match.elem1]
454
- return nil unless range1
455
-
456
- output = []
457
- path_str = match.path.join("/")
458
- output << colorize("Element: #{path_str} [DELETED]", :red, :bold)
459
-
460
- # Show all lines as deleted
461
- (range1.start_line..range1.end_line).each do |i|
462
- output << format_unified_line(i + 1, nil, "-", lines1[i], :red)
463
- end
464
-
465
- output.join("\n")
466
- end
467
-
468
- # Format an inserted element
469
- def format_inserted_element(match, map2, lines2)
470
- range2 = map2[match.elem2]
471
- return nil unless range2
472
-
473
- output = []
474
- path_str = match.path.join("/")
475
- output << colorize("Element: #{path_str} [INSERTED]", :green, :bold)
476
-
477
- # Show all lines as inserted
478
- (range2.start_line..range2.end_line).each do |i|
479
- output << format_unified_line(nil, i + 1, "+", lines2[i], :green)
480
- end
481
-
482
- output.join("\n")
483
- end
484
192
  end
485
193
  end
486
194
  end
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.26"
4
+ VERSION = "0.3.27"
5
5
  end
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.26
4
+ version: 0.3.27
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-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs