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
@@ -10,7 +10,7 @@ module Canon
10
10
  class HtmlFormatter < BaseFormatter
11
11
  attr_reader :html_version
12
12
 
13
- # rubocop:disable Metrics/ParameterLists
13
+ # rubocop:disable-next Metrics/ParameterLists
14
14
  def initialize(use_color: true, context_lines: 3,
15
15
  diff_grouping_lines: nil, visualization_map: nil,
16
16
  html_version: :html4, show_diffs: :all, differences: [],
@@ -25,7 +25,6 @@ module Canon
25
25
  character_visualization: character_visualization)
26
26
  @html_version = html_version
27
27
  end
28
- # rubocop:enable Metrics/ParameterLists
29
28
 
30
29
  # Format DOM-guided HTML diff
31
30
  #
@@ -9,7 +9,7 @@ module Canon
9
9
  # Receives preprocessed document strings from the DiffFormatter facade
10
10
  # and delegates to format-specific ByLine formatters (XML, HTML, JSON, YAML).
11
11
  class ByLineFormatter
12
- # rubocop:disable Metrics/ParameterLists
12
+ # rubocop:disable-next Metrics/ParameterLists
13
13
  def initialize(use_color:, visualization_map:, context_lines:,
14
14
  diff_grouping_lines:, show_diffs:, character_visualization:,
15
15
  legacy_terminal:, diff_mode:)
@@ -22,7 +22,6 @@ module Canon
22
22
  @legacy_terminal = legacy_terminal
23
23
  @diff_mode = diff_mode
24
24
  end
25
- # rubocop:enable Metrics/ParameterLists
26
25
 
27
26
  # Format a line-by-line diff between two documents.
28
27
  #
@@ -226,7 +226,7 @@ module Canon
226
226
  end
227
227
 
228
228
  def serialize_backend_node_compact(node)
229
- if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Node)
229
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
230
230
  serialize_nokogiri_node_compact(node)
231
231
  elsif node.is_a?(Canon::Xml::Node)
232
232
  serialize_node_compact(node)
@@ -256,7 +256,7 @@ module Canon
256
256
  end
257
257
 
258
258
  def serialize_backend_open_tag(node)
259
- if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Element)
259
+ if defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Element)
260
260
  tag = node.name.to_s
261
261
  attrs = node.attribute_nodes.map do |a|
262
262
  " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
@@ -125,11 +125,11 @@ module Canon
125
125
  },
