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
@@ -1,25 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri" unless RUBY_ENGINE == "opal"
4
- require_relative "nodes/root_node"
5
- require_relative "nodes/element_node"
6
- require_relative "nodes/namespace_node"
7
- require_relative "nodes/attribute_node"
8
- require_relative "nodes/text_node"
9
- require_relative "nodes/comment_node"
10
- require_relative "nodes/processing_instruction_node"
11
-
12
3
  module Canon
13
4
  module Xml
14
- # Builds Canon::Xml::Node tree using Nokogiri SAX parser
15
- #
16
- # This is MUCH faster than DOM parsing + conversion because:
17
- # 1. No intermediate Nokogiri DOM tree (saves ~60ms)
18
- # 2. No tree traversal to build Canon (saves ~1200ms)
19
- # 3. No memory overhead of two complete DOM trees
5
+ # Builds Canon::Xml::Node tree from SAX events.
20
6
  #
21
- # Current (SLOW): XML String → Nokogiri DOM (~60ms) → Canon DOM (~1200ms) = ~1260ms
22
- # Optimized (FAST): XML String → Nokogiri SAX → Canon DOM (~200ms) = ~200ms
7
+ # Engine-neutral: the event protocol is Nokogiri-shaped (qname +
8
+ # attribute pairs with xmlns declarations inline); Canon::Xml::Sax
9
+ # selects the driver. Much faster than DOM parsing + conversion —
10
+ # no intermediate engine DOM tree, no traversal conversion pass.
23
11
  #
24
12
  # Usage:
25
13
  # root = SaxBuilder.parse(xml_string, preserve_whitespace: false)
@@ -28,7 +16,7 @@ module Canon
28
16
  # For C14N, use strip_doctype: true to avoid DTD default attribute expansion:
29
17
  # root = SaxBuilder.parse(xml_string, strip_doctype: true)
30
18
  #
31
- class SaxBuilder < (RUBY_ENGINE == "opal" ? Object : Nokogiri::XML::SAX::Document)
19
+ class SaxBuilder
32
20
  # Parse XML string and return Canon::Xml::Node tree
33
21
  #
34
22
  # @param xml_string [String] XML content to parse
@@ -37,7 +25,7 @@ module Canon
37
25
  # @return [Nodes::RootNode] Root of the data model tree
38
26
  def self.parse(xml_string, preserve_whitespace: false,
39
27
  strip_doctype: false)
40
- # Strip DOCTYPE to prevent Nokogiri SAX from expanding DTD default attributes
28
+ # Strip DOCTYPE to prevent the SAX engine from expanding DTD default attributes
41
29
  # This is needed for C14N which should NOT include default attributes from DTD
42
30
  # Use string methods instead of complex regex to avoid ReDoS vulnerability
43
31
  if strip_doctype
@@ -45,8 +33,7 @@ strip_doctype: false)
45
33
  end
46
34
 
47
35
  builder = new(preserve_whitespace: preserve_whitespace)
48
- parser = Nokogiri::XML::SAX::Parser.new(builder)
49
- parser.parse(xml_string)
36
+ Canon::Xml::Sax.parse(xml_string, builder)
50
37
  builder.result
51
38
  end
52
39
 
@@ -86,7 +73,6 @@ strip_doctype: false)
86
73
  #
87
74
  # @param preserve_whitespace [Boolean] Whether to preserve whitespace-only text nodes
88
75
  def initialize(preserve_whitespace: false)
89
- super()
90
76
  @preserve_whitespace = preserve_whitespace
91
77
  @root = Nodes::RootNode.new
92
78
  @stack = [@root]
@@ -175,13 +161,30 @@ strip_doctype: false)
175
161
  def characters(string)
176
162
  return if string.nil?
177
163
 
