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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +69 -92
- data/README.adoc +13 -13
- data/docs/.lycheeignore +69 -0
- data/docs/Gemfile +1 -0
- data/docs/_config.yml +90 -1
- data/docs/advanced/diff-classification.adoc +82 -2
- data/docs/advanced/extending-canon.adoc +193 -0
- data/docs/features/match-options/index.adoc +239 -1
- data/docs/internals/diffnode-enrichment.adoc +611 -0
- data/docs/internals/index.adoc +251 -0
- data/docs/lychee.toml +13 -6
- data/docs/understanding/architecture.adoc +749 -33
- data/docs/understanding/comparison-pipeline.adoc +122 -0
- data/lib/canon/cache.rb +129 -0
- data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +68 -0
- data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +68 -0
- data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +171 -0
- data/lib/canon/comparison/dimensions/base_dimension.rb +107 -0
- data/lib/canon/comparison/dimensions/comments_dimension.rb +121 -0
- data/lib/canon/comparison/dimensions/element_position_dimension.rb +90 -0
- data/lib/canon/comparison/dimensions/registry.rb +77 -0
- data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +119 -0
- data/lib/canon/comparison/dimensions/text_content_dimension.rb +96 -0
- data/lib/canon/comparison/dimensions.rb +54 -0
- data/lib/canon/comparison/format_detector.rb +87 -0
- data/lib/canon/comparison/html_comparator.rb +70 -26
- data/lib/canon/comparison/html_compare_profile.rb +8 -2
- data/lib/canon/comparison/html_parser.rb +80 -0
- data/lib/canon/comparison/json_comparator.rb +12 -0
- data/lib/canon/comparison/json_parser.rb +19 -0
- data/lib/canon/comparison/markup_comparator.rb +293 -0
- data/lib/canon/comparison/match_options/base_resolver.rb +150 -0
- data/lib/canon/comparison/match_options/json_resolver.rb +82 -0
- data/lib/canon/comparison/match_options/xml_resolver.rb +151 -0
- data/lib/canon/comparison/match_options/yaml_resolver.rb +87 -0
- data/lib/canon/comparison/match_options.rb +68 -463
- data/lib/canon/comparison/profile_definition.rb +149 -0
- data/lib/canon/comparison/ruby_object_comparator.rb +180 -0
- data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +7 -10
- data/lib/canon/comparison/whitespace_sensitivity.rb +208 -0
- data/lib/canon/comparison/xml_comparator/attribute_comparator.rb +177 -0
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +136 -0
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +197 -0
- data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +115 -0
- data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +186 -0
- data/lib/canon/comparison/xml_comparator/node_parser.rb +79 -0
- data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +102 -0
- data/lib/canon/comparison/xml_comparator.rb +97 -684
- data/lib/canon/comparison/xml_node_comparison.rb +319 -0
- data/lib/canon/comparison/xml_parser.rb +19 -0
- data/lib/canon/comparison/yaml_comparator.rb +3 -3
- data/lib/canon/comparison.rb +265 -110
- data/lib/canon/diff/diff_classifier.rb +101 -2
- data/lib/canon/diff/diff_node.rb +32 -2
- data/lib/canon/diff/formatting_detector.rb +1 -1
- data/lib/canon/diff/node_serializer.rb +191 -0
- data/lib/canon/diff/path_builder.rb +143 -0
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +251 -0
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +6 -248
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +38 -229
- data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +30 -0
- data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +579 -0
- data/lib/canon/diff_formatter/diff_detail_formatter/location_extractor.rb +121 -0
- data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +253 -0
- data/lib/canon/diff_formatter/diff_detail_formatter/text_utils.rb +61 -0
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +31 -1028
- data/lib/canon/diff_formatter.rb +1 -1
- data/lib/canon/rspec_matchers.rb +38 -9
- data/lib/canon/tree_diff/operation_converter.rb +92 -338
- data/lib/canon/tree_diff/operation_converter_helpers/metadata_enricher.rb +71 -0
- data/lib/canon/tree_diff/operation_converter_helpers/post_processor.rb +103 -0
- data/lib/canon/tree_diff/operation_converter_helpers/reason_builder.rb +168 -0
- data/lib/canon/tree_diff/operation_converter_helpers/update_change_handler.rb +188 -0
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +24 -13
- 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
|