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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ # SAX engine selection and driving for the data-model builder.
6
+ #
7
+ # The builder (SaxBuilder) owns tree construction and speaks a single
8
+ # engine-neutral event protocol (Nokogiri-shaped: qname + attribute
9
+ # pairs with xmlns entries inline). Each driver adapts one engine's SAX
10
+ # API to that protocol. OCP: a new engine is a new driver plus one line
11
+ # here; the builder never changes.
12
+ #
13
+ # Engine choice: leptris SAX outperforms Nokogiri SAX (1.10x min-of-N
14
+ # on canon's builder, 53KB doc) through moxml 0.5.12's
15
+ # interest-proportional callbacks — BUT the batch-attribute transport
16
+ # reads uninitialized memory at certain buffer offsets, corrupting the
17
+ # first N attributes of an element (leptris-ruby#95). Until that is
18
+ # fixed, CRuby keeps the Nokogiri driver; flipping is the one-line
19
+ # change below.
20
+ module Sax
21
+ autoload :NokogiriDriver, "canon/xml/sax/nokogiri_driver"
22
+ autoload :MoxmlDriver, "canon/xml/sax/moxml_driver"
23
+
24
+ class << self
25
+ # Drive `builder` with the runtime's SAX engine.
26
+ def parse(xml_string, builder)
27
+ if RUBY_ENGINE == "opal"
28
+ MoxmlDriver.new(builder).parse(xml_string)
29
+ else
30
+ NokogiriDriver.new(builder).parse(xml_string)
31
+ end
32
+ nil
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,18 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri" unless RUBY_ENGINE == "opal"
4
-
5
3
  module Canon
6
4
  module Xml
7
- # Builds Canon::Xml::Node tree using Nokogiri SAX parser
8
- #
9
- # This is MUCH faster than DOM parsing + conversion because:
10
- # 1. No intermediate Nokogiri DOM tree (saves ~60ms)
11
- # 2. No tree traversal to build Canon (saves ~1200ms)
12
- # 3. No memory overhead of two complete DOM trees
5
+ # Builds Canon::Xml::Node tree from SAX events.
13
6
  #
14
- # Current (SLOW): XML String Nokogiri DOM (~60ms) → Canon DOM (~1200ms) = ~1260ms
15
- # 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.
16
11
  #
17
12
  # Usage:
18
13
  # root = SaxBuilder.parse(xml_string, preserve_whitespace: false)
@@ -21,7 +16,7 @@ module Canon
21
16
  # For C14N, use strip_doctype: true to avoid DTD default attribute expansion:
22
17
  # root = SaxBuilder.parse(xml_string, strip_doctype: true)
23
18
  #
24
- class SaxBuilder < (RUBY_ENGINE == "opal" ? Object : Nokogiri::XML::SAX::Document)
19
+ class SaxBuilder
25
20
  # Parse XML string and return Canon::Xml::Node tree
26
21
  #
27
22
  # @param xml_string [String] XML content to parse
@@ -30,7 +25,7 @@ module Canon
30
25
  # @return [Nodes::RootNode] Root of the data model tree
31
26
  def self.parse(xml_string, preserve_whitespace: false,
32
27
  strip_doctype: false)
33
- # Strip DOCTYPE to prevent Nokogiri SAX from expanding DTD default attributes
28
+ # Strip DOCTYPE to prevent the SAX engine from expanding DTD default attributes
34
29
  # This is needed for C14N which should NOT include default attributes from DTD
35
30
  # Use string methods instead of complex regex to avoid ReDoS vulnerability
36
31
  if strip_doctype
@@ -38,8 +33,7 @@ strip_doctype: false)
38
33
  end
39
34
 
40
35
  builder = new(preserve_whitespace: preserve_whitespace)
41
- parser = Nokogiri::XML::SAX::Parser.new(builder)
42
- parser.parse(xml_string)
36
+ Canon::Xml::Sax.parse(xml_string, builder)
43
37
  builder.result
44
38
  end
45
39
 
@@ -79,7 +73,6 @@ strip_doctype: false)
79
73
  #
80
74
  # @param preserve_whitespace [Boolean] Whether to preserve whitespace-only text nodes
