canon 0.2.8 → 0.2.9

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec-opal +7 -0
  3. data/.rubocop_todo.yml +14 -71
  4. data/Rakefile +17 -0
  5. data/lib/canon/cli.rb +1 -1
  6. data/lib/canon/color_detector.rb +3 -5
  7. data/lib/canon/comparison/compare_profile.rb +1 -4
  8. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +2 -6
  9. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +2 -6
  10. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +2 -6
  11. data/lib/canon/comparison/dimensions/comments_dimension.rb +2 -6
  12. data/lib/canon/comparison/dimensions/element_position_dimension.rb +2 -6
  13. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +2 -6
  14. data/lib/canon/comparison/dimensions/text_content_dimension.rb +3 -5
  15. data/lib/canon/comparison/format_detector.rb +29 -20
  16. data/lib/canon/comparison/html_comparator.rb +18 -29
  17. data/lib/canon/comparison/html_compare_profile.rb +3 -10
  18. data/lib/canon/comparison/html_parser.rb +1 -1
  19. data/lib/canon/comparison/json_comparator.rb +8 -0
  20. data/lib/canon/comparison/node_inspector.rb +146 -80
  21. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +6 -8
  22. data/lib/canon/comparison/whitespace_sensitivity.rb +55 -193
  23. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +5 -10
  24. data/lib/canon/comparison/xml_comparator/child_comparison.rb +4 -4
  25. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +10 -8
  26. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +14 -28
  27. data/lib/canon/comparison/xml_comparator/node_parser.rb +12 -11
  28. data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +30 -58
  29. data/lib/canon/comparison/xml_comparator.rb +61 -83
  30. data/lib/canon/comparison/xml_node_comparison.rb +15 -15
  31. data/lib/canon/comparison/yaml_comparator.rb +8 -0
  32. data/lib/canon/comparison.rb +23 -23
  33. data/lib/canon/config/profile_loader.rb +13 -13
  34. data/lib/canon/config.rb +29 -5
  35. data/lib/canon/diff/diff_classifier.rb +7 -41
  36. data/lib/canon/diff/diff_line.rb +1 -1
  37. data/lib/canon/diff/diff_node_enricher.rb +22 -24
  38. data/lib/canon/diff/node_serializer.rb +23 -30
  39. data/lib/canon/diff/path_builder.rb +24 -37
  40. data/lib/canon/diff/source_locator.rb +0 -3
  41. data/lib/canon/diff/xml_serialization_formatter.rb +8 -81
  42. data/lib/canon/diff_formatter/by_line/base_formatter.rb +7 -7
  43. data/lib/canon/diff_formatter/by_line/json_formatter.rb +1 -1
  44. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +1 -1
  45. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +2 -2
  46. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +1 -1
  47. data/lib/canon/diff_formatter/by_line_formatter.rb +1 -1
  48. data/lib/canon/diff_formatter/by_object/base_formatter.rb +11 -15
  49. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +8 -10
  50. data/lib/canon/diff_formatter/by_object_formatter.rb +1 -1
  51. data/lib/canon/diff_formatter/debug_output.rb +12 -24
  52. data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +2 -2
  53. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +146 -318
  54. data/lib/canon/diff_formatter/diff_detail_formatter.rb +28 -20
  55. data/lib/canon/diff_formatter/legend.rb +2 -2
  56. data/lib/canon/diff_formatter/pretty_diff_formatter.rb +2 -2
  57. data/lib/canon/diff_formatter/theme.rb +4 -4
  58. data/lib/canon/diff_formatter.rb +2 -2
  59. data/lib/canon/formatters/html_formatter.rb +1 -1
  60. data/lib/canon/formatters/html_formatter_base.rb +1 -1
  61. data/lib/canon/formatters/xml_formatter.rb +7 -32
  62. data/lib/canon/html/data_model.rb +1 -1
  63. data/lib/canon/pretty_printer/html.rb +1 -1
  64. data/lib/canon/pretty_printer/xml.rb +16 -7
  65. data/lib/canon/pretty_printer/xml_normalized.rb +9 -3
  66. data/lib/canon/rspec_matchers.rb +2 -2
  67. data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
  68. data/lib/canon/tree_diff/adapters/xml_adapter.rb +1 -1
  69. data/lib/canon/tree_diff/core/tree_node.rb +1 -3
  70. data/lib/canon/validators/html_validator.rb +1 -1
  71. data/lib/canon/validators/xml_validator.rb +1 -1
  72. data/lib/canon/version.rb +1 -1
  73. data/lib/canon/xml/data_model.rb +131 -137
  74. data/lib/canon/xml/namespace_helper.rb +5 -0
  75. data/lib/canon/xml/node.rb +2 -1
  76. data/lib/canon/xml/nodes/root_node.rb +4 -0
  77. data/lib/canon/xml/nodes/text_node.rb +6 -1
  78. data/lib/canon/xml/sax_builder.rb +4 -6
  79. data/lib/canon/xml_backend.rb +49 -0
  80. data/lib/canon/xml_parsing.rb +271 -0
  81. data/lib/canon.rb +3 -1
  82. data/lib/tasks/benchmark_runner.rb +1 -1
  83. data/lib/tasks/performance_helpers.rb +1 -1
  84. metadata +5 -2