178
- parent = @stack.last
164
+ append_text(decode_character_references(string), string)
165
+ end
166
+
167
+ # Called for CDATA content. CDATA is literal character data:
168
+ # character references inside it are NOT decoded (a literal
169
+ # &#65; stays as written), unlike regular text where they are
170
+ # resolved. Whitespace and adjacency rules match characters so
171
+ # the two forms of character data build identical trees.
172
+ #
173
+ # @param string [String] CDATA content
174
+ def cdata(string)
175
+ return if string.nil?
179
176
 
180
- # Capture raw text BEFORE entity resolution for accurate serialization
181
- raw_string = string
177
+ append_text(string, string)
178
+ end
182
179
 
183
- # Decode numeric character references
184
- decoded_string = decode_character_references(string)
180
+ # Append character data to the tree: combine with an adjacent
181
+ # text node if present, else create one (respecting the
182
+ # whitespace-only skip rules).
183
+ #
184
+ # @param decoded_string [String] value with character references resolved
185
+ # @param raw_string [String] value as delivered (pre-resolution)
186
+ def append_text(decoded_string, raw_string)
187
+ parent = @stack.last
185
188
 
186
189
  # Combine with previous text node if adjacent (SAX can split text content)
187
190
  # This MUST happen before whitespace check, because SAX may split "foo "
@@ -215,6 +218,7 @@ strip_doctype: false)
215
218
  text = Nodes::TextNode.new(value: decoded_string, original: raw_string)
216
219
  parent.add_child(text)
217
220
  end
221
+ private :append_text
218
222
 
219
223
  # Called for comments
220
224
  #
data/lib/canon/xml.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ # Native XML data model, parsing, and DOM utilities.
5
+ #
6
+ # This namespace holds Canon's own XML representation (independent of
7
+ # Nokogiri/Moxml), including:
8
+ # * the XPath data model nodes (Canon::Xml::Node and Canon::Xml::Nodes::*)
9
+ # * the SAX builder that produces them
10
+ # * element matching, line-range mapping, xpath, C14N, processors
11
+ # * canonicalization (C14n) and serialization helpers
12
+ #
13
+ # All children are autoloaded from this file. The nested Nodes namespace
14
+ # is itself a sibling and is autoloaded on first reference to
15
+ # Canon::Xml::Nodes.
16
+ module Xml
17
+ autoload :AttributeHandler, "canon/xml/attribute_handler"
18
+ autoload :C14n, "canon/xml/c14n"
19
+ autoload :CharacterEncoder, "canon/xml/character_encoder"
20
+ autoload :DataModel, "canon/xml/data_model"
21
+ autoload :ElementMatcher, "canon/xml/element_matcher"
22
+ autoload :LineRangeMapper, "canon/xml/line_range_mapper"
23
+ autoload :NamespaceHandler, "canon/xml/namespace_handler"
24
+ autoload :NamespaceHelper, "canon/xml/namespace_helper"
25
+ autoload :Node, "canon/xml/node"
26
+ autoload :Nodes, "canon/xml/nodes"
27
+ autoload :Processor, "canon/xml/processor"
28
+ autoload :Sax, "canon/xml/sax"
29
+ autoload :SaxBuilder, "canon/xml/sax_builder"
30
+ autoload :WhitespaceNormalizer, "canon/xml/whitespace_normalizer"
31
+ autoload :XmlBaseHandler, "canon/xml/xml_base_handler"
32
+ autoload :XPathEngine, "canon/xml/xpath_engine"
33
+ end
34
+ end
@@ -1,24 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- # Centralized XML backend detection for Canon.
4
+ # Selection of the XML engine.
5
5
  #
6
- # Canon supports two XML backends:
7
- # - :nokogiri — MRI with Nokogiri installed (default, existing code path)
8
- # - :moxml — Opal runtime or MRI without Nokogiri (uses Oga via moxml)
6
+ # Two independent concerns, deliberately separated (MECE):
7
+ # - XML engine — :nokogiri (raw) or :moxml (adapter-mediated). Drives DOM
8
+ # parsing and serialization of XML (see Canon::XmlParsing).
9
+ # - HTML support — always Nokogiri on CRuby; moxml has no HTML adapter and
10
+ # leptris no HTML parser (see Canon::Html::NokogiriSupport).
9
11
  #