126
126
  added: {
127
127
  marker: { color: :green, bg: nil, bold: false },
128
- content: { color: :green, bg: nil, bold: false,
128
+ content: { color: :green, bg: nil, bold: false,
129
129
  underline: false, strikethrough: false },
130
130
  },
131
131
  changed: {
132
- marker: { color: :yellow, bg: nil, bold: true },
132
+ marker: { color: :yellow, bg: nil, bold: true },
133
133
  content_old: { color: :bright_red, bg: nil, bold: false,
134
134
  underline: false, strikethrough: true },
135
135
  content_new: { color: :bright_green, bg: nil, bold: false,
@@ -311,11 +311,10 @@ module Canon
311
311
  # In by-line mode with both docs present, always use by-line diff
312
312
  if @mode == :by_line && doc1 && doc2
313
313
  doc1, doc2 = apply_display_preprocessing(doc1, doc2, format)
314
- # rubocop:disable Layout/HashAlignment
314
+ # rubocop:disable-next Layout/HashAlignment
315
315
  return by_line_formatter.format(doc1, doc2, format: format,
316
316
  html_version: html_version,
317
317
  differences: differences)
318
- # rubocop:enable Layout/HashAlignment
319
318
  end
320
319
 
321
320
  # In pretty_diff mode, always use text-LCS diff (bypasses DiffNodeMapper).
@@ -343,11 +342,10 @@ module Canon
343
342
  doc1, doc2 = apply_display_preprocessing(doc1, doc2,
344
343
  format)
345
344
  end
346
- # rubocop:disable Layout/HashAlignment
345
+ # rubocop:disable-next Layout/HashAlignment
347
346
  by_line_formatter.format(doc1, doc2, format: format,
348
347
  html_version: html_version,
349
348
  differences: differences)
350
- # rubocop:enable Layout/HashAlignment
351
349
  when :pretty_diff
352
350
  d1, d2 = if doc1 && doc2
353
351
  apply_display_preprocessing(doc1, doc2,
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Html
5
+ # Nokogiri node utilities for the HTML pipeline.
6
+ #
7
+ # HTML parsing is Nokogiri's job on every CRuby runtime regardless of
8
+ # the XML engine (XmlBackend): moxml has no HTML adapter and leptris no
9
+ # HTML parser. Under Opal Nokogiri is unavailable and these helpers
10
+ # degrade the same way the moxml backend always did (false / nil /
11
+ # raise), which keeps HTML comparison unsupported there.
12
+ module NokogiriSupport
13
+ class << self
14
+ # Whether the node is a document fragment (any variant).
15
+ def document_fragment?(node)
16
+ return false unless defined?(Nokogiri)
17
+
18
+ node.is_a?(Nokogiri::XML::DocumentFragment) ||
19
+ node.is_a?(Nokogiri::HTML4::DocumentFragment) ||
20
+ node.is_a?(Nokogiri::HTML5::DocumentFragment)
21
+ end
22
+
23
+ # Whether the node is an HTML document (any variant).
24
+ def html_document?(node)
25
+ return false unless defined?(Nokogiri)
26
+
27
+ node.is_a?(Nokogiri::HTML::Document) ||
28
+ node.is_a?(Nokogiri::HTML4::Document) ||
29
+ node.is_a?(Nokogiri::HTML5::Document)
30
+ end
31
+
32
+ # Detect HTML version from a Nokogiri node.
33
+ # Returns :html5 or :html4. Defaults to :html5 for non-Nokogiri nodes.
34
+ def html_version_from_node(node)
35
+ return :html5 unless defined?(Nokogiri)
36
+
37
+ if node.is_a?(Nokogiri::HTML5::Document) ||
38
+ node.is_a?(Nokogiri::HTML5::DocumentFragment)
39
+ :html5
40
+ elsif node.is_a?(Nokogiri::HTML4::Document) ||
41
+ node.is_a?(Nokogiri::HTML4::DocumentFragment)
42
+ :html4
43
+ else
44
+ :html5
45
+ end
46
+ end
47
+
48
+ # Parse an HTML string into an XML fragment for the XML comparator.
49
+ def xml_fragment(html_string)
50
+ unless defined?(Nokogiri)
51
+ raise Canon::Error,
52
+ "HTML fragment parsing requires Nokogiri"
53
+ end
54
+
55
+ Nokogiri::XML.fragment(html_string)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/canon/html.rb CHANGED
@@ -6,5 +6,6 @@ module Canon
6
6
  # Children are autoloaded — never `require_relative` them.
7
7
  module Html
8
8
  autoload :DataModel, "canon/html/data_model"
9
+ autoload :NokogiriSupport, "canon/html/nokogiri_support"
9
10
  end
10
11
  end
@@ -11,7 +11,12 @@ module Canon
11
11
  end
12
12
 
13
13
  def format(xml_string)
14
- if Canon::XmlBackend.nokogiri?
14
+ # Output parity: pretty-printed bytes are canon's product. The
15
+ # moxml serializer cannot yet match Nokogiri's output (empty
16
+ # element expansion, declaration placement, tab indent), so the
17
+ # Nokogiri pipeline is used whenever available — the moxml branch
18
+ # is the Opal fallback.
19
+ if defined?(Nokogiri)
15
20
  nokogiri_format(xml_string)
16
21
  else
17
22
  moxml_format(xml_string)
@@ -153,12 +153,12 @@ module Canon
153
153
  # @return [String] Serialized XML, one node per line, with content
154
154
  # whitespace visualized at line boundaries
155
155
  def format(xml_string)
156
- doc = if Canon::XmlBackend.moxml?
157
- Canon::XmlParsing.parse(xml_string)
158
- elsif @html_mode
159
- Nokogiri::HTML5(xml_string)
156
+ # Output parity: see PrettyPrinter::Xml — the Nokogiri pipeline
157
+ # produces canon's byte-stable output; moxml is the Opal fallback.
158
+ doc = if defined?(Nokogiri)
159
+ @html_mode ? Nokogiri::HTML5(xml_string) : Nokogiri::XML(xml_string)
160
160
  else
161
- Nokogiri::XML(xml_string)
161
+ Canon::XmlParsing.parse(xml_string)
162
162
  end
163
163
  lines = []
164
164
 
@@ -356,10 +356,11 @@ module Canon
356
356
  end
357
357
 
358
358
  # True when the supplied source node is a Nokogiri node that
359
- # exposes an XPath via +path+. Nokogiri is an optional
360
- # backend so the constant is guarded.
359
+ # exposes an XPath via +path+. Nokogiri nodes arrive from user
360
+ # input under any engine, so this is a type check; the constant
361
+ # is guarded for Opal.
361
362
  def nokogiri_source?(node)
362
- return false unless Canon::XmlBackend.nokogiri?
363
+ return false unless defined?(Nokogiri)
363
364
 
364
365
  node.is_a?(Nokogiri::XML::Node)
365
366
  end
@@ -66,7 +66,7 @@ module Canon
66
66
  return false unless node
67
67
  return false unless Canon::Comparison::NodeInspector.element_node?(node)
68
68
 
69
- Canon::XmlBackend.nokogiri? ? node.is_a?(Nokogiri::XML::Node) : false
69
+ Canon::XmlParsing.xml_node?(node) && defined?(Nokogiri) && node.is_a?(Nokogiri::XML::Node)
70
70
  end
71
71
  private_class_method :backend_element?
72
72
 
data/lib/canon/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.2.12"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -159,7 +159,7 @@ preserve_whitespace: false)
159
159
  when Nokogiri::XML::Element
160
160
  build_element_node(nokogiri_node,
161
161
  preserve_whitespace: preserve_whitespace)
162
- when Nokogiri::XML::Text
162
+ when Nokogiri::XML::Text, Nokogiri::XML::CDATA
163
163
  build_text_node(nokogiri_node,
164
164
  preserve_whitespace: preserve_whitespace)
165
165
  when Nokogiri::XML::Comment
@@ -222,7 +222,10 @@ preserve_whitespace: false)
222
222
  end
223
223
 
224
224
  def self.build_attribute_nodes(nokogiri_element, element)
225
- nokogiri_element.attributes.each_value do |attr|
225
+ # attribute_nodes, not the attributes hash: the hash is keyed by
226
+ # local name and collapses e.g. attr / a:attr / b:attr into one
227
+ # entry, losing namespaced attributes (W3C C14N ex 3.3).
228
+ nokogiri_element.attribute_nodes.each do |attr|
226
229
  attr_node = Nodes::AttributeNode.new(
227
230
  name: attr.name,
228
231
  value: attr.value,
@@ -259,26 +262,183 @@ preserve_whitespace: false)
259
262
 
260
263
  def self.from_moxml_xml(xml_string, preserve_whitespace:)
261
264
  doc = Canon::XmlParsing.parse(xml_string)
262
- build_from_moxml(doc, preserve_whitespace: preserve_whitespace)
265
+ check_moxml_relative_namespace_uris(doc)
266
+ result = build_from_moxml(doc, preserve_whitespace: preserve_whitespace)
267
+ # Canon owns this document's full lifecycle: the canon tree holds
268
+ # no engine references once built, so release the native tree now
269
+ # instead of waiting for the GC finalizer (moxml#134; no-op on
270
+ # GC-managed adapters).
271
+ doc.free
272
+ result
273
+ end
274
+
275
+ def self.check_moxml_relative_namespace_uris(node)
276
+ case node
277
+ when Moxml::Element
278
+ node.namespace_definitions.each do |ns|
279
+ href = ns.uri
280
+ next if href.nil? || href.empty?
281
+ if relative_uri?(href)
282
+ raise Canon::Error,
283
+ "Relative namespace URI not allowed: #{href}"
284
+ end
285
+ end
286
+ node.children.each { |child| check_moxml_relative_namespace_uris(child) }
287
+ when Moxml::Document
288
+ node.children.each { |child| check_moxml_relative_namespace_uris(child) }
289
+ end
263
290
  end
264
291
 
265
292
  def self.build_from_moxml(moxml_doc, preserve_whitespace: false)
266
293
  root = Nodes::RootNode.new
267
294
 
268
295
  if moxml_doc.is_a?(Moxml::Document) && moxml_doc.root
269
- root.add_child(build_moxml_element_node(moxml_doc.root,
270
- preserve_whitespace: preserve_whitespace))
296
+ element = build_moxml_subtree_from_records(moxml_doc.root,
297
+ preserve_whitespace: preserve_whitespace)
298
+ root.add_child(element) if element
299
+ moxml_doc.children.each do |child|
300
+ next if child.equal?(moxml_doc.root)
301
+ next if child.is_a?(Moxml::Doctype)
302
+
303
+ node = build_moxml_node(child,
304
+ preserve_whitespace: preserve_whitespace)
305
+ root.add_child(node) if node
306
+ end
307
+ else
308
+ moxml_doc.children.each do |child|
309
+ next if child.is_a?(Moxml::Doctype)
310
+
311
+ node = build_moxml_node(child,
312
+ preserve_whitespace: preserve_whitespace)
313
+ root.add_child(node) if node
314
+ end
271
315
  end
272
316
 
273
317
  root
274
318
  end
275
319
 
276
- def self.build_moxml_node(node, preserve_whitespace: false)
320
+ # Document path: build the root subtree from materialize records —
321
+ # one flattened record stream (moxml#132/#138) instead of a wrapper
322
+ # walk, so conversion costs no Moxml::Node allocation per node.
323
+ #
324
+ # Records arrive post-order (children before parents): completed
325
+ # nodes stack up with their depth, and an element record adopts
326
+ # every completed node deeper than itself. Each element's OWN
327
+ # namespace declarations ride an identity-keyed stash; afterwards
328
+ # assign_moxml_namespace_scopes expands them to in-scope scopes
329
+ # with the same first-wins/xml-prebound semantics as the Nokogiri
330
+ # collector.
331
+ def self.build_moxml_subtree_from_records(moxml_element,
332
+ preserve_whitespace: false)
333
+ frames = []
334
+ own_namespaces = {}.compare_by_identity
335
+
336
+ moxml_element.materialize do |record|
337
+ # The record contract is root-subtree-only since moxml 0.5.11
338
+ # (moxml#140). Older 0.5.x releases — still allowed by canon's
339
+ # gemspec floor — leaked epilog document-level records at depth
340
+ # 0, which the document-children enumeration below also covers;
341
+ # one integer compare per record keeps those working.
342
+ next if record[:depth].zero? && record[:kind] != :element
343
+
344
+ node = case record[:kind]
345
+ when :element
346
+ build_moxml_element_from_record(record, frames,
347
+ own_namespaces)
348
+ when :text, :cdata
349
+ build_moxml_text_from_record(record, preserve_whitespace)
350
+ when :comment
351
+ Nodes::CommentNode.new(value: record[:text])
352
+ when :processing_instruction
353
+ Nodes::ProcessingInstructionNode.new(
354
+ target: record[:qname],
355
+ data: record[:text] || "",
356
+ )
357
+ end
358
+
359
+ frames.push([record[:depth], node]) if node
360
+ end
361
+
362
+ top = frames.last
363
+ return nil unless top
364
+
365
+ element = top[1]
366
+ assign_moxml_namespace_scopes(element,
367
+ { "xml" => "http://www.w3.org/XML/1998/namespace" },
368
+ own_namespaces)
369
+ element
370
+ end
371
+
372
+ def self.build_moxml_element_from_record(record, frames, own_namespaces)
373
+ element = Nodes::ElementNode.new(
374
+ name: record[:qname],
375
+ namespace_uri: record[:namespace_uri],
376
+ prefix: record[:prefix],
377
+ )
378
+
379
+ seen = {}
380
+ record[:attributes].each do |name, value, namespace_uri, prefix|
381
+ key = [name, namespace_uri]
382
+ next if seen.key?(key)
383
+
384
+ seen[key] = true
385
+ element.add_attribute(Nodes::AttributeNode.new(
386
+ name: name,
387
+ value: value,
388
+ namespace_uri: namespace_uri,
389
+ prefix: prefix,
390
+ ))
391
+ end
392
+
393
+ # Adopt completed children (they stack in reverse: unshift
394
+ # restores document order).
395
+ children = []
396
+ children.unshift(frames.pop[1]) while frames.any? && frames.last[0] > record[:depth]
397
+ children.each { |child| element.add_child(child) }
398
+
399
+ own_namespaces[element] = record[:namespaces]
400
+ element
401
+ end
402
+
403
+ # Text records inside a document subtree always have an element
404
+ # parent (only the document element sits at depth 0), so the
405
+ # whitespace-only skip rule matches the wrapper path exactly.
406
+ def self.build_moxml_text_from_record(record, preserve_whitespace)
407
+ content = record[:text].to_s
408
+ return nil if !preserve_whitespace && content.strip.empty?
409
+
410
+ Nodes::TextNode.new(value: content, original: content)
411
+ end
412
+
413
+ # Expand each element's own declarations to in-scope bindings:
414
+ # the element's declarations shadow inherited ones; xml is
415
+ # prebound. Scope flows down the canon tree — no engine calls.
416
+ def self.assign_moxml_namespace_scopes(element, inherited, own_namespaces)
417
+ scope = inherited.dup
418
+ own_namespaces[element].to_a.each do |prefix, uri|
419
+ scope[prefix || ""] = uri
420
+ end
421
+
422
+ scope.each do |prefix, uri|
423
+ element.add_namespace(Nodes::NamespaceNode.new(
424
+ prefix: prefix,
425
+ uri: uri,
426
+ ))
427
+ end
428
+
429
+ element.children.each do |child|
430
+ assign_moxml_namespace_scopes(child, scope, own_namespaces) if child.is_a?(Nodes::ElementNode)
431
+ end
432
+ end
433
+
434
+ def self.build_moxml_node(node, preserve_whitespace: false,
435
+ inherited_namespaces: nil)
277
436
  case node
278
437
  when Moxml::Element
279
438
  build_moxml_element_node(node,
280
- preserve_whitespace: preserve_whitespace)
281
- when Moxml::Text
439
+ preserve_whitespace: preserve_whitespace,
440
+ inherited_namespaces: inherited_namespaces)
441
+ when Moxml::Text, Moxml::Cdata
282
442
  build_moxml_text_node(node, preserve_whitespace: preserve_whitespace)
283
443
  when Moxml::Comment
284
444
  build_moxml_comment_node(node)
@@ -288,7 +448,8 @@ preserve_whitespace: false)
288
448
  end
289
449
 
290
450
  def self.build_moxml_element_node(moxml_element,
291
- preserve_whitespace: false)
451
+ preserve_whitespace: false,
452
+ inherited_namespaces: nil)
292
453
  ns = moxml_element.namespace
