canon 0.2.8 → 0.2.11

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec-opal +7 -0
  3. data/.rubocop_todo.yml +25 -73
  4. data/Rakefile +37 -0
  5. data/lib/canon/cache.rb +16 -27
  6. data/lib/canon/cli.rb +1 -1
  7. data/lib/canon/color_detector.rb +3 -5
  8. data/lib/canon/comparison/compare_profile.rb +1 -4
  9. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +2 -6
  10. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +2 -6
  11. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +2 -6
  12. data/lib/canon/comparison/dimensions/comments_dimension.rb +2 -6
  13. data/lib/canon/comparison/dimensions/element_position_dimension.rb +2 -6
  14. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +2 -6
  15. data/lib/canon/comparison/dimensions/text_content_dimension.rb +3 -5
  16. data/lib/canon/comparison/format_detector.rb +29 -20
  17. data/lib/canon/comparison/html_comparator.rb +20 -29
  18. data/lib/canon/comparison/html_compare_profile.rb +3 -10
  19. data/lib/canon/comparison/html_parser.rb +1 -1
  20. data/lib/canon/comparison/json_comparator.rb +8 -0
  21. data/lib/canon/comparison/node_inspector.rb +117 -86
  22. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +6 -8
  23. data/lib/canon/comparison/whitespace_sensitivity.rb +55 -193
  24. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +19 -2
  25. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +5 -10
  26. data/lib/canon/comparison/xml_comparator/child_comparison.rb +4 -4
  27. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +40 -8
  28. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +14 -28
  29. data/lib/canon/comparison/xml_comparator/node_parser.rb +14 -13
  30. data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +30 -58
  31. data/lib/canon/comparison/xml_comparator.rb +63 -85
  32. data/lib/canon/comparison/xml_node_comparison.rb +15 -15
  33. data/lib/canon/comparison/yaml_comparator.rb +8 -0
  34. data/lib/canon/comparison.rb +24 -24
  35. data/lib/canon/config/profile_loader.rb +13 -13
  36. data/lib/canon/config.rb +29 -5
  37. data/lib/canon/diff/diff_classifier.rb +7 -41
  38. data/lib/canon/diff/diff_line.rb +1 -1
  39. data/lib/canon/diff/diff_line_builder.rb +2 -0
  40. data/lib/canon/diff/diff_node_enricher.rb +22 -24
  41. data/lib/canon/diff/diff_node_mapper.rb +10 -8
  42. data/lib/canon/diff/formatting_detector.rb +3 -2
  43. data/lib/canon/diff/node_serializer.rb +23 -30
  44. data/lib/canon/diff/path_builder.rb +24 -37
  45. data/lib/canon/diff/source_locator.rb +0 -3
  46. data/lib/canon/diff/xml_serialization_formatter.rb +8 -84
  47. data/lib/canon/diff_formatter/by_line/base_formatter.rb +7 -7
  48. data/lib/canon/diff_formatter/by_line/json_formatter.rb +1 -1
  49. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +1 -1
  50. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +2 -2
  51. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +1 -1
  52. data/lib/canon/diff_formatter/by_line_formatter.rb +1 -1
  53. data/lib/canon/diff_formatter/by_object/base_formatter.rb +23 -17
  54. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +127 -11
  55. data/lib/canon/diff_formatter/by_object_formatter.rb +2 -6
  56. data/lib/canon/diff_formatter/debug_output.rb +12 -24
  57. data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +2 -2
  58. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +3 -3
  59. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +26 -27
  60. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +146 -318
  61. data/lib/canon/diff_formatter/diff_detail_formatter.rb +28 -20
  62. data/lib/canon/diff_formatter/legend.rb +2 -2
  63. data/lib/canon/diff_formatter/pretty_diff_formatter.rb +2 -2
  64. data/lib/canon/diff_formatter/theme.rb +4 -4
  65. data/lib/canon/diff_formatter.rb +17 -13
  66. data/lib/canon/formatters/html_formatter.rb +1 -1
  67. data/lib/canon/formatters/html_formatter_base.rb +1 -1
  68. data/lib/canon/formatters/xml_formatter.rb +7 -32
  69. data/lib/canon/html/data_model.rb +2 -2
  70. data/lib/canon/pretty_printer/html.rb +1 -1
  71. data/lib/canon/pretty_printer/xml.rb +16 -7
  72. data/lib/canon/pretty_printer/xml_normalized.rb +9 -3
  73. data/lib/canon/rspec_matchers.rb +2 -2
  74. data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
  75. data/lib/canon/tree_diff/adapters/xml_adapter.rb +1 -1
  76. data/lib/canon/tree_diff/core/tree_node.rb +1 -3
  77. data/lib/canon/tree_diff/operation_converter.rb +7 -7
  78. data/lib/canon/tree_diff/operations/operation_detector.rb +4 -0
  79. data/lib/canon/validators/base_validator.rb +5 -8
  80. data/lib/canon/validators/html_validator.rb +3 -8
  81. data/lib/canon/validators/xml_validator.rb +3 -8
  82. data/lib/canon/version.rb +1 -1
  83. data/lib/canon/xml/data_model.rb +132 -138
  84. data/lib/canon/xml/namespace_helper.rb +5 -0
  85. data/lib/canon/xml/node.rb +2 -1
  86. data/lib/canon/xml/nodes/root_node.rb +4 -0
  87. data/lib/canon/xml/nodes/text_node.rb +6 -1
  88. data/lib/canon/xml/sax_builder.rb +5 -7
  89. data/lib/canon/xml/whitespace_normalizer.rb +2 -2
  90. data/lib/canon/xml_backend.rb +49 -0
  91. data/lib/canon/xml_parsing.rb +283 -0
  92. data/lib/canon.rb +3 -1
  93. data/lib/tasks/benchmark_runner.rb +1 -1
  94. data/lib/tasks/performance_helpers.rb +1 -1
  95. metadata +9 -6
