canon 0.2.12 → 0.3.0

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +54 -7
  3. data/CLAUDE.md +197 -0
  4. data/lib/canon/commands/diff_command.rb +1 -2
  5. data/lib/canon/comparison/comparison_result.rb +1 -2
  6. data/lib/canon/comparison/diff_node_builder.rb +1 -1
  7. data/lib/canon/comparison/format_detector.rb +27 -29
  8. data/lib/canon/comparison/html_comparator.rb +14 -14
  9. data/lib/canon/comparison/node_inspector.rb +5 -9
  10. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +1 -1
  11. data/lib/canon/comparison/xml_comparator/node_parser.rb +3 -3
  12. data/lib/canon/comparison/xml_node_comparison.rb +2 -2
  13. data/lib/canon/comparison.rb +1 -1
  14. data/lib/canon/diff_formatter/by_line/base_formatter.rb +1 -2
  15. data/lib/canon/diff_formatter/by_line/html_formatter.rb +1 -2
  16. data/lib/canon/diff_formatter/by_line_formatter.rb +1 -2
  17. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +2 -2
  18. data/lib/canon/diff_formatter/theme.rb +2 -2
  19. data/lib/canon/diff_formatter.rb +2 -4
  20. data/lib/canon/html/nokogiri_support.rb +60 -0
  21. data/lib/canon/html.rb +1 -0
  22. data/lib/canon/pretty_printer/xml.rb +6 -1
  23. data/lib/canon/pretty_printer/xml_normalized.rb +5 -5
  24. data/lib/canon/tree_diff/core/tree_node.rb +4 -3
  25. data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +1 -1
  26. data/lib/canon/version.rb +1 -1
  27. data/lib/canon/xml/data_model.rb +206 -33
  28. data/lib/canon/xml/processor.rb +10 -2
  29. data/lib/canon/xml/sax/moxml_driver.rb +58 -0
  30. data/lib/canon/xml/sax/nokogiri_driver.rb +54 -0
  31. data/lib/canon/xml/sax.rb +37 -0
  32. data/lib/canon/xml/sax_builder.rb +31 -20
  33. data/lib/canon/xml.rb +1 -0
  34. data/lib/canon/xml_backend.rb +39 -54
  35. data/lib/canon/xml_parsing.rb +89 -118
  36. data/lib/canon.rb +1 -2
  37. data/lib/tasks/benchmark_runner.rb +4 -0
  38. data/lib/tasks/performance.rake +2 -0
  39. data/lib/tasks/performance_report.rb +12 -8
  40. metadata +9 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48895efa0afcaaa525d8dcb9b04c1eb5969aeacd96c2e9b1f575383109229ce8
4
- data.tar.gz: 12baa3b412f857b7b6038149f8344f2aa295d8b29e96ed2a0401e64010df5478
3
+ metadata.gz: 0d1f4de27225e068e0bc1a00b2b7b2d980847bf9027a21f042de148aae65926e
4
+ data.tar.gz: 4561eaa1efb9abcd541ec8da05f2e33df11859c35d6e5fe3f5fee9fa9d22af3d
5
5
  SHA512:
6
- metadata.gz: 9d9e2d1bfce446d850e1c991e161991a5112bdccdcb3d57d28170ba9d3180bcd0897aba7c78c7c2e6fe3ec3f5d5ba0e295c9c80627fd0d3c9e2a8bfef1434125
7
- data.tar.gz: ec91e939f8d39f21e9c93fcfea178241eb601c6e5f66e9956468bfef0bf480ba05d587b705d108f39de661e184d7aaae3e0b79cf4c907a2d0c98b062d4564ec5
6
+ metadata.gz: ee29ec80689f0c53771017dca13d48ba4c3162db98f0e3605c3198148035cd564d818b8e4c08728608ef3abd055231d82850f79f933d2206ec4a36d488c6faa6
7
+ data.tar.gz: 37ae2bd7e928265b8c54e4025ae73d7e8761ce6e28ee2933e005129bd218d7003a5d547103bba140e11e829ef4d45aa7ef41768766b302f6608814f08fb74983
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-06-15 09:49:31 UTC using RuboCop version 1.87.0.
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: 1272
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: MaximumRangeSize.
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: 298
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: 29
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: 261
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: 93
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 Canon::XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Node)
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
- if XmlBackend.moxml?
26
- case obj
27
- when Moxml::Node, Moxml::Document
28
- :xml
29
- when String
30
- detect_string(obj)
31
- when Hash, Array
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
- case obj
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
- XmlBackend.document_fragment?(node)
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 XmlBackend.document_fragment?(node)
220
+ if Html::NokogiriSupport.document_fragment?(node)
221
221
  return node
222
222
  end
223
223
 
224
- html_string = if XmlBackend.document_fragment?(node)
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 = XmlBackend.xml_fragment(
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 XmlBackend.html_document?(node)
319
+ if Html::NokogiriSupport.html_document?(node)
320
320
  root = node.at_css("html") || node.root
321
321
  if root
322
- node = XmlBackend.xml_fragment(root.to_html)
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 = XmlBackend.document_fragment?(node) ? node : XmlBackend.xml_fragment(node.to_html)
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 = XmlBackend.xml_fragment(
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
- XmlBackend.html_version_from_node(node)
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
- Canon::XmlBackend.nokogiri? ? node.to_html : Canon::XmlParsing.serialize(node)
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
- Canon::XmlBackend.nokogiri? ? html.to_html : html.to_s
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 XmlBackend.html_document?(node) || XmlBackend.document_fragment?(node)
674
+ return false if Html::NokogiriSupport.html_document?(node) || Html::NokogiriSupport.document_fragment?(node)
675
675
 
676
- if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Document) && node.children.any? do |child|
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
- XmlBackend.nokogiri? && node.children.any? do |child|
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 XmlBackend.nokogiri?
39
- return true if node.is_a?(Nokogiri::XML::Node) && node.comment?
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.is_a?(Nokogiri::XML::Node) && node.text?
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 XmlBackend.nokogiri?
158
- if node.is_a?(Nokogiri::XML::Document) || node.is_a?(Nokogiri::HTML5::Document)
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 Canon::XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Element)
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 && RUBY_ENGINE != "opal"
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 && RUBY_ENGINE != "opal"
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
- Canon::XmlBackend.nokogiri? ? :sax : :dom
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 Canon::XmlBackend.nokogiri?
220
- node1.is_a?(Nokogiri::XML::Node) && node1.node_type == node2.node_type
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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "moxml"
4
- require "nokogiri" if Canon::XmlBackend.nokogiri?
4
+ require "nokogiri" unless RUBY_ENGINE == "opal"
5
5
 
6
6
  module Canon
7
7
  # Comparison module for XML, HTML, JSON, and YAML documents
@@ -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