@@ -1,444 +1,272 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "nokogiri"
3
+ require "cgi"
4
4
  require_relative "../../xml/namespace_helper"
5
5
 
6
6
  module Canon
7
7
  class DiffFormatter
8
8
  module DiffDetailFormatterHelpers
9
- # Node utility methods
9
+ # Node utility methods for the diff detail formatter.
10
10
  #
11
- # Provides helper methods for extracting information from nodes.
11
+ # All node queries delegate to NodeInspector / XmlParsing.
12
+ # No respond_to? — types are known at every call site.
12
13
  module NodeUtils
13
- # Get attribute names from a node
14
- #
15
- # @param node [Object] Node to extract attributes from
16
- # @return [Array<String>] Array of attribute names
14
+ # --- Attribute extraction ---
15
+
17
16
  def self.get_attribute_names(node)
18
- return [] unless node
19
-
20
- attrs = if node.respond_to?(:attribute_nodes)
21
- node.attribute_nodes
22
- elsif node.respond_to?(:attributes)
23
- node.attributes
24
- elsif node.respond_to?(:[]) && node.respond_to?(:each)
25
- # Hash-like node
26
- node.keys
27
- else
28
- []
29
- end
30
-
31
- return [] unless attrs
32
-
33
- # Handle different attribute formats
34
- if attrs.is_a?(Array)
35
- attrs.map { |attr| attr.respond_to?(:name) ? attr.name : attr.to_s }
36
- elsif attrs.respond_to?(:keys)
37
- attrs.keys.map(&:to_s)
38
- else
39
- []
40
- end
17
+ extract_attribute_names(node)
18
+ end
19
+
20
+ def self.get_attribute_names_in_order(node)
21
+ extract_attribute_names(node)
41
22
  end
42
23
 
43
- # Find all differing attributes between two nodes
44
- #
45
- # @param node1 [Object] First node
46
- # @param node2 [Object] Second node
47
- # @return [Array<String>] Array of attribute names with different values
48
24
  def self.find_all_differing_attributes(node1, node2)
49
25
  return [] unless node1 && node2
50
26
 
51
27
  attrs1 = get_attributes_hash(node1)
52
28
  attrs2 = get_attributes_hash(node2)
53
29
 
54
- all_keys = (attrs1.keys | attrs2.keys)
55
-
56
- all_keys.reject do |key|
30
+ (attrs1.keys | attrs2.keys).reject do |key|
57
31
  attrs1[key.to_s] == attrs2[key.to_s]