@@ -54,10 +54,10 @@ module Canon
54
54
  while current && depth < max_depth
55
55
  segments.unshift(segment_for_node(current))
56
56
 
57
- # Move to parent if available
58
- break unless current.respond_to?(:parent)
57
+ parent = node_parent(current)
58
+ break unless parent
59
59
 
60
- current = current.parent
60
+ current = parent
61
61
  depth += 1
62
62
  end
63
63
 
@@ -71,27 +71,16 @@ module Canon
71
71
  # @param tree_node [Object] Node (TreeNode, Canon::Xml::Node, or Nokogiri)
72
72
  # @return [String] Path segment with ordinal index
73
73
  def self.segment_for_node(tree_node)
74
- # Handle both TreeNodes (with label) and raw nodes (with name)
75
- label = if tree_node.respond_to?(:label)
76
- tree_node.label
77
- elsif tree_node.respond_to?(:name)
78
- tree_node.name
79
- else
80
- "unknown"
81
- end
74
+ label = node_label(tree_node)
82
75
 
83
76
  # Get ordinal index (position among siblings with same label)
84
77
  index = ordinal_index(tree_node)
85
78
 
86
79
  # For text nodes, use parent element name for clarity
87
80
  # e.g., instead of "/p/#text[0]" use "/p/text()[0]"
88
- if ["text",
89
- "#text"].include?(label) && tree_node.respond_to?(:parent) && tree_node.parent
90
- parent_name = if tree_node.parent.respond_to?(:label)
91
- tree_node.parent.label
92
- elsif tree_node.parent.respond_to?(:name)
93
- tree_node.parent.name
94
- end
81
+ parent = node_parent(tree_node)
82
+ if ["text", "#text"].include?(label) && parent
83
+ parent_name = node_label(parent)
95
84
  if parent_name && parent_name != "#document" && parent_name != "#document-fragment"
96
85
  return "#{parent_name}/text()[#{index}]"
97
86
  end
@@ -106,35 +95,21 @@ module Canon
106
95
  # @param tree_node [Object] Node (TreeNode, Canon::Xml::Node, or Nokogiri)
107
96
  # @return [Integer] Zero-based ordinal index