10
- # The active backend is determined once at load time and cached.
11
- # All XML-related code should check `Canon::XmlBackend.moxml?` or
12
- # `Canon::XmlBackend.nokogiri?` to select the appropriate code path.
12
+ # SSOT: moxml owns adapter preference (Moxml::Config prefers leptris when
13
+ # installed and capable; see XmlParsing.moxml_adapter_name). Canon's
14
+ # default engine is Nokogiri on CRuby until leptris WINS somewhere
15
+ # measurably: conformance parity is complete (engine_parity_spec 12/12),
16
+ # but the leptris SAX driver is blocked by a data-corruption bug
17
+ # (leptris-ruby#95) and the record-based conversion runs ~0.55x Nokogiri
18
+ # (CI performance gate: -42..45% on from_xml) pending lighter record
19
+ # emission upstream. Flipping the default is the one-line change in
20
+ # #detect; CANON_XML_BACKEND=moxml opts in today. Under Opal the engine
21
+ # is moxml (rexml adapter).
13
22
  #
14
- # This module intentionally does NOT wrap Nokogiri through moxml.
15
- # Each backend path is independent — the Nokogiri path is the existing
16
- # battle-tested code; the moxml path is a parallel implementation for
17
- # environments where Nokogiri is unavailable.
23
+ # HTML stays on Nokogiri on CRuby (Canon::Html::NokogiriSupport) and the
24
+ # pretty-printers keep the Nokogiri pipeline — pretty-printed bytes are
25
+ # canon's product (moxml#129).
18
26
  module XmlBackend
27
+ VALID_BACKENDS = %i[nokogiri moxml].freeze
28
+
19
29
  class << self
20
30
  def active
21
- @active ||= detect
31
+ @active ||= forced || detect
22
32
  end
23
33
 
24
34
  def nokogiri?
@@ -35,14 +45,25 @@ module Canon
35
45
 
36
46
  private
37
47
 
38
- def detect
39
- if RUBY_ENGINE == "opal"
40
- :moxml
41
- elsif defined?(Nokogiri)
42
- :nokogiri
43
- else
44
- :moxml
48
+ # Escape hatch and A/B test switch: CANON_XML_BACKEND=nokogiri|moxml.
49
+ def forced
50
+ value = ENV.fetch("CANON_XML_BACKEND", nil)
51
+ return nil if value.nil? || value.empty?
52
+
53
+ backend = value.to_sym
54
+ unless VALID_BACKENDS.include?(backend)
55
+ raise Canon::Error,
56
+ "Invalid CANON_XML_BACKEND: #{value.inspect}. " \
57
+ "Must be one of: #{VALID_BACKENDS.join(', ')}"
45
58
  end
59
+
60
+ backend
61
+ end
62
+
63
+ def detect
64
+ return :moxml if RUBY_ENGINE == "opal"
65
+
66
+ :nokogiri
46
67
  end
47
68
  end
48
69
  end
@@ -3,18 +3,35 @@
3
3
  module Canon
4
4
  # Backend-agnostic XML parsing, serialization, and type dispatch.
5
5
  #
6
- # Provides a unified API that delegates to the active backend
7
- # (Nokogiri or moxml/Oga). Uses backend-branching (`if XmlBackend.nokogiri?`)
8
- # rather than `case/when` with constant references — this ensures Nokogiri
9
- # constants are never resolved under Opal, preventing NameError at runtime.
6
+ # Responsibilities (MECE):
7
+ # - Engine actions: `parse` selects the XML engine chosen by
8
+ # Canon::XmlBackend (raw Nokogiri or moxml with its resolved adapter —
9
+ # leptris when installed, nokogiri otherwise).
10
+ # - Node type dispatch: type queries and traversal answer for ANY
11
+ # recognized node (Nokogiri or moxml) regardless of the active engine —
12
+ # callers may hand us nodes from either library (user input, format
13
+ # detection). Dispatch is by node type, never by backend.
10
14
  #