58
32
  end
59
33
  end
60
34
 
61
- # Get attribute names in order from a node
62
- #
63
- # @param node [Object] Node to extract from
64
- # @return [Array<String>] Ordered array of attribute names
65
- def self.get_attribute_names_in_order(node)
66
- return [] unless node
67
-
68
- attrs = if node.respond_to?(:attribute_nodes)
69
- node.attribute_nodes
70
- elsif node.respond_to?(:attributes)
71
- node.attributes
72
- else
73
- []
74
- end
75
-
76
- return [] unless attrs
77
-
78
- if attrs.is_a?(Array)
79
- attrs.map { |attr| attr.respond_to?(:name) ? attr.name : attr.to_s }
80
- else
81
- attrs.keys.map(&:to_s)
82
- end
83
- end
84
-
85
- # Get attributes as a hash
86
- #
87
- # @param node [Object] Node to extract from
88
- # @return [Hash] Attributes hash
89
35
  def self.get_attributes_hash(node)
90
36
  return {} unless node
91
37
 
92
- attrs = if node.respond_to?(:attribute_nodes)
93
- node.attribute_nodes
94
- elsif node.respond_to?(:attributes)
95
- node.attributes
96
- else
97
- {}
98
- end
99
-
100
- return {} unless attrs
101
-
102
- result = {}
103
- if attrs.is_a?(Array)
104
- attrs.each do |attr|
105
- name = attr.respond_to?(:name) ? attr.name : attr.to_s
106
- value = attr.respond_to?(:value) ? attr.value : attr.to_s
107
- result[name] = value
108
- end
109
- elsif attrs.respond_to?(:each)
110
- attrs.each do |key, val|
111
- name = key.to_s
112
- value = if val.respond_to?(:value)
113
- val.value
114
- elsif val.respond_to?(:content)
115
- val.content
116
- else
117
- val.to_s
118
- end
119
- result[name] = value
120
- end
38
+ case node
39
+ when Canon::Xml::Nodes::ElementNode
40
+ node.attribute_nodes.to_h { |a| [a.name.to_s, a.value.to_s] }
41
+ else
42
+ backend_attributes_hash(node)
121
43
  end
122
-
123
- result
124
44
  end
125
45
 
126
- # Get attribute value from a node
127
- #
128
- # @param node [Object] Node to extract from
129
- # @param attr_name [String] Attribute name
130
- # @return [String, nil] Attribute value or nil
131
46
  def self.get_attribute_value(node, attr_name)
132
47
  return nil unless node && attr_name
133
48
 
134
- if node.respond_to?(:[])
135
- value = node[attr_name]
136
- if value.respond_to?(:value)
137
- value.value
138
- elsif value.respond_to?(:content)
139
- value.content
140
- elsif value.respond_to?(:to_s)
141
- value.to_s
142
- else
143
- value
144
- end
145
- elsif node.respond_to?(:get_attribute)
146
- attr = node.get_attribute(attr_name)
147
- attr.respond_to?(:value) ? attr.value : attr
148
- elsif node.respond_to?(:attribute_nodes)
149
- attribute_node = node.attribute_nodes.find do |attr|
150
- attr.name == attr_name.to_s
151
- end
152
- attribute_node&.value
49
+ case node
50
+ when Canon::Xml::Nodes::ElementNode
51
+ attr = node.attribute_nodes.find { |a| a.name == attr_name.to_s }
52
+ attr&.value
53
+ else
54
+ XmlParsing.attribute_value(node, attr_name)
153
55
  end
154
56
  end
155
57
 
156
- # Get text content from a node
157
- #
158
- # @param node [Object] Node to extract from
159
- # @return [String] Text content
58
+ # --- Text / name / namespace ---
59
+
160
60
  def self.get_node_text(node)
161
61
  return "" unless node
162
62
 
