canon 0.2.11 → 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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +62 -25
  3. data/CLAUDE.md +197 -0
  4. data/Rakefile +5 -2
  5. data/lib/canon/cache.rb +3 -1
  6. data/lib/canon/cli.rb +0 -3
  7. data/lib/canon/commands/diff_command.rb +1 -8
  8. data/lib/canon/commands/format_command.rb +0 -4
  9. data/lib/canon/commands.rb +9 -0
  10. data/lib/canon/comparison/child_realignment.rb +0 -2
  11. data/lib/canon/comparison/compare_profile.rb +30 -36
  12. data/lib/canon/comparison/comparison_result.rb +1 -4
  13. data/lib/canon/comparison/diff_node_builder.rb +353 -0
  14. data/lib/canon/comparison/dimensions/dimension.rb +51 -0
  15. data/lib/canon/comparison/dimensions/dimension_set.rb +49 -0
  16. data/lib/canon/comparison/dimensions/registry.rb +101 -60
  17. data/lib/canon/comparison/dimensions.rb +15 -46
  18. data/lib/canon/comparison/format_detector.rb +27 -29
  19. data/lib/canon/comparison/html_comparator.rb +20 -143
  20. data/lib/canon/comparison/html_compare_profile.rb +15 -18
  21. data/lib/canon/comparison/json_comparator.rb +4 -165
  22. data/lib/canon/comparison/json_parser.rb +0 -2
  23. data/lib/canon/comparison/markup_comparator.rb +14 -210
  24. data/lib/canon/comparison/match_options/base_resolver.rb +18 -29
  25. data/lib/canon/comparison/match_options/json_resolver.rb +4 -28
  26. data/lib/canon/comparison/match_options/xml_resolver.rb +4 -45
  27. data/lib/canon/comparison/match_options/yaml_resolver.rb +4 -30
  28. data/lib/canon/comparison/match_options.rb +13 -88
  29. data/lib/canon/comparison/node_inspector.rb +5 -9
  30. data/lib/canon/comparison/pipeline.rb +269 -0
  31. data/lib/canon/comparison/profile_definition.rb +0 -2
  32. data/lib/canon/comparison/ruby_object_comparator.rb +1 -1
  33. data/lib/canon/comparison/strategies/match_strategy_factory.rb +9 -58
  34. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +4 -11
  35. data/lib/canon/comparison/strategies.rb +16 -0
  36. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +1 -4
  37. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +0 -3
  38. data/lib/canon/comparison/xml_comparator/child_comparison.rb +0 -6
  39. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +1 -6
  40. data/lib/canon/comparison/xml_comparator/node_parser.rb +3 -7
  41. data/lib/canon/comparison/xml_comparator.rb +4 -492
  42. data/lib/canon/comparison/xml_comparator_helpers.rb +21 -0
  43. data/lib/canon/comparison/xml_node_comparison.rb +6 -121
  44. data/lib/canon/comparison/yaml_comparator.rb +0 -3
  45. data/lib/canon/comparison.rb +144 -267
  46. data/lib/canon/config/config_dsl.rb +159 -0
  47. data/lib/canon/config/env_provider.rb +0 -3
  48. data/lib/canon/config/env_schema.rb +48 -58
  49. data/lib/canon/config/profile_loader.rb +0 -1
  50. data/lib/canon/config.rb +116 -468
  51. data/lib/canon/diff/diff_block_builder.rb +0 -2
  52. data/lib/canon/diff/diff_classifier.rb +0 -5
  53. data/lib/canon/diff/diff_context.rb +0 -2
  54. data/lib/canon/diff/diff_context_builder.rb +0 -2
  55. data/lib/canon/diff/diff_line_builder.rb +0 -3
  56. data/lib/canon/diff/diff_node_enricher.rb +0 -4
  57. data/lib/canon/diff/diff_node_mapper.rb +0 -4
  58. data/lib/canon/diff/diff_report_builder.rb +0 -4
  59. data/lib/canon/diff/formatting_detector.rb +0 -1
  60. data/lib/canon/diff/node_serializer.rb +0 -7
  61. data/lib/canon/diff.rb +39 -0
  62. data/lib/canon/diff_formatter/by_line/base_formatter.rb +5 -19
  63. data/lib/canon/diff_formatter/by_line/html_formatter.rb +8 -21
  64. data/lib/canon/diff_formatter/by_line/json_formatter.rb +0 -3
  65. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +0 -3
  66. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +7 -26
  67. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +0 -3
  68. data/lib/canon/diff_formatter/by_line_formatter.rb +1 -2
  69. data/lib/canon/diff_formatter/by_object/base_formatter.rb +8 -15
  70. data/lib/canon/diff_formatter/by_object/json_formatter.rb +0 -2
  71. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +0 -2
  72. data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +0 -2
  73. data/lib/canon/diff_formatter/debug_output.rb +0 -2
  74. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +24 -58
  75. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +0 -2
  76. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +3 -4
  77. data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +1 -7
  78. data/lib/canon/diff_formatter/diff_detail_formatter.rb +0 -7
  79. data/lib/canon/diff_formatter/diff_detail_formatter_helpers.rb +23 -0
  80. data/lib/canon/diff_formatter/theme.rb +2 -2
  81. data/lib/canon/diff_formatter.rb +13 -13
  82. data/lib/canon/formatters/html4_formatter.rb +0 -2
  83. data/lib/canon/formatters/html5_formatter.rb +0 -2
  84. data/lib/canon/formatters/html_formatter.rb +0 -3
  85. data/lib/canon/formatters/json_formatter.rb +0 -1
  86. data/lib/canon/formatters/xml_formatter.rb +0 -4
  87. data/lib/canon/formatters/yaml_formatter.rb +0 -1
  88. data/lib/canon/formatters.rb +16 -0
  89. data/lib/canon/html/data_model.rb +0 -10
  90. data/lib/canon/html/nokogiri_support.rb +60 -0
  91. data/lib/canon/html.rb +5 -3
  92. data/lib/canon/options/cli_generator.rb +0 -2
  93. data/lib/canon/options/registry.rb +0 -2
  94. data/lib/canon/options.rb +9 -0
  95. data/lib/canon/pretty_printer/html.rb +0 -1
  96. data/lib/canon/pretty_printer/xml.rb +6 -1
  97. data/lib/canon/pretty_printer/xml_normalized.rb +5 -7
  98. data/lib/canon/pretty_printer.rb +12 -0
  99. data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
  100. data/lib/canon/tree_diff/adapters.rb +14 -0
  101. data/lib/canon/tree_diff/core/attribute_comparator.rb +0 -6
  102. data/lib/canon/tree_diff/core/node_signature.rb +1 -1
  103. data/lib/canon/tree_diff/core/tree_node.rb +13 -5
  104. data/lib/canon/tree_diff/core.rb +17 -0
  105. data/lib/canon/tree_diff/matchers/hash_matcher.rb +0 -7
  106. data/lib/canon/tree_diff/matchers/similarity_matcher.rb +1 -5
  107. data/lib/canon/tree_diff/matchers/structural_propagator.rb +1 -5
  108. data/lib/canon/tree_diff/matchers.rb +15 -0
  109. data/lib/canon/tree_diff/operation_converter.rb +0 -8
  110. data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +2 -12
  111. data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +13 -7
  112. data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +2 -2
  113. data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +4 -6
  114. data/lib/canon/tree_diff/operation_converter_helpers.rb +18 -0
  115. data/lib/canon/tree_diff/operations/operation_detector.rb +2 -5
  116. data/lib/canon/tree_diff/operations.rb +13 -0
  117. data/lib/canon/tree_diff.rb +26 -27
  118. data/lib/canon/validators/base_validator.rb +0 -2
  119. data/lib/canon/validators/html_validator.rb +0 -1
  120. data/lib/canon/validators/json_validator.rb +0 -1
  121. data/lib/canon/validators/xml_validator.rb +0 -1
  122. data/lib/canon/validators/yaml_validator.rb +0 -1
  123. data/lib/canon/validators.rb +12 -0
  124. data/lib/canon/version.rb +1 -1
  125. data/lib/canon/xml/c14n.rb +0 -4
  126. data/lib/canon/xml/data_model.rb +206 -43
  127. data/lib/canon/xml/line_range_mapper.rb +0 -2
  128. data/lib/canon/xml/nodes/attribute_node.rb +0 -2
  129. data/lib/canon/xml/nodes/comment_node.rb +0 -2
  130. data/lib/canon/xml/nodes/element_node.rb +0 -2
  131. data/lib/canon/xml/nodes/namespace_node.rb +0 -2
  132. data/lib/canon/xml/nodes/processing_instruction_node.rb +0 -2
  133. data/lib/canon/xml/nodes/root_node.rb +0 -2
  134. data/lib/canon/xml/nodes/text_node.rb +0 -2
  135. data/lib/canon/xml/nodes.rb +19 -0
  136. data/lib/canon/xml/processor.rb +10 -7
  137. data/lib/canon/xml/sax/moxml_driver.rb +58 -0
  138. data/lib/canon/xml/sax/nokogiri_driver.rb +54 -0
  139. data/lib/canon/xml/sax.rb +37 -0
  140. data/lib/canon/xml/sax_builder.rb +31 -27
  141. data/lib/canon/xml.rb +34 -0
  142. data/lib/canon/xml_backend.rb +40 -19
  143. data/lib/canon/xml_parsing.rb +89 -116
  144. data/lib/canon.rb +26 -17
  145. data/lib/tasks/benchmark_runner.rb +4 -0
  146. data/lib/tasks/performance.rake +2 -58
  147. data/lib/tasks/performance_comparator.rb +132 -65
  148. data/lib/tasks/performance_helpers.rb +4 -249
  149. data/lib/tasks/performance_report.rb +313 -0
  150. metadata +31 -13
  151. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +0 -64
  152. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +0 -64
  153. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +0 -167
  154. data/lib/canon/comparison/dimensions/base_dimension.rb +0 -107
  155. data/lib/canon/comparison/dimensions/comments_dimension.rb +0 -117
  156. data/lib/canon/comparison/dimensions/element_position_dimension.rb +0 -86
  157. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +0 -115
  158. data/lib/canon/comparison/dimensions/text_content_dimension.rb +0 -102
  159. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +0 -300
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8fcacddf719d0cb69472bc55b2a23d724c938fdf76f995eb77be962aeda42182
4
- data.tar.gz: 3a1d14c811209e8353c7539d12c2f7a4d23d44c1e6742b6f48a8d94f65282bd5
3
+ metadata.gz: 0d1f4de27225e068e0bc1a00b2b7b2d980847bf9027a21f042de148aae65926e
4
+ data.tar.gz: 4561eaa1efb9abcd541ec8da05f2e33df11859c35d6e5fe3f5fee9fa9d22af3d
5
5
  SHA512:
6
- metadata.gz: f4685b05d13d6cedad780751a8011a722511a9a5623a3a3fa14ce548b95d036c8fd36cabb10d8793ede0a97cf5a1e72d8f53729022c90c8b316c510dc133983e
7
- data.tar.gz: 5201625d93a1ed9c2efb929905c1d14f4a292e914604149f8e5902a61d1ea6059b987c8875b073ec71340adb1833a33bbce450f58006f29736a37d810f326d6d
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
- # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
- # using RuboCop version 1.86.0.
2
+ # `rubocop --auto-gen-config`
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,14 +11,44 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'canon.gemspec'
13
13
 
14
- # Offense count: 1357
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
 
21
- # Offense count: 58
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
+
51
+ # Offense count: 57
22
52
  # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
23
53
  Lint/DuplicateBranch:
24
54
  Enabled: false
@@ -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: 316
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,12 +108,12 @@ Metrics/BlockLength:
78
108
  Metrics/BlockNesting:
79
109
  Max: 4
80
110
 
81
- # Offense count: 276
111
+ # Offense count: 262
82
112
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
83
113
  Metrics/CyclomaticComplexity:
84
114
  Enabled: false
85
115
 
86
- # Offense count: 527
116
+ # Offense count: 501
87
117
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
88
118
  Metrics/MethodLength:
