canon 0.2.4 → 0.2.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.
- checksums.yaml +4 -4
- data/docs/advanced/semantic-diff-report.adoc +65 -0
- data/docs/features/diff-formatting/index.adoc +3 -0
- data/docs/features/diff-formatting/whitespace-adjacency.adoc +140 -0
- data/docs/reference/environment-variables.adoc +3 -1
- data/lib/canon/comparison/comparison_result.rb +16 -2
- data/lib/canon/comparison/html_comparator.rb +4 -0
- data/lib/canon/comparison/markup_comparator.rb +49 -71
- data/lib/canon/comparison/node_inspector.rb +103 -0
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +127 -55
- data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +24 -23
- data/lib/canon/comparison/xml_comparator.rb +94 -3
- data/lib/canon/comparison/xml_node_comparison.rb +37 -81
- data/lib/canon/comparison.rb +59 -0
- data/lib/canon/diff/diff_classifier.rb +37 -39
- data/lib/canon/diff/xml_serialization_formatter.rb +27 -42
- data/lib/canon/diff_formatter/diff_detail_formatter/dimension_formatter.rb +119 -9
- data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +75 -4
- data/lib/canon/diff_formatter.rb +71 -2
- data/lib/canon/pretty_printer/html.rb +76 -14
- data/lib/canon/pretty_printer/html_void_elements.rb +20 -0
- data/lib/canon/pretty_printer/xml_normalized.rb +10 -3
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +13 -1
- data/lib/canon/xml/node.rb +15 -0
- data/lib/canon/xml/sax_builder.rb +18 -0
- metadata +5 -2
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "node_inspector"
|
|
4
|
+
|
|
3
5
|
module Canon
|
|
4
6
|
module Comparison
|
|
5
7
|
# XML Node Comparison Utilities
|
|
@@ -180,13 +182,9 @@ differences)
|
|
|
180
182
|
# @return [Symbol] Comparison result constant
|
|
181
183
|
def self.dispatch_by_node_type(node1, node2, opts, child_opts,
|
|
182
184
|
diff_children, differences)
|
|
183
|
-
|
|
184
|
-
# Nokogiri also has .node_type but returns integers, so check for Symbol
|
|
185
|
-
if node1.respond_to?(:node_type) && node2.respond_to?(:node_type) &&
|
|
186
|
-
node1.node_type.is_a?(Symbol) && node2.node_type.is_a?(Symbol)
|
|
185
|
+
if node1.is_a?(Canon::Xml::Node) && node2.is_a?(Canon::Xml::Node)
|
|
187
186
|
dispatch_canon_node_type(node1, node2, opts, child_opts,
|
|
188
187
|
diff_children, differences)
|
|
189
|
-
# Moxml/Nokogiri types use .element?, .text?, etc. methods
|
|
190
188
|
else
|
|
191
189
|
dispatch_legacy_node_type(node1, node2, opts, child_opts,
|
|
192
190
|
diff_children, differences)
|
|
@@ -286,8 +284,8 @@ diff_children, differences)
|
|
|
286
284
|
def self.same_node_type?(node1, node2)
|
|
287
285
|
return false if node1.class != node2.class
|
|
288
286
|
|
|
289
|
-
|
|
290
|
-
|
|
287
|
+
case node1
|
|
288
|
+
when Canon::Xml::Node, Nokogiri::XML::Node
|
|
291
289
|
node1.node_type == node2.node_type
|
|
292
290
|
else
|
|
293
291
|
true
|
|
@@ -305,34 +303,13 @@ diff_children, differences)
|
|
|
305
303
|
# @param check_children [Boolean] Whether to check child nodes
|
|
306
304
|
# @return [Boolean] true if node is a comment
|
|
307
305
|
def self.comment_node?(node, check_children: false)
|
|
308
|
-
|
|
309
|
-
return true if node.respond_to?(:comment?) && node.comment?
|
|
310
|
-
return true if node.respond_to?(:node_type) && node.node_type == :comment
|
|
311
|
-
|
|
312
|
-
if node.is_a?(Nokogiri::XML::Element) && !node.children.empty? && check_children
|
|
313
|
-
node.children.each do |child|
|
|
314
|
-
# Recursively check child nodes for comments
|
|
315
|
-
# limit depth to avoid infinite recursion
|
|
316
|
-
# in case of circular structures (if any)
|
|
317
|
-
if comment_node?(child, check_children: false)
|
|
318
|
-
result = true
|
|
319
|
-
break
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
end
|
|
323
|
-
return true if result
|
|
324
|
-
|
|
325
|
-
# HTML comments are parsed as TEXT nodes by Nokogiri
|
|
326
|
-
# Check if this is a text node with HTML comment content
|
|
327
|
-
if text_node?(node)
|
|
328
|
-
text = node_text(node)
|
|
329
|
-
# Strip whitespace and backslashes for comparison
|
|
330
|
-
# Nokogiri escapes HTML comments as "<\\!-- comment -->" in full documents
|
|
331
|
-
text_stripped = text.to_s.strip.gsub("\\", "")
|
|
332
|
-
return true if text_stripped.start_with?("<!--") && text_stripped.end_with?("-->")
|
|
333
|
-
end
|
|
306
|
+
return true if NodeInspector.comment_node?(node)
|
|
334
307
|
|
|
335
|
-
|
|
308
|
+
if check_children && node.is_a?(Nokogiri::XML::Element) && !node.children.empty?
|
|
309
|
+
node.children.any? { |child| NodeInspector.comment_node?(child) }
|
|
310
|
+
else
|
|
311
|
+
false
|
|
312
|
+
end
|
|
336
313
|
end
|
|
337
314
|
|
|
338
315
|
# Check if a node is a text node
|
|
@@ -340,24 +317,7 @@ diff_children, differences)
|
|
|
340
317
|
# @param node [Object] Node to check
|
|
341
318
|
# @return [Boolean] true if node is a text node
|
|
342
319
|
def self.text_node?(node)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
# Nokogiri text nodes (XML, HTML4, HTML5) — call element? rather
|
|
346
|
-
# than respond_to?(:element?), which always returns true for
|
|
347
|
-
# Nokogiri::XML::Node and made this predicate vacuously false
|
|
348
|
-
# for every Nokogiri text node. See issue #118.
|
|
349
|
-
if node.is_a?(Nokogiri::XML::Node)
|
|
350
|
-
return node.text? && !node.element?
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# Canon::Xml::Nodes types and other ducktyped nodes.
|
|
354
|
-
if node.respond_to?(:text?) && node.text? &&
|
|
355
|
-
!node.respond_to?(:element?)
|
|
356
|
-
return true
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
# Symbol-style node_type (Canon's own node objects).
|
|
360
|
-
node.respond_to?(:node_type) && node.node_type == :text
|
|
320
|
+
NodeInspector.text_node?(node)
|
|
361
321
|
end
|
|
362
322
|
|
|
363
323
|
# Extract text content from a node
|
|
@@ -367,15 +327,7 @@ diff_children, differences)
|
|
|
367
327
|
def self.node_text(node)
|
|
368
328
|
return "" unless node
|
|
369
329
|
|
|
370
|
-
|
|
371
|
-
node.content.to_s
|
|
372
|
-
elsif node.respond_to?(:text)
|
|
373
|
-
node.text.to_s
|
|
374
|
-
elsif node.respond_to?(:value)
|
|
375
|
-
node.value.to_s
|
|
376
|
-
else
|
|
377
|
-
""
|
|
378
|
-
end
|
|
330
|
+
NodeInspector.text_content(node)
|
|
379
331
|
end
|
|
380
332
|
|
|
381
333
|
# Dispatch by Canon::Xml::Node type
|
|
@@ -411,21 +363,26 @@ diff_children, differences)
|
|
|
411
363
|
# Import XmlComparator to use its comparison methods
|
|
412
364
|
require_relative "xml_comparator"
|
|
413
365
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
diff_children, differences)
|
|
417
|
-
elsif node1.respond_to?(:text?) && node1.text?
|
|
418
|
-
XmlComparator.compare_text_nodes(node1, node2, opts, differences)
|
|
419
|
-
elsif node1.respond_to?(:comment?) && node1.comment?
|
|
420
|
-
XmlComparator.compare_comment_nodes(node1, node2, opts, differences)
|
|
421
|
-
elsif node1.respond_to?(:cdata?) && node1.cdata?
|
|
422
|
-
XmlComparator.compare_text_nodes(node1, node2, opts, differences)
|
|
423
|
-
elsif node1.respond_to?(:processing_instruction?) && node1.processing_instruction?
|
|
424
|
-
XmlComparator.compare_processing_instruction_nodes(node1, node2,
|
|
425
|
-
opts, differences)
|
|
426
|
-
elsif node1.respond_to?(:root)
|
|
366
|
+
case node1
|
|
367
|
+
when Nokogiri::XML::Document
|
|
427
368
|
XmlComparator.compare_document_nodes(node1, node2, opts, child_opts,
|
|
428
369
|
diff_children, differences)
|
|
370
|
+
when Nokogiri::XML::Node
|
|
371
|
+
if node1.element?
|
|
372
|
+
XmlComparator.compare_element_nodes(node1, node2, opts, child_opts,
|
|
373
|
+
diff_children, differences)
|
|
374
|
+
elsif node1.text?
|
|
375
|
+
XmlComparator.compare_text_nodes(node1, node2, opts, differences)
|
|
376
|
+
elsif node1.comment?
|
|
377
|
+
XmlComparator.compare_comment_nodes(node1, node2, opts, differences)
|
|
378
|
+
elsif node1.cdata?
|
|
379
|
+
XmlComparator.compare_text_nodes(node1, node2, opts, differences)
|
|
380
|
+
elsif node1.processing_instruction?
|
|
381
|
+
XmlComparator.compare_processing_instruction_nodes(node1, node2,
|
|
382
|
+
opts, differences)
|
|
383
|
+
else
|
|
384
|
+
Comparison::EQUIVALENT
|
|
385
|
+
end
|
|
429
386
|
else
|
|
430
387
|
Comparison::EQUIVALENT
|
|
431
388
|
end
|
|
@@ -457,10 +414,11 @@ differences)
|
|
|
457
414
|
# @param node [Canon::Xml::Node, Object] Node to serialize
|
|
458
415
|
# @return [String] XML string representation
|
|
459
416
|
def self.serialize_node_to_xml(node)
|
|
460
|
-
|
|
417
|
+
case node
|
|
418
|
+
when Canon::Xml::Nodes::RootNode
|
|
461
419
|
# Serialize all children of root
|
|
462
420
|
node.children.map { |child| serialize_node_to_xml(child) }.join
|
|
463
|
-
|
|
421
|
+
when Canon::Xml::Nodes::ElementNode
|
|
464
422
|
# Serialize element with attributes and children
|
|
465
423
|
attrs = node.attribute_nodes.map do |a|
|
|
466
424
|
" #{a.name}=\"#{a.value}\""
|
|
@@ -474,14 +432,12 @@ differences)
|
|
|
474
432
|
else
|
|
475
433
|
"<#{node.name}#{attrs}>#{children_xml}</#{node.name}>"
|
|
476
434
|
end
|
|
477
|
-
|
|
435
|
+
when Canon::Xml::Nodes::TextNode
|
|
478
436
|
node.value
|
|
479
|
-
|
|
437
|
+
when Canon::Xml::Nodes::CommentNode
|
|
480
438
|
"<!--#{node.value}-->"
|
|
481
|
-
|
|
439
|
+
when Canon::Xml::Nodes::ProcessingInstructionNode
|
|
482
440
|
"<?#{node.target} #{node.data}?>"
|
|
483
|
-
elsif node.respond_to?(:to_xml)
|
|
484
|
-
node.to_xml
|
|
485
441
|
else
|
|
486
442
|
node.to_s
|
|
487
443
|
end
|
data/lib/canon/comparison.rb
CHANGED
|
@@ -122,6 +122,65 @@ module Canon
|
|
|
122
122
|
UNEQUAL_TYPES = 15
|
|
123
123
|
UNEQUAL_PRIMITIVES = 16
|
|
124
124
|
|
|
125
|
+
# Human-readable labels for the integer comparison-result constants
|
|
126
|
+
# above. Used by the diff reason builders so user-facing reason text
|
|
127
|
+
# never leaks raw numeric codes (e.g. "7 vs 7" — see lutaml/canon#127).
|
|
128
|
+
# String diff codes (e.g. "position 3" emitted by ChildComparison)
|
|
129
|
+
# pass through +code_label+ unchanged.
|
|
130
|
+
CODE_LABELS = {
|
|
131
|
+
EQUIVALENT => "equivalent",
|
|
132
|
+
MISSING_ATTRIBUTE => "missing attribute",
|
|
133
|
+
MISSING_NODE => "missing",
|
|
134
|
+
UNEQUAL_ATTRIBUTES => "attributes differ",
|
|
135
|
+
UNEQUAL_COMMENTS => "comments differ",
|
|
136
|
+
UNEQUAL_DOCUMENTS => "documents differ",
|
|
137
|
+
UNEQUAL_ELEMENTS => "elements differ",
|
|
138
|
+
UNEQUAL_NODES_TYPES => "node types differ",
|
|
139
|
+
UNEQUAL_TEXT_CONTENTS => "text content differs",
|
|
140
|
+
MISSING_HASH_KEY => "missing hash key",
|
|
141
|
+
UNEQUAL_HASH_VALUES => "hash values differ",
|
|
142
|
+
UNEQUAL_HASH_KEY_ORDER => "hash key order differs",
|
|
143
|
+
UNEQUAL_ARRAY_LENGTHS => "array lengths differ",
|
|
144
|
+
UNEQUAL_ARRAY_ELEMENTS => "array elements differ",
|
|
145
|
+
UNEQUAL_TYPES => "types differ",
|
|
146
|
+
UNEQUAL_PRIMITIVES => "primitives differ",
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
# Translate a comparison result code (Integer constant or String label
|
|
150
|
+
# like "position 3") into a human-readable reason fragment. Unknown
|
|
151
|
+
# values pass through via +to_s+ as a defensive fallback.
|
|
152
|
+
#
|
|
153
|
+
# @param code [Integer, String] Comparison result code
|
|
154
|
+
# @return [String] Human-readable label
|
|
155
|
+
def self.code_label(code)
|
|
156
|
+
return code if code.is_a?(String)
|
|
157
|
+
|
|
158
|
+
CODE_LABELS[code] || code.to_s
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Build a "diff1 [vs diff2]" reason fragment that never leaks raw
|
|
162
|
+
# integer constants. When both codes are equal, returns the single
|
|
163
|
+
# label (e.g. "elements differ") rather than "elements differ vs
|
|
164
|
+
# elements differ". See lutaml/canon#127.
|
|
165
|
+
#
|
|
166
|
+
# @param diff1 [Integer, String] First diff code
|
|
167
|
+
# @param diff2 [Integer, String] Second diff code
|
|
168
|
+
# @return [String] Reason fragment
|
|
169
|
+
def self.code_pair_label(diff1, diff2)
|
|
170
|
+
return code_label(diff1) if diff1 == diff2
|
|
171
|
+
|
|
172
|
+
"#{code_label(diff1)} vs #{code_label(diff2)}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Extract parse-time errors from a parsed-tree or Nokogiri fragment.
|
|
176
|
+
# Delegates to NodeInspector for cross-backend type dispatch.
|
|
177
|
+
#
|
|
178
|
+
# @param node [Object, nil] Parsed node
|
|
179
|
+
# @return [Array<String>] Parse errors as strings (empty by default)
|
|
180
|
+
def self.parse_errors_for(node)
|
|
181
|
+
NodeInspector.parse_errors(node)
|
|
182
|
+
end
|
|
183
|
+
|
|
125
184
|
class << self
|
|
126
185
|
# Auto-detect format and compare two objects
|
|
127
186
|
#
|
|
@@ -73,6 +73,16 @@ module Canon
|
|
|
73
73
|
return diff_node
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# :whitespace_adjacency is a report-only re-label of an
|
|
77
|
+
# asymmetric whitespace mismatch emitted by ChildComparison's
|
|
78
|
+
# two-cursor walk. Equivalence behaviour is unchanged — the
|
|
79
|
+
# underlying mismatch is normative regardless of match options.
|
|
80
|
+
if diff_node.dimension == :whitespace_adjacency
|
|
81
|
+
diff_node.formatting = false
|
|
82
|
+
diff_node.normative = true
|
|
83
|
+
return diff_node
|
|
84
|
+
end
|
|
85
|
+
|
|
76
86
|
# THIRD: Determine if this dimension is normative based on CompareProfile
|
|
77
87
|
# This respects the policy settings (strict/normalize/ignore)
|
|
78
88
|
is_normative = profile.normative_dimension?(diff_node.dimension)
|
|
@@ -210,28 +220,21 @@ module Canon
|
|
|
210
220
|
def extract_text_content(node)
|
|
211
221
|
return nil if node.nil?
|
|
212
222
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
# For simple text nodes or strings
|
|
229
|
-
return node.to_s if node.is_a?(String)
|
|
230
|
-
|
|
231
|
-
# For other node types, try to_s
|
|
232
|
-
node.to_s
|
|
223
|
+
case node
|
|
224
|
+
when Canon::Xml::Nodes::TextNode
|
|
225
|
+
node.value
|
|
226
|
+
when Canon::Xml::Node
|
|
227
|
+
node.text_content
|
|
228
|
+
when Nokogiri::XML::Node
|
|
229
|
+
node.content.to_s
|
|
230
|
+
when Moxml::Node
|
|
231
|
+
node.content.to_s
|
|
232
|
+
when String
|
|
233
|
+
node
|
|
234
|
+
else
|
|
235
|
+
node.to_s
|
|
236
|
+
end
|
|
233
237
|
rescue StandardError
|
|
234
|
-
# If extraction fails, return nil (not formatting-only)
|
|
235
238
|
nil
|
|
236
239
|
end
|
|
237
240
|
|
|
@@ -241,25 +244,20 @@ module Canon
|
|
|
241
244
|
def text_node?(node)
|
|
242
245
|
return false if node.nil?
|
|
243
246
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
247
|
+
case node
|
|
248
|
+
when Canon::Xml::Nodes::TextNode
|
|
249
|
+
true
|
|
250
|
+
when Canon::Xml::Node
|
|
251
|
+
node.node_type == :text
|
|
252
|
+
when Nokogiri::XML::Node
|
|
250
253
|
node.node_type == Nokogiri::XML::Node::TEXT_NODE
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
# Test doubles or objects with text node-like interface
|
|
259
|
-
# Check if it has a value method (contains text content)
|
|
260
|
-
return true if node.respond_to?(:value)
|
|
261
|
-
|
|
262
|
-
false
|
|
254
|
+
when Moxml::Node
|
|
255
|
+
node.text?
|
|
256
|
+
when String
|
|
257
|
+
true
|
|
258
|
+
else
|
|
259
|
+
false
|
|
260
|
+
end
|
|
263
261
|
end
|
|
264
262
|
end
|
|
265
263
|
end
|
|
@@ -91,28 +91,20 @@ module Canon
|
|
|
91
91
|
def self.text_node?(node)
|
|
92
92
|
return false if node.nil?
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# Nokogiri text nodes (node_type returns integer constant like 3)
|
|
101
|
-
return true if node.respond_to?(:node_type) &&
|
|
102
|
-
node.node_type.is_a?(Integer) &&
|
|
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
|
|
103
100
|
node.node_type == Nokogiri::XML::Node::TEXT_NODE
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
# Test doubles or objects with text node-like interface
|
|
112
|
-
# Check if it has a value method (contains text content)
|
|
113
|
-
return true if node.respond_to?(:value)
|
|
114
|
-
|
|
115
|
-
false
|
|
101
|
+
when Moxml::Node
|
|
102
|
+
node.text?
|
|
103
|
+
when String
|
|
104
|
+
true
|
|
105
|
+
else
|
|
106
|
+
false
|
|
107
|
+
end
|
|
116
108
|
end
|
|
117
109
|
|
|
118
110
|
# Extract text content from a node
|
|
@@ -121,28 +113,21 @@ module Canon
|
|
|
121
113
|
def self.extract_text_content(node)
|
|
122
114
|
return nil if node.nil?
|
|
123
115
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
# For simple text nodes or strings
|
|
140
|
-
return node.to_s if node.is_a?(String)
|
|
141
|
-
|
|
142
|
-
# For other node types, try to_s
|
|
143
|
-
node.to_s
|
|
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
|
|
144
130
|
rescue StandardError
|
|
145
|
-
# If extraction fails, return nil (not a serialization difference)
|
|
146
131
|
nil
|
|
147
132
|
end
|
|
148
133
|
|
|
@@ -34,6 +34,8 @@ expand_difference: false)
|
|
|
34
34
|
format_attribute_order_details(diff, use_color)
|
|
35
35
|
when :text_content
|
|
36
36
|
format_text_content_details(diff, use_color, compact: compact)
|
|
37
|
+
when :whitespace_adjacency
|
|
38
|
+
format_whitespace_adjacency_details(diff, use_color)
|
|
37
39
|
when :structural_whitespace
|
|
38
40
|
format_structural_whitespace_details(diff, use_color)
|
|
39
41
|
when :comments
|
|
@@ -366,18 +368,21 @@ expand_difference: false)
|
|
|
366
368
|
node1 = extract_node1(diff)
|
|
367
369
|
node2 = extract_node2(diff)
|
|
368
370
|
|
|
371
|
+
# Symmetric one-sided rendering for missing/extra text nodes.
|
|
372
|
+
# When exactly one side is nil, render "(not present)" on that
|
|
373
|
+
# side and the present side's raw text content (whitespace-
|
|
374
|
+
# visualised, with a brief parent open-tag hint for context).
|
|
375
|
+
# Mirrors format_element_structure_details above. Without this
|
|
376
|
+
# short-circuit, the ambiguous-pair fallback further down would
|
|
377
|
+
# serialize the present side's *parent subtree* in full,
|
|
378
|
+
# producing a misleading diff payload. See lutaml/canon#125.
|
|
379
|
+
if node1.nil? ^ node2.nil?
|
|
380
|
+
return format_text_content_one_sided(node1, node2, use_color)
|
|
381
|
+
end
|
|
382
|
+
|
|
369
383
|
text1 = NodeUtils.node_to_display(node1, compact: compact)
|
|
370
384
|
text2 = NodeUtils.node_to_display(node2, compact: compact)
|
|
371
385
|
|
|
372
|
-
# Handle cases where one node is missing (e.g. text added or removed)
|
|
373
|
-
if node1.nil? || node2.nil?
|
|
374
|
-
if node1.nil?
|
|
375
|
-
text2 = NodeUtils.node_to_display(node2, compact: compact)
|
|
376
|
-
else
|
|
377
|
-
text1 = NodeUtils.node_to_display(node1, compact: compact)
|
|
378
|
-
end
|
|
379
|
-
end
|
|
380
|
-
|
|
381
386
|
if NodeUtils.inside_preserve_element?(node1) || NodeUtils.inside_preserve_element?(node2)
|
|
382
387
|
detail1 = ColorHelper.colorize(
|
|
383
388
|
TextUtils.visualize_whitespace(text1), :red, use_color
|
|
@@ -432,8 +437,113 @@ expand_difference: false)
|
|
|
432
437
|
[detail1, detail2, changes]
|
|
433
438
|
end
|
|
434
439
|
|
|
440
|
+
# Whether a node is an element (Canon or Nokogiri), used to
|
|
441
|
+
# detect element-shaped diffs that have been misclassified as
|
|
442
|
+
# :text_content and route them to element-structure rendering.
|
|
443
|
+
# See lutaml/canon#125 follow-up.
|
|
444
|
+
def self.present_is_element?(node)
|
|
445
|
+
return false unless node
|
|
446
|
+
|
|
447
|
+
case node
|
|
448
|
+
when Canon::Xml::Node
|
|
449
|
+
node.node_type == :element
|
|
450
|
+
when Nokogiri::XML::Node
|
|
451
|
+
node.element?
|
|
452
|
+
else
|
|
453
|
+
false
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Render a one-sided text-content diff (one node nil, the other a
|
|
458
|
+
# text node). Mirrors the +has1+/+has2+ branches of
|
|
459
|
+
# +format_element_structure_details+: "(not present)" on the nil
|
|
460
|
+
# side, the present side's raw text content (whitespace-visualised
|
|
461
|
+
# and quoted) plus a brief parent open-tag hint for context.
|
|
462
|
+
#
|
|
463
|
+
# @param node1 [Object, nil] First node (nil if removed)
|
|
464
|
+
# @param node2 [Object, nil] Second node (nil if added)
|
|
465
|
+
# @param use_color [Boolean] Whether to apply ANSI colours
|
|
466
|
+
# @return [Array<String>] Tuple of [detail1, detail2, changes]
|
|
467
|
+
def self.format_text_content_one_sided(node1, node2, use_color)
|
|
468
|
+
require_relative "color_helper"
|
|
469
|
+
require_relative "node_utils"
|
|
470
|
+
require_relative "text_utils"
|
|
471
|
+
|
|
472
|
+
present = node1 || node2
|
|
473
|
+
|
|
474
|
+
# Defensive: if a one-sided text-content diff carries an
|
|
475
|
+
# *element* on the present side (e.g. because an upstream
|
|
476
|
+
# comparator misclassified an element orphan as
|
|
477
|
+
# :text_content), delegate to the element-structure
|
|
478
|
+
# formatter rather than rendering the element as +text ""+.
|
|
479
|
+
# The construction-side fix in lutaml/canon#125 follow-up
|
|
480
|
+
# removes the immediate failure mode, but other paths could
|
|
481
|
+
# still misclassify and the formatter must produce a
|
|
482
|
+
# best-effort element representation, never +text ""+.
|
|
483
|
+
if present_is_element?(present)
|
|
484
|
+
return format_element_structure_details(
|
|
485
|
+
{ node1: node1, node2: node2 }, use_color
|
|
486
|
+
)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
removed = node2.nil?
|
|
490
|
+
|
|
491
|
+
raw = NodeUtils.raw_text_value(present)
|
|
492
|
+
visible = TextUtils.visualize_whitespace(raw)
|
|
493
|
+
parent = NodeUtils.parent_of(present)
|
|
494
|
+
context = parent ? " in #{NodeUtils.serialize_open_tag(parent)}" : ""
|
|
495
|
+
present_str = "text \"#{visible}\"#{context}"
|
|
496
|
+
|
|
497
|
+
if removed
|
|
498
|
+
detail1 = ColorHelper.colorize(present_str, :red, use_color)
|
|
499
|
+
detail2 = ColorHelper.colorize("(not present)", :green, use_color)
|
|
500
|
+
changes = "Text removed: #{detail1}"
|
|
501
|
+
else
|
|
502
|
+
detail1 = ColorHelper.colorize("(not present)", :red, use_color)
|
|
503
|
+
detail2 = ColorHelper.colorize(present_str, :green, use_color)
|
|
504
|
+
changes = "Text added: #{detail2}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
[detail1, detail2, changes]
|
|
508
|
+
end
|
|
509
|
+
|
|
435
510
|
# Format structural whitespace differences
|
|
436
511
|
#
|
|
512
|
+
# Format a :whitespace_adjacency diff (#137).
|
|
513
|
+
#
|
|
514
|
+
# @param diff [DiffNode, Hash] Difference node
|
|
515
|
+
# @param use_color [Boolean] Whether to use colors
|
|
516
|
+
# @return [Array] Tuple of [detail1, detail2, changes]
|
|
517
|
+
def self.format_whitespace_adjacency_details(diff, use_color)
|
|
518
|
+
require_relative "color_helper"
|
|
519
|
+
require_relative "node_utils"
|
|
520
|
+
require_relative "text_utils"
|
|
521
|
+
|
|
522
|
+
node1 = extract_node1(diff)
|
|
523
|
+
node2 = extract_node2(diff)
|
|
524
|
+
|
|
525
|
+
text1 = NodeUtils.get_node_text(node1).to_s
|
|
526
|
+
text2 = NodeUtils.get_node_text(node2).to_s
|
|
527
|
+
|
|
528
|
+
detail1 = ColorHelper.colorize(
|
|
529
|
+
"\"#{TextUtils.visualize_whitespace(text1)}\"",
|
|
530
|
+
:red, use_color
|
|
531
|
+
)
|
|
532
|
+
detail2 = ColorHelper.colorize(
|
|
533
|
+
"\"#{TextUtils.visualize_whitespace(text2)}\"",
|
|
534
|
+
:green, use_color
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
reason = if diff.is_a?(Canon::Diff::DiffNode)
|
|
538
|
+
diff.reason
|
|
539
|
+
else
|
|
540
|
+
diff.is_a?(Hash) ? diff[:reason] : nil
|
|
541
|
+
end
|
|
542
|
+
changes = reason.to_s
|
|
543
|
+
|
|
544
|
+
[detail1, detail2, changes]
|
|
545
|
+
end
|
|
546
|
+
|
|
437
547
|
# @param diff [DiffNode, Hash] Difference node
|
|
438
548
|
# @param use_color [Boolean] Whether to use colors
|
|
439
549
|
# @return [Array] Tuple of [detail1, detail2, changes]
|