163
- text = if node.respond_to?(:text)
164
- node.text
165
- elsif node.respond_to?(:content)
166
- node.content
167
- elsif node.respond_to?(:inner_text)
168
- node.inner_text
169
- elsif node.respond_to?(:value)
170
- node.value
171
- elsif node.respond_to?(:node_info)
172
- node.node_info
173
- elsif node.respond_to?(:to_s)
174
- node.to_s
175
- else
176
- ""
177
- end
178
-
179
- strip_ascii_whitespace(text.to_s)
63
+ strip_ascii_whitespace(Canon::Comparison::NodeInspector.text_content(node).to_s)
180
64
  end
181
65
 
182
- # Strip only ASCII whitespace (space, tab, CR, LF) but preserve Unicode
183
- # whitespace like non-breaking space (\u00A0). Ruby's String#strip removes
184
- # all Unicode whitespace, which destroys meaningful content like \u00A0.
185
- #
186
- # @param str [String] String to strip
187
- # @return [String] String with leading/trailing ASCII whitespace removed
188
- ASCII_WHITESPACE_BYTES = [32, 9, 13, 10].freeze # ' ', '\t', '\r', '\n'
66
+ ASCII_WHITESPACE_PATTERN = /[ \t\r\n]/
189
67
 
190
68
  def self.strip_ascii_whitespace(str)
191
69
  return "" if str.nil?
192
70
  return str if str.empty?
193
71
 
194
- # Find first non-ASCII-whitespace character position
195
72
  first_pos = str.index(/[^ \t\r\n]/)
196
73
  return "" unless first_pos
197
74
 
198
- # Find last non-ASCII-whitespace character position (from end)
199
- # Use reverse and index, then convert back to forward position
200
75
  reversed_pos = str.reverse.index(/[^ \t\r\n]/)
201
76
  last_pos = str.length - 1 - reversed_pos
202
77
 
203
78
  str[first_pos..last_pos]
204
79
  end
205
80
 
206
- # Get element name for display
207
- #
208
- # @param node [Object] Node to get name from
209
- # @return [String] Element name
210
81
  def self.get_element_name_for_display(node)
211
82
  return "" unless node
212
83
 
213
- # Handle TextNode specially since it doesn't respond to :name
214
- if node.is_a?(Canon::Xml::Nodes::TextNode)
215
- return "text"
216
- end
217
-
218
- # Handle CommentNode specially since it doesn't respond to :name
219
- if node.is_a?(Canon::Xml::Nodes::CommentNode)
220
- return "comment"
221
- end
222
-
223
- if node.respond_to?(:name)
224
- node.name.to_s
84
+ case node
85
+ when Canon::Xml::Nodes::TextNode
86
+ "text"
87
+ when Canon::Xml::Nodes::CommentNode
88
+ "comment"
225
89
  else
226
- node.class.name
90
+ Canon::Comparison::NodeInspector.name(node).to_s
227
91
  end
228
92
  end
229
93
 
230
- # Get namespace URI for display
231
- #
232
- # @param node [Object] Node to get namespace from
233
- # @return [String] Namespace URI
234
94
  def self.get_namespace_uri_for_display(node)
235
95
  return "" unless node
236
96
 
237
- if node.respond_to?(:namespace_uri)
238
- node.namespace_uri.to_s
239
- elsif node.respond_to?(:namespace)
240
- ns = node.namespace
241
- ns.respond_to?(:href) ? ns.href.to_s : ""
242
- else
243
- ""
244
- end
97
+ Canon::Comparison::Canon::Comparison::NodeInspector.namespace_uri(node).to_s
245
98
  end
246
99
 
247
- # Format node briefly for display
248
- #
249
- # @param node [Object] Node to format
250
- # @return [String] Brief node description
100
+ # --- Display helpers ---
101
+
251
102
  def self.format_node_brief(node)
252
103
  return "" unless node
253
104
 
254
105
  name = get_element_name_for_display(node)