89
119
  Max: 146
@@ -93,12 +123,12 @@ Metrics/MethodLength:
93
123
  Metrics/ParameterLists:
94
124
  Max: 10
95
125
 
96
- # Offense count: 214
126
+ # Offense count: 207
97
127
  # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
98
128
  Metrics/PerceivedComplexity:
99
129
  Enabled: false
100
130
 
101
- # Offense count: 30
131
+ # Offense count: 28
102
132
  # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
103
133
  # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
104
134
  Naming/MethodParameterName:
@@ -115,16 +145,6 @@ Naming/PredicateMethod:
115
145
  Exclude:
116
146
  - 'spec/canon/comparison/xml_attribute_diff_spec.rb'
117
147
 
118
- # Offense count: 6
119
- # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
120
- # SupportedStyles: snake_case, normalcase, non_integer
121
- # AllowedIdentifiers: TLS1_1, TLS1_2, capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64
122
- Naming/VariableNumber:
123
- Exclude:
124
- - 'lib/canon/comparison/json_comparator.rb'
125
- - 'lib/canon/comparison/markup_comparator.rb'
126
- - 'lib/canon/comparison/xml_comparator/diff_node_builder.rb'
127
-
128
148
  # Offense count: 4
129
149
  # Configuration parameters: MinSize.
