canon 0.2.12 → 0.3.1
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/.rubocop_todo.yml +54 -7
- data/CLAUDE.md +197 -0
- data/lib/canon/commands/diff_command.rb +1 -2
- data/lib/canon/comparison/comparison_result.rb +1 -2
- data/lib/canon/comparison/diff_node_builder.rb +1 -1
- data/lib/canon/comparison/format_detector.rb +27 -29
- data/lib/canon/comparison/html_comparator.rb +14 -14
- data/lib/canon/comparison/node_inspector.rb +5 -9
- data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +1 -1
- data/lib/canon/comparison/xml_comparator/node_parser.rb +3 -3
- data/lib/canon/comparison/xml_node_comparison.rb +2 -2
- data/lib/canon/comparison.rb +1 -1
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +1 -2
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +1 -2
- data/lib/canon/diff_formatter/by_line_formatter.rb +1 -2
- data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +2 -2
- data/lib/canon/diff_formatter/theme.rb +2 -2
- data/lib/canon/diff_formatter.rb +2 -4
- data/lib/canon/html/nokogiri_support.rb +60 -0
- data/lib/canon/html.rb +1 -0
- data/lib/canon/pretty_printer/xml.rb +6 -1
- data/lib/canon/pretty_printer/xml_normalized.rb +5 -5
- data/lib/canon/tree_diff/core/tree_node.rb +4 -3
- data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +1 -1
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +207 -34
- data/lib/canon/xml/processor.rb +10 -2
- data/lib/canon/xml/sax/moxml_driver.rb +58 -0
- data/lib/canon/xml/sax/nokogiri_driver.rb +54 -0
- data/lib/canon/xml/sax.rb +37 -0
- data/lib/canon/xml/sax_builder.rb +31 -20
- data/lib/canon/xml.rb +1 -0
- data/lib/canon/xml_backend.rb +39 -54
- data/lib/canon/xml_parsing.rb +89 -118
- data/lib/canon.rb +1 -2
- data/lib/tasks/benchmark_runner.rb +4 -0
- data/lib/tasks/performance.rake +2 -0
- data/lib/tasks/performance_report.rb +12 -8
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8180c4d10ad157f5fd7d401d7c4723a70d53daa90894a43c33016253d0355093
|
|
4
|
+
data.tar.gz: 513538a92c8f209b2eeec7ecf2adedf9e2a86de8e2c6c0e807b45522afffa9b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15bbb37c41f2fe0809be3a1ae9e6e3bf438d7d509eb478b92eb1ace044d511017a257409024c0c7e042bf5dc146af0dbbfc9397c6ffea13a5d410fb9019c716d
|
|
7
|
+
data.tar.gz: 81c275d67d47bfeb87a1c5969e90643da41e8816e882a40fa5affa8337fbd3c5bd0ee82428ef63b36bc5dff8338776e8df4da53394d3319663f3167433664785
|
data/.rubocop_todo.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# This configuration was generated by
|
|
2
2
|
# `rubocop --auto-gen-config`
|
|
3
|
-
# on 2026-
|
|
3
|
+
# on 2026-08-27 03:37:16 UTC using RuboCop version 1.90.0.
|
|
4
4
|
# The point is for the user to remove these configuration records
|
|
5
5
|
# one by one as the offenses are removed from the code base.
|
|
6
6
|
# Note that changes in the inspected code, or installation of new
|
|
@@ -11,13 +11,43 @@ Gemspec/RequiredRubyVersion:
|
|
|
11
11
|
Exclude:
|
|
12
12
|
- 'canon.gemspec'
|
|
13
13
|
|
|
14
|
-
# Offense count:
|
|
14
|
+
# Offense count: 6
|
|
15
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
16
|
+
# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment.
|
|
17
|
+
Layout/ExtraSpacing:
|
|
18
|
+
Exclude:
|
|
19
|
+
- 'lib/canon/diff_formatter/theme.rb'
|
|
20
|
+
- 'lib/tasks/performance_report.rb'
|
|
21
|
+
|
|
22
|
+
# Offense count: 4
|
|
23
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
24
|
+
# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
|
|
25
|
+
# SupportedHashRocketStyles: key, separator, table
|
|
26
|
+
# SupportedColonStyles: key, separator, table
|
|
27
|
+
# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
|
|
28
|
+
Layout/HashAlignment:
|
|
29
|
+
Exclude:
|
|
30
|
+
- 'lib/tasks/performance_report.rb'
|
|
31
|
+
|
|
32
|
+
# Offense count: 1267
|
|
15
33
|
# This cop supports safe autocorrection (--autocorrect).
|
|
16
34
|
# Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
|
|
17
35
|
# URISchemes: http, https
|
|
18
36
|
Layout/LineLength:
|
|
19
37
|
Enabled: false
|
|
20
38
|
|
|
39
|
+
# Offense count: 4
|
|
40
|
+
# This cop supports safe autocorrection (--autocorrect).
|
|
41
|
+
# Configuration parameters: AllowInHeredoc.
|
|
42
|
+
Layout/TrailingWhitespace:
|
|
43
|
+
Exclude:
|
|
44
|
+
- 'lib/tasks/performance_report.rb'
|
|
45
|
+
|
|
46
|
+
# Offense count: 1
|
|
47
|
+
Lint/CopDirectiveSyntax:
|
|
48
|
+
Exclude:
|
|
49
|
+
- 'lib/canon/comparison/comparison_result.rb'
|
|
50
|
+
|
|
21
51
|
# Offense count: 57
|
|
22
52
|
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
|
|
23
53
|
Lint/DuplicateBranch:
|
|
@@ -32,7 +62,7 @@ Lint/EmptyConditionalBody:
|
|
|
32
62
|
- 'spec/canon/comparison_spec.rb'
|
|
33
63
|
|
|
34
64
|
# Offense count: 5
|
|
35
|
-
# Configuration parameters:
|
|
65
|
+
# Configuration parameters: MaxRangeSize.
|
|
36
66
|
Lint/MissingCopEnableDirective:
|
|
37
67
|
Exclude:
|
|
38
68
|
- 'lib/canon/commands/format_command.rb'
|
|
@@ -62,12 +92,12 @@ Lint/UselessConstantScoping:
|
|
|
62
92
|
Exclude:
|
|
63
93
|
- 'lib/canon/diff_formatter/theme.rb'
|
|
64
94
|
|
|
65
|
-
# Offense count:
|
|
95
|
+
# Offense count: 296
|
|
66
96
|
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
|
|
67
97
|
Metrics/AbcSize:
|
|
68
98
|
Enabled: false
|
|
69
99
|
|
|
70
|
-
# Offense count:
|
|
100
|
+
# Offense count: 28
|
|
71
101
|
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
|
|
72
102
|
# AllowedMethods: refine
|
|
73
103
|
Metrics/BlockLength:
|
|
@@ -78,7 +108,7 @@ Metrics/BlockLength:
|
|
|
78
108
|
Metrics/BlockNesting:
|
|
79
109
|
Max: 4
|
|
80
110
|
|
|
81
|
-
# Offense count:
|
|
111
|
+
# Offense count: 262
|
|
82
112
|
# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
|
|
83
113
|
Metrics/CyclomaticComplexity:
|
|
84
114
|
Enabled: false
|
|
@@ -197,7 +227,7 @@ RSpec/MultipleDescribes:
|
|
|
197
227
|
RSpec/MultipleExpectations:
|
|
198
228
|
Max: 15
|
|
199
229
|
|
|
200
|
-
# Offense count:
|
|
230
|
+
# Offense count: 92
|
|
201
231
|
# Configuration parameters: AllowSubject.
|
|
202
232
|
RSpec/MultipleMemoizedHelpers:
|
|
203
233
|
Max: 16
|
|
@@ -257,6 +287,23 @@ RSpec/VerifiedDoubles:
|
|
|
257
287
|
- 'spec/canon/diff_formatter/diff_detail_formatter_spec.rb'
|
|
258
288
|
- 'spec/canon/tree_diff/operation_converter_spec.rb'
|
|
259
289
|
|
|
290
|
+
# Offense count: 13
|
|
291
|
+
# This cop supports unsafe autocorrection (--autocorrect-all).
|
|
292
|
+
Style/DirectiveScope:
|
|
293
|
+
Exclude:
|
|
294
|
+
- 'lib/canon.rb'
|
|
295
|
+
- 'lib/canon/commands/diff_command.rb'
|
|
296
|
+
- 'lib/canon/comparison/comparison_result.rb'
|
|
297
|
+
- 'lib/canon/diff_formatter.rb'
|
|
298
|
+
- 'lib/canon/diff_formatter/by_line/base_formatter.rb'
|
|
299
|
+
- 'lib/canon/diff_formatter/by_line/html_formatter.rb'
|
|
300
|
+
- 'lib/canon/diff_formatter/by_line_formatter.rb'
|
|
301
|
+
- 'spec/canon/comparison/xml_comparator_spec.rb'
|
|
302
|
+
- 'spec/canon/comparison_spec.rb'
|
|
303
|
+
- 'spec/canon/diff_formatter/formatting_diff_visualization_spec.rb'
|
|
304
|
+
- 'spec/canon/fixtures_integrity_spec.rb'
|
|
305
|
+
- 'spec/canon/string_matcher_spec.rb'
|
|
306
|
+
|
|
260
307
|
# Offense count: 1
|
|
261
308
|
# This cop supports safe autocorrection (--autocorrect).
|
|
262
309
|
# Configuration parameters: EnforcedStyle, AllowComments.
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Canon is a canonicalization, formatting, and comparison library for serialization formats (XML, HTML, JSON, YAML). It produces standardized forms suitable for comparison, testing, digital signatures, and human-readable output.
|
|
8
|
+
|
|
9
|
+
## Common Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Install dependencies
|
|
13
|
+
bundle install
|
|
14
|
+
|
|
15
|
+
# Run all tests
|
|
16
|
+
bundle exec rake spec # or: bundle exec rspec
|
|
17
|
+
|
|
18
|
+
# Run a single test file
|
|
19
|
+
bundle exec rspec spec/canon/comparison/xml_comparator_spec.rb
|
|
20
|
+
|
|
21
|
+
# Run tests with specific pattern
|
|
22
|
+
bundle exec rspec spec/canon/comparison --tag focus
|
|
23
|
+
|
|
24
|
+
# Lint (rubocop)
|
|
25
|
+
bundle exec rake rubocop
|
|
26
|
+
|
|
27
|
+
# Default task (runs tests + lint)
|
|
28
|
+
bundle exec rake
|
|
29
|
+
|
|
30
|
+
# Performance benchmarks (takes ~5 minutes)
|
|
31
|
+
bundle exec rake performance:run
|
|
32
|
+
|
|
33
|
+
# Quick benchmark (~30 seconds)
|
|
34
|
+
bundle exec rake performance:quick
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Size Limits
|
|
38
|
+
|
|
39
|
+
Canon protects against pathologically large files with configurable limits:
|
|
40
|
+
- **File size**: Default 5MB (`CANON_MAX_FILE_SIZE`)
|
|
41
|
+
- **Node count**: Default 10,000 (`CANON_MAX_NODE_COUNT`)
|
|
42
|
+
- **Diff output**: Default 10,000 lines (`CANON_MAX_DIFF_LINES`)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export CANON_MAX_FILE_SIZE=10485760 # 10MB
|
|
46
|
+
export CANON_MAX_NODE_COUNT=50000
|
|
47
|
+
bundle exec rspec
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Architecture
|
|
51
|
+
|
|
52
|
+
### Core Entry Points
|
|
53
|
+
|
|
54
|
+
- `lib/canon.rb` — Main module entry point. Provides `Canon.format`, `Canon.parse`, and shorthand methods like `Canon.format_xml`, `Canon.parse_json`. Defines `SUPPORTED_FORMATS = [:xml, :yaml, :json, :html, :html4, :html5, :string]`.
|
|
55
|
+
- `lib/canon/cli.rb` — Thor-based CLI. Two commands: `canon format` (canonicalize/pretty-print) and `canon diff` (semantic comparison). See CLI long descriptions for full option documentation.
|
|
56
|
+
- `exe/canon` — CLI entry point (runs the Thor CLI).
|
|
57
|
+
|
|
58
|
+
### Two Comparison Algorithms — Distinct by Design
|
|
59
|
+
|
|
60
|
+
Canon provides two **fundamentally different** comparison algorithms. They are NOT alternative implementations of the same approach. They use different methods, accept different options, produce different intermediate representations, and solve different problems. The pipelines must never be merged or "unified."
|
|
61
|
+
|
|
62
|
+
#### DOM Algorithm (`diff_algorithm: :dom`, default)
|
|
63
|
+
|
|
64
|
+
Position-based recursive tree walk. Compares children by position, attributes by name, namespace by URI.
|
|
65
|
+
|
|
66
|
+
- **Method**: Positional recursive descent through the DOM tree
|
|
67
|
+
- **Code path**: `dom_diff` → format comparators (`XmlComparator`, `HtmlComparator`, `JsonComparator`, `YamlComparator`)
|
|
68
|
+
- **Algorithm-specific options**: Filtering (`ignore_children`, `ignore_text_nodes`, `ignore_attrs`, `ignore_nodes`, `ignore_attr_content`, `diff_children`)
|
|
69
|
+
- **Intermediate representation**: Integer comparison-result codes (EQUIVALENT, MISSING_NODE, UNEQUAL_TEXT_CONTENTS, etc.)
|
|
70
|
+
- **Output**: DiffNodes with diff codes
|
|
71
|
+
- **Strength**: Deterministic positional comparison with fine-grained filtering; O(n) performance
|
|
72
|
+
- **Location**: `lib/canon/comparison/`
|
|
73
|
+
|
|
74
|
+
#### Semantic Tree Algorithm (`diff_algorithm: :semantic`)
|
|
75
|
+
|
|
76
|
+
Signature-based tree matching. Computes node signatures, uses hash matching + similarity matching + structural propagation to find node correspondences even when nodes have moved. Produces insert/update/delete/move operations.
|
|
77
|
+
|
|
78
|
+
- **Method**: Signature computation → three-phase matching (hash, similarity, structural propagation) → operation detection
|
|
79
|
+
- **Code path**: `semantic_diff` → `TreeDiffIntegrator` → format adapters → `OperationConverter` → DiffNodes
|
|
80
|
+
- **Algorithm-specific options**: Matching strategy (`similarity_threshold`, `hash_matching`, `similarity_matching`, `propagation`)
|
|
81
|
+
- **Intermediate representation**: Operations (INSERT, DELETE, UPDATE, MOVE)
|
|
82
|
+
- **Output**: Operations → converted to DiffNodes
|
|
83
|
+
- **Strength**: Detects moved/renamed nodes and structural reorganization
|
|
84
|
+
- **Location**: `lib/canon/tree_diff/`
|
|
85
|
+
|
|
86
|
+
#### Shared Infrastructure (safe to consolidate)
|
|
87
|
+
|
|
88
|
+
Both algorithms share these pipeline steps — this is the ONLY layer where consolidation makes sense:
|
|
89
|
+
- Format detection (`FormatDetector`)
|
|
90
|
+
- Config resolution (`Canon::Config`)
|
|
91
|
+
- Match option resolution (`MatchOptions` resolvers, profiles, dimensions)
|
|
92
|
+
- Preprocessing (`:none`, `:c14n`, `:normalize`, `:format`)
|
|
93
|
+
- Parsing (format-specific `.parse` methods)
|
|
94
|
+
- DiffNode output format — both produce DiffNodes
|
|
95
|
+
- Diff formatting (`by_line` / `by_object`) — both feed into the same formatters
|
|
96
|
+
|
|
97
|
+
#### NOT Shared (do NOT merge)
|
|
98
|
+
|
|
99
|
+
- The comparison engines are entirely separate codepaths with no shared comparison logic
|
|
100
|
+
- DOM-specific filtering options (`ignore_*`) have no meaning in semantic matching
|
|
101
|
+
- Semantic-specific matching options (`similarity_threshold`, `hash_matching`) have no meaning in DOM comparison
|
|
102
|
+
- Intermediate representations differ: diff codes vs tree-matching operations
|
|
103
|
+
|
|
104
|
+
#### Two Activation Paths
|
|
105
|
+
|
|
106
|
+
Semantic diff can be activated two ways:
|
|
107
|
+
1. `diff_algorithm: :semantic` — caught by `Comparison.equivalent?`, routes to `Comparison.semantic_diff` (owns full pipeline end-to-end)
|
|
108
|
+
2. `match: { semantic_diff: true }` — passes through `dom_diff` to the format comparator, which detects the flag and calls its own `perform_semantic_tree_diff`
|
|
109
|
+
|
|
110
|
+
Path 1 is the primary API. Path 2 exists for direct comparator calls and is tested in `spec/canon/tree_diff/canon_integration_spec.rb`.
|
|
111
|
+
|
|
112
|
+
### Four-Layer Architecture
|
|
113
|
+
|
|
114
|
+
The comparison pipeline flows through four independent layers:
|
|
115
|
+
|
|
116
|
+
1. **Preprocessing** — Optional normalization (`:none`, `:c14n`, `:normalize`, `:format`)
|
|
117
|
+
2. **Algorithm Selection** — `:dom` (position-based, stable) or `:semantic` (signature-based, experimental)
|
|
118
|
+
3. **Match Options** — Dimension behaviors per format (`:text_content`, `:structural_whitespace`, `:attribute_values`, etc.) controlled via profiles (`:strict`, `:spec_friendly`, `:rendered`, `:content_only`)
|
|
119
|
+
4. **Diff Formatting** — `by_line` or `by_object` output mode
|
|
120
|
+
|
|
121
|
+
**Critical distinction**: Do NOT use `Canon.format_xml` output for string comparison in tests. The formatting process changes line counts and causes false failures. Use `Canon::Comparison.equivalent?` or RSpec matchers instead.
|
|
122
|
+
|
|
123
|
+
### Key Modules
|
|
124
|
+
|
|
125
|
+
- `lib/canon/comparison/` — DOM-based comparison logic. `comparison.rb` is the main facade; comparators live in submodules. `MatchOptions::Xml` defines profiles (`:strict`, `:spec_friendly`, `:rendered`, `:content_only`) and per-dimension behaviors.
|
|
126
|
+
- `lib/canon/tree_diff/` — Semantic tree diff engine. Separate from the DOM comparators. Integrates back into the main pipeline via `OperationConverter`.
|
|
127
|
+
- `lib/canon/diff_formatter/` — Output formatters for diff results. Two modes: `by_line` (line-by-line, used for HTML and strings) and `by_object` (semantic/object-level, used for XML/JSON/YAML). Contains format-specific formatters for XML, HTML, JSON, YAML output.
|
|
128
|
+
- `lib/canon/formatters/` — Pretty-printers for canonicalization/formatting (distinct from diff formatters). `XmlFormatter` supports Canonical XML (C14N).
|
|
129
|
+
- `lib/canon/config.rb` — Global configuration with per-format settings (profiles, preprocessing, diff options). Read at runtime via `Canon::Config.instance`.
|
|
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).
|
|
132
|
+
|
|
133
|
+
### XML Engines
|
|
134
|
+
|
|
135
|
+
Canon is engine-agnostic across three seams (MECE — one concern per module):
|
|
136
|
+
|
|
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
|
+
- `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
|
+
- `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.
|
|
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.
|
|
143
|
+
|
|
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
|
+
|
|
146
|
+
### Format Detection
|
|
147
|
+
|
|
148
|
+
`Canon::Comparison::FormatDetector` auto-detects format from string content or object type (Moxml::Node → XML, Nokogiri::HTML → HTML, Hash → JSON, etc.). HTML4 vs HTML5 is determined by DOCTYPE.
|
|
149
|
+
|
|
150
|
+
### Difference Result Format
|
|
151
|
+
|
|
152
|
+
When `verbose: true`, comparison returns `Canon::Comparison::ComparisonResult` (or legacy Hash/Array). The result exposes:
|
|
153
|
+
- `differences` — array of `DiffNode` objects
|
|
154
|
+
- `equivalent?` — boolean
|
|
155
|
+
- `preprocessed_strings` / `original_strings` — for diff display
|
|
156
|
+
- `tree_diff_operations` / `tree_diff_statistics` / `tree_diff_matching` — only when using semantic algorithm
|
|
157
|
+
|
|
158
|
+
### Difference Classification
|
|
159
|
+
|
|
160
|
+
Differences are classified into three tiers:
|
|
161
|
+
- **Normative** — Affects equivalence (documents not equivalent if different)
|
|
162
|
+
- **Informative** — Tracked but doesn't affect equivalence
|
|
163
|
+
- **Formatting-only** — Pure whitespace/formatting differences when normalized content matches
|
|
164
|
+
|
|
165
|
+
Use `show_diffs: :normative` to display only equivalence-affecting differences.
|
|
166
|
+
|
|
167
|
+
### Testing Notes
|
|
168
|
+
|
|
169
|
+
- Tests use a shared fixture system (`spec/canon/fixtures/`). `spec/canon/fixtures_integrity_spec.rb` validates fixture references.
|
|
170
|
+
- `spec/spec_helper.rb` disables monkey-patching and uses `expect` syntax.
|
|
171
|
+
- Specs named `*_spec.rb` under `spec/canon/` map to `lib/canon/`.
|
|
172
|
+
|
|
173
|
+
## Architectural Rules
|
|
174
|
+
|
|
175
|
+
These rules are non-negotiable. Violations must be fixed before merge.
|
|
176
|
+
|
|
177
|
+
### No `respond_to?` — use proper types
|
|
178
|
+
|
|
179
|
+
`respond_to?` is a type-system bypass. It means the code does not know what it is working with, which is a failure of the model. Every object flowing through the comparison pipeline is one of a known set of types (`Canon::Xml::Node` and subclasses, `Nokogiri::XML::Node` and subclasses, `ComparisonResult`, `DiffNode`, `Hash`, `String`). Use `is_a?` for type dispatch, or better yet, design the classes so that polymorphism handles dispatch automatically (e.g., a shared base class or module providing the same interface).
|
|
180
|
+
|
|
181
|
+
If you find yourself writing `respond_to?`, stop and introduce a proper type check or a shared protocol instead.
|
|
182
|
+
|
|
183
|
+
### No `send` to bypass visibility — make the method public
|
|
184
|
+
|
|
185
|
+
Using `send` to call a private method from another module or class is an encapsulation violation. If another object needs to call a method, that method must be public. Private means "internal implementation detail of this class" — if it is needed externally, it is not private. Either make it public or rethink the boundary.
|
|
186
|
+
|
|
187
|
+
### No duplicated type-checking logic
|
|
188
|
+
|
|
189
|
+
Backend-agnostic node queries (text node?, text content, whitespace check) must live in one place. The `Canon::Comparison::NodeInspector` module provides a single source of truth for cross-backend node type operations. All code that needs to query node properties must use it — never re-implement type dispatch inline.
|
|
190
|
+
|
|
191
|
+
### Single module for cross-cutting utilities
|
|
192
|
+
|
|
193
|
+
When multiple modules need the same capability (e.g., checking if a node is a whitespace-only text node), extract a single utility module. Do not duplicate the logic in each consumer.
|
|
194
|
+
|
|
195
|
+
### Two algorithm pipelines must remain separate
|
|
196
|
+
|
|
197
|
+
DOM and Semantic comparison are fundamentally different algorithms with different option sets, different intermediate representations, and different codepaths. Proposals to "unify" or "merge" the algorithm pipelines are always wrong. The correct approach is to extract shared infrastructure (format detection, config, parsing, output formatting) into reusable methods while keeping the algorithm cores independent. See "Two Comparison Algorithms — Distinct by Design" above.
|
|
@@ -12,7 +12,7 @@ module Canon
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# rubocop:disable Metrics/MethodLength
|
|
15
|
-
# rubocop:disable Metrics/AbcSize
|
|
15
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
16
16
|
def run(file1, file2)
|
|
17
17
|
# Detect formats
|
|
18
18
|
format1 = @options[:format1] || @options[:format] || detect_format(file1)
|
|
@@ -98,7 +98,6 @@ module Canon
|
|
|
98
98
|
rescue StandardError => e
|
|
99
99
|
abort "Error processing files: #{e.message}"
|
|
100
100
|
end
|
|
101
|
-
# rubocop:enable Metrics/AbcSize
|
|
102
101
|
# rubocop:enable Metrics/MethodLength
|
|
103
102
|
|
|
104
103
|
private
|
|
@@ -162,13 +162,12 @@ show_diffs: :all, diff_mode: :separate, legacy_terminal: false)
|
|
|
162
162
|
def summarize_diff_node(diff)
|
|
163
163
|
parts = ["Not equivalent:"]
|
|
164
164
|
|
|
165
|
-
# rubocop:disable Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
|
|
165
|
+
# rubocop:disable-next Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
|
|
166
166
|
if diff.path
|
|
167
167
|
parts << "#{diff.reason} at #{diff.path}"
|
|
168
168
|
else
|
|
169
169
|
parts << diff.reason.to_s
|
|
170
170
|
end
|
|
171
|
-
# rubocop:enable Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
|
|
172
171
|
|
|
173
172
|
if diff.serialized_before && diff.serialized_after
|
|
174
173
|
before_preview = truncate_preview(diff.serialized_before)
|
|
@@ -145,7 +145,7 @@ module Canon
|
|
|
145
145
|
when Canon::Xml::Node
|
|
146
146
|
node.text_content
|
|
147
147
|
else
|
|
148
|
-
if
|
|
148
|
+
if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
|
|
149
149
|
node.content.to_s
|
|
150
150
|
elsif Canon::XmlParsing.xml_node?(node)
|
|
151
151
|
Canon::XmlParsing.text_content(node)
|
|
@@ -22,39 +22,37 @@ module Canon
|
|
|
22
22
|
# @param obj [Object] Object to detect format of
|
|
23
23
|
# @return [Symbol] Format type (:xml, :html, :json, :yaml, :ruby_object, :string)
|
|
24
24
|
def detect(obj)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
:ruby_object
|
|
33
|
-
else
|
|
34
|
-
raise Canon::Error, "Unknown format for object: #{obj.class}"
|
|
35
|
-
end
|
|
25
|
+
case obj
|
|
26
|
+
when Moxml::Node, Moxml::Document
|
|
27
|
+
:xml
|
|
28
|
+
when String
|
|
29
|
+
detect_string(obj)
|
|
30
|
+
when Hash, Array
|
|
31
|
+
:ruby_object
|
|
36
32
|
else
|
|
37
|
-
|
|
38
|
-
when Moxml::Node, Moxml::Document
|
|
39
|
-
:xml
|
|
40
|
-
when Nokogiri::HTML::DocumentFragment, Nokogiri::HTML5::DocumentFragment
|
|
41
|
-
:html
|
|
42
|
-
when Nokogiri::XML::DocumentFragment
|
|
43
|
-
obj.document&.html? ? :html : :xml
|
|
44
|
-
when Nokogiri::XML::Document, Nokogiri::XML::Node
|
|
45
|
-
obj.html? ? :html : :xml
|
|
46
|
-
when Nokogiri::HTML::Document, Nokogiri::HTML5::Document
|
|
47
|
-
:html
|
|
48
|
-
when String
|
|
49
|
-
detect_string(obj)
|
|
50
|
-
when Hash, Array
|
|
51
|
-
:ruby_object
|
|
52
|
-
else
|
|
53
|
-
raise Canon::Error, "Unknown format for object: #{obj.class}"
|
|
54
|
-
end
|
|
33
|
+
detect_nokogiri(obj)
|
|
55
34
|
end
|
|
56
35
|
end
|
|
57
36
|
|
|
37
|
+
# Nokogiri nodes arrive from user input regardless of the active
|
|
38
|
+
# XML engine, so detection is by node type (Opal never sees them —
|
|
39
|
+
# Nokogiri is not loaded there).
|
|
40
|
+
def detect_nokogiri(obj)
|
|
41
|
+
return :html if defined?(Nokogiri) && obj.is_a?(Nokogiri::HTML::DocumentFragment)
|
|
42
|
+
return :html if defined?(Nokogiri) && obj.is_a?(Nokogiri::HTML5::DocumentFragment)
|
|
43
|
+
|
|
44
|
+
if defined?(Nokogiri) && obj.is_a?(Nokogiri::XML::DocumentFragment)
|
|
45
|
+
return obj.document&.html? ? :html : :xml
|
|
46
|
+
end
|
|
47
|
+
if defined?(Nokogiri) && (obj.is_a?(Nokogiri::XML::Document) || obj.is_a?(Nokogiri::XML::Node))
|
|
48
|
+
return obj.html? ? :html : :xml
|
|
49
|
+
end
|
|
50
|
+
return :html if defined?(Nokogiri) && obj.is_a?(Nokogiri::HTML::Document)
|
|
51
|
+
return :html if defined?(Nokogiri) && obj.is_a?(Nokogiri::HTML5::Document)
|
|
52
|
+
|
|
53
|
+
raise Canon::Error, "Unknown format for object: #{obj.class}"
|
|
54
|
+
end
|
|
55
|
+
|
|
58
56
|
# Detect the format of a string with caching
|
|
59
57
|
#
|
|
60
58
|
# @param str [String] String to detect format of
|
|
@@ -167,7 +167,7 @@ module Canon
|
|
|
167
167
|
# accepted: dom_diff routes html/html4/html5 input through
|
|
168
168
|
# Nokogiri::HTML5.fragment per #118.
|
|
169
169
|
def fragment_node?(node)
|
|
170
|
-
|
|
170
|
+
Html::NokogiriSupport.document_fragment?(node)
|
|
171
171
|
end
|
|
172
172
|
|
|
173
173
|
# Compare children of document fragments using the shared
|
|
@@ -217,11 +217,11 @@ module Canon
|
|
|
217
217
|
# @param match_opts [Hash] Match options
|
|
218
218
|
# @return [Nokogiri::HTML::DocumentFragment] Parsed fragment
|
|
219
219
|
def parse_node_as_fragment(node, preprocessing = :none, match_opts = {})
|
|
220
|
-
if
|
|
220
|
+
if Html::NokogiriSupport.document_fragment?(node)
|
|
221
221
|
return node
|
|
222
222
|
end
|
|
223
223
|
|
|
224
|
-
html_string = if
|
|
224
|
+
html_string = if Html::NokogiriSupport.document_fragment?(node)
|
|
225
225
|
node.to_s # Use to_s to avoid re-inserting meta tags
|
|
226
226
|
elsif node.is_a?(String)
|
|
227
227
|
node
|
|
@@ -229,7 +229,7 @@ module Canon
|
|
|
229
229
|
node.to_html
|
|
230
230
|
end
|
|
231
231
|
|
|
232
|
-
frag =
|
|
232
|
+
frag = Html::NokogiriSupport.xml_fragment(
|
|
233
233
|
decode_html_named_entities(html_string),
|
|
234
234
|
)
|
|
235
235
|
|
|
@@ -316,15 +316,15 @@ module Canon
|
|
|
316
316
|
|
|
317
317
|
# Normalize HTML documents to fragments to avoid DTD differences
|
|
318
318
|
# This ensures comparing string with document works correctly
|
|
319
|
-
if
|
|
319
|
+
if Html::NokogiriSupport.html_document?(node)
|
|
320
320
|
root = node.at_css("html") || node.root
|
|
321
321
|
if root
|
|
322
|
-
node =
|
|
322
|
+
node = Html::NokogiriSupport.xml_fragment(root.to_html)
|
|
323
323
|
end
|
|
324
324
|
end
|
|
325
325
|
|
|
326
326
|
if %i[normalize format rendered].include?(preprocessing)
|
|
327
|
-
frag =
|
|
327
|
+
frag = Html::NokogiriSupport.document_fragment?(node) ? node : Html::NokogiriSupport.xml_fragment(node.to_html)
|
|
328
328
|
normalize_html_style_script_comments(frag)
|
|
329
329
|
if preprocessing == :rendered
|
|
330
330
|
normalize_rendered_whitespace(frag, match_opts)
|
|
@@ -372,7 +372,7 @@ module Canon
|
|
|
372
372
|
node
|
|
373
373
|
end
|
|
374
374
|
|
|
375
|
-
frag =
|
|
375
|
+
frag = Html::NokogiriSupport.xml_fragment(
|
|
376
376
|
decode_html_named_entities(html_string),
|
|
377
377
|
)
|
|
378
378
|
|
|
@@ -466,7 +466,7 @@ module Canon
|
|
|
466
466
|
# @param node [Canon::Xml::Node, Nokogiri::XML::Node] HTML node
|
|
467
467
|
# @return [Symbol] :html5 or :html4
|
|
468
468
|
def detect_html_version_from_node(node)
|
|
469
|
-
|
|
469
|
+
Html::NokogiriSupport.html_version_from_node(node)
|
|
470
470
|
end
|
|
471
471
|
|
|
472
472
|
# Serialize node to string for diff display
|
|
@@ -478,7 +478,7 @@ module Canon
|
|
|
478
478
|
if node.is_a?(Canon::Xml::Node)
|
|
479
479
|
Canon::Diff::NodeSerializer.serialize(node)
|
|
480
480
|
elsif Canon::XmlParsing.xml_node?(node)
|
|
481
|
-
|
|
481
|
+
defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node) ? node.to_html : Canon::XmlParsing.serialize(node)
|
|
482
482
|
else
|
|
483
483
|
node.to_s
|
|
484
484
|
end
|
|
@@ -495,7 +495,7 @@ module Canon
|
|
|
495
495
|
elsif html.is_a?(Canon::Xml::Node)
|
|
496
496
|
Canon::Xml::DataModel.serialize(html)
|
|
497
497
|
elsif Canon::XmlParsing.xml_node?(html)
|
|
498
|
-
|
|
498
|
+
defined?(Nokogiri) && html.is_a?(Nokogiri::XML::Node) ? html.to_html : html.to_s
|
|
499
499
|
else
|
|
500
500
|
html.to_s
|
|
501
501
|
end
|
|
@@ -671,16 +671,16 @@ compare_profile = nil)
|
|
|
671
671
|
# XML documents typically have XML processing instructions or are
|
|
672
672
|
# instances of Nokogiri::XML::Document (not HTML variants)
|
|
673
673
|
def xml_document?(node)
|
|
674
|
-
return false if
|
|
674
|
+
return false if Html::NokogiriSupport.html_document?(node) || Html::NokogiriSupport.document_fragment?(node)
|
|
675
675
|
|
|
676
|
-
if
|
|
676
|
+
if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Document) && node.children.any? do |child|
|
|
677
677
|
child.is_a?(Nokogiri::XML::ProcessingInstruction) && child.name == "xml"
|
|
678
678
|
end
|
|
679
679
|
return true
|
|
680
680
|
end
|
|
681
681
|
|
|
682
682
|
if (node.is_a?(Canon::Xml::Node) || Canon::XmlParsing.xml_node?(node)) &&
|
|
683
|
-
|
|
683
|
+
defined?(Nokogiri) && node.children.any? do |child|
|
|
684
684
|
child.is_a?(Nokogiri::XML::ProcessingInstruction) && child.name == "xml"
|
|
685
685
|
end
|
|
686
686
|
return true
|
|
@@ -35,11 +35,11 @@ module Canon
|
|
|
35
35
|
return false unless node
|
|
36
36
|
return node.node_type == :comment if node.is_a?(Canon::Xml::Node)
|
|
37
37
|
|
|
38
|
-
if
|
|
39
|
-
return true if node.
|
|
38
|
+
if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
|
|
39
|
+
return true if node.comment?
|
|
40
40
|
|
|
41
41
|
# HTML comments are parsed as TEXT nodes by Nokogiri
|
|
42
|
-
if node.
|
|
42
|
+
if node.text?
|
|
43
43
|
text_stripped = text_content(node).to_s.strip.gsub("\\", "")
|
|
44
44
|
return true if text_stripped.start_with?("<!--") && text_stripped.end_with?("-->")
|
|
45
45
|
end
|
|
@@ -154,12 +154,8 @@ module Canon
|
|
|
154
154
|
return [] if node.nil?
|
|
155
155
|
return Array(node.parse_errors).map(&:to_s) if node.is_a?(Canon::Xml::Node)
|
|
156
156
|
|
|
157
|
-
if
|
|
158
|
-
|
|
159
|
-
Array(node.errors).map(&:to_s)
|
|
160
|
-
else
|
|
161
|
-
[]
|
|
162
|
-
end
|
|
157
|
+
if defined?(Nokogiri) && (node.is_a?(Nokogiri::XML::Document) || node.is_a?(Nokogiri::HTML5::Document))
|
|
158
|
+
Array(node.errors).map(&:to_s)
|
|
163
159
|
else
|
|
164
160
|
[]
|
|
165
161
|
end
|
|
@@ -178,7 +178,7 @@ dimension:, differences:, **opts)
|
|
|
178
178
|
when Canon::Xml::Nodes::ElementNode
|
|
179
179
|
node.attribute_nodes
|
|
180
180
|
else
|
|
181
|
-
if
|
|
181
|
+
if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
|
|
182
182
|
node.attribute_nodes
|
|
183
183
|
else
|
|
184
184
|
node.attributes
|
|
@@ -35,7 +35,7 @@ module Canon
|
|
|
35
35
|
# Select parser backend
|
|
36
36
|
resolved_parser = parser || resolve_parser_config
|
|
37
37
|
|
|
38
|
-
if resolved_parser == :sax
|
|
38
|
+
if resolved_parser == :sax
|
|
39
39
|
Canon::Xml::SaxBuilder.parse(xml_string,
|
|
40
40
|
preserve_whitespace: preserve_whitespace)
|
|
41
41
|
else
|
|
@@ -94,7 +94,7 @@ parser: nil)
|
|
|
94
94
|
|
|
95
95
|
resolved_parser = parser || resolve_parser_config
|
|
96
96
|
|
|
97
|
-
if resolved_parser == :sax
|
|
97
|
+
if resolved_parser == :sax
|
|
98
98
|
Canon::Xml::SaxBuilder.parse(xml_str,
|
|
99
99
|
preserve_whitespace: preserve_whitespace)
|
|
100
100
|
else
|
|
@@ -109,7 +109,7 @@ parser: nil)
|
|
|
109
109
|
def self.resolve_parser_config
|
|
110
110
|
Canon::Config.instance.xml.diff.parser
|
|
111
111
|
rescue StandardError
|
|
112
|
-
|
|
112
|
+
:sax
|
|
113
113
|
end
|
|
114
114
|
end
|
|
115
115
|
end
|
|
@@ -216,8 +216,8 @@ diff_children, differences)
|
|
|
216
216
|
when Canon::Xml::Node
|
|
217
217
|
node1.node_type == node2.node_type
|
|
218
218
|
else
|
|
219
|
-
if
|
|
220
|
-
node1.
|
|
219
|
+
if defined?(Nokogiri) && node1.is_a?(Nokogiri::XML::Node)
|
|
220
|
+
node1.node_type == node2.node_type
|
|
221
221
|
else
|
|
222
222
|
Canon::XmlParsing.xml_node?(node1) && Canon::XmlParsing.node_type(node1) == Canon::XmlParsing.node_type(node2)
|
|
223
223
|
end
|
data/lib/canon/comparison.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Canon
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
# rubocop:disable Metrics/ParameterLists
|
|
41
|
+
# rubocop:disable-next Metrics/ParameterLists
|
|
42
42
|
def initialize(use_color: true, context_lines: 3,
|
|
43
43
|
diff_grouping_lines: nil, visualization_map: nil,
|
|
44
44
|
show_diffs: :all, differences: [],
|
|
@@ -58,7 +58,6 @@ module Canon
|
|
|
58
58
|
@theme = theme
|
|
59
59
|
@character_visualization = character_visualization
|
|
60
60
|
end
|
|
61
|
-
# rubocop:enable Metrics/ParameterLists
|
|
62
61
|
|
|
63
62
|
# Get the resolved theme hash
|
|
64
63
|
# @return [Hash] Theme hash
|