255
106
  text = get_node_text(node)
256
107
 
257
- if text && !text.empty?
258
- "#{name}(\"#{text}\")"
108
+ text && !text.empty? ? "#{name}(\"#{text}\")" : name
109
+ end
110
+
111
+ def self.node_to_display(node, compact: false)
112
+ if compact && node.is_a?(Canon::Xml::Nodes::ElementNode)
113
+ serialize_node_compact(node)
259
114
  else
260
- name
115
+ get_node_text(node)
261
116
  end
262
117
  end
263
118
 
264
- # Serialize a node tree as compact XML for display.
265
- #
266
- # Produces a human-readable inline XML string without namespace
267
- # declarations and without indentation — suitable for use in Semantic
268
- # Diff Report entries. Handles both +Canon::Xml::Nodes+ types and
269
- # Nokogiri XML/HTML nodes (the html DOM comparison path uses
270
- # Nokogiri nodes, so element-structure diffs originating there must
271
- # be rendered structurally too — see issue #120). For any other
272
- # node type, falls back to +get_node_text+.
273
- #
274
- # @param node [Object] Node to serialize
275
- # @return [String] Compact XML string
119
+ # --- Serialization ---
120
+
276
121
  def self.serialize_node_compact(node)
277
- require "cgi"
278
122
  return "" unless node
279
123
 
280
124
  case node
281
125
  when Canon::Xml::Nodes::TextNode
282
126
  CGI.escapeHTML(node.value.to_s)
283
- when Canon::Xml::Nodes::ElementNode
284
- tag = node.name.to_s
285
- attrs = node.attribute_nodes.map do |attr|
286
- attr_name = attr.respond_to?(:name) ? attr.name.to_s : attr.to_s
287
- attr_value = attr.respond_to?(:value) ? attr.value.to_s : ""
288
- " #{attr_name}=\"#{CGI.escapeHTML(attr_value)}\""
289
- end.join
290
- children_xml = node.children.map do |c|
291
- serialize_node_compact(c)
292
- end.join
293
- if children_xml.empty?
294
- "<#{tag}#{attrs}/>"
295
- else
296
- "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
297
- end
298
127
  when Canon::Xml::Nodes::CommentNode
299
- text = node.respond_to?(:value) ? node.value.to_s : ""
300
- "<!--#{CGI.escapeHTML(text)}-->"
301
- when Nokogiri::XML::Text, Nokogiri::XML::CDATA
302
- CGI.escapeHTML(node.content.to_s)
303
- when Nokogiri::XML::Comment
304
- "<!--#{CGI.escapeHTML(node.content.to_s)}-->"
305
- when Nokogiri::XML::Element
306
- tag = node.name.to_s
307
- attrs = node.attribute_nodes.map do |a|
308
- " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
309
- end.join
310
- children_xml = node.children.map do |c|
311
- serialize_node_compact(c)
312
- end.join
313
- if children_xml.empty?
314
- "<#{tag}#{attrs}/>"
315
- else
316
- "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
317
- end
128
+ "<!--#{CGI.escapeHTML(node.value.to_s)}-->"
129
+ when Canon::Xml::Nodes::ElementNode
130
+ serialize_element_compact(node)
318
131
  else
319
- # Unknown node types — fall back to text extraction
320
- get_node_text(node)
132
+ serialize_backend_node_compact(node)
321
133
  end
322
134
  end
323
135
 
324
- # Serialize a node's open tag only — name + attributes, no children,
325
- # no closing tag. Used by +format_text_content_one_sided+ to render
326
- # a brief parent-element context hint (e.g. +<div id="A">+) for a
327
- # one-sided text diff, instead of the full ancestor subtree that
328
- # +serialize_node_compact+ would produce. See lutaml/canon#125.
329
- #
330
- # @param node [Object] Element node to serialize
331
- # @return [String] Open-tag string, or "" for non-elements / nil
332
136
  def self.serialize_open_tag(node)