108
97
  def self.ordinal_index(tree_node)
109
- # Defensive: return 0 if no parent or doesn't respond to parent
110
- return 0 unless tree_node.respond_to?(:parent)
111
- return 0 unless tree_node.parent
98
+ parent = node_parent(tree_node)
99
+ return 0 unless parent
112
100
 
113
- # Check if parent has children
114
- return 0 unless tree_node.parent.respond_to?(:children)
115
-
116
- siblings = tree_node.parent.children
101
+ siblings = node_children(parent)
117
102
  return 0 unless siblings
118
103
 
119
104
  # Convert to array if it's a NodeSet (Nokogiri) or similar
120
105
  siblings = siblings.to_a unless siblings.is_a?(Array)
121
106
 
122
- # Get the label/name for comparison
123
- my_label = if tree_node.respond_to?(:label)
124
- tree_node.label
125
- elsif tree_node.respond_to?(:name)
126
- tree_node.name
127
- end
128
-
107
+ my_label = node_label(tree_node)
129
108
  return 0 unless my_label
130
109
 
131
110
  # Count siblings with same label that appear before this node
132
111
  same_label_siblings = siblings.select do |s|
133
- sibling_label = if s.respond_to?(:label)
134
- s.label
135
- elsif s.respond_to?(:name)
136
- s.name
137
- end
112
+ sibling_label = node_label(s)
138
113
  sibling_label == my_label
139
114
  end
140
115
 
@@ -152,6 +127,18 @@ module Canon
152
127
  segments = build_segments(tree_node)
153
128
  segments.join(" → ")
154
129
  end
130
+
131
+ def self.node_label(node)
132
+ Canon::Comparison::NodeInspector.name(node) || "unknown"
133
+ end
134
+
135
+ def self.node_parent(node)
136
+ Canon::Comparison::NodeInspector.parent(node)
137
+ end
138
+
139
+ def self.node_children(node)
140
+ Canon::Comparison::NodeInspector.children(node)
141
+ end
155
142
  end
156
143
  end
157
144
  end
@@ -86,15 +86,12 @@ module Canon
86
86
  end
87
87
 
88
88
  class << self
89
- private
90
-
91
89
  # Binary search for the line containing a character offset.
92
90
  #
93
91
  # @param char_offset [Integer] the character offset
94
92
  # @param line_map [Array<Hash>] the line offset map
95
93
  # @return [Integer, nil] the 0-based line index, or nil
96
94
  def find_line_for_offset(char_offset, line_map)
97
- # Use bsearch for efficiency on large files
98
95
  line_map.bsearch_index do |entry|
99
96
  entry[:end_offset] > char_offset
100
97
  end
@@ -4,59 +4,25 @@ module Canon
4
4
  module Diff
5
5
  # Detects and classifies XML serialization-level formatting differences.
6
6
  #
7
- # Serialization-level formatting differences are differences in XML syntax
8
- # that do not affect the semantic content of the document. These differences
9
- # arise from different valid ways to serialize the same semantic content.
10
- #
11
- # These differences are ALWAYS non-normative (formatting-only) regardless
12
- # of match options, because they are purely syntactic variations.
13
- #
14
- # Examples:
15
- # - Self-closing vs explicit closing tags: <tag/> vs <tag></tag>
16
- # - Attribute quote style: attr="value" vs attr='value' (parser-normalized)
17
- # - Whitespace within tags: <tag a="1" b="2"> vs <tag a="1" b="2"> (parser-normalized)
18
- #
19
- # Note: Some serialization differences are normalized away by XML parsers
20
- # (attribute quotes, tag spacing). This class focuses on differences that
21
- # survive parsing and comparison, such as self-closing vs explicit closing.
7
+ # Serialization formatting differences are ALWAYS non-normative (formatting-only)
8
+ # regardless of match options, because they are purely syntactic variations.
22
9
  class XmlSerializationFormatter