293
454
  element = Nodes::ElementNode.new(
294
455
  name: moxml_element.name,
@@ -296,44 +457,56 @@ preserve_whitespace: false)
296
457
  prefix: ns&.prefix,
297
458
  )
298
459
 
299
- build_moxml_namespace_nodes(moxml_element, element)
460
+ scope = moxml_namespace_scope(moxml_element, inherited_namespaces)
461
+ scope.each do |prefix, uri|
462
+ element.add_namespace(Nodes::NamespaceNode.new(
463
+ prefix: prefix,
464
+ uri: uri,
465
+ ))
466
+ end
467
+
300
468
  build_moxml_attribute_nodes(moxml_element, element)
301
469
 
302
470
  moxml_element.children.each do |child|
303
471
  node = build_moxml_node(child,
304
- preserve_whitespace: preserve_whitespace)
472
+ preserve_whitespace: preserve_whitespace,
473
+ inherited_namespaces: scope)
305
474
  element.add_child(node) if node
306
475
  end
307
476
 
308
477
  element
309
478
  end
310
479
 
311
- def self.build_moxml_namespace_nodes(moxml_element, element)
312
- moxml_element.namespace_definitions.each do |ns|
313
- ns_node = Nodes::NamespaceNode.new(
314
- prefix: ns.prefix || "",
315
- uri: ns.uri,
316
- )
317
- element.add_namespace(ns_node)
318
- end
319
-
320
- unless element.namespace_nodes.any? do |n|
321
- n.prefix == "xml"
322
- end
323
- element.add_namespace(Nodes::NamespaceNode.new(
324
- prefix: "xml",
325
- uri: "http://www.w3.org/XML/1998/namespace",
326
- ))
480
+ # In-scope namespace bindings: the element's own declarations
481
+ # shadow inherited ones, xml is prebound. The scope is passed down
482
+ # the recursion so each element pays one declaration fetch instead
483
+ # of an ancestor walk through the adapter layer (the Nokogiri
484
+ # collector walks ancestors because those calls are cheap there).
485
+ def self.moxml_namespace_scope(moxml_element, inherited)
486
+ scope = inherited ? inherited.dup : { "xml" => "http://www.w3.org/XML/1998/namespace" }
487
+ moxml_element.namespace_definitions.each do |decl|
488
+ scope[decl.prefix || ""] = decl.uri
327
489
  end