333
- require "cgi"
334
137
  return "" unless node
335
138
 
336
139
  case node
337
140
  when Canon::Xml::Nodes::ElementNode
338
- tag = node.name.to_s
339
- attrs = node.attribute_nodes.map do |attr|
340
- " #{attr.name}=\"#{CGI.escapeHTML(attr.value.to_s)}\""
341
- end.join
342
- "<#{tag}#{attrs}>"
343
- when Nokogiri::XML::Element
344
141
  tag = node.name.to_s
345
142
  attrs = node.attribute_nodes.map do |a|
346
143
  " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
347
144
  end.join
348
145
  "<#{tag}#{attrs}>"
349
146
  else
350
- ""
147
+ serialize_backend_open_tag(node)
351
148
  end
352
149
  end
353
150
 
354
- # Return the raw text content of a text node without stripping
355
- # whitespace. +get_node_text+ strips ASCII whitespace, which
356
- # destroys whitespace-only payloads that callers (e.g. one-sided
357
- # text-content diff rendering) need to display verbatim.
358
- #
359
- # @param node [Object] Text node
360
- # @return [String] Raw text content, or "" if not a text-bearing node
361
151
  def self.raw_text_value(node)
362
152
  return "" unless node
363
153
 
364
- case node
365
- when Canon::Xml::Node
366
- node.value.to_s
367
- when Nokogiri::XML::Node
368
- node.content.to_s
369
- else
370
- ""
371
- end
154
+ Canon::Comparison::NodeInspector.text_content(node).to_s
372
155
  end
373
156
 
374
- # Return the best display string for a node.
375
- #
376
- # When +compact: true+ and the node is a Canon ElementNode, returns a
377
- # compact XML serialization (e.g. +<strong>Annex</strong>+) instead of
378
- # the +node_info+ description string that +get_node_text+ would produce.
379
- # In all other cases, delegates to +get_node_text+.
380
- #
381
- # @param node [Object] Node to display
382
- # @param compact [Boolean] Whether to use compact XML for element nodes
383
- # @return [String] Display string
384
- def self.node_to_display(node, compact: false)
385
- if compact && node.is_a?(Canon::Xml::Nodes::ElementNode)
386
- serialize_node_compact(node)
387
- else
388
- get_node_text(node)
389
- end
390
- end
157
+ # --- Traversal ---
391
158
 
392
- # Return the parent of a node, or nil, regardless of the node API.
393
- #
394
- # Canon::Xml nodes expose +parent+; some Nokogiri-shaped nodes expose
395
- # +parent_node+. This helper abstracts over both.
396
- #
397
- # @param node [Object] Node to query
398
- # @return [Object, nil] Parent node or nil
399
159
  def self.parent_of(node)
400
- return nil unless node
401
-
402
- if node.respond_to?(:parent)
403
- node.parent
404
- elsif node.respond_to?(:parent_node)
405
- node.parent_node
406
- end
160
+ Canon::Comparison::NodeInspector.parent(node)
407
161
  end
408
162
 
409
- # Check if node is inside a preserve-whitespace element
410
- #
411
- # @param node [Object] Node to check
412
- # @return [Boolean] true if inside preserve element
413
163
  def self.inside_preserve_element?(node)
414
164
  return false unless node
415
165
 
416
166
  preserve_elements = %w[pre code textarea script style]
417
167
 
418
- # Check the node itself
419
- if node.respond_to?(:name) && preserve_elements.include?(node.name.to_s.downcase)
420
- return true
421
- end
422
-
423
- # Check ancestors
424
168
  current = node
425
169
  while current