23
- # Detect if a diff node represents an XML serialization formatting difference.
24
- #
25
- # Serialization formatting differences are ALWAYS non-normative because they
26
- # represent different valid serializations of the same semantic content.
27
- #
28
- # @param diff_node [DiffNode] The diff node to check
29
- # @return [Boolean] true if this is a serialization formatting difference
10
+ NI = Canon::Comparison::NodeInspector
11
+
30
12
  def self.serialization_formatting?(diff_node)
31
- # Currently only handles text_content dimension
32
- # Future: add detection for other dimensions
33
13
  return false unless diff_node.dimension == :text_content
34
14
 
35
15
  empty_text_content_serialization_diff?(diff_node)
36
16
  end
37
17
 
38
- # Check if a text_content difference is from XML serialization format.
39
- #
40
- # Specifically detects self-closing tags (<tag/>) vs explicit closing tags
41
- # (<tag></tag>), which create different text node structures:
42
- # - Self-closing: no text node (nil)
43
- # - Explicit closing: empty or whitespace-only text node ("", " ", "\n", etc.)
44
- #
45
- # Per XML standards, these forms are semantically equivalent.
46
- #
47
- # @param diff_node [DiffNode] The diff node to check
48
- # @return [Boolean] true if this is a serialization formatting difference
49
18
  def self.empty_text_content_serialization_diff?(diff_node)
50
19
  return false unless diff_node.dimension == :text_content
51
20
 
52
21
  node1 = diff_node.node1
53
22
  node2 = diff_node.node2
54
23
 
55
- # Both nodes are nil - no actual difference, not a serialization formatting diff
56
24
  return false if node1.nil? && node2.nil?
57
25
 
58
- # Only one is nil (e.g., one doc has self-closing, other has text)
59
- # If the non-nil one is blank, it's still serialization formatting
60
26
  if node1.nil? || node2.nil?
61
27
  non_nil = node1 || node2
62
28
  return false unless text_node?(non_nil)
@@ -65,74 +31,32 @@ module Canon
65
31
  return blank?(text)
66
32
  end
67
33
 
68
- # Both must be text nodes
69
34
  return false unless text_node?(node1) && text_node?(node2)
70
35
 
71
36
  text1 = extract_text_content(node1)
72
37
  text2 = extract_text_content(node2)
73
38
 
74
- # Check if both texts are blank/whitespace-only
75
- # This indicates self-closing vs explicit closing tag syntax
76
39
  blank?(text1) && blank?(text2)
77
40
  end
78
41
 
79
- # Check if a value is blank (nil or whitespace-only)
80
- # @param value [String, nil] Value to check
81
- # @return [Boolean] true if blank
82
42
  def self.blank?(value)
83
- value.nil? ||
84
- (value.respond_to?(:empty?) && value.empty?) ||
85
- (value.respond_to?(:strip) && value.strip.empty?)
43
+ value.nil? || value.to_s.strip.empty?
86
44
  end
87
45
 
88
- # Check if a node is a text node
89
- # @param node [Object] The node to check
90
- # @return [Boolean] true if the node is a text node
91
46
  def self.text_node?(node)
92
47
  return false if node.nil?
48
+ return true if node.is_a?(String)
93
49
 
94
- case node
95
- when Canon::Xml::Nodes::TextNode
96
- true
97
- when Canon::Xml::Node
98
- node.node_type == :text
99
- when Nokogiri::XML::Node
100
- node.node_type == Nokogiri::XML::Node::TEXT_NODE
101
- when Moxml::Node
102
- node.text?
103
- when String
104
- true
105
- else
106
- false
107
- end
50
+ NI.text_node?(node)
108
51
  end
109
52
 
110
- # Extract text content from a node
111
- # @param node [Object] The node to extract text from
112
- # @return [String, nil] The text content or nil
113
53
  def self.extract_text_content(node)
114
54
  return nil if node.nil?
115
55
 
116
- case node
117
- when Canon::Xml::Nodes::TextNode
118
- node.value
119
- when Canon::Xml::Node
120
- node.text_content
121
- when Nokogiri::XML::Node
122
- node.content.to_s
123
- when Moxml::Node
124
- node.content.to_s
125
- when String
126
- node
127
- else
128
- node.to_s
129
- end
56
+ NI.text_content(node)
130
57
  rescue StandardError