490
+ scope
328
491
  end
329
492
 
330
493
  def self.build_moxml_attribute_nodes(moxml_element, element)
494
+ seen = {}
331
495
  moxml_element.attributes.each do |attr|
332
- attr_node = Nodes::AttributeNode.new(
333
- name: attr.name,
334
- value: attr.value,
335
- )
336
- element.add_attribute(attr_node)
496
+ # Duplicate attribute names are invalid XML; engines expose
497
+ # them differently (libxml2 lists repeats, libleptris dedups
498
+ # last-wins). Canon follows the Nokogiri presentation contract:
499
+ # first occurrence wins, keyed by name + namespace.
500
+ key = [attr.name, attr.namespace&.uri]
501
+ next if seen.key?(key)
502
+
503
+ seen[key] = true
504
+ element.add_attribute(Nodes::AttributeNode.new(
505
+ name: attr.name,
506
+ value: attr.value,
507
+ namespace_uri: attr.namespace&.uri,
508
+ prefix: attr.namespace&.prefix,
509
+ ))
337
510
  end
338
511
  end
339
512
 
@@ -354,7 +527,7 @@ preserve_whitespace: false)
354
527
  def self.build_moxml_pi_node(moxml_pi)
355
528
  Nodes::ProcessingInstructionNode.new(
356
529
  target: moxml_pi.target,
357
- data: moxml_pi.data,
530
+ data: moxml_pi.content,
358
531
  )