130
150
  Performance/CollectionLiteralInLoop:
@@ -134,7 +154,7 @@ Performance/CollectionLiteralInLoop:
134
154
  - 'lib/canon/xml/xml_base_handler.rb'
135
155
  - 'spec/canon/diff/diff_node_mapper_comments_spec.rb'
136
156
 
137
- # Offense count: 107
157
+ # Offense count: 111
138
158
  # Configuration parameters: Prefixes, AllowedPatterns.
139
159
  # Prefixes: when, with, without
140
160
  RSpec/ContextWording:
@@ -151,7 +171,7 @@ RSpec/DescribeMethod:
151
171
  - 'spec/canon/comparison/multiple_differences_spec.rb'
152
172
  - 'spec/canon/diff_formatter/character_map_customization_spec.rb'
153
173
 
154
- # Offense count: 893
174
+ # Offense count: 916
155
175
  # Configuration parameters: CountAsOne.
156
176
  RSpec/ExampleLength:
157
177
  Max: 44
@@ -203,11 +223,11 @@ RSpec/MultipleDescribes:
203
223
  Exclude:
204
224
  - 'spec/canon/comparison/match_options_spec.rb'
205
225
 
206
- # Offense count: 749
226
+ # Offense count: 783
207
227
  RSpec/MultipleExpectations:
208
228
  Max: 15
209
229
 
210
- # Offense count: 93
230
+ # Offense count: 92
211
231
  # Configuration parameters: AllowSubject.
212
232
  RSpec/MultipleMemoizedHelpers:
213
233
  Max: 16
@@ -267,6 +287,23 @@ RSpec/VerifiedDoubles:
267
287
  - 'spec/canon/diff_formatter/diff_detail_formatter_spec.rb'
268
288
  - 'spec/canon/tree_diff/operation_converter_spec.rb'
269
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
+
270
307
  # Offense count: 1
271
308
  # This cop supports safe autocorrection (--autocorrect).
272
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.
data/Rakefile CHANGED
@@ -24,7 +24,9 @@ begin
24
24
  end
25
25
 
26
26
  # REXML: bundled gem since Ruby 3.4
27
- rexml_lib = $LOAD_PATH.find { |p| File.exist?(File.join(p, "rexml", "document.rb")) }
27
+ rexml_lib = $LOAD_PATH.find do |p|
28
+ File.exist?(File.join(p, "rexml", "document.rb"))
29
+ end
28
30
  Opal.append_path rexml_lib if rexml_lib
29
31
  end
30
32
  rescue LoadError
@@ -42,7 +44,8 @@ namespace :spec do
42
44
  desc "Run Opal (JavaScript) tests"
43
45
  Opal::RSpec::RakeTask.new(:opal) do |_server, runner|
44
46
  runner.default_path = "spec"
45
- runner.requires = %w[rexml_compat rexml/document rexml/xpath moxml/adapter/rexml spec_helper]
47
+ runner.requires = %w[rexml_compat rexml/document rexml/xpath
48
+ moxml moxml/adapter/rexml spec_helper]
46
49
  runner.pattern = "spec/canon/opal_xml_smoke_spec.rb"
47
50
  end
48
51
  end
data/lib/canon/cache.rb CHANGED
@@ -101,7 +101,9 @@ module Canon
101
101
  Digest::SHA256.hexdigest(content)[0..16]
102
102
  else
103
103
  # Opal fallback: simple string hash
104
- h = content.each_char.reduce(0) { |acc, c| ((acc * 31) + c.ord) & 0xFFFFFFFF }
104
+ h = content.each_char.reduce(0) do |acc, c|
105
+ ((acc * 31) + c.ord) & 0xFFFFFFFF
106
+ end
105
107
  h.to_s(16).rjust(8, "0")
106
108
  end
107
109
  end