131
58
  nil
132
59
  end
133
-
134
- private_class_method :blank?, :text_node?, :extract_text_content,
135
- :empty_text_content_serialization_diff?
136
60
  end
137
61
  end
138
62
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "diff/lcs"
4
- require "diff/lcs/hunk"
3
+ require "diff/lcs" unless RUBY_ENGINE == "opal"
4
+ require "diff/lcs/hunk" unless RUBY_ENGINE == "opal"
5
5
  require_relative "../debug_output"
6
6
  require_relative "../theme"
7
7
 
@@ -233,7 +233,7 @@ module Canon
233
233
  # @return [Rainbow::Presenter] Colorized presenter
234
234
  def apply_color(presenter, color)
235
235
  valid_colors = normalize_color_for_rainbow(color)
236
- valid_colors.each { |c| presenter = presenter.send(c) }
236
+ valid_colors.each { |c| presenter = presenter.public_send(c) }
237
237
  presenter
238
238
  end
239
239
 
@@ -385,7 +385,7 @@ module Canon
385
385
  rainbow = Rainbow.new
386
386
  rainbow.enabled = true
387
387
  presenter = rainbow.wrap(text)
388
- valid_colors.each { |c| presenter = presenter.send(c) }
388
+ valid_colors.each { |c| presenter = presenter.public_send(c) }
389
389
  presenter.to_s
390
390
  end
391
391
 
@@ -675,13 +675,13 @@ module Canon
675
675
  # Handle Rainbow color methods - :bright_blue -> .blue.bright, etc.
676
676
  if color.to_s.start_with?("bright_")
677
677
  base_color = color.to_s.sub(/^bright_/, "").to_sym
678
- presenter = presenter.send(base_color).bright
678
+ presenter = presenter.public_send(base_color).bright
679
679
  elsif color.to_s.start_with?("light_")
680
680
  # Rainbow doesn't have light_ versions, treat as white on bg
681
681
  base_color = color.to_s.sub(/^light_/, "").to_sym
682
- presenter = presenter.send(base_color)
682
+ presenter = presenter.public_send(base_color)
683
683
  else
684
- presenter = presenter.send(color)
684
+ presenter = presenter.public_send(color)
685
685
  end
686
686
 
687
687
  presenter.to_s
@@ -253,7 +253,7 @@ module Canon
253
253
 
254
254
  if color && @use_color
255
255
  require "rainbow"
256
- Rainbow(visual).send(color).bright.to_s
256
+ Rainbow(visual).public_send(color).bright.to_s
257
257
  else
258
258
  visual
259
259
  end
@@ -151,7 +151,7 @@ module Canon
151
151
  # Apply color if provided and color is enabled
152
152
  if color && @use_color
153
153
  require "rainbow"
154
- Rainbow(visual).send(color).bright.to_s
154
+ Rainbow(visual).public_send(color).bright.to_s
155
155
  else
156
156
  visual
157
157
  end
@@ -1205,11 +1205,11 @@ informative: false, formatting: false)
1205
1205
  # Apply effect if specified (map :strikethrough to :cross_out for Rainbow)
1206
1206
  if effect
1207
1207
  rainbow_effect = effect == :strikethrough ? :cross_out : effect
1208
- presenter = presenter.send(rainbow_effect)
1208
+ presenter = presenter.public_send(rainbow_effect)
1209
1209
  end
1210
1210
 
1211
1211
  # Apply color if specified
1212
- presenter = presenter.send(color) if color
1212
+ presenter = presenter.public_send(color) if color
1213
1213
 
1214
1214
  presenter.to_s
1215
1215
  else
@@ -261,7 +261,7 @@ module Canon
261
261
 
262
262
  if color && @use_color
263
263
  require "rainbow"
264
- Rainbow(visual).send(color).bright.to_s
264
+ Rainbow(visual).public_send(color).bright.to_s
265
265
  else
266
266
  visual
