canon 0.1.3 → 0.1.5

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -1
  3. data/.rubocop_todo.yml +276 -7
  4. data/README.adoc +203 -138
  5. data/_config.yml +116 -0
  6. data/docs/ADVANCED_TOPICS.adoc +20 -0
  7. data/docs/BASIC_USAGE.adoc +16 -0
  8. data/docs/CHARACTER_VISUALIZATION.adoc +567 -0
  9. data/docs/CLI.adoc +493 -0
  10. data/docs/CUSTOMIZING_BEHAVIOR.adoc +19 -0
  11. data/docs/DIFF_ARCHITECTURE.adoc +435 -0
  12. data/docs/DIFF_FORMATTING.adoc +540 -0
  13. data/docs/FORMATS.adoc +447 -0
  14. data/docs/INDEX.adoc +222 -0
  15. data/docs/INPUT_VALIDATION.adoc +477 -0
  16. data/docs/MATCH_ARCHITECTURE.adoc +463 -0
  17. data/docs/MATCH_OPTIONS.adoc +719 -0
  18. data/docs/MODES.adoc +432 -0
  19. data/docs/NORMATIVE_INFORMATIVE_DIFFS.adoc +219 -0
  20. data/docs/OPTIONS.adoc +1387 -0
  21. data/docs/PREPROCESSING.adoc +491 -0
  22. data/docs/RSPEC.adoc +605 -0
  23. data/docs/RUBY_API.adoc +478 -0
  24. data/docs/SEMANTIC_DIFF_REPORT.adoc +528 -0
  25. data/docs/UNDERSTANDING_CANON.adoc +17 -0
  26. data/docs/VERBOSE.adoc +482 -0
  27. data/exe/canon +7 -0
  28. data/lib/canon/cli.rb +179 -0
  29. data/lib/canon/commands/diff_command.rb +195 -0
  30. data/lib/canon/commands/format_command.rb +113 -0
  31. data/lib/canon/comparison/base_comparator.rb +39 -0
  32. data/lib/canon/comparison/comparison_result.rb +79 -0
  33. data/lib/canon/comparison/html_comparator.rb +410 -0
  34. data/lib/canon/comparison/json_comparator.rb +212 -0
  35. data/lib/canon/comparison/match_options.rb +616 -0
  36. data/lib/canon/comparison/xml_comparator.rb +566 -0
  37. data/lib/canon/comparison/yaml_comparator.rb +93 -0
  38. data/lib/canon/comparison.rb +239 -0
  39. data/lib/canon/config.rb +172 -0
  40. data/lib/canon/diff/diff_block.rb +71 -0
  41. data/lib/canon/diff/diff_block_builder.rb +105 -0
  42. data/lib/canon/diff/diff_classifier.rb +46 -0
  43. data/lib/canon/diff/diff_context.rb +85 -0
  44. data/lib/canon/diff/diff_context_builder.rb +107 -0
  45. data/lib/canon/diff/diff_line.rb +77 -0
  46. data/lib/canon/diff/diff_node.rb +56 -0
  47. data/lib/canon/diff/diff_node_mapper.rb +148 -0
  48. data/lib/canon/diff/diff_report.rb +133 -0
  49. data/lib/canon/diff/diff_report_builder.rb +62 -0
  50. data/lib/canon/diff_formatter/by_line/base_formatter.rb +407 -0
  51. data/lib/canon/diff_formatter/by_line/html_formatter.rb +672 -0
  52. data/lib/canon/diff_formatter/by_line/json_formatter.rb +284 -0
  53. data/lib/canon/diff_formatter/by_line/simple_formatter.rb +190 -0
  54. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +860 -0
  55. data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +292 -0
  56. data/lib/canon/diff_formatter/by_object/base_formatter.rb +199 -0
  57. data/lib/canon/diff_formatter/by_object/json_formatter.rb +305 -0
  58. data/lib/canon/diff_formatter/by_object/xml_formatter.rb +248 -0
  59. data/lib/canon/diff_formatter/by_object/yaml_formatter.rb +17 -0
  60. data/lib/canon/diff_formatter/character_map.yml +197 -0
  61. data/lib/canon/diff_formatter/debug_output.rb +431 -0
  62. data/lib/canon/diff_formatter/diff_detail_formatter.rb +551 -0
  63. data/lib/canon/diff_formatter/legend.rb +141 -0
  64. data/lib/canon/diff_formatter.rb +520 -0
  65. data/lib/canon/errors.rb +56 -0
  66. data/lib/canon/formatters/html4_formatter.rb +17 -0
  67. data/lib/canon/formatters/html5_formatter.rb +17 -0
  68. data/lib/canon/formatters/html_formatter.rb +37 -0
  69. data/lib/canon/formatters/html_formatter_base.rb +163 -0
  70. data/lib/canon/formatters/json_formatter.rb +3 -0
  71. data/lib/canon/formatters/xml_formatter.rb +20 -55
  72. data/lib/canon/formatters/yaml_formatter.rb +4 -1
  73. data/lib/canon/pretty_printer/html.rb +57 -0
  74. data/lib/canon/pretty_printer/json.rb +25 -0
  75. data/lib/canon/pretty_printer/xml.rb +29 -0
  76. data/lib/canon/rspec_matchers.rb +222 -80
  77. data/lib/canon/validators/base_validator.rb +49 -0
  78. data/lib/canon/validators/html_validator.rb +138 -0
  79. data/lib/canon/validators/json_validator.rb +89 -0
  80. data/lib/canon/validators/xml_validator.rb +53 -0
  81. data/lib/canon/validators/yaml_validator.rb +73 -0
  82. data/lib/canon/version.rb +1 -1
  83. data/lib/canon/xml/attribute_handler.rb +80 -0
  84. data/lib/canon/xml/c14n.rb +36 -0
  85. data/lib/canon/xml/character_encoder.rb +38 -0
  86. data/lib/canon/xml/data_model.rb +225 -0
  87. data/lib/canon/xml/element_matcher.rb +196 -0
  88. data/lib/canon/xml/line_range_mapper.rb +158 -0
  89. data/lib/canon/xml/namespace_handler.rb +86 -0
  90. data/lib/canon/xml/node.rb +32 -0
  91. data/lib/canon/xml/nodes/attribute_node.rb +54 -0
  92. data/lib/canon/xml/nodes/comment_node.rb +23 -0
  93. data/lib/canon/xml/nodes/element_node.rb +56 -0
  94. data/lib/canon/xml/nodes/namespace_node.rb +38 -0
  95. data/lib/canon/xml/nodes/processing_instruction_node.rb +24 -0
  96. data/lib/canon/xml/nodes/root_node.rb +16 -0
  97. data/lib/canon/xml/nodes/text_node.rb +23 -0
  98. data/lib/canon/xml/processor.rb +151 -0
  99. data/lib/canon/xml/whitespace_normalizer.rb +72 -0
  100. data/lib/canon/xml/xml_base_handler.rb +188 -0
  101. data/lib/canon.rb +14 -3
  102. metadata +116 -21
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module Canon
6
+ class DiffFormatter
7
+ module ByObject
8
+ # JSON tree formatter for by-object diffs
9
+ # Handles Ruby object differences (hashes and arrays)
10
+ class JsonFormatter < BaseFormatter
11
+ # Render a diff node for JSON/Ruby object differences
12
+ #
13
+ # @param key [String] Hash key or array index
14
+ # @param diff [Hash] Difference information
15
+ # @param prefix [String] Tree prefix for indentation
16
+ # @param connector [String] Box-drawing connector character
17
+ # @return [String] Formatted diff node
18
+ def render_diff_node(key, diff, prefix, connector)
19
+ output = []
20
+
21
+ # Show full path if available (path in cyan, no color on tree structure)
22
+ path_display = if diff[:path] && !diff[:path].empty?
23
+ colorize(diff[:path].to_s, :cyan, :bold)
24
+ else
25
+ colorize(key.to_s, :cyan)
26
+ end
27
+
28
+ output << "#{prefix}#{connector}#{path_display}:"
29
+
30
+ # Determine continuation for nested values
31
+ continuation = connector.start_with?("├") ? "│ " : " "
32
+ value_prefix = prefix + continuation
33
+
34
+ diff_code = diff[:diff_code] || diff[:diff1]
35
+
36
+ case diff_code
37
+ when Comparison::MISSING_HASH_KEY
38
+ render_missing_key(diff, value_prefix, output)
39
+ when Comparison::UNEQUAL_PRIMITIVES
40
+ render_unequal_primitives(diff, value_prefix, output)
41
+ when Comparison::UNEQUAL_HASH_VALUES
42
+ render_unequal_hash_values(diff, value_prefix, output)
43
+ when Comparison::UNEQUAL_ARRAY_ELEMENTS
44
+ render_unequal_array_elements(diff, value_prefix, output)
45
+ when Comparison::UNEQUAL_ARRAY_LENGTHS
46
+ render_unequal_array_lengths(diff, value_prefix, output)
47
+ when Comparison::UNEQUAL_TYPES
48
+ render_unequal_types(diff, value_prefix, output)
49
+ else
50
+ # Fallback for unknown diff types
51
+ render_fallback(diff, value_prefix, output)
52
+ end
53
+
54
+ output.join("\n")
55
+ end
56
+
57
+ private
58
+
59
+ # Render missing hash key
60
+ def render_missing_key(diff, prefix, output)
61
+ if diff[:value1].nil?
62
+ # Key added in file2
63
+ if diff[:value2].is_a?(Hash) && !diff[:value2].empty?
64
+ output.concat(render_added_hash(diff[:value2], prefix))
65
+ else
66
+ value_str = format_value_for_diff(diff[:value2])
67
+ output << "#{prefix}└── + #{colorize(value_str, :green)}"
68
+ end
69
+ elsif diff[:value1].is_a?(Hash) && !diff[:value1].empty?
70
+ # Key removed in file2
71
+ output.concat(render_removed_hash(diff[:value1], prefix))
72
+ else
73
+ value_str = format_value_for_diff(diff[:value1])
74
+ output << "#{prefix}└── - #{colorize(value_str, :red)}"
75
+ end
76
+ end
77
+
78
+ # Render unequal primitives
79
+ def render_unequal_primitives(diff, prefix, output)
80
+ output.concat(render_value_diff(diff[:value1], diff[:value2],
81
+ prefix))
82
+ end
83
+
84
+ # Render unequal hash values
85
+ def render_unequal_hash_values(diff, prefix, output)
86
+ output.concat(render_value_diff(diff[:value1], diff[:value2],
87
+ prefix))
88
+ end
89
+
90
+ # Render unequal array elements
91
+ def render_unequal_array_elements(diff, prefix, output)
92
+ output.concat(render_value_diff(diff[:value1], diff[:value2],
93
+ prefix))
94
+ end
95
+
96
+ # Render unequal array lengths
97
+ def render_unequal_array_lengths(diff, prefix, output)
98
+ output.concat(render_value_diff(diff[:value1], diff[:value2],
99
+ prefix))
100
+ end
101
+
102
+ # Render unequal types
103
+ def render_unequal_types(diff, prefix, output)
104
+ output << "#{prefix}├── - #{colorize(
105
+ "#{diff[:value1].class.name}: #{format_value_for_diff(diff[:value1])}",
106
+ :red,
107
+ )}"
108
+ output << "#{prefix}└── + #{colorize(
109
+ "#{diff[:value2].class.name}: #{format_value_for_diff(diff[:value2])}",
110
+ :green,
111
+ )}"
112
+ end
113
+
114
+ # Render fallback for unknown diff types
115
+ def render_fallback(diff, prefix, output)
116
+ if diff[:value1] && diff[:value2]
117
+ output.concat(render_value_diff(diff[:value1], diff[:value2],
118
+ prefix))
119
+ elsif diff[:value1]
120
+ value_str = format_value_for_diff(diff[:value1])
121
+ output << "#{prefix}└── - #{colorize(value_str, :red)}"
122
+ elsif diff[:value2]
123
+ value_str = format_value_for_diff(diff[:value2])
124
+ output << "#{prefix}└── + #{colorize(value_str, :green)}"
125
+ else
126
+ output << "#{prefix}└── [UNKNOWN CHANGE]"
127
+ end
128
+ end
129
+
130
+ # Render an added hash with nested structure
131
+ def render_added_hash(hash, prefix)
132
+ output = []
133
+ sorted_keys = hash.keys.sort_by(&:to_s)
134
+
135
+ sorted_keys.each_with_index do |key, index|
136
+ is_last = (index == sorted_keys.length - 1)
137
+ connector = is_last ? "└──" : "├──"
138
+ continuation = is_last ? " " : "│ "
139
+
140
+ value = hash[key]
141
+ if value.is_a?(Hash) && !value.empty?
142
+ # Nested hash - recurse
143
+ output << "#{prefix}#{connector} + #{colorize(key.to_s, :green)}:"
144
+ output.concat(render_added_hash(value, prefix + continuation))
145
+ else
146
+ # Leaf value
147
+ value_str = format_value_for_diff(value)
148
+ output << "#{prefix}#{connector} + #{colorize(key.to_s,
149
+ :green)}: #{colorize(
150
+ value_str, :green
151
+ )}"
152
+ end
153
+ end
154
+
155
+ output
156
+ end
157
+
158
+ # Render a removed hash with nested structure
159
+ def render_removed_hash(hash, prefix)
160
+ output = []
161
+ sorted_keys = hash.keys.sort_by(&:to_s)
162
+
163
+ sorted_keys.each_with_index do |key, index|
164
+ is_last = (index == sorted_keys.length - 1)
165
+ connector = is_last ? "└──" : "├──"
166
+ continuation = is_last ? " " : "│ "
167
+
168
+ value = hash[key]
169
+ if value.is_a?(Hash) && !value.empty?
170
+ # Nested hash - recurse
171
+ output << "#{prefix}#{connector} - #{colorize(key.to_s, :red)}:"
172
+ output.concat(render_removed_hash(value, prefix + continuation))
173
+ else
174
+ # Leaf value
175
+ value_str = format_value_for_diff(value)
176
+ output << "#{prefix}#{connector} - #{colorize(key.to_s,
177
+ :red)}: #{colorize(
178
+ value_str, :red
179
+ )}"
180
+ end
181
+ end
182
+
183
+ output
184
+ end
185
+
186
+ # Render a detailed diff for two values
187
+ def render_value_diff(val1, val2, prefix)
188
+ output = []
189
+
190
+ # Handle arrays - show element-by-element comparison
191
+ if val1.is_a?(Array) && val2.is_a?(Array)
192
+ output.concat(render_array_diff(val1, val2, prefix))
193
+ elsif val1.is_a?(Hash) && val2.is_a?(Hash)
194
+ # For hashes, show summary (detailed comparison happens recursively)
195
+ val1_str = format_value_for_diff(val1)
196
+ val2_str = format_value_for_diff(val2)
197
+ output << "#{prefix}├── - #{colorize(val1_str, :red)}"
198
+ output << "#{prefix}└── + #{colorize(val2_str, :green)}"
199
+ else
200
+ # Primitives - show actual values
201
+ val1_str = format_value_for_diff(val1)
202
+ val2_str = format_value_for_diff(val2)
203
+ output << "#{prefix}├── - #{colorize(val1_str, :red)}"
204
+ output << "#{prefix}└── + #{colorize(val2_str, :green)}"
205
+ end
206
+
207
+ output
208
+ end
209
+
210
+ # Render array diff with element-by-element comparison
211
+ def render_array_diff(arr1, arr2, prefix)
212
+ output = []
213
+ max_len = [arr1.length, arr2.length].max
214
+ changes = []
215
+
216
+ (0...max_len).each do |i|
217
+ elem1 = i < arr1.length ? arr1[i] : nil
218
+ elem2 = i < arr2.length ? arr2[i] : nil
219
+
220
+ if elem1.nil?
221
+ # Element added
222
+ elem_str = format_value_for_diff(elem2)
223
+ changes << { type: :add, index: i, value: elem_str }
224
+ elsif elem2.nil?
225
+ # Element removed
226
+ elem_str = format_value_for_diff(elem1)
227
+ changes << { type: :remove, index: i, value: elem_str }
228
+ elsif elem1 != elem2
229
+ # Element changed
230
+ elem1_str = format_value_for_diff(elem1)
231
+ elem2_str = format_value_for_diff(elem2)
232
+ changes << { type: :change, index: i, old: elem1_str,
233
+ new: elem2_str }
234
+ end
235
+ # Skip if elements are equal
236
+ end
237
+
238
+ # Render changes with proper connectors
239
+ changes.each_with_index do |change, idx|
240
+ is_last = (idx == changes.length - 1)
241
+ connector = is_last ? "└──" : "├──"
242
+
243
+ case change[:type]
244
+ when :add
245
+ output << "#{prefix}#{connector} [#{change[:index]}] + #{colorize(
246
+ change[:value], :green
247
+ )}"
248
+ when :remove
249
+ output << "#{prefix}#{connector} [#{change[:index]}] - #{colorize(
250
+ change[:value], :red
251
+ )}"
252
+ when :change
253
+ output << "#{prefix}├── [#{change[:index]}] - #{colorize(
254
+ change[:old], :red
255
+ )}"
256
+ output << if is_last
257
+ "#{prefix}└── [#{change[:index]}] + #{colorize(
258
+ change[:new], :green
259
+ )}"
260
+ else
261
+ "#{prefix}├── [#{change[:index]}] + #{colorize(
262
+ change[:new], :green
263
+ )}"
264
+ end
265
+ end
266
+ end
267
+
268
+ output
269
+ end
270
+
271
+ # Format a value for diff display
272
+ def format_value_for_diff(value)
273
+ case value
274
+ when String
275
+ "\"#{value}\""
276
+ when Numeric, TrueClass, FalseClass
277
+ value.to_s
278
+ when NilClass
279
+ "nil"
280
+ when Array
281
+ if value.empty?
282
+ "[]"
283
+ elsif value.all? do |v|
284
+ v.is_a?(String) || v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass) || v.nil?
285
+ end
286
+ # Simple array - show inline
287
+ "[#{value.map { |v| format_value_for_diff(v) }.join(', ')}]"
288
+ else
289
+ # Complex array - show summary
290
+ "{Array with #{value.length} elements}"
291
+ end
292
+ when Hash
293
+ if value.empty?
294
+ "{}"
295
+ else
296
+ "{Hash with #{value.keys.length} keys: #{value.keys.take(3).map(&:to_s).join(', ')}#{value.keys.length > 3 ? '...' : ''}}"
297
+ end
298
+ else
299
+ value.inspect
300
+ end
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_formatter"
4
+
5
+ module Canon
6
+ class DiffFormatter
7
+ module ByObject
8
+ # XML/HTML tree formatter for by-object diffs
9
+ # Handles DOM node differences with proper path extraction
10
+ class XmlFormatter < BaseFormatter
11
+ # Render a diff node for XML/HTML DOM differences
12
+ #
13
+ # @param key [String] Node name or path segment
14
+ # @param diff [Hash] Difference information
15
+ # @param prefix [String] Tree prefix for indentation
16
+ # @param connector [String] Box-drawing connector character
17
+ # @return [String] Formatted diff node
18
+ def render_diff_node(key, diff, prefix, connector)
19
+ output = []
20
+
21
+ # Show full path if available (path in cyan, no color on tree structure)
22
+ path_display = if diff.is_a?(Hash) && diff[:path] && !diff[:path].empty?
23
+ colorize(diff[:path].to_s, :cyan, :bold)
24
+ else
25
+ colorize(key.to_s, :cyan)
26
+ end
27
+
28
+ output << "#{prefix}#{connector}#{path_display}:"
29
+
30
+ # Determine continuation for nested values
31
+ continuation = connector.start_with?("├") ? "│ " : " "
32
+ value_prefix = prefix + continuation
33
+
34
+ # Handle both DiffNode and Hash formats
35
+ if diff.is_a?(Canon::Diff::DiffNode)
36
+ # DiffNode format - render based on dimension
37
+ render_diffnode(diff, value_prefix, output)
38
+ else
39
+ # Hash format - use diff codes
40
+ diff_code = diff[:diff_code] || diff[:diff1]
41
+
42
+ case diff_code
43
+ when Comparison::UNEQUAL_ELEMENTS
44
+ render_unequal_elements(diff, value_prefix, output)
45
+ when Comparison::UNEQUAL_TEXT_CONTENTS
46
+ render_unequal_text(diff, value_prefix, output)
47
+ when Comparison::UNEQUAL_ATTRIBUTES
48
+ render_unequal_attributes(diff, value_prefix, output)
49
+ when Comparison::MISSING_ATTRIBUTE
50
+ render_missing_attribute(diff, value_prefix, output)
51
+ when Comparison::UNEQUAL_COMMENTS
52
+ render_unequal_comments(diff, value_prefix, output)
53
+ when Comparison::MISSING_NODE
54
+ render_missing_node(diff, value_prefix, output)
55
+ else
56
+ # Fallback for unknown diff types
57
+ render_fallback(diff, value_prefix, output)
58
+ end
59
+ end
60
+
61
+ output.join("\n")
62
+ end
63
+
64
+ # Add a difference to the tree structure
65
+ # Handles DOM nodes by extracting their path
66
+ #
67
+ # @param tree [Hash] Tree structure to add to
68
+ # @param path [String, Array] Path to the difference
69
+ # @param diff [Hash, DiffNode] Difference information
70
+ def add_to_tree(tree, path, diff)
71
+ # For DOM differences (Hash format), extract path from node
72
+ if diff.is_a?(Hash) && !diff.key?(:path) && (diff[:node1] || diff[:node2])
73
+ path = extract_dom_path(diff)
74
+ end
75
+
76
+ super(tree, path, diff)
77
+ end
78
+
79
+ private
80
+
81
+ # Render DiffNode object
82
+ def render_diffnode(diff_node, prefix, output)
83
+ # Extract nodes for display
84
+ node1 = diff_node.node1
85
+ node2 = diff_node.node2
86
+
87
+ # Display based on dimension
88
+ case diff_node.dimension
89
+ when :text_content
90
+ if node1 && node2
91
+ text1 = extract_text(node1)
92
+ text2 = extract_text(node2)
93
+ output << "#{prefix}├── - #{colorize(format_text_inline(text1),
94
+ :red)}"
95
+ output << "#{prefix}└── + #{colorize(format_text_inline(text2),
96
+ :green)}"
97
+ else
98
+ output << "#{prefix}└── #{colorize(
99
+ "[#{diff_node.dimension}: #{diff_node.reason}]", :yellow
100
+ )}"
101
+ end
102
+ when :structural_whitespace, :attribute_whitespace, :attribute_values
103
+ output << "#{prefix}└── #{colorize(
104
+ "[#{diff_node.dimension}: #{diff_node.reason}]", :yellow
105
+ )}"
106
+ else
107
+ # Fallback
108
+ output << "#{prefix}└── #{colorize(
109
+ "[#{diff_node.dimension}: #{diff_node.reason}]", :cyan
110
+ )}"
111
+ end
112
+ end
113
+
114
+ # Render unequal elements
115
+ def render_unequal_elements(diff, prefix, output)
116
+ node1 = diff[:node1]
117
+ node2 = diff[:node2]
118
+ output << "#{prefix}├── - #{colorize("<#{node1.name}>", :red)}"
119
+ output << "#{prefix}└── + #{colorize("<#{node2.name}>", :green)}"
120
+ end
121
+
122
+ # Render unequal text contents
123
+ def render_unequal_text(diff, prefix, output)
124
+ node1 = diff[:node1]
125
+ node2 = diff[:node2]
126
+
127
+ text1 = extract_text(node1)
128
+ text2 = extract_text(node2)
129
+
130
+ # Show parent element if available
131
+ if node1.respond_to?(:parent) && node1.parent.respond_to?(:name)
132
+ output << "#{prefix} #{colorize(
133
+ "Element: <#{node1.parent.name}>", :blue
134
+ )}"
135
+ end
136
+
137
+ output << "#{prefix}├── - #{colorize(format_text_inline(text1),
138
+ :red)}"
139
+ output << "#{prefix}└── + #{colorize(format_text_inline(text2),
140
+ :green)}"
141
+ end
142
+
143
+ # Render unequal attributes
144
+ def render_unequal_attributes(diff, prefix, output)
145
+ node1 = diff[:node1]
146
+ output << "#{prefix}└── #{colorize(
147
+ "Element: <#{node1.name}> [attributes differ]", :yellow
148
+ )}"
149
+ end
150
+
151
+ # Render missing attribute
152
+ def render_missing_attribute(diff, prefix, output)
153
+ node1 = diff[:node1]
154
+ output << "#{prefix}└── #{colorize(
155
+ "Element: <#{node1.name}> [attribute mismatch]", :yellow
156
+ )}"
157
+ end
158
+
159
+ # Render unequal comments
160
+ def render_unequal_comments(diff, prefix, output)
161
+ node1 = diff[:node1]
162
+ node2 = diff[:node2]
163
+
164
+ content1 = extract_text(node1)
165
+ content2 = extract_text(node2)
166
+
167
+ output << "#{prefix}├── - #{colorize(
168
+ "<!-- #{format_text_inline(content1)} -->", :red
169
+ )}"
170
+ output << "#{prefix}└── + #{colorize(
171
+ "<!-- #{format_text_inline(content2)} -->", :green
172
+ )}"
173
+ end
174
+
175
+ # Render missing node
176
+ def render_missing_node(diff, prefix, output)
177
+ output << if diff[:node1] && !diff[:node2]
178
+ "#{prefix}└── - #{colorize('[node deleted]', :red)}"
179
+ elsif diff[:node2] && !diff[:node1]
180
+ "#{prefix}└── + #{colorize('[node inserted]', :green)}"
181
+ else
182
+ "#{prefix}└── #{colorize('[node mismatch]', :yellow)}"
183
+ end
184
+ end
185
+
186
+ # Render fallback for unknown diff types
187
+ def render_fallback(diff, prefix, output)
188
+ if diff[:node1] && diff[:node2]
189
+ output << "#{prefix}├── - #{colorize('[file1 node]', :red)}"
190
+ output << "#{prefix}└── + #{colorize('[file2 node]', :green)}"
191
+ elsif diff[:node1]
192
+ output << "#{prefix}└── - #{colorize('[file1 only]', :red)}"
193
+ elsif diff[:node2]
194
+ output << "#{prefix}└── + #{colorize('[file2 only]', :green)}"
195
+ else
196
+ output << "#{prefix}└── #{colorize('[unknown change]', :yellow)}"
197
+ end
198
+ end
199
+
200
+ # Extract DOM path from a difference
201
+ #
202
+ # @param diff [Hash] Difference with node1 or node2
203
+ # @return [String] Path string
204
+ def extract_dom_path(diff)
205
+ node = diff[:node1] || diff[:node2]
206
+ return "" unless node
207
+
208
+ parts = []
209
+ current = node
210
+
211
+ while current.respond_to?(:name)
212
+ parts.unshift(current.name) if current.name
213
+ current = current.parent if current.respond_to?(:parent)
214
+ end
215
+
216
+ parts.join(".")
217
+ end
218
+
219
+ # Extract text from a node
220
+ #
221
+ # @param node [Object] Node with content or text
222
+ # @return [String] Text content
223
+ def extract_text(node)
224
+ if node.respond_to?(:content)
225
+ node.content.to_s
226
+ elsif node.respond_to?(:text)
227
+ node.text.to_s
228
+ else
229
+ ""
230
+ end
231
+ end
232
+
233
+ # Format text for inline display (truncate if too long)
234
+ #
235
+ # @param text [String] Text to format
236
+ # @return [String] Formatted text
237
+ def format_text_inline(text)
238
+ # Truncate long text
239
+ if text.length > 60
240
+ "\"#{text[0..57]}...\""
241
+ else
242
+ "\"#{text}\""
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "json_formatter"
4
+
5
+ module Canon
6
+ class DiffFormatter
7
+ module ByObject
8
+ # YAML tree formatter for by-object diffs
9
+ # Inherits from JsonFormatter since YAML and JSON share the same
10
+ # Ruby object structure (hashes and arrays)
11
+ class YamlFormatter < JsonFormatter
12
+ # YAML uses the same rendering logic as JSON since both formats
13
+ # represent Ruby objects (hashes and arrays) with the same structure
14
+ end
15
+ end
16
+ end
17
+ end