11
- # OCP: adding a new backend only requires updating this module.
12
- # DRY: all backend dispatch centralized here, not scattered across
13
- # comparator/formatter files.
15
+ # `defined?(Nokogiri)` guards keep Nokogiri constants unresolved under
16
+ # Opal (no NameError at runtime).
17
+ #
18
+ # OCP: adding a new engine means extending Canon::XmlBackend detection
19
+ # plus the node-type union here; all comparator/formatter code stays
20
+ # untouched because it goes through this module.
14
21
  module XmlParsing
15
22
  class << self
16
23
  def moxml_context
17
- @moxml_context ||= Moxml.new(RUBY_ENGINE == "opal" ? :rexml : :oga)
24
+ # Opal needs the explicit rexml adapter: stock oga requires its
25
+ # C extension there. CRuby defers to moxml's preferred adapter
26
+ # (leptris when installed and capable, nokogiri otherwise).
27
+ @moxml_context ||= Moxml.new(RUBY_ENGINE == "opal" ? :rexml : nil)
28
+ end
29
+
30
+ # The adapter moxml resolved on this runtime. This is the single
31
+ # source of truth for engine capability: moxml owns the preference
32
+ # order, canon never probes gems itself.
33
+ def moxml_adapter_name
34
+ moxml_context.config.adapter_name
18
35
  end
19
36
 
20
37
  # --- Parsing ---
@@ -31,7 +48,7 @@ module Canon
31
48
  if XmlBackend.nokogiri?
32
49
  Nokogiri::XML.fragment(xml_string).children.to_a
33
50
  else
34
- doc = moxml_context.parse("<__frag__>#{xml_string}</__frag__>")
51
+ doc = moxml_context.parse("<__frag__>#{xml_string}</__frag__>", readonly: true)
35
52
  doc.root.children.to_a
36
53
  end
37
54
  end
@@ -39,113 +56,88 @@ module Canon
39
56
  # --- Serialization ---
40
57
 
41
58
  def serialize(node)
42
- if XmlBackend.nokogiri?
43
- nokogiri_serialize(node)
59
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Document)
60
+ node.to_xml(encoding: "UTF-8")
44
61
  else
45
- moxml_serialize(node)
62
+ node.to_xml
46
63
  end
47
64
  end
48
65
 
49
- # --- Type checks (backend-safe) ---
50
- #
51
- # Both Nokogiri and Moxml are loaded as dependencies. XmlBackend
52
- # determines which is used for *parsing*, but nodes from either
53
- # library may flow through comparison code (e.g. tests, format
54
- # detection). Under Nokogiri backend, both types are checked.
66
+ # --- Type checks (any recognized engine node) ---
55
67
 
56
68
  def document?(obj)
57
- if XmlBackend.nokogiri?
58
- obj.is_a?(Nokogiri::XML::Document) || obj.is_a?(Moxml::Document)
59
- else
60
- obj.is_a?(Moxml::Document)
61
- end
69
+ return true if defined?(Nokogiri) && obj.is_a?(Nokogiri::XML::Document)
70
+
71
+ obj.is_a?(Moxml::Document)
62
72
  end
63
73
 
64
74
  def xml_node?(obj)
65
- if XmlBackend.nokogiri?
66
- obj.is_a?(Nokogiri::XML::Node) || obj.is_a?(Moxml::Node)
67
- else
68
- obj.is_a?(Moxml::Node)
69
- end
75
+ return true if defined?(Nokogiri) && obj.is_a?(Nokogiri::XML::Node)
76
+
77
+ obj.is_a?(Moxml::Node)
70
78
  end
71
79
 
72
80
  def element?(node)