81
75
  def initialize(preserve_whitespace: false)
82
- super()
83
76
  @preserve_whitespace = preserve_whitespace
84
77
  @root = Nodes::RootNode.new
85
78
  @stack = [@root]
@@ -168,13 +161,30 @@ strip_doctype: false)
168
161
  def characters(string)
169
162
  return if string.nil?
170
163
 
171
- 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?
172
176
 
173
- # Capture raw text BEFORE entity resolution for accurate serialization
174
- raw_string = string
177
+ append_text(string, string)
178
+ end
175
179
 
176
- # Decode numeric character references
177
- 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
178
188
 
179
189
  # Combine with previous text node if adjacent (SAX can split text content)
180
190
  # This MUST happen before whitespace check, because SAX may split "foo "
@@ -208,6 +218,7 @@ strip_doctype: false)
208
218
  text = Nodes::TextNode.new(value: decoded_string, original: raw_string)
209
219
  parent.add_child(text)
210
220
  end
221
+ private :append_text
211
222
 
212
223
  # Called for comments
213
224
  #
data/lib/canon/xml.rb CHANGED
@@ -25,6 +25,7 @@ module Canon
25
25
  autoload :Node, "canon/xml/node"
26
26
  autoload :Nodes, "canon/xml/nodes"
27
27
  autoload :Processor, "canon/xml/processor"
28
+ autoload :Sax, "canon/xml/sax"
28
29
  autoload :SaxBuilder, "canon/xml/sax_builder"
29
30
  autoload :WhitespaceNormalizer, "canon/xml/whitespace_normalizer"
30
31
  autoload :XmlBaseHandler, "canon/xml/xml_base_handler"
@@ -1,10 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
+ # Selection of the XML engine.
5
+ #
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).
11
+ #
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).
22
+ #
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).
4
26
  module XmlBackend
27
+ VALID_BACKENDS = %i[nokogiri moxml].freeze
28
+
5
29
  class << self
6
30
  def active
7
- @active ||= detect
31
+ @active ||= forced || detect
8
32
  end
9
33
 
10
34
  def nokogiri?
@@ -19,66 +43,27 @@ module Canon
19
43
  @active = nil
20
44
  end
21
45
 
22
- # Whether the node is a document fragment (any variant).
23
- def document_fragment?(node)
24
- if nokogiri?
25
- node.is_a?(Nokogiri::XML::DocumentFragment) ||
26
- node.is_a?(Nokogiri::HTML4::DocumentFragment) ||
27
- node.is_a?(Nokogiri::HTML5::DocumentFragment)
28
- else
29
- false
30
- end
31
- end
32
-
33
- # Whether the node is an HTML document (any variant).
34
- def html_document?(node)
35
- if nokogiri?
36
- node.is_a?(Nokogiri::HTML::Document) ||
37
- node.is_a?(Nokogiri::HTML4::Document) ||
38
- node.is_a?(Nokogiri::HTML5::Document)
39
- else
40
- false
41
- end
42
- end
46
+ private
43
47
 
44
- # Detect HTML version from a Nokogiri node.
45
- # Returns :html5 or :html4. Defaults to :html5 for non-Nokogiri nodes.
46
- def html_version_from_node(node)
47
- if nokogiri?
48
- if node.is_a?(Nokogiri::HTML5::Document) ||
49
- node.is_a?(Nokogiri::HTML5::DocumentFragment)
50
- :html5
51
- elsif node.is_a?(Nokogiri::HTML4::Document) ||
52
- node.is_a?(Nokogiri::HTML4::DocumentFragment)
53
- :html4
54
- else
55
- :html5
56
- end
57
- else
58
- :html5
59
- end
60
- end
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?
61
52
 
62
- # Parse an HTML string into an XML fragment.
63
- def xml_fragment(html_string)
64
- if nokogiri?
65
- Nokogiri::XML.fragment(html_string)
66
- else
53
+ backend = value.to_sym
54
+ unless VALID_BACKENDS.include?(backend)
67
55
  raise Canon::Error,
