canon 0.2.4 → 0.2.6

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.
@@ -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
- # Canon::Xml::Node types use .node_type method that returns symbols
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
- # For Nokogiri/Canon::Xml nodes, check node type
290
- if node1.respond_to?(:node_type) && node2.respond_to?(:node_type)
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
- result = false
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
- result
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
- return false unless node
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
- if node.respond_to?(:content)
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
- if node1.respond_to?(:element?) && node1.element?
415
- XmlComparator.compare_element_nodes(node1, node2, opts, child_opts,
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
- if node.is_a?(Canon::Xml::Nodes::RootNode)
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
- elsif node.is_a?(Canon::Xml::Nodes::ElementNode)
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
- elsif node.is_a?(Canon::Xml::Nodes::TextNode)
435
+ when Canon::Xml::Nodes::TextNode
478
436
  node.value
479
- elsif node.is_a?(Canon::Xml::Nodes::CommentNode)
437
+ when Canon::Xml::Nodes::CommentNode
480
438
  "<!--#{node.value}-->"
481
- elsif node.is_a?(Canon::Xml::Nodes::ProcessingInstructionNode)
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
@@ -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
- # For TextNode with value attribute (Canon::Xml::Nodes::TextNode)
214
- return node.value if node.respond_to?(:value) && node.is_a?(Canon::Xml::Nodes::TextNode)
215
-
216
- # For XML/HTML nodes with text_content method
217
- return node.text_content if node.respond_to?(:text_content)
218
-
219
- # For nodes with text method
220
- return node.text if node.respond_to?(:text)
221
-
222
- # For nodes with content method
223
- return node.content if node.respond_to?(:content)
224
-
225
- # For nodes with value method (other types)
226
- return node.value if node.respond_to?(:value)
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
- # Canon::Xml::Nodes::TextNode
245
- return true if node.is_a?(Canon::Xml::Nodes::TextNode)
246
-
247
- # Nokogiri text nodes (node_type returns integer constant like 3)
248
- return true if node.respond_to?(:node_type) &&
249
- node.node_type.is_a?(Integer) &&
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
- # Moxml text nodes (node_type returns symbol)
253
- return true if node.respond_to?(:node_type) && node.node_type == :text
254
-
255
- # String
256
- return true if node.is_a?(String)
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
- # Canon::Xml::Nodes::TextNode
95
- return true if node.is_a?(Canon::Xml::Nodes::TextNode)
96
-
97
- # Moxml::Text (check before generic node_type check)
98
- return true if node.is_a?(Moxml::Text)
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
- # Moxml text nodes (node_type returns symbol) - for when using Moxml adapters
106
- return true if node.respond_to?(:node_type) && node.node_type == :text
107
-
108
- # String
109
- return true if node.is_a?(String)
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
- # For TextNode with value attribute (Canon::Xml::Nodes::TextNode)
125
- return node.value if node.respond_to?(:value) && node.is_a?(Canon::Xml::Nodes::TextNode)
126
-
127
- # For XML/HTML nodes with text_content method
128
- return node.text_content if node.respond_to?(:text_content)
129
-
130
- # For nodes with content method (try before text, as Moxml::Text.text returns "")
131
- return node.content if node.respond_to?(:content)
132
-
133
- # For nodes with text method
134
- return node.text if node.respond_to?(:text)
135
-
136
- # For nodes with value method (other types)
137
- return node.value if node.respond_to?(:value)
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]