267
267
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "paint"
3
+ require "paint" unless RUBY_ENGINE == "opal"
4
4
 
5
5
  module Canon
6
6
  class DiffFormatter
@@ -26,7 +26,7 @@ show_diffs: :all, theme: nil)
26
26
  # @return [String] Formatted output
27
27
  def format(differences, _format)
28
28
  # Handle both ComparisonResult (production) and Array (low-level tests)
29
- if differences.respond_to?(:equivalent?)
29
+ if differences.is_a?(Canon::Comparison::ComparisonResult)
30
30
  # ComparisonResult object
31
31
  return success_message if differences.equivalent?
32
32
 
@@ -130,15 +130,11 @@ show_diffs: :all, theme: nil)
130
130
  return differences if @show_diffs.nil? || @show_diffs == :all
131
131
 
132
132
  differences.select do |diff|
133
- # Handle both DiffNode objects and legacy Hash format
134
- is_normative = if diff.respond_to?(:normative?)
135
- diff.normative?
136
- elsif diff.is_a?(Hash) && diff.key?(:normative)
137
- diff[:normative]
138
- else
139
- # Default to normative if unknown
140
- true
141
- end
133
+ is_normative = begin
134
+ diff.normative?
135
+ rescue NoMethodError
136
+ diff.is_a?(Hash) && diff.key?(:normative) ? diff[:normative] : true
137
+ end
142
138
 
143
139
  case @show_diffs
144
140
  when :normative
@@ -207,9 +203,9 @@ show_diffs: :all, theme: nil)
207
203
  parts = []
208
204
  current = node
209
205
 
210
- while current.respond_to?(:name)
206
+ while Canon::XmlParsing.xml_node?(current) || current.is_a?(Canon::Xml::Node)
211
207
  parts.unshift(current.name) if current.name
212
- current = current.parent if current.respond_to?(:parent)
208
+ current = current.parent
213
209
  end
214
210
 
215
211
  parts.join(".")
@@ -274,9 +270,12 @@ show_diffs: :all, theme: nil)
274
270
 
275
271
  if diffs && !diffs.empty?
276
272
  # Render all differences at this path
273
+ has_children = value.is_a?(Hash) &&
274
+ (value.keys - [:__diffs__]).any?
275
+
277
276
  diffs.each_with_index do |diff, diff_idx|
278
- # Use proper connector for each diff
279
- current_connector = if diff_idx == diffs.length - 1
277
+ is_last_diff = diff_idx == diffs.length - 1
278
+ current_connector = if is_last_diff && !has_children
280
279
  connector
281
280
  else
282
281
  is_last_item ? "├── " : "├── "
@@ -286,6 +285,13 @@ show_diffs: :all, theme: nil)
286
285
  output << line
287
286
  @line_count += line.count("\n") + 1
288
287
  end
288
+
289
+ # Recurse into child diffs at this path
290
+ if has_children
291
+ subtree = render_tree(value, prefix: prefix + continuation,
292
+ is_last: is_last_item)
293
+ output << subtree
294
+ end
289
295
  else
290
296
  # Render intermediate path
291
297
  line = colorize("#{prefix}#{connector}#{key}:",
@@ -328,13 +334,13 @@ show_diffs: :all, theme: nil)
328
334
  # Handle bright_ colors: :bright_blue -> .blue.bright
329
335
  if c.to_s.start_with?("bright_")
330
336
  base = c.to_s.sub(/^bright_/, "").to_sym
331
- presenter = presenter.send(base).bright
337
+ presenter = presenter.public_send(base).bright
332
338
  elsif c.to_s.start_with?("light_")
333
339
  # Rainbow doesn't have light_ versions
334
340
  base = c.to_s.sub(/^light_/, "").to_sym
335
- presenter = presenter.send(base)
341
+ presenter = presenter.public_send(base)
336
342
  else
337
- presenter = presenter.send(c)
343
+ presenter = presenter.public_send(c)
338
344
  end
339
345
  end
340
346
  presenter.to_s
@@ -136,7 +136,16 @@ module Canon
136
136
  theme_color(:changed, :marker) || :yellow,