426
- if current.respond_to?(:parent)
427
- current = current.parent
428
- elsif current.respond_to?(:parent_node)
429
- current = current.parent_node
170
+ name = Canon::Comparison::NodeInspector.name(current)
171
+ return true if name && preserve_elements.include?(name.to_s.downcase)
172
+
173
+ parent = Canon::Comparison::NodeInspector.parent(current)
174
+ break if parent.nil? || parent == current
175
+
176
+ current = parent
177
+ end
178
+
179
+ false
180
+ end
181
+
182
+ class << self
183
+ private
184
+
185
+ def extract_attribute_names(node)
186
+ return [] unless node
187
+
188
+ case node
189
+ when Canon::Xml::Nodes::ElementNode
190
+ node.attribute_nodes.map(&:name)
430
191
  else
431
- break
192
+ attrs = XmlParsing.attributes(node)
193
+ return [] unless attrs
194
+ return attrs.map { |a| a.name.to_s } if attrs.is_a?(Array)
195
+
196
+ attrs.keys.map(&:to_s)
432
197
  end
198
+ end
433
199
 
434
- next unless current
200
+ def backend_attributes_hash(node)
201
+ attrs = XmlParsing.attributes(node)
202
+ return {} unless attrs
435
203
 
436
- if current.respond_to?(:name) && preserve_elements.include?(current.name.to_s.downcase)
437
- return true
204
+ if attrs.is_a?(Array)
205
+ attrs.each_with_object({}) do |attr, h|
206
+ name = attr.is_a?(Canon::Xml::Nodes::AttributeNode) ? attr.name : XmlParsing.name(attr).to_s
207
+ value = attr.is_a?(Canon::Xml::Nodes::AttributeNode) ? attr.value : XmlParsing.text_content(attr).to_s
208
+ h[name.to_s] = value
209
+ end
210
+ else
211
+ attrs.each_with_object({}) do |(key, val), h|
212
+ h[key.to_s] =
213
+ val.is_a?(String) ? val : XmlParsing.text_content(val).to_s
214
+ end
438
215
  end
439
216
  end
440
217
 
441
- false
218
+ def serialize_element_compact(element_node)
219
+ tag = element_node.name.to_s
220
+ attrs = element_node.attribute_nodes.map do |a|
221
+ " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
222
+ end.join
223
+ children_xml = element_node.children.map do |c|
224
+ serialize_node_compact(c)
225
+ end.join
226
+ children_xml.empty? ? "<#{tag}#{attrs}/>" : "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
227
+ end
228
+
229
+ def serialize_backend_node_compact(node)
230
+ if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Node)
231
+ serialize_nokogiri_node_compact(node)
232
+ elsif node.is_a?(Canon::Xml::Node)
233
+ serialize_node_compact(node)
234
+ else
235
+ get_node_text(node)
236
+ end
237
+ end
238
+
239
+ def serialize_nokogiri_node_compact(node)
240
+ case node
241
+ when Nokogiri::XML::Text, Nokogiri::XML::CDATA
242
+ CGI.escapeHTML(node.content.to_s)
243
+ when Nokogiri::XML::Comment
244
+ "<!--#{CGI.escapeHTML(node.content.to_s)}-->"
245
+ when Nokogiri::XML::Element
246
+ tag = node.name.to_s
247
+ attrs = node.attribute_nodes.map do |a|
248
+ " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
249
+ end.join
250
+ children_xml = node.children.map do |c|
251
+ serialize_node_compact(c)
252
+ end.join
253
+ children_xml.empty? ? "<#{tag}#{attrs}/>" : "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
254
+ else
255
+ get_node_text(node)
256
+ end
257
+ end
258
+
259
+ def serialize_backend_open_tag(node)
260
+ if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Element)
261
+ tag = node.name.to_s
262
+ attrs = node.attribute_nodes.map do |a|
263
+ " #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
264
+ end.join
265
+ "<#{tag}#{attrs}>"
266
+ else
267
+ ""
268
+ end
269
+ end
442
270
  end
443
271
  end
444
272
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rainbow"
3
+ require "rainbow" unless RUBY_ENGINE == "opal"
4
4
  require_relative "../xml/namespace_helper"