data/lib/canon/cli.rb CHANGED
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor" unless RUBY_ENGINE == "opal"
4
- require_relative "commands/format_command"
5
- require_relative "commands/diff_command"
6
- require_relative "options/registry"
7
4
 
8
5
  module Canon
9
6
  # Command-line interface for Canon
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../comparison"
4
- require_relative "../diff_formatter"
5
- require_relative "../color_detector"
6
3
  require "json"
7
4
  require "yaml"
8
5
 
@@ -15,7 +12,7 @@ module Canon
15
12
  end
16
13
 
17
14
  # rubocop:disable Metrics/MethodLength
18
- # rubocop:disable Metrics/AbcSize
15
+ # rubocop:disable-next Metrics/AbcSize
19
16
  def run(file1, file2)
20
17
  # Detect formats
21
18
  format1 = @options[:format1] || @options[:format] || detect_format(file1)
@@ -66,7 +63,6 @@ module Canon
66
63
 
67
64
  # Show configuration in verbose mode using shared DebugOutput
68
65
  if @options[:verbose]
69
- require_relative "../diff_formatter/debug_output"
70
66
  config_output = Canon::DiffFormatter::DebugOutput.verbose_tables_only(
71
67
  result,
72
68
  {
@@ -102,7 +98,6 @@ module Canon
102
98
  rescue StandardError => e
103
99
  abort "Error processing files: #{e.message}"
104
100
  end
105
- # rubocop:enable Metrics/AbcSize
106
101
  # rubocop:enable Metrics/MethodLength
107
102
 
108
103
  private
@@ -219,12 +214,10 @@ module Canon
219
214
 
220
215
  case format
221
216
  when :xml
222
- require_relative "../pretty_printer/xml"
223
217
  formatted1 = Canon::PrettyPrinter::Xml.new(indent: 2).format(content1)
224
218
  formatted2 = Canon::PrettyPrinter::Xml.new(indent: 2).format(content2)
225
219
  [formatted1, formatted2]
226
220
  when :html
227
- require_relative "../pretty_printer/html"
228
221
  formatted1 = Canon::PrettyPrinter::Html.new(indent: 2).format(content1)
229
222
  formatted2 = Canon::PrettyPrinter::Html.new(indent: 2).format(content2)
230
223
  [formatted1, formatted2]
@@ -1,9 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../pretty_printer/xml"
4
- require_relative "../pretty_printer/json"
5
- require_relative "../pretty_printer/html"
6
-
7
3
  module Canon
8
4
  module Commands
9
5
  # Command for canonicalizing files
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ # Thor command implementations invoked by {CLI}. Children are autoloaded.
5
+ module Commands
6
+ autoload :DiffCommand, "canon/commands/diff_command"
7
+ autoload :FormatCommand, "canon/commands/format_command"
8
+ end
9
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "node_inspector"
4
-
5
3
  module Canon
6
4
  module Comparison
7
5
  # Shared two-cursor walk over child arrays with noise-aware realignment.
@@ -3,7 +3,7 @@
3
3
  module Canon
4
4
  module Comparison
5
5
  # CompareProfile encapsulates the policy decisions about how differences
6
- # in various dimensions should be handled during comparison
6
+ # in various dimensions should be handled during comparison.
7
7
  #
8
8
  # This class provides separation of concerns:
9
9
  # - CompareProfile: Policy decisions (what to track, what affects equivalence)
@@ -17,69 +17,46 @@ module Canon
17
17
  @match_options = match_options
18
18
  end
19
19
 
20
- # Should DiffNodes be created for differences in this dimension?
21
- #
22
- # In verbose mode, we want to track ALL differences for reporting.
23
- # In non-verbose mode, we only need to track normative differences.
24
- #
25
- # @param dimension [Symbol] The match dimension to check
26
- # @return [Boolean] true if differences should be tracked
27
20
  def track_dimension?(_dimension)
28
- # Always track dimensions that affect equivalence
29
- # In verbose mode, also track informative dimensions
30
21
  true
31
22
  end
32
23
 
33
24
  # Should differences in this dimension affect equivalence?
34
25
  #
35
- # This determines the return value of the comparison:
36
- # - true: differences make documents non-equivalent
37
- # - false: differences are informative only
38
- #
39
26
  # @param dimension [Symbol] The match dimension to check
40
27
  # @return [Boolean] true if differences affect equivalence
41
28
  def affects_equivalence?(dimension)
42
29
  behavior = behavior_for(dimension)
43
-
44
- # :strict → affects equivalence
45
- # :normalize → might affect (if normalization fails)
46
- # :ignore → does NOT affect equivalence
47
30
  behavior != :ignore
48
31
  end
49
32
 
50
33
  # Is a difference in this dimension normative (affects equivalence)?
51
34
  #
52
- # This is used by DiffClassifier to determine the normative flag.
53
- #
54
- # Normative rules by dimension:
55
- # - structural_whitespace: only :strict is normative (:normalize and :ignore are informative)
56
- # - all other dimensions: normative unless behavior is :ignore
35
+ # Delegates to the Dimension object's normative? rule. Falls back to
36
+ # the default rule (normative unless :ignore) for dimensions not in the
37
+ # format's dimension set (e.g., derived dimensions like :element_structure).
57
38
  #
58
39
  # @param dimension [Symbol] The match dimension to check
59
40
  # @return [Boolean] true if normative, false if informative
60
41
  def normative_dimension?(dimension)
61
- # Structural whitespace with :normalize or :ignore behavior is INFORMATIVE
62
- # Only :strict mode makes whitespace normative
63
- if dimension == :structural_whitespace
64
- behavior = behavior_for(dimension)
65
- return behavior == :strict
42
+ dim = dimension_for(dimension)
43
+ if dim
44
+ dim.normative?(behavior_for(dimension))
45
+ else
46
+ behavior_for(dimension) != :ignore
66
47
  end
67
-
68
- # For all other dimensions, normative if behavior affects equivalence
69
- affects_equivalence?(dimension)
70
48
  end
71
49
 
72
50
  # Can a difference in this dimension be formatting-only?
73
51
  #
74
- # This determines whether FormattingDetector should be applied.
75
- # Only text/content dimensions can have formatting-only differences.
52
+ # Delegates to the Dimension object's supports_formatting_detection?
53
+ # flag. Falls back to false for unknown dimensions.
76
54
  #
77
55
  # @param dimension [Symbol] The match dimension to check
78
56
  # @return [Boolean] true if formatting detection should apply
79
57
  def supports_formatting_detection?(dimension)
80
- # Only text_content and structural_whitespace can have formatting-only diffs
81
- # Comments are policy-based (strict/ignore), not formatting-based
82
- %i[text_content structural_whitespace].include?(dimension)
58
+ dim = dimension_for(dimension)
59
+ dim ? dim.supports_formatting_detection? : false
83
60
  end
84
61
 
85
62
  # Get the behavior setting for a dimension
@@ -94,6 +71,23 @@ module Canon
94
71
  :strict
95
72
  end
96
73
  end
74
+
75
+ private
76
+
77
+ def dimension_for(name)
78
+ set = Dimensions::Registry.for(extract_format)
79
+ set[name]
80
+ end
81
+
82
+ def extract_format
83
+ if match_options.is_a?(ResolvedMatchOptions)
84
+ match_options.format
85
+ elsif match_options.is_a?(Hash)
86
+ match_options[:format]
87
+ else
88
+ :xml
89
+ end
90
+ end
97
91
  end
98
92
  end
99
93
  end
@@ -133,8 +133,6 @@ parse_errors_expected: nil, parse_errors_received: nil)
133
133
  # @return [String] Formatted diff output
134
134
  def diff(use_color: true, context_lines: 3, diff_grouping_lines: nil,
135
135
  show_diffs: :all, diff_mode: :separate, legacy_terminal: false)
136
- require_relative "../diff_formatter"
137
-
138
136
  formatter = Canon::DiffFormatter.new(
139
137
  use_color: use_color,
140
138
  mode: :by_line,
@@ -164,13 +162,12 @@ show_diffs: :all, diff_mode: :separate, legacy_terminal: false)
164
162
  def summarize_diff_node(diff)
165
163
  parts = ["Not equivalent:"]
166
164
 
167
- # rubocop:disable Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
165
+ # rubocop:disable-next Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
168
166
  if diff.path
169
167
  parts << "#{diff.reason} at #{diff.path}"
170
168
  else
171
169
  parts << diff.reason.to_s
172
170
  end
173
- # rubocop:enable Layout/SpaceBeforeInterpolation,Style/ConditionalAssignment
174
171
 
175
172
  if diff.serialized_before && diff.serialized_after
176
173
  before_preview = truncate_preview(diff.serialized_before)