canon 0.1.7 → 0.1.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +69 -92
  3. data/README.adoc +13 -13
  4. data/docs/.lycheeignore +69 -0
  5. data/docs/Gemfile +1 -0
  6. data/docs/_config.yml +90 -1
  7. data/docs/advanced/diff-classification.adoc +82 -2
  8. data/docs/advanced/extending-canon.adoc +193 -0
  9. data/docs/features/match-options/index.adoc +239 -1
  10. data/docs/internals/diffnode-enrichment.adoc +611 -0
  11. data/docs/internals/index.adoc +251 -0
  12. data/docs/lychee.toml +13 -6
  13. data/docs/understanding/architecture.adoc +749 -33
  14. data/docs/understanding/comparison-pipeline.adoc +122 -0
  15. data/lib/canon/cache.rb +129 -0
  16. data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +68 -0
  17. data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +68 -0
  18. data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +171 -0
  19. data/lib/canon/comparison/dimensions/base_dimension.rb +107 -0
  20. data/lib/canon/comparison/dimensions/comments_dimension.rb +121 -0
  21. data/lib/canon/comparison/dimensions/element_position_dimension.rb +90 -0
  22. data/lib/canon/comparison/dimensions/registry.rb +77 -0
  23. data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +119 -0
  24. data/lib/canon/comparison/dimensions/text_content_dimension.rb +96 -0
  25. data/lib/canon/comparison/dimensions.rb +54 -0
  26. data/lib/canon/comparison/format_detector.rb +87 -0
  27. data/lib/canon/comparison/html_comparator.rb +70 -26
  28. data/lib/canon/comparison/html_compare_profile.rb +8 -2
  29. data/lib/canon/comparison/html_parser.rb +80 -0
  30. data/lib/canon/comparison/json_comparator.rb +12 -0
  31. data/lib/canon/comparison/json_parser.rb +19 -0
  32. data/lib/canon/comparison/markup_comparator.rb +293 -0
  33. data/lib/canon/comparison/match_options/base_resolver.rb +150 -0
  34. data/lib/canon/comparison/match_options/json_resolver.rb +82 -0
  35. data/lib/canon/comparison/match_options/xml_resolver.rb +151 -0
  36. data/lib/canon/comparison/match_options/yaml_resolver.rb +87 -0
  37. data/lib/canon/comparison/match_options.rb +68 -463
  38. data/lib/canon/comparison/profile_definition.rb +149 -0
  39. data/lib/canon/comparison/ruby_object_comparator.rb +180 -0
  40. data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +7 -10
  41. data/lib/canon/comparison/whitespace_sensitivity.rb +208 -0
  42. data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +177 -0
  43. data/lib/canon/comparison/xml_comparator/attribute_filter.rb +136 -0
  44. data/lib/canon/comparison/xml_comparator/child_comparison.rb +197 -0
  45. data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +115 -0
  46. data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +186 -0
  47. data/lib/canon/comparison/xml_comparator/node_parser.rb +79 -0
  48. data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +102 -0
  49. data/lib/canon/comparison/xml_comparator.rb +97 -684
  50. data/lib/canon/comparison/xml_node_comparison.rb +319 -0
  51. data/lib/canon/comparison/xml_parser.rb +19 -0
  52. data/lib/canon/comparison/yaml_comparator.rb +3 -3
  53. data/lib/canon/comparison.rb +265 -110
  54. data/lib/canon/diff/diff_classifier.rb +101 -2
  55. data/lib/canon/diff/diff_node.rb +32 -2
  56. data/lib/canon/diff/formatting_detector.rb +1 -1
  57. data/lib/canon/diff/node_serializer.rb +191 -0
  58. data/lib/canon/diff/path_builder.rb +143 -0
  59. data/lib/canon/diff_formatter/by_line/base_formatter.rb +251 -0
  60. data/lib/canon/diff_formatter/by_line/html_formatter.rb +6 -248
  61. data/lib/canon/diff_formatter/by_line/xml_formatter.rb +38 -229
  62. data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +30 -0
  63. data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +579 -0
  64. data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +121 -0
  65. data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +253 -0
  66. data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +61 -0
  67. data/lib/canon/diff_formatter/diff_detail_formatter.rb +31 -1028
  68. data/lib/canon/diff_formatter.rb +1 -1
  69. data/lib/canon/rspec_matchers.rb +38 -9
  70. data/lib/canon/tree_diff/operation_converter.rb +92 -338
  71. data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +71 -0
  72. data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +103 -0
  73. data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +168 -0
  74. data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +188 -0
  75. data/lib/canon/version.rb +1 -1
  76. data/lib/canon/xml/data_model.rb +24 -13
  77. metadata +48 -2