359
532
  end
360
533
  end
@@ -130,10 +130,18 @@ module Canon
130
130
  # Add line break before PI if it's outside document element
131
131
  output << "\n" if parent_element.nil? && output.length.positive?
132
132
 
133
+ # C14N renders "<?target S data?>". Both CRuby engines now
134
+ # consume the leading whitespace run after the PI target
135
+ # (leptris-ruby#85), but the canonical writer owns the rule —
136
+ # it must not depend on engine pre-processing (and the pure-Ruby
137
+ # adapters don't). Trailing whitespace and internal newlines are
138
+ # preserved (W3C ex 3.1 keeps them).
139
+ data = node.data.to_s.sub(/\A[ \t\r\n]+/, "")
140
+
133
141
  output << "<?" << node.target
134
142
 
135
- unless node.data.empty?
136
- output << " " << node.data
143
+ unless data.empty?
144
+ output << " " << data
137
145
  end
138
146
 
139
147
  output << "?>"
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Xml
5
+ module Sax
6
+ # moxml SAX driver (rexml adapter under Opal). moxml splits namespace
7
+ # declarations out of the attribute hash and reports them separately,
8
+ # so this driver recombines them as inline "xmlns"/"xmlns:prefix"
9
+ # pairs to match the builder's engine-neutral protocol.
10
+ class MoxmlDriver < Moxml::SAX::Handler
11
+ def initialize(builder)
12
+ super()
13
+ @builder = builder
14
+ end
15
+
16
+ def parse(xml_string)
17
+ Canon::XmlParsing.moxml_context.sax_parse(xml_string, self)
18
+ end
19
+
20
+ def on_start_element(name, attributes = {}, namespaces = {})
21
+ attrs = attributes.to_a
22
+ namespaces.each do |prefix, uri|
23
+ attrs << if prefix.nil? || prefix.empty?
24
+ ["xmlns", uri]
25
+ else
26
+ ["xmlns:#{prefix}", uri]
27
+ end
28
+ end
29
+ @builder.start_element(name, attrs)
30
+ end
31
+
32
+ def on_end_element(name)
33
+ @builder.end_element(name)
34
+ end
35
+
36
+ def on_characters(text)
37
+ @builder.characters(text)
38
+ end
39
+
40
+ def on_cdata(text)
41
+ @builder.cdata(text)
42
+ end
43
+
44
+ def on_comment(text)
45
+ @builder.comment(text)
46
+ end
47
+
48
+ def on_processing_instruction(name, content)
49
+ @builder.processing_instruction(name, content || "")
50
+ end
51
+
52
+ def on_error(error)
53
+ @builder.error(error.message.to_s)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri" unless RUBY_ENGINE == "opal"
4
+
5
+ module Canon
6
+ module Xml
7
+ module Sax
8
+ # Nokogiri SAX driver: the builder's protocol already matches
9
+ # Nokogiri::XML::SAX::Document, so every event is a pass-through.
10
+ class NokogiriDriver < Nokogiri::XML::SAX::Document
11
+ def initialize(builder)
12
+ super()
13
+ @builder = builder
14
+ end
15
+
16
+ def parse(xml_string)
17
+ Nokogiri::XML::SAX::Parser.new(self).parse(xml_string)
18
+ end
19
+
20
+ def error(string)
21
+ @builder.error(string)
22
+ end
23
+
24
+ def warning(string)
25
+ @builder.warning(string)
26
+ end
27
+
28
+ def start_element(name, attrs = [])
29
+ @builder.start_element(name, attrs)
30
+ end
31
+
32
+ def end_element(name)
33
+ @builder.end_element(name)
34
+ end
35
+
36
+ def characters(string)
37
+ @builder.characters(string)
38
+ end
39
+
40
+ def cdata_block(string)
41
+ @builder.cdata(string)
42
+ end
43
+
44
+ def comment(string)
45
+ @builder.comment(string)
46
+ end
47
+
48
+ def processing_instruction(name, content)
49
+ @builder.processing_instruction(name, content)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end