137
137
  )}"
138
138
  end
139
- when :structural_whitespace, :attribute_whitespace, :attribute_values
139
+ when :attribute_values
140
+ render_attribute_values_diffnode(diff_node, node1, node2, prefix,
141
+ output)
142
+ when :attribute_presence
143
+ render_attribute_presence_diffnode(diff_node, node1, node2, prefix,
144
+ output)
145
+ when :attribute_order
146
+ render_attribute_order_diffnode(diff_node, node1, node2, prefix,
147
+ output)
148
+ when :structural_whitespace, :attribute_whitespace
140
149
  output << "#{prefix}└── #{colorize(
141
150
  "[#{diff_node.dimension}: #{diff_node.reason}]",
142
151
  theme_color(:changed, :marker) || :yellow,
@@ -150,6 +159,115 @@ module Canon
150
159
  end
151
160
  end
152
161
 
162
+ # Render attribute value DiffNode with per-attribute before/after
163
+ def render_attribute_values_diffnode(diff_node, node1, node2, prefix,
164
+ output)
165
+ attrs1 = diff_node.attributes_before ||
166
+ extract_attributes_hash(node1)
167
+ attrs2 = diff_node.attributes_after ||
168
+ extract_attributes_hash(node2)
169
+ element_name = if node1
170
+ node1.name
171
+ else
172
+ (node2 ? node2.name : "?")
173
+ end
174
+
175
+ output << "#{prefix}└── #{colorize(
176
+ "Element: <#{element_name}>",
177
+ theme_color(:changed, :marker) || :yellow,
178
+ )}"
179
+
180
+ differing = find_differing_attributes(attrs1, attrs2)
181
+ differing.each_with_index do |name, i|
182
+ connector = i < differing.size - 1 ? "├──" : "└──"
183
+ val1 = attrs1[name].to_s
184
+ val2 = attrs2[name].to_s
185
+ output << "#{prefix} #{connector} " \
186
+ "#{colorize("#{name}:", :cyan)} " \
187
+ "#{colorize(val1,
188
+ theme_color(:removed, :content) || :red)} " \
189
+ "→ " \
190
+ "#{colorize(val2,
191
+ theme_color(:added, :content) || :green)}"
192
+ end
193
+ end
194
+
195
+ # Render attribute presence DiffNode with added/removed attributes
196
+ def render_attribute_presence_diffnode(diff_node, node1, node2, prefix,
197
+ output)
198
+ attrs1 = diff_node.attributes_before ||
199
+ extract_attributes_hash(node1)
200
+ attrs2 = diff_node.attributes_after ||
201
+ extract_attributes_hash(node2)
202
+ element_name = if node1
203
+ node1.name
204
+ else
205
+ (node2 ? node2.name : "?")
206
+ end
207
+
208
+ output << "#{prefix}└── #{colorize(
209
+ "Element: <#{element_name}>",
210
+ theme_color(:changed, :marker) || :yellow,
211
+ )}"
212
+
213
+ keys1 = attrs1.keys.to_set
214
+ keys2 = attrs2.keys.to_set
215
+ removed = (keys1 - keys2).sort
216
+ added = (keys2 - keys1).sort
217
+ items = removed.map { |k| [:removed, k, attrs1[k]] }
218
+ added.each { |k| items << [:added, k, attrs2[k]] }
219
+
220
+ items.each_with_index do |(type, name, val), i|
221
+ connector = i < items.size - 1 ? "├──" : "└──"
222
+ color = if type == :removed
223
+ theme_color(:removed,
224
+ :content) || :red
225
+ else
226
+ theme_color(
227
+ :added, :content
228
+ ) || :green
229
+ end
230
+ sign = type == :removed ? "-" : "+"
231
+ output << "#{prefix} #{connector} " \
232
+ "#{colorize("#{sign} #{name}=\"#{val}\"", color)}"
233
+ end
234
+ end
235
+
236
+ # Render attribute order DiffNode with before/after order
237
+ def render_attribute_order_diffnode(diff_node, node1, node2, prefix,
238
+ output)
239
+ attrs1 = diff_node.attributes_before ||
240
+ extract_attributes_hash(node1)
241
+ attrs2 = diff_node.attributes_after ||
242
+ extract_attributes_hash(node2)
243
+
244
+ order1 = attrs1.keys.join(", ")
245
+ order2 = attrs2.keys.join(", ")
246
+
247
+ output << "#{prefix}├── #{colorize(
248
+ "- [#{order1}]",
249
+ theme_color(:removed, :content) || :red,
250
+ )}"
251
+ output << "#{prefix}└── #{colorize(
252
+ "+ [#{order2}]",
253
+ theme_color(:added, :content) || :green,
254
+ )}"
255
+ end
256
+
257
+ # Extract attributes hash from a node
258
+ def extract_attributes_hash(node)
259
+ return {} unless node
260
+
261
+ Canon::Diff::NodeSerializer.extract_attributes(node) || {}
262
+ end
263
+
264
+ # Find attributes that differ between two attribute hashes
265
+ def find_differing_attributes(attrs1, attrs2)
266
+ (attrs1.keys | attrs2.keys).reject do |k|
267
+ attrs1[k.to_s] == attrs2[k.to_s]
268
+ end.sort
269
+ end
270
+
153
271
  # Render unequal elements