68
- "HTML fragment parsing requires the Nokogiri backend"
56
+ "Invalid CANON_XML_BACKEND: #{value.inspect}. " \
57
+ "Must be one of: #{VALID_BACKENDS.join(', ')}"
69
58
  end
70
- end
71
59
 
72
- private
60
+ backend
61
+ end
73
62
 
74
63
  def detect
75
- if RUBY_ENGINE == "opal"
76
- :moxml
77
- elsif defined?(Nokogiri)
78
- :nokogiri
79
- else
80
- :moxml
81
- end
64
+ return :moxml if RUBY_ENGINE == "opal"
65
+
66
+ :nokogiri
82
67
  end
83
68
  end
84
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,28 +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
- raise Canon::Error,
269
- "C14N canonicalization is not supported by the moxml backend. " \
270
- "Use the Nokogiri backend or a different preprocessing option."
241
+ moxml_context.parse(xml_string, readonly: true)
271
242
  end
272
243
 
273
244
  def moxml_node_type(node)
data/lib/canon.rb CHANGED
@@ -44,7 +44,7 @@ module Canon
44
44
  get_formatter(format).parse(content)
45
45
  end
46
46
 
47
- # rubocop:disable Metrics/MethodLength
47
+ # rubocop:disable-next Metrics/MethodLength
48
48
  def self.get_formatter(format)
49
49
  case format.to_sym
50
50
  when :xml
@@ -63,7 +63,6 @@ module Canon
63
63
  raise Error, "Unsupported format: #{format}"
64
64
  end
65
65
  end
66
- # rubocop:enable Metrics/MethodLength
67
66
 
68
67
  # Define shorthand methods for each supported format
69
68
  # Creates parse_{format} and format_{format} methods
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require_relative "benchmark_runner"
3
5
  require_relative "performance_comparator"
4
6
 
5
7
  desc "Run performance benchmarks"
@@ -32,18 +32,22 @@ module Performance
32
32
  DEFAULT_ITEMS = Integer(ENV.fetch("CANON_PERF_ITEMS", "50"))
33
33
 
34
34
  BENCHMARKS = {
35
- xml_parse_dom_simple: { category: "xml_parsing", name: "DOM (simple)" },
36
- xml_parse_sax_simple: { category: "xml_parsing", name: "SAX (simple)" },
37
- xml_parse_dom_large: { category: "xml_parsing", name: "DOM (large)" },
38
- xml_parse_sax_large: { category: "xml_parsing", name: "SAX (large)" },
35
+ xml_parse_dom_simple: { category: "xml_parsing", name: "DOM (simple)" },
36
+ xml_parse_sax_simple: { category: "xml_parsing", name: "SAX (simple)" },
37
+ xml_parse_dom_large: { category: "xml_parsing", name: "DOM (large)" },
38
+ xml_parse_sax_large: { category: "xml_parsing", name: "SAX (large)" },
39
39
  html_parse_simple: { category: "html_parsing", name: "Simple HTML" },
40
40
  html_parse_complex: { category: "html_parsing", name: "Complex HTML" },
41
- xml_compare_identical: { category: "xml_comparison", name: "Identical XML" },
41
+ xml_compare_identical: { category: "xml_comparison",
42
+ name: "Identical XML" },
42
43
  xml_compare_similar: { category: "xml_comparison", name: "Similar XML" },
43
- xml_compare_different: { category: "xml_comparison", name: "Different XML" },
44
- html_compare_identical: { category: "html_comparison", name: "Identical HTML" },
44
+ xml_compare_different: { category: "xml_comparison",
45
+ name: "Different XML" },
46
+ html_compare_identical: { category: "html_comparison",
47
+ name: "Identical HTML" },
45
48
  html_compare_similar: { category: "html_comparison", name: "Similar HTML" },
46
- html_compare_different: { category: "html_comparison", name: "Different HTML" },
49
+ html_compare_different: { category: "html_comparison",
50
+ name: "Different HTML" },
47
51
  xml_c14n_format: { category: "formatting", name: "XML C14N" },
48
52
  json_format: { category: "formatting", name: "JSON" },
49
53
  yaml_format: { category: "formatting", name: "YAML" },