73
- if XmlBackend.nokogiri?
74
- node.is_a?(Nokogiri::XML::Element) || node.is_a?(Moxml::Element)
75
- else
76
- node.is_a?(Moxml::Element)
77
- end
81
+ return true if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
82
+
83
+ node.is_a?(Moxml::Element)
78
84
  end
79
85
 
80
86
  def text_node?(node)
81
- if XmlBackend.nokogiri?
82
- node.is_a?(Nokogiri::XML::Text) || node.is_a?(Moxml::Text)
83
- else
84
- node.is_a?(Moxml::Text)
85
- end
87
+ return true if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Text)
88
+
89
+ node.is_a?(Moxml::Text)
86
90
  end
87
91
 
88
92
  def comment?(node)
89
- if XmlBackend.nokogiri?
90
- node.is_a?(Nokogiri::XML::Comment) || node.is_a?(Moxml::Comment)
91
- else
92
- node.is_a?(Moxml::Comment)
93
- end
93
+ return true if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Comment)
94
+
95
+ node.is_a?(Moxml::Comment)
94
96
  end
95
97
 
96
98
  def cdata?(node)
97
- if XmlBackend.nokogiri?
98
- node.is_a?(Nokogiri::XML::CDATA) || node.is_a?(Moxml::Cdata)
99
- else
100
- node.is_a?(Moxml::Cdata)
101
- end
99
+ return true if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::CDATA)
100
+
101
+ node.is_a?(Moxml::Cdata)
102
102
  end
103
103
 
104
104
  def processing_instruction?(node)
105
- if XmlBackend.nokogiri?
106
- node.is_a?(Nokogiri::XML::ProcessingInstruction) || node.is_a?(Moxml::ProcessingInstruction)
107
- else
108
- node.is_a?(Moxml::ProcessingInstruction)
109
- end
105
+ return true if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::ProcessingInstruction)
106
+
107
+ node.is_a?(Moxml::ProcessingInstruction)
110
108
  end
111
109
 
112
110
  def document_fragment?(obj)
113
- if XmlBackend.nokogiri?
114
- obj.is_a?(Nokogiri::XML::DocumentFragment)
115
- else
116
- false
117
- end
111
+ defined?(Nokogiri) && obj.is_a?(Nokogiri::XML::DocumentFragment)
118
112
  end
119
113
 
120
114
  def dtd?(node)
121
- if XmlBackend.nokogiri?
122
- node.is_a?(Nokogiri::XML::DTD)
123
- else
124
- false
125
- end
115
+ defined?(Nokogiri) && node.is_a?(Nokogiri::XML::DTD)
126
116
  end
127
117
 
128
118
  # --- Node traversal ---
129
119
 
130
120
  def children(node)
131
- if XmlBackend.nokogiri?
132
- node.is_a?(Nokogiri::XML::Node) ? node.children.to_a : []
121
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
122
+ node.children.to_a
123
+ elsif node.is_a?(Moxml::Node)
124
+ node.children.to_a
133
125
  else
134
- node.is_a?(Moxml::Node) ? node.children.to_a : []
126
+ []
135
127
  end
136
128
  end
137
129
 
138
130
  def name(node)
139
- if XmlBackend.nokogiri?
140
- node.is_a?(Nokogiri::XML::Node) ? node.name : nil
141
- else
142
- node.is_a?(Moxml::Node) ? node.name : nil
131
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
132
+ node.name
133
+ elsif node.is_a?(Moxml::Node)
134
+ node.name
143
135
  end
144
136
  end
145
137
 
146
138
  def text_content(node)
147
- if XmlBackend.nokogiri?
148
- node.is_a?(Nokogiri::XML::Node) ? node.content : node.to_s
139
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
140
+ node.content
149
141
  else
150
142
  case node
151
143
  when Moxml::Text, Moxml::Cdata, Moxml::Comment
@@ -159,26 +151,30 @@ module Canon
159
151
  end
160
152
 
161
153
  def attributes(node)