154
272
  def render_unequal_elements(diff, prefix, output)
155
273
  node1 = diff[:node1]
@@ -171,7 +289,8 @@ module Canon
171
289
  text2 = extract_text(node2)
172
290
 
173
291
  # Show parent element if available
174
- if node1.respond_to?(:parent) && node1.parent.respond_to?(:name)
292
+ if (Canon::XmlParsing.xml_node?(node1) || node1.is_a?(Canon::Xml::Node)) &&
293
+ node1.parent
175
294
  output << "#{prefix} #{colorize(
176
295
  "Element: <#{node1.parent.name}>",
177
296
  theme_color(:informative, :content) || :blue,
@@ -274,9 +393,9 @@ module Canon
274
393
  parts = []
275
394
  current = node
276
395
 
277
- while current.respond_to?(:name)
396
+ while Canon::XmlParsing.xml_node?(current) || current.is_a?(Canon::Xml::Node)
278
397
  parts.unshift(current.name) if current.name
279
- current = current.parent if current.respond_to?(:parent)
398
+ current = current.parent
280
399
  end
281
400
 
282
401
  parts.join(".")
@@ -287,13 +406,10 @@ module Canon
287
406
  # @param node [Object] Node with content or text
288
407
  # @return [String] Text content
289
408
  def extract_text(node)
290
- if node.respond_to?(:value)
291
- # CommentNode and similar nodes use .value
292
- node.value.to_s
293
- elsif node.respond_to?(:content)
294
- node.content.to_s
295
- elsif node.respond_to?(:text)
296
- node.text.to_s
409
+ if node.is_a?(Canon::Xml::Node)
410
+ node.text_content.to_s
411
+ elsif Canon::XmlParsing.xml_node?(node)
412
+ Canon::XmlParsing.text_content(node).to_s
297
413
  else
298
414
  ""
299
415
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "paint"
3
+ require "paint" unless RUBY_ENGINE == "opal"
4
4
 
5
5
  module Canon
6
6
  class DiffFormatter
@@ -21,9 +21,6 @@ module Canon
21
21
  # @param format [Symbol] Document format (:xml, :json, :yaml)
22
22
  # @return [String] Formatted diff output
23
23
  def format(differences, format)
24
- output = []
25
- output << colorize("Visual Diff:", :cyan, :bold)
26
-
27
24
  diffs_array = if differences.is_a?(Canon::Comparison::ComparisonResult)
28
25
  differences.differences
29
26
  else
@@ -37,8 +34,7 @@ module Canon
37
34
  show_diffs: @show_diffs,
38
35
  )
39
36
 
40
- output << formatter.format(diffs_array, format)
41
- output.join("\n")
37
+ formatter.format(diffs_array, format)
42
38
  end
43
39
 
44
40
  private