5
5
  # DiffDetailFormatter helper modules
6
6
  require_relative "diff_detail_formatter/text_utils"
@@ -33,11 +33,8 @@ module Canon
33
33
  return "" if differences.empty?
34
34
 
35
35
  # Group differences by normative status
36
- normative = differences.select do |diff|
37
- diff.respond_to?(:normative?) ? diff.normative? : true
38
- end
39
- informative = differences.select do |diff|
40
- diff.respond_to?(:normative?) && !diff.normative?
36
+ normative, informative = differences.partition do |diff|
37
+ normative?(diff)
41
38
  end
42
39
 
43
40
  # Apply show_diffs filter — same semantics as the line-diff filter
@@ -100,24 +97,14 @@ compact: false, expand_difference: false)
100
97
  output = []
101
98
 
102
99
  # Header - handle both DiffNode and Hash
103
- status = section || (if diff.respond_to?(:normative?)
104
- diff.normative? ? "NORMATIVE" : "INFORMATIVE"
105
- else
106
- "NORMATIVE" # Hash diffs are always normative
107
- end)
100
+ status = section || (normative?(diff) ? "NORMATIVE" : "INFORMATIVE")
108
101
  status_color = status == "NORMATIVE" ? :green : :yellow
109
102
  output << colorize("🔍 DIFFERENCE ##{number}/#{total} [#{status}]",
110
103
  status_color, use_color, bold: true)
111
104
  output << colorize("─" * 70, :cyan, use_color)
112
105
 
113
106
  # Dimension - handle both DiffNode and Hash
114
- dimension = if diff.respond_to?(:dimension)
115
- diff.dimension
116
- elsif diff.is_a?(Hash)
117
- diff[:diff_code] || diff[:dimension] || "unknown"
118
- else
119
- "unknown"
120
- end
107
+ dimension = diff_dimension(diff)
121
108
  output << "#{colorize('Dimension:', :cyan, use_color,
122
109
  bold: true)} #{colorize(dimension.to_s,
123
110
  :magenta, use_color)}"
@@ -129,7 +116,7 @@ compact: false, expand_difference: false)
129
116
  use_color)}"
130
117
 
131
118
  # show reason if available
132
- if diff.respond_to?(:reason) && diff.reason
119
+ if diff_reason(diff)
133
120
  format_reason_line(output, diff.reason, use_color)
134
121
  end
135
122
  output << ""
@@ -157,7 +144,7 @@ compact: false, expand_difference: false)
157
144
  end
158
145
 
159
146
  dimension = begin
160
- diff.respond_to?(:dimension) ? diff.dimension : "unknown"
147
+ diff_dimension(diff)
161
148
  rescue StandardError
162
149
  "unknown"
163
150
  end
@@ -177,6 +164,27 @@ compact: false, expand_difference: false)
177
164
  colorize(error_msg, :red, use_color, bold: true)
178
165
  end
179
166
 
167
+ # Protocol helpers — call DiffNode-like methods safely on any object.
168
+ # DiffNode, FakeDiff (tests), and Hash all support these via duck typing.
169
+ # Using rescue avoids respond_to? while remaining polymorphic.
170
+ def normative?(diff)
171
+ diff.normative?
172
+ rescue NoMethodError
173
+ true
174
+ end
175
+
176
+ def diff_dimension(diff)
177
+ diff.dimension
178
+ rescue NoMethodError
179
+ diff.is_a?(Hash) ? (diff[:diff_code] || diff[:dimension] || "unknown") : "unknown"
180
+ end
181
+
182
+ def diff_reason(diff)
183
+ diff.reason
184
+ rescue NoMethodError
185
+ nil
186
+ end
187
+
180
188
  # Format the Reason line. When the reason contains visualized
181
189
  # spaces (░), split into two vertically-aligned lines so the
182
190
  # before/after text can be compared visually.