@@ -0,0 +1,579 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ class DiffFormatter
5
+ module DiffDetailFormatterHelpers
6
+ # Dimension-specific formatting
7
+ #
8
+ # Formats details for specific comparison dimensions.
9
+ module DimensionFormatter
10
+ # Format dimension details based on diff type
11
+ #
12
+ # @param diff [DiffNode, Hash] Difference node
13
+ # @param use_color [Boolean] Whether to use colors
14
+ # @return [String] Formatted dimension details
15
+ def self.format_dimension_details(diff, use_color)
16
+ dimension = extract_dimension(diff)
17
+
18
+ case dimension
19
+ when :namespace_uri
20
+ format_namespace_uri_details(diff, use_color)
21
+ when :namespace_declarations
22
+ format_namespace_declarations_details(diff, use_color)
23
+ when :element_structure
24
+ format_element_structure_details(diff, use_color)
25
+ when :attribute_presence
26
+ format_attribute_presence_details(diff, use_color)
27
+ when :attribute_values
28
+ format_attribute_values_details(diff, use_color)
29
+ when :attribute_order
30
+ format_attribute_order_details(diff, use_color)
31
+ when :text_content
32
+ format_text_content_details(diff, use_color)
33
+ when :structural_whitespace
34
+ format_structural_whitespace_details(diff, use_color)
35
+ when :comments
36
+ format_comments_details(diff, use_color)
37
+ when :hash_diff
38
+ format_hash_diff_details(diff, use_color)
39
+ else
40
+ format_fallback_details(diff, use_color)
41
+ end
42
+ end
43
+
44
+ # Format namespace URI differences
45
+ #
46
+ # @param diff [DiffNode, Hash] Difference node
47
+ # @param use_color [Boolean] Whether to use colors
48
+ # @return [Array] Tuple of [detail1, detail2, changes]
49
+ def self.format_namespace_uri_details(diff, use_color)
50
+ require_relative "color_helper"
51
+ require_relative "node_utils"
52
+
53
+ node1 = extract_node1(diff)
54
+ node2 = extract_node2(diff)
55
+
56
+ # Use NamespaceHelper for consistent formatting
57
+ ns1_display = Canon::Xml::NamespaceHelper.format_namespace(
58
+ NodeUtils.get_namespace_uri_for_display(node1),
59
+ )
60
+ ns2_display = Canon::Xml::NamespaceHelper.format_namespace(
61
+ NodeUtils.get_namespace_uri_for_display(node2),
62
+ )
63
+
64
+ element_name = NodeUtils.get_element_name_for_display(node1) || "element"
65
+
66
+ detail1 = "<#{element_name}> #{ColorHelper.colorize(ns1_display,
67
+ :cyan, use_color)}"
68
+ detail2 = "<#{element_name}> #{ColorHelper.colorize(ns2_display,
69
+ :cyan, use_color)}"
70
+
71
+ changes = "Namespace differs: #{ColorHelper.colorize(ns1_display,
72
+ :red, use_color)} → " \
73
+ "#{ColorHelper.colorize(ns2_display, :green, use_color)}"
74
+
75
+ [detail1, detail2, changes]
76
+ end
77
+
78
+ # Format namespace declaration differences
79
+ #
80
+ # @param diff [DiffNode, Hash] Difference node
81
+ # @param use_color [Boolean] Whether to use colors
82
+ # @return [Array] Tuple of [detail1, detail2, changes]
83
+ def self.format_namespace_declarations_details(diff, use_color)
84
+ require_relative "color_helper"
85
+ require_relative "node_utils"
86
+ require_relative "text_utils"
87
+
88
+ node1 = extract_node1(diff)
89
+ node2 = extract_node2(diff)
90
+
91
+ # Extract namespace declarations from both nodes
92
+ ns_decls1 = extract_namespace_declarations_from_node(node1)
93
+ ns_decls2 = extract_namespace_declarations_from_node(node2)
94
+
95
+ element_name = NodeUtils.get_element_name_for_display(node1) || "element"
96
+
97
+ # Format namespace declarations for display
98
+ detail1 = if ns_decls1.empty?
99
+ "<#{element_name}> #{ColorHelper.colorize(
100
+ '(no namespace declarations)', :red, use_color
101
+ )}"
102
+ else
103
+ ns_str = ns_decls1.map do |prefix, uri|
104
+ attr_name = prefix.empty? ? "xmlns" : "xmlns:#{prefix}"
105
+ "#{attr_name}=\"#{uri}\""
106
+ end.join(" ")
107
+ "<#{element_name}> #{ns_str}"
108
+ end
109
+
110
+ detail2 = if ns_decls2.empty?
111
+ "<#{element_name}> #{ColorHelper.colorize(
112
+ '(no namespace declarations)', :green, use_color
113
+ )}"
114
+ else
115
+ ns_str = ns_decls2.map do |prefix, uri|
116
+ attr_name = prefix.empty? ? "xmlns" : "xmlns:#{prefix}"
117
+ "#{attr_name}=\"#{uri}\""
118
+ end.join(" ")
119
+ "<#{element_name}> #{ns_str}"
120
+ end
121
+
122
+ # Analyze changes
123
+ missing = ns_decls1.keys - ns_decls2.keys
124
+ extra = ns_decls2.keys - ns_decls1.keys
125
+ changed = ns_decls1.select do |k, v|
126
+ ns_decls2[k] && ns_decls2[k] != v
127
+ end.keys
128
+
129
+ # Format changes
130
+ changes_parts = []
131
+ if missing.any?
132
+ missing_str = missing.map do |prefix|
133
+ attr_name = prefix.empty? ? "xmlns" : "xmlns:#{prefix}"
134
+ ColorHelper.colorize("-#{attr_name}=\"#{ns_decls1[prefix]}\"",
135
+ :red, use_color)
136
+ end.join(", ")
137
+ changes_parts << "Removed: #{missing_str}"
138
+ end
139
+ if extra.any?
140
+ extra_str = extra.map do |prefix|
141
+ attr_name = prefix.empty? ? "xmlns" : "xmlns:#{prefix}"
142
+ ColorHelper.colorize("+#{attr_name}=\"#{ns_decls2[prefix]}\"",
143
+ :green, use_color)
144
+ end.join(", ")
145
+ changes_parts << "Added: #{extra_str}"
146
+ end
147
+ if changed.any?
148
+ changed_str = changed.map do |prefix|
149
+ attr_name = prefix.empty? ? "xmlns" : "xmlns:#{prefix}"
150
+ "#{ColorHelper.colorize(attr_name, :cyan, use_color)}: " \
151
+ "\"#{ns_decls1[prefix]}\" → \"#{ns_decls2[prefix]}\""
152
+ end.join(", ")
153
+ changes_parts << "Changed: #{changed_str}"
154
+ end
155
+
156
+ changes = changes_parts.join(" | ")
157
+
158
+ [detail1, detail2, changes]
159
+ end
160
+
161
+ # Format element structure differences
162
+ #
163
+ # @param diff [DiffNode, Hash] Difference node
164
+ # @param use_color [Boolean] Whether to use colors
165
+ # @return [Array] Tuple of [detail1, detail2, changes]
166
+ def self.format_element_structure_details(diff, use_color)
167
+ require_relative "color_helper"
168
+ require_relative "node_utils"
169
+
170
+ node1 = extract_node1(diff)
171
+ node2 = extract_node2(diff)
172
+
173
+ name1 = NodeUtils.get_element_name_for_display(node1)
174
+ name2 = NodeUtils.get_element_name_for_display(node2)
175
+
176
+ detail1 = "<#{ColorHelper.colorize(name1, :red, use_color)}>"
177
+ detail2 = "<#{ColorHelper.colorize(name2, :green, use_color)}>"
178
+
179
+ changes = "Element differs: #{ColorHelper.colorize(name1, :red,
180
+ use_color)} → " \
181
+ "#{ColorHelper.colorize(name2, :green, use_color)}"
182
+
183
+ [detail1, detail2, changes]
184
+ end
185
+
186
+ # Format attribute presence differences
187
+ #
188
+ # @param diff [DiffNode, Hash] Difference node
189
+ # @param use_color [Boolean] Whether to use colors
190
+ # @return [Array] Tuple of [detail1, detail2, changes]
191
+ def self.format_attribute_presence_details(diff, use_color)
192
+ require_relative "color_helper"
193
+ require_relative "node_utils"
194
+
195
+ node1 = extract_node1(diff)
196
+ node2 = extract_node2(diff)
197
+
198
+ attrs1 = NodeUtils.get_attribute_names(node1).sort
199
+ attrs2 = NodeUtils.get_attribute_names(node2).sort
200
+
201
+ missing = attrs1 - attrs2
202
+ extra = attrs2 - attrs1
203
+
204
+ # Format the attribute lists
205
+ detail1 = if attrs1.empty?
206
+ ColorHelper.colorize("(no attributes)", :red, use_color)
207
+ else
208
+ attrs1.map do |a|
209
+ ColorHelper.colorize(a, :red, use_color)
210
+ end.join(", ")
211
+ end
212
+
213
+ detail2 = if attrs2.empty?
214
+ ColorHelper.colorize("(no attributes)", :green, use_color)
215
+ else
216
+ attrs2.map do |a|
217
+ ColorHelper.colorize(a, :green, use_color)
218
+ end.join(", ")
219
+ end
220
+
221
+ # Build changes description
222
+ changes_parts = []
223
+ if missing.any?
224
+ missing_str = missing.map do |a|
225
+ ColorHelper.colorize("-#{a}", :red, use_color)
226
+ end.join(", ")
227
+ changes_parts << "Missing: #{missing_str}"
228
+ end
229
+ if extra.any?
230
+ extra_str = extra.map do |a|
231
+ ColorHelper.colorize("+#{a}", :green, use_color)
232
+ end.join(", ")
233
+ changes_parts << "Extra: #{extra_str}"
234
+ end
235
+
236
+ changes = changes_parts.join(" | ")
237
+
238
+ [detail1, detail2, changes]
239
+ end
240
+
241
+ # Format attribute value differences
242
+ #
243
+ # @param diff [DiffNode, Hash] Difference node
244
+ # @param use_color [Boolean] Whether to use colors
245
+ # @return [Array] Tuple of [detail1, detail2, changes]
246
+ def self.format_attribute_values_details(diff, use_color)
247
+ require_relative "color_helper"
248
+ require_relative "node_utils"
249
+
250
+ node1 = extract_node1(diff)
251
+ node2 = extract_node2(diff)
252
+
253
+ differing = NodeUtils.find_all_differing_attributes(node1, node2)
254
+
255
+ # Format all differing attributes
256
+ attrs1_parts = []
257
+ attrs2_parts = []
258
+ changes_parts = []
259
+
260
+ differing.each do |attr_name|
261
+ val1 = NodeUtils.get_attribute_value(node1, attr_name)
262
+ val2 = NodeUtils.get_attribute_value(node2, attr_name)
263
+
264
+ attrs1_parts << "#{attr_name}=#{format_json_value(val1)}"
265
+ attrs2_parts << "#{attr_name}=#{format_json_value(val2)}"
266
+ changes_parts << "#{attr_name}: #{ColorHelper.colorize(
267
+ format_json_value(val1), :red, use_color
268
+ )} → " \
269
+ "#{ColorHelper.colorize(format_json_value(val2),
270
+ :green, use_color)}"
271
+ end
272
+
273
+ detail1 = attrs1_parts.join(", ")
274
+ detail2 = attrs2_parts.join(", ")
275
+ changes = changes_parts.join(" | ")
276
+
277
+ [detail1, detail2, changes]
278
+ end
279
+
280
+ # Format attribute order differences
281
+ #
282
+ # @param diff [DiffNode, Hash] Difference node
283
+ # @param use_color [Boolean] Whether to use colors
284
+ # @return [Array] Tuple of [detail1, detail2, changes]
285
+ def self.format_attribute_order_details(diff, use_color)
286
+ require_relative "color_helper"
287
+ require_relative "node_utils"
288
+
289
+ node1 = extract_node1(diff)
290
+ node2 = extract_node2(diff)
291
+
292
+ order1 = NodeUtils.get_attribute_names_in_order(node1)
293
+ order2 = NodeUtils.get_attribute_names_in_order(node2)
294
+
295
+ detail1 = order1.map do |a|
296
+ ColorHelper.colorize(a, :red, use_color)
297
+ end.join(", ")
298
+ detail2 = order2.map do |a|
299
+ ColorHelper.colorize(a, :green, use_color)
300
+ end.join(", ")
301
+
302
+ changes = "Order differs: #{ColorHelper.colorize(order1.join(', '),
303
+ :red, use_color)} → " \
304
+ "#{ColorHelper.colorize(order2.join(', '), :green,
305
+ use_color)}"
306
+
307
+ [detail1, detail2, changes]
308
+ end
309
+
310
+ # Format text content differences
311
+ #
312
+ # @param diff [DiffNode, Hash] Difference node
313
+ # @param use_color [Boolean] Whether to use colors
314
+ # @return [Array] Tuple of [detail1, detail2, changes]
315
+ def self.format_text_content_details(diff, use_color)
316
+ require_relative "color_helper"
317
+ require_relative "node_utils"
318
+ require_relative "text_utils"
319
+
320
+ node1 = extract_node1(diff)
321
+ node2 = extract_node2(diff)
322
+
323
+ text1 = NodeUtils.get_node_text(node1)
324
+ text2 = NodeUtils.get_node_text(node2)
325
+
326
+ if NodeUtils.inside_preserve_element?(node1) || NodeUtils.inside_preserve_element?(node2)
327
+ detail1 = ColorHelper.colorize(
328
+ TextUtils.visualize_whitespace(text1), :red, use_color
329
+ )
330
+ detail2 = ColorHelper.colorize(
331
+ TextUtils.visualize_whitespace(text2), :green, use_color
332
+ )
333
+ else
334
+ detail1 = ColorHelper.colorize(format_json_value(text1), :red,
335
+ use_color)
336
+ detail2 = ColorHelper.colorize(format_json_value(text2), :green,
337
+ use_color)
338
+ end
339
+
340
+ changes = "Content differs: #{detail1} → #{detail2}"
341
+
342
+ [detail1, detail2, changes]
343
+ end
344
+
345
+ # Format structural whitespace differences
346
+ #
347
+ # @param diff [DiffNode, Hash] Difference node
348
+ # @param use_color [Boolean] Whether to use colors
349
+ # @return [Array] Tuple of [detail1, detail2, changes]
350
+ def self.format_structural_whitespace_details(diff, use_color)
351
+ require_relative "color_helper"
352
+ require_relative "node_utils"
353
+ require_relative "text_utils"
354
+
355
+ node1 = extract_node1(diff)
356
+ node2 = extract_node2(diff)
357
+
358
+ text1 = NodeUtils.get_node_text(node1)
359
+ text2 = NodeUtils.get_node_text(node2)
360
+
361
+ detail1 = ColorHelper.colorize(TextUtils.visualize_whitespace(text1),
362
+ :red, use_color)
363
+ detail2 = ColorHelper.colorize(TextUtils.visualize_whitespace(text2),
364
+ :green, use_color)
365
+
366
+ changes = "Whitespace differs: #{detail1} → #{detail2}"
367
+
368
+ [detail1, detail2, changes]
369
+ end
370
+
371
+ # Format comment differences
372
+ #
373
+ # @param diff [DiffNode, Hash] Difference node
374
+ # @param use_color [Boolean] Whether to use colors
375
+ # @return [Array] Tuple of [detail1, detail2, changes]
376
+ def self.format_comments_details(diff, use_color)
377
+ require_relative "color_helper"
378
+ require_relative "node_utils"
379
+
380
+ node1 = extract_node1(diff)
381
+ node2 = extract_node2(diff)
382
+
383
+ text1 = NodeUtils.get_node_text(node1)
384
+ text2 = NodeUtils.get_node_text(node2)
385
+
386
+ detail1 = ColorHelper.colorize(format_json_value(text1), :red,
387
+ use_color)
388
+ detail2 = ColorHelper.colorize(format_json_value(text2), :green,
389
+ use_color)
390
+
391
+ changes = "Comment differs: #{detail1} → #{detail2}"
392
+
393
+ [detail1, detail2, changes]
394
+ end
395
+
396
+ # Format hash differences
397
+ #
398
+ # @param diff [DiffNode, Hash] Difference node
399
+ # @param use_color [Boolean] Whether to use colors
400
+ # @return [Array] Tuple of [detail1, detail2, changes]
401
+ def self.format_hash_diff_details(diff, use_color)
402
+ require_relative "color_helper"
403
+
404
+ detail1 = if diff.is_a?(Hash) && diff[:value1]
405
+ ColorHelper.colorize(format_json_value(diff[:value1]),
406
+ :red, use_color)
407
+ else
408
+ ColorHelper.colorize("(no value)", :red, use_color)
409
+ end
410
+
411
+ detail2 = if diff.is_a?(Hash) && diff[:value2]
412
+ ColorHelper.colorize(format_json_value(diff[:value2]),
413
+ :green, use_color)
414
+ else
415
+ ColorHelper.colorize("(no value)", :green, use_color)
416
+ end
417
+
418
+ path_str = if diff.is_a?(Hash) && diff[:path]
419
+ " at #{diff[:path]}"
420
+ else
421
+ ""
422
+ end
423
+
424
+ changes = "Value differs#{path_str}: #{detail1} → #{detail2}"
425
+
426
+ [detail1, detail2, changes]
427
+ end
428
+
429
+ # Format fallback details for unknown dimensions
430
+ #
431
+ # @param diff [DiffNode, Hash] Difference node
432
+ # @param use_color [Boolean] Whether to use colors
433
+ # @return [Array] Tuple of [detail1, detail2, changes]
434
+ def self.format_fallback_details(diff, use_color)
435
+ require_relative "color_helper"
436
+ require_relative "node_utils"
437
+
438
+ node1 = extract_node1(diff)
439
+ node2 = extract_node2(diff)
440
+
441
+ detail1 = ColorHelper.colorize(NodeUtils.format_node_brief(node1),
442
+ :red, use_color)
443
+ detail2 = ColorHelper.colorize(NodeUtils.format_node_brief(node2),
444
+ :green, use_color)
445
+
446
+ dimension = extract_dimension(diff)
447
+ changes = "#{dimension.to_s.capitalize} differs: #{detail1} → #{detail2}"
448
+
449
+ [detail1, detail2, changes]
450
+ end
451
+
452
+ # Format JSON value for display
453
+ #
454
+ # @param value [Object] Value to format
455
+ # @return [String] Formatted value
456
+ def self.format_json_value(value)
457
+ require "json"
458
+
459
+ case value
460
+ when String
461
+ # Use JSON.generate for proper, well-tested escaping
462
+ JSON.generate(value)
463
+ when Numeric, TrueClass, FalseClass, NilClass
464
+ value.to_s
465
+ else
466
+ # Use JSON.pretty_generate for complex types
467
+ JSON.pretty_generate(value)
468
+ end
469
+ rescue StandardError
470
+ value.inspect
471
+ end
472
+
473
+ # Extract dimension from diff
474
+ #
475
+ # @param diff [DiffNode, Hash] Difference node
476
+ # @return [Symbol] Dimension
477
+ def self.extract_dimension(diff)
478
+ if diff.respond_to?(:dimension)
479
+ diff.dimension
480
+ elsif diff.is_a?(Hash)
481
+ diff[:dimension] || diff[:diff_code] || :unknown
482
+ else
483
+ :unknown
484
+ end
485
+ end
486
+
487
+ # Extract node1 from diff
488
+ #
489
+ # @param diff [DiffNode, Hash] Difference node
490
+ # @return [Object] Node1
491
+ def self.extract_node1(diff)
492
+ if diff.respond_to?(:node1)
493
+ diff.node1
494
+ elsif diff.is_a?(Hash)
495
+ diff[:node1]
496
+ end
497
+ end
498
+
499
+ # Extract node2 from diff
500
+ #
501
+ # @param diff [DiffNode, Hash] Difference node
502
+ # @return [Object] Node2
503
+ def self.extract_node2(diff)
504
+ if diff.respond_to?(:node2)
505
+ diff.node2
506
+ elsif diff.is_a?(Hash)
507
+ diff[:node2]
508
+ end
509
+ end
510
+
511
+ # Extract namespace declarations from a node
512
+ #
513
+ # @param node [Object] Node to extract from
514
+ # @return [Hash] Namespace declarations
515
+ def self.extract_namespace_declarations_from_node(node)
516
+ return {} unless node
517
+
518
+ declarations = {}
519
+
520
+ # Handle Canon::Xml::Node (uses namespace_nodes)
521
+ if node.respond_to?(:namespace_nodes)
522
+ node.namespace_nodes.each do |ns|
523
+ next if ns.prefix == "xml" && ns.uri == "http://www.w3.org/XML/1998/namespace"
524
+
525
+ prefix = ns.prefix || ""
526
+ declarations[prefix] = ns.uri
527
+ end
528
+ return declarations
529
+ end
530
+
531
+ # Handle Nokogiri/Moxml nodes (use attributes)
532
+ raw_attrs = node.respond_to?(:attribute_nodes) ? node.attribute_nodes : node.attributes
533
+
534
+ if raw_attrs.is_a?(Array)
535
+ raw_attrs.each do |attr|
536
+ name = attr.respond_to?(:name) ? attr.name : attr.to_s
537
+ value = attr.respond_to?(:value) ? attr.value : attr.to_s
538
+
539
+ if namespace_declaration?(name)
540
+ prefix = name == "xmlns" ? "" : name.split(":", 2)[1]
541
+ declarations[prefix] = value
542
+ end
543
+ end
544
+ elsif raw_attrs.respond_to?(:each)
545
+ raw_attrs.each do |key, val|
546
+ name = if key.is_a?(String)
547
+ key
548
+ else
549
+ (key.respond_to?(:name) ? key.name : key.to_s)
550
+ end
551
+ value = if val.respond_to?(:value)
552
+ val.value
553
+ elsif val.respond_to?(:content)
554
+ val.content
555
+ else
556
+ val.to_s
557
+ end
558
+
559
+ if namespace_declaration?(name)
560
+ prefix = name == "xmlns" ? "" : name.split(":", 2)[1]
561
+ declarations[prefix] = value
562
+ end
563
+ end
564
+ end
565
+
566
+ declarations
567
+ end
568
+
569
+ # Check if an attribute name is a namespace declaration
570
+ #
571
+ # @param name [String] Attribute name
572
+ # @return [Boolean] true if namespace declaration
573
+ def self.namespace_declaration?(name)
574
+ name == "xmlns" || name.to_s.start_with?("xmlns:")
575
+ end
576
+ end
577
+ end
578
+ end
579
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../xml/namespace_helper"
4
+
5
+ module Canon
6
+ class DiffFormatter
7
+ module DiffDetailFormatterHelpers
8
+ # Location extraction from diffs
9
+ #
10
+ # Extracts and formats location information (XPath, file position).
11
+ module LocationExtractor
12
+ # Extract location information from a diff
13
+ #
14
+ # @param diff [DiffNode, Hash] Difference node
15
+ # @return [String] Location string
16
+ def self.extract_location(diff)
17
+ return "" unless diff
18
+
19
+ # Get the appropriate node based on diff type
20
+ node = if diff.respond_to?(:node1)
21
+ diff.node1 || diff.node2
22
+ elsif diff.is_a?(Hash)
23
+ diff[:node1] || diff[:node2]
24
+ end
25
+
26
+ return "" unless node
27
+
28
+ xpath = extract_xpath(node)
29
+ xpath.empty? ? "" : "Location: #{xpath}"
30
+ end
31
+
32
+ # Extract XPath from a node
33
+ #
34
+ # @param node [Object] Node to extract XPath from
35
+ # @return [String] XPath string
36
+ def self.extract_xpath(node)
37
+ return "" unless node
38
+
39
+ # Use PathBuilder if available
40
+ if defined?(Canon::Diff::PathBuilder)
41
+ begin
42
+ path = Canon::Diff::PathBuilder.build_path(node)
43
+ return path unless path.nil? || path.empty?
44
+ rescue StandardError
45
+ # Fall through to manual extraction
46
+ end
47
+ end
48
+
49
+ # Manual XPath extraction
50
+ manual_xpath(node)
51
+ end
52
+
53
+ # Manual XPath extraction fallback
54
+ #
55
+ # @param node [Object] Node to extract XPath from
56
+ # @return [String] XPath string
57
+ def self.manual_xpath(node)
58
+ return "" unless node
59
+
60
+ parts = []
61
+ current = node
62
+
63
+ while current
64
+ break unless current.respond_to?(:name)
65
+
66
+ name = current.name
67
+ break if name.nil? || name.empty?
68
+
69
+ # Calculate position among siblings
70
+ index = calculate_sibling_index(current, name)
71
+ parts.unshift("#{name}[#{index}]")
72
+
73
+ # Move to parent
74
+ current = if current.respond_to?(:parent)
75
+ current.parent
76
+ elsif current.respond_to?(:parent_node)
77
+ current.parent_node
78
+ end
79
+
80
+ # Stop at document root
81
+ break if current.respond_to?(:document) && current == current.document
82
+ end
83
+
84
+ parts.empty? ? "" : "/#{parts.join('/')}"
85
+ end
86
+
87
+ # Calculate sibling index for XPath
88
+ #
89
+ # @param node [Object] Node to calculate index for
90
+ # @param name [String] Node name
91
+ # @return [Integer] 1-based index
92
+ def self.calculate_sibling_index(node, name)
93
+ return 1 unless node.respond_to?(:parent) || node.respond_to?(:parent_node)
94
+
95
+ parent = if node.respond_to?(:parent)
96
+ node.parent
97
+ elsif node.respond_to?(:parent_node)
98
+ node.parent_node
99
+ end
100
+
101
+ return 1 unless parent
102
+
103
+ # Get siblings with same name
104
+ siblings = if parent.respond_to?(:children)
105
+ parent.children.select do |n|
106
+ n.respond_to?(:name) && n.name == name
107
+ end
108
+ elsif parent.respond_to?(:child_nodes)
109
+ parent.child_nodes.select do |n|
110
+ n.respond_to?(:name) && n.name == name
111
+ end
112
+ else
113
+ [node]
114
+ end
115
+
116
+ siblings.index(node) + 1
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end