162
- if XmlBackend.nokogiri?
163
- node.is_a?(Nokogiri::XML::Element) ? node.attributes.values : []
154
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
155
+ node.attributes.values
156
+ elsif node.is_a?(Moxml::Element)
157
+ node.attributes
164
158
  else
165
- node.is_a?(Moxml::Element) ? node.attributes : []
159
+ []
166
160
  end
167
161
  end
168
162
 
169
163
  def attribute_value(node, attr_name)
170
- if XmlBackend.nokogiri?
171
- node.is_a?(Nokogiri::XML::Element) ? node[attr_name.to_s] : nil
172
- else
173
- node.is_a?(Moxml::Element) ? node[attr_name.to_s] : nil
164
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
165
+ node[attr_name.to_s]
166
+ elsif node.is_a?(Moxml::Element)
167
+ node[attr_name.to_s]
174
168
  end
175
169
  end
176
170
 
177
171
  def namespace_definitions(node)
178
- if XmlBackend.nokogiri?
179
- node.is_a?(Nokogiri::XML::Element) ? node.namespace_definitions : []
172
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
173
+ node.namespace_definitions
174
+ elsif node.is_a?(Moxml::Element)
175
+ node.namespace_definitions
180
176
  else
181
- node.is_a?(Moxml::Element) ? node.namespace_definitions : []
177
+ []
182
178
  end
183
179
  end
184
180
 
@@ -191,34 +187,26 @@ module Canon
191
187
  end
192
188
 
193
189
  def namespace_uri(node)
194
- if XmlBackend.nokogiri?
195
- node.namespace&.href if node.is_a?(Nokogiri::XML::Element)
190
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
191
+ node.namespace&.href
196
192
  elsif node.is_a?(Moxml::Element)
197
193
  node.namespace_uri
198
194
  end
199
195
  end
200
196
 
201
- # Returns a symbol for all backends (:element, :text, :comment, etc.)
197
+ # Returns a symbol for all engines (:element, :text, :comment, etc.)
202
198
  # or nil for unrecognised nodes.
203
199
  def node_type(node)
204
- if XmlBackend.nokogiri?
200
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
205
201
  nokogiri_node_type(node)
206
- else
202
+ elsif node.is_a?(Moxml::Node)
207
203
  moxml_node_type(node)
208
204
  end
209
205
  end
210
206
 
211
- def canonicalize(node, options = {})
212
- if XmlBackend.nokogiri?
213
- node.canonicalize(options)
214
- else
215
- moxml_canonicalize(node, options)
216
- end
217
- end
218
-
219
207
  private
220
208
 
221
- # --- Nokogiri backend ---
209
+ # --- Nokogiri engine ---
222
210
 
223
211
  def nokogiri_type_map
224
212
  @nokogiri_type_map ||= {
@@ -235,8 +223,6 @@ module Canon
235
223
  end
236
224
 
237
225
  def nokogiri_node_type(node)
238
- return nil unless node.is_a?(Nokogiri::XML::Node)
239
-
240
226
  nokogiri_type_map[node.node_type]
241
227
  end
242
228
 
@@ -246,26 +232,13 @@ module Canon
246
232
  doc
247
233
  end
248
234
 
249
- def nokogiri_serialize(node)
250
- if node.is_a?(Nokogiri::XML::Document)
251
- node.to_xml(encoding: "UTF-8")
252
- else
253
- node.to_xml
254
- end
255
- end
256
-
257
- # --- Moxml backend ---
235
+ # --- Moxml engine ---
258
236
 
237
+ # Readonly: canon only ever reads engine documents (conversion,
238
+ # serialization) — leptris memoizes reads on readonly documents,
239
+ # and mutation is refused loudly rather than silently corrupting.
259
240
  def moxml_parse(xml_string, _options)
260
- moxml_context.parse(xml_string)
261
- end
262
-
263
- def moxml_serialize(node)
264
- node.to_xml
265
- end
266
-
267
- def moxml_canonicalize(node, _options)
268
- node.to_xml
241
+ moxml_context.parse(xml_string, readonly: true)
269
242
  end
270
243
 
271
244
  def moxml_node_type(node)
data/lib/canon.rb CHANGED
@@ -1,23 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "canon/version"
4
- require_relative "canon/errors"
5
- require_relative "canon/xml_backend"
6
- require_relative "canon/xml_parsing"
7
- require_relative "canon/config"
8
- require_relative "canon/data_model"
9
- require_relative "canon/html"
10
- require_relative "canon/formatters/xml_formatter"
11
- require_relative "canon/formatters/yaml_formatter"
12
- require_relative "canon/formatters/json_formatter"
13
- require_relative "canon/formatters/html_formatter"
14
- require_relative "canon/formatters/html4_formatter"
15
- require_relative "canon/formatters/html5_formatter"
16
- require_relative "canon/comparison"
3
+ require "canon/version"
4
+ require "canon/errors"
5
+ require "nokogiri" unless RUBY_ENGINE == "opal"
6
+ require "canon/xml_backend"
7
+ require "canon/xml_parsing"
8
+ require "canon/config"
9
+ require "canon/data_model"
10
+ require "canon/xml"
11
+ require "canon/html"
12
+ require "canon/formatters"
13
+ require "canon/comparison"
14
+ require "canon/diff"
15
+ require "canon/tree_diff"
16
+ require "canon/validators"
17
+ require "canon/pretty_printer"
18
+ require "canon/options"
19
+ require "canon/commands"
17
20
 
18
- require_relative "canon/rspec_matchers" if defined?(RSpec.configure)
21
+ require "canon/rspec_matchers" if defined?(RSpec.configure)
19
22
 
20
23
  module Canon
24
+ autoload :Cache, "canon/cache"
25
+ autoload :Cli, "canon/cli"
26
+ autoload :ColorDetector, "canon/color_detector"
27
+ autoload :DiffFormatter, "canon/diff_formatter"
28
+
21
29
  SUPPORTED_FORMATS = %i[xml yaml json html html4 html5 string].freeze
22
30
 
23
31
  # Format content based on the specified format type
@@ -36,7 +44,7 @@ module Canon
36
44
  get_formatter(format).parse(content)
37
45
  end
38
46
 
39
- # rubocop:disable Metrics/MethodLength
47
+ # rubocop:disable-next Metrics/MethodLength
40
48
  def self.get_formatter(format)
41
49
  case format.to_sym
42
50
  when :xml
@@ -55,11 +63,12 @@ module Canon
55
63
  raise Error, "Unsupported format: #{format}"
56
64
  end
57
65
  end
58
- # rubocop:enable Metrics/MethodLength
59
66
 
60
67
  # Define shorthand methods for each supported format
61
68
  # Creates parse_{format} and format_{format} methods
62
69
  SUPPORTED_FORMATS.each do |format|
70
+ next if format == :string # comparison-only format, no formatter
71
+
63
72
  define_singleton_method("parse_#{format}") do |content|
64
73
  parse(content, format)
65
74
  end
@@ -96,7 +96,11 @@ class BenchmarkRunner
96
96
  def self.env_info(ruby_version, platform)
97
97
  puts
98
98
  puts " #{DIM}Environment:#{CLEAR}"
99
+ engine = Canon::XmlBackend.active
100
+ engine_info = "XML engine: #{engine}"
101
+ engine_info += " (forced)" unless ENV["CANON_XML_BACKEND"].to_s.empty?
99
102
  puts " #{VL} Ruby #{ruby_version} on #{platform}#{' ' * (60 - ruby_version.length - platform.length)}#{VL}"
103
+ puts " #{VL} #{engine_info}#{' ' * (60 - engine_info.length)}#{VL}"
100
104
  puts " #{DIM}#{BL}#{HL * 76}#{BR}#{CLEAR}"
101
105
  puts
102
106
  end