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,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Comparison
5
+ module XmlComparatorHelpers
6
+ # Attribute comparison logic
7
+ # Handles comparison of attribute sets with filtering and ordering
8
+ class AttributeComparator
9
+ # Compare attribute sets between two nodes
10
+ #
11
+ # @param node1 [Object] First node
12
+ # @param node2 [Object] Second node
13
+ # @param opts [Hash] Comparison options
14
+ # @param differences [Array] Array to append differences to
15
+ # @return [Symbol] Comparison result
16
+ def self.compare(node1, node2, opts, differences)
17
+ # Get attributes using the appropriate method for each node type
18
+ raw_attrs1 = node1.respond_to?(:attribute_nodes) ? node1.attribute_nodes : node1.attributes
19
+ raw_attrs2 = node2.respond_to?(:attribute_nodes) ? node2.attribute_nodes : node2.attributes
20
+
21
+ attrs1 = XmlComparatorHelpers::AttributeFilter.filter(raw_attrs1,
22
+ opts)
23
+ attrs2 = XmlComparatorHelpers::AttributeFilter.filter(raw_attrs2,
24
+ opts)
25
+
26
+ match_opts = opts[:match_opts]
27
+ attribute_order_behavior = match_opts[:attribute_order] || :strict
28
+
29
+ # Check attribute order if not ignored
30
+ keys1 = attrs1.keys.map(&:to_s)
31
+ keys2 = attrs2.keys.map(&:to_s)
32
+
33
+ if attribute_order_behavior == :strict
34
+ compare_strict_order(node1, node2, attrs1, attrs2, keys1, keys2, opts,
35
+ differences)
36
+ else
37
+ compare_flexible_order(node1, node2, attrs1, attrs2, keys1, keys2, opts,
38
+ differences)
39
+ end
40
+ end
41
+
42
+ # Compare with strict attribute ordering
43
+ #
44
+ # @param node1 [Object] First node
45
+ # @param node2 [Object] Second node
46
+ # @param attrs1 [Hash] First node's attributes
47
+ # @param attrs2 [Hash] Second node's attributes
48
+ # @param keys1 [Array<String>] First node's attribute keys
49
+ # @param keys2 [Array<String>] Second node's attribute keys
50
+ # @param opts [Hash] Comparison options
51
+ # @param differences [Array] Array to append differences to
52
+ # @return [Symbol] Comparison result
53
+ def self.compare_strict_order(node1, node2, attrs1, attrs2, keys1, keys2, opts,
54
+ differences)
55
+ if keys1 != keys2
56
+ # Keys are different or in different order
57
+ if keys1.sort == keys2.sort
58
+ # Same keys, different order - attribute_order difference
59
+ add_attribute_difference(n1: node1, n2: node2,
60
+ diff1: Comparison::UNEQUAL_ATTRIBUTES,
61
+ diff2: Comparison::UNEQUAL_ATTRIBUTES,
62
+ dimension: :attribute_order,
63
+ opts: opts,
64
+ differences: differences)
65
+ return Comparison::UNEQUAL_ATTRIBUTES
66
+ else
67
+ # Different keys - attribute_presence difference
68
+ add_attribute_difference(n1: node1, n2: node2,
69
+ diff1: Comparison::MISSING_ATTRIBUTE,
70
+ diff2: Comparison::MISSING_ATTRIBUTE,
71
+ dimension: :attribute_presence,
72
+ opts: opts,
73
+ differences: differences)
74
+ return Comparison::MISSING_ATTRIBUTE
75
+ end
76
+ end
77
+
78
+ # Order matches, check values
79
+ compare_attribute_values(node1, node2, attrs1, attrs2, opts,
80
+ differences)
81
+ end
82
+
83
+ # Compare with flexible attribute ordering
84
+ #
85
+ # @param node1 [Object] First node
86
+ # @param node2 [Object] Second node
87
+ # @param attrs1 [Hash] First node's attributes
88
+ # @param attrs2 [Hash] Second node's attributes
89
+ # @param keys1 [Array<String>] First node's attribute keys
90
+ # @param keys2 [Array<String>] Second node's attribute keys
91
+ # @param opts [Hash] Comparison options
92
+ # @param differences [Array] Array to append differences to
93
+ # @return [Symbol] Comparison result
94
+ def self.compare_flexible_order(node1, node2, attrs1, attrs2, keys1, keys2, opts,
95
+ differences)
96
+ # Check if order differs (but keys are the same) - track as informative
97
+ if keys1 != keys2 && keys1.sort == keys2.sort && opts[:verbose]
98
+ add_attribute_difference(n1: node1, n2: node2,
99
+ diff1: Comparison::UNEQUAL_ATTRIBUTES,
100
+ diff2: Comparison::UNEQUAL_ATTRIBUTES,
101
+ dimension: :attribute_order,
102
+ opts: opts,
103
+ differences: differences)
104
+ end
105
+
106
+ # Sort attributes so order doesn't matter for comparison
107
+ attrs1 = attrs1.sort_by { |k, _v| k.to_s }.to_h
108
+ attrs2 = attrs2.sort_by { |k, _v| k.to_s }.to_h
109
+
110
+ unless attrs1.keys.map(&:to_s).sort == attrs2.keys.map(&:to_s).sort
111
+ add_attribute_difference(n1: node1, n2: node2,
112
+ diff1: Comparison::MISSING_ATTRIBUTE,
113
+ diff2: Comparison::MISSING_ATTRIBUTE,
114
+ dimension: :attribute_presence,
115
+ opts: opts,
116
+ differences: differences)
117
+ return Comparison::MISSING_ATTRIBUTE
118
+ end
119
+
120
+ compare_attribute_values(node1, node2, attrs1, attrs2, opts,
121
+ differences)
122
+ end
123
+
124
+ # Compare attribute values
125
+ #
126
+ # @param node1 [Object] First node
127
+ # @param node2 [Object] Second node
128
+ # @param attrs1 [Hash] First node's attributes
129
+ # @param attrs2 [Hash] Second node's attributes
130
+ # @param opts [Hash] Comparison options
131
+ # @param differences [Array] Array to append differences to
132
+ # @return [Symbol] Comparison result
133
+ def self.compare_attribute_values(node1, node2, attrs1, attrs2, opts,
134
+ differences)
135
+ attrs1.each do |name, value|
136
+ unless attrs2[name] == value
137
+ add_attribute_difference(n1: node1, n2: node2,
138
+ diff1: Comparison::UNEQUAL_ATTRIBUTES,
139
+ diff2: Comparison::UNEQUAL_ATTRIBUTES,
140
+ dimension: :attribute_values,
141
+ opts: opts,
142
+ differences: differences)
143
+ return Comparison::UNEQUAL_ATTRIBUTES
144
+ end
145
+ end
146
+
147
+ Comparison::EQUIVALENT
148
+ end
149
+
150
+ # Add an attribute difference
151
+ #
152
+ # @param n1 [Object] First node
153
+ # @param n2 [Object] Second node
154
+ # @param diff1 [String] Difference type for node1
155
+ # @param diff2 [String] Difference type for node2
156
+ # @param dimension [Symbol] The match dimension
157
+ # @param opts [Hash] Options
158
+ # @param differences [Array] Array to append difference to
159
+ def self.add_attribute_difference(n1:, n2:, diff1:, diff2:,
160
+ dimension:, differences:, **opts)
161
+ # Import DiffNodeBuilder to avoid circular dependency
162
+ require_relative "diff_node_builder"
163
+
164
+ diff_node = Canon::Comparison::DiffNodeBuilder.build(
165
+ node1: n1,
166
+ node2: n2,
167
+ diff1: diff1,
168
+ diff2: diff2,
169
+ dimension: dimension,
170
+ **opts,
171
+ )
172
+ differences << diff_node if diff_node
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../match_options"
4
+
5
+ module Canon
6
+ module Comparison
7
+ module XmlComparatorHelpers
8
+ # Attribute filtering logic
9
+ # Handles filtering of attributes based on options and match settings
10
+ class AttributeFilter
11
+ # Filter attributes based on options
12
+ #
13
+ # @param attributes [Array, Hash] Raw attributes
14
+ # @param opts [Hash] Comparison options
15
+ # @return [Hash] Filtered attributes
16
+ def self.filter(attributes, opts)
17
+ filtered = {}
18
+ match_opts = opts[:match_opts]
19
+
20
+ # Handle Canon::Xml::Node attribute format (array of AttributeNode)
21
+ if attributes.is_a?(Array)
22
+ filter_array_attributes(attributes, opts, match_opts, filtered)
23
+ else
24
+ # Handle Nokogiri and Moxml attribute formats (Hash-like)
25
+ filter_hash_attributes(attributes, opts, match_opts, filtered)
26
+ end
27
+
28
+ filtered
29
+ end
30
+
31
+ # Filter array-format attributes (Canon::Xml::Node)
32
+ #
33
+ # @param attributes [Array] Array of AttributeNode objects
34
+ # @param opts [Hash] Comparison options
35
+ # @param match_opts [Hash] Resolved match options
36
+ # @param filtered [Hash] Output hash to populate
37
+ def self.filter_array_attributes(attributes, opts, match_opts, filtered)
38
+ attributes.each do |attr|
39
+ name = attr.name
40
+ value = attr.value
41
+
42
+ # Skip namespace declarations - they're handled separately
43
+ next if namespace_declaration?(name)
44
+
45
+ # Skip if attribute name should be ignored
46
+ next if ignore_by_name?(name, opts)
47
+
48
+ # Skip if attribute content should be ignored
49
+ next if ignore_by_content?(value, opts)
50
+
51
+ # Apply match options for attribute values
52
+ behavior = match_opts[:attribute_values] || :strict
53
+ value = MatchOptions.process_attribute_value(value, behavior)
54
+
55
+ filtered[name] = value
56
+ end
57
+ end
58
+
59
+ # Filter hash-format attributes (Nokogiri/Moxml)
60
+ #
61
+ # @param attributes [Hash] Hash-like attributes
62
+ # @param opts [Hash] Comparison options
63
+ # @param match_opts [Hash] Resolved match options
64
+ # @param filtered [Hash] Output hash to populate
65
+ def self.filter_hash_attributes(attributes, opts, match_opts, filtered)
66
+ attributes.each do |key, val|
67
+ # Normalize key and value
68
+ name, value = normalize_attribute_pair(key, val)
69
+
70
+ # Skip namespace declarations - they're handled separately
71
+ next if namespace_declaration?(name)
72
+
73
+ # Skip if attribute name should be ignored
74
+ next if ignore_by_name?(name, opts)
75
+
76
+ # Skip if attribute content should be ignored
77
+ next if ignore_by_content?(value, opts)
78
+
79
+ # Apply match options for attribute values
80
+ behavior = match_opts[:attribute_values] || :strict
81
+ value = MatchOptions.process_attribute_value(value, behavior)
82
+
83
+ filtered[name] = value
84
+ end
85
+ end
86
+
87
+ # Normalize attribute key-value pair from different formats
88
+ #
89
+ # @param key [Object] Attribute key (String or Attribute object)
90
+ # @param val [Object] Attribute value
91
+ # @return [Array<String, String>] Normalized [name, value] pair
92
+ def self.normalize_attribute_pair(key, val)
93
+ if key.is_a?(String)
94
+ # Nokogiri format: key=name (String), val=attr object
95
+ name = key
96
+ value = val.respond_to?(:value) ? val.value : val.to_s
97
+ else
98
+ # Moxml format: key=attr object, val=nil
99
+ name = key.respond_to?(:name) ? key.name : key.to_s
100
+ value = key.respond_to?(:value) ? key.value : key.to_s
101
+ end
102
+
103
+ [name, value]
104
+ end
105
+
106
+ # Check if attribute should be ignored by name
107
+ #
108
+ # @param name [String] Attribute name
109
+ # @param opts [Hash] Comparison options
110
+ # @return [Boolean] true if should ignore
111
+ def self.ignore_by_name?(name, opts)
112
+ opts[:ignore_attrs_by_name].any? { |pattern| name.include?(pattern) }
113
+ end
114
+
115
+ # Check if attribute should be ignored by content
116
+ #
117
+ # @param value [String] Attribute value
118
+ # @param opts [Hash] Comparison options
119
+ # @return [Boolean] true if should ignore
120
+ def self.ignore_by_content?(value, opts)
121
+ opts[:ignore_attr_content].any? do |pattern|
122
+ value.to_s.include?(pattern)
123
+ end
124
+ end
125
+
126
+ # Check if an attribute name is a namespace declaration
127
+ #
128
+ # @param attr_name [String] Attribute name
129
+ # @return [Boolean] true if it's a namespace declaration
130
+ def self.namespace_declaration?(attr_name)
131
+ attr_name == "xmlns" || attr_name.start_with?("xmlns:")
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Canon
4
+ module Comparison
5
+ module XmlComparatorHelpers
6
+ # Child comparison service for XML nodes
7
+ #
8
+ # Handles comparison of child nodes using both semantic matching (ElementMatcher)
9
+ # and simple positional comparison. Delegates back to the comparator for
10
+ # individual node comparisons.
11
+ #
12
+ # This module encapsulates the complex child comparison logic, making the
13
+ # main XmlComparator cleaner and more maintainable.
14
+ module ChildComparison
15
+ class << self
16
+ # Compare children of two nodes using semantic matching
17
+ #
18
+ # Uses ElementMatcher to pair children semantically (by identity attributes
19
+ # or position), then compares matched pairs and detects position changes.
20
+ #
21
+ # @param node1 [Object] First parent node
22
+ # @param node2 [Object] Second parent node
23
+ # @param comparator [XmlComparator] The comparator instance for delegation
24
+ # @param opts [Hash] Comparison options
25
+ # @param child_opts [Hash] Options for child comparison
26
+ # @param diff_children [Boolean] Whether to diff children
27
+ # @param differences [Array] Array to collect differences
28
+ # @return [Integer] Comparison result code
29
+ def compare(node1, node2, comparator, opts, child_opts,
30
+ diff_children, differences)
31
+ children1 = comparator.send(:filter_children, node1.children, opts)
32
+ children2 = comparator.send(:filter_children, node2.children, opts)
33
+
34
+ # Quick check: if both have no children, they're equivalent
35
+ return Comparison::EQUIVALENT if children1.empty? && children2.empty?
36
+
37
+ # Check if we can use ElementMatcher (requires Canon::Xml::DataModel nodes)
38
+ if can_use_element_matcher?(children1, children2)
39
+ use_element_matcher_comparison(children1, children2, node1, comparator,
40
+ opts, child_opts, diff_children, differences)
41
+ else
42
+ use_positional_comparison(children1, children2, node1, comparator,
43
+ opts, child_opts, diff_children, differences)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ # Check if ElementMatcher can be used for these children
50
+ #
51
+ # ElementMatcher expects Canon::Xml::DataModel nodes with .node_type
52
+ # method that returns symbols, and only works with element nodes.
53
+ def can_use_element_matcher?(children1, children2)
54
+ !children1.empty? && !children2.empty? &&
55
+ children1.all? do |c|
56
+ c.is_a?(Canon::Xml::Node) && c.node_type == :element
57
+ end &&
58
+ children2.all? { |c| c.is_a?(Canon::Xml::Node) && c.node_type == :element }
59
+ end
60
+
61
+ # Use ElementMatcher for semantic comparison
62
+ def use_element_matcher_comparison(children1, children2, parent_node, comparator,
63
+ opts, child_opts, diff_children, differences)
64
+ require_relative "../../xml/element_matcher"
65
+ require_relative "../../xml/nodes/root_node"
66
+
67
+ # Create temporary RootNode wrappers
68
+ temp_root1 = Canon::Xml::Nodes::RootNode.new
69
+ temp_root1.instance_variable_set(:@children, children1.dup)
70
+
71
+ temp_root2 = Canon::Xml::Nodes::RootNode.new
72
+ temp_root2.instance_variable_set(:@children, children2.dup)
73
+
74
+ matcher = Canon::Xml::ElementMatcher.new
75
+ matches = matcher.match_trees(temp_root1, temp_root2)
76
+
77
+ # Filter matches to only include direct children
78
+ matches = matches.select do |m|
79
+ (m.elem1.nil? || children1.include?(m.elem1)) &&
80
+ (m.elem2.nil? || children2.include?(m.elem2))
81
+ end
82
+
83
+ # If no matches and children exist, they're all different
84
+ if matches.empty? && (!children1.empty? || !children2.empty?)
85
+ comparator.send(:add_difference, parent_node, parent_node,
86
+ Comparison::MISSING_NODE, Comparison::MISSING_NODE,
87
+ :text_content, opts, differences)
88
+ return Comparison::UNEQUAL_ELEMENTS
89
+ end
90
+
91
+ process_matches(matches, children1, children2, parent_node, comparator,
92
+ opts, child_opts, diff_children, differences)
93
+ end
94
+
95
+ # Process ElementMatcher results
96
+ def process_matches(matches, _children1, _children2, _parent_node, comparator,
97
+ opts, child_opts, diff_children, differences)
98
+ all_equivalent = true
99
+
100
+ matches.each do |match|
101
+ case match.status
102
+ when :matched
103
+ # Check if element position changed
104
+ if match.position_changed?
105
+ match_opts = opts[:match_opts]
106
+ position_behavior = match_opts[:element_position] || :strict
107
+
108
+ # Only create DiffNode if element_position is not :ignore
109
+ if position_behavior != :ignore
110
+ comparator.send(:add_difference, match.elem1, match.elem2,
111
+ "position #{match.pos1}", "position #{match.pos2}",
112
+ :element_position, opts, differences)
113
+ all_equivalent = false if position_behavior == :strict
114
+ end
115
+ end
116
+
117
+ # Compare the matched elements for content/attribute differences
118
+ result = comparator.send(:compare_nodes, match.elem1, match.elem2,
119
+ child_opts, child_opts, diff_children, differences)
120
+ all_equivalent = false unless result == Comparison::EQUIVALENT
121
+
122
+ when :deleted
123
+ # Element present in first tree but not second
124
+ comparator.send(:add_difference, match.elem1, nil,
125
+ Comparison::MISSING_NODE, Comparison::MISSING_NODE,
126
+ :element_structure, opts, differences)
127
+ all_equivalent = false
128
+
129
+ when :inserted
130
+ # Element present in second tree but not first
131
+ comparator.send(:add_difference, nil, match.elem2,
132
+ Comparison::MISSING_NODE, Comparison::MISSING_NODE,
133
+ :element_structure, opts, differences)
134
+ all_equivalent = false
135
+ end
136
+ end
137
+
138
+ all_equivalent ? Comparison::EQUIVALENT : Comparison::UNEQUAL_ELEMENTS
139
+ end
140
+
141
+ # Use simple positional comparison for children
142
+ def use_positional_comparison(children1, children2, parent_node, comparator,
143
+ opts, child_opts, diff_children, differences)
144
+ # Length check
145
+ unless children1.length == children2.length
146
+ dimension = determine_dimension_for_mismatch(children1,
147
+ children2, comparator)
148
+ comparator.send(:add_difference, parent_node, parent_node,
149
+ Comparison::MISSING_NODE, Comparison::MISSING_NODE,
150
+ dimension, opts, differences)
151
+ return Comparison::MISSING_NODE
152
+ end
153
+
154
+ # Compare children pairwise by position
155
+ result = Comparison::EQUIVALENT
156
+ children1.zip(children2).each do |child1, child2|
157
+ child_result = comparator.send(:compare_nodes, child1, child2,
158
+ child_opts, child_opts, diff_children, differences)
159
+ result = child_result unless child_result == Comparison::EQUIVALENT
160
+ end
161
+
162
+ result
163
+ end
164
+
165
+ # Determine dimension for length mismatch
166
+ def determine_dimension_for_mismatch(children1, children2, comparator)
167
+ dimension = :text_content # default
168
+
169
+ # Compare position by position to find first difference
170
+ max_len = [children1.length, children2.length].max
171
+ (0...max_len).each do |i|
172
+ if i >= children1.length
173
+ # Extra child in children2
174
+ dimension = comparator.send(:determine_node_dimension,
175
+ children2[i])
176
+ break
177
+ elsif i >= children2.length
178
+ # Extra child in children1
179
+ dimension = comparator.send(:determine_node_dimension,
180
+ children1[i])
181
+ break
182
+ elsif !comparator.send(:same_node_type?, children1[i],
183
+ children2[i])
184
+ # Different node types at same position
185
+ dimension = comparator.send(:determine_node_dimension,
186
+ children1[i])
187
+ break
188
+ end
189
+ end
190
+
191
+ dimension
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../diff/diff_node"
4
+ require_relative "../../diff/path_builder"
5
+ require_relative "../../diff/node_serializer"
6
+
7
+ module Canon
8
+ module Comparison
9
+ # Builder for creating enriched DiffNode objects
10
+ # Handles path building, serialization, and attribute extraction
11
+ class DiffNodeBuilder
12
+ # Build an enriched DiffNode
13
+ #
14
+ # @param node1 [Object, nil] First node
15
+ # @param node2 [Object, nil] Second node
16
+ # @param diff1 [String] Difference type for node1
17
+ # @param diff2 [String] Difference type for node2
18
+ # @param dimension [Symbol] The match dimension causing this difference
19
+ # @return [DiffNode, nil] Enriched DiffNode or nil if dimension is nil
20
+ def self.build(node1:, node2:, diff1:, diff2:, dimension:, **_opts)
21
+ # Validate dimension is required
22
+ if dimension.nil?
23
+ raise ArgumentError,
24
+ "dimension required for DiffNode"
25
+ end
26
+
27
+ # Build informative reason message
28
+ reason = build_reason(node1, node2, diff1, diff2, dimension)
29
+
30
+ # Enrich with path, serialized content, and attributes for Stage 4 rendering
31
+ metadata = enrich_metadata(node1, node2)
32
+
33
+ Canon::Diff::DiffNode.new(
34
+ node1: node1,
35
+ node2: node2,
36
+ dimension: dimension,
37
+ reason: reason,
38
+ **metadata,
39
+ )
40
+ end
41
+
42
+ # Build a human-readable reason for a difference
43
+ #
44
+ # @param node1 [Object] First node
45
+ # @param node2 [Object] Second node
46
+ # @param diff1 [String] Difference type for node1
47
+ # @param diff2 [String] Difference type for node2
48
+ # @param dimension [Symbol] The dimension of the difference
49
+ # @return [String] Human-readable reason
50
+ def self.build_reason(node1, node2, diff1, diff2, dimension)
51
+ # For deleted/inserted nodes, include namespace information if available
52
+ if dimension == :text_content && (node1.nil? || node2.nil?)
53
+ node = node1 || node2
54
+ if node.respond_to?(:name) && node.respond_to?(:namespace_uri)
55
+ ns = node.namespace_uri
56
+ ns_info = if ns.nil? || ns.empty?
57
+ ""
58
+ else
59
+ " (namespace: #{ns})"
60
+ end
61
+ return "element '#{node.name}'#{ns_info}: #{diff1} vs #{diff2}"
62
+ end
63
+ end
64
+
65
+ "#{diff1} vs #{diff2}"
66
+ end
67
+
68
+ # Enrich DiffNode with canonical path, serialized content, and attributes
69
+ # This extracts presentation-ready metadata from nodes for Stage 4 rendering
70
+ #
71
+ # @param node1 [Object, nil] First node
72
+ # @param node2 [Object, nil] Second node
73
+ # @return [Hash] Enriched metadata hash
74
+ def self.enrich_metadata(node1, node2)
75
+ {
76
+ path: build_path(node1 || node2),
77
+ serialized_before: serialize(node1),
78
+ serialized_after: serialize(node2),
79
+ attributes_before: extract_attributes(node1),
80
+ attributes_after: extract_attributes(node2),
81
+ }
82
+ end
83
+
84
+ # Build canonical path for a node
85
+ #
86
+ # @param node [Object] Node to build path for
87
+ # @return [String, nil] Canonical path with ordinal indices
88
+ def self.build_path(node)
89
+ return nil if node.nil?
90
+
91
+ Canon::Diff::PathBuilder.build(node, format: :document)
92
+ end
93
+
94
+ # Serialize a node to string for display
95
+ #
96
+ # @param node [Object, nil] Node to serialize
97
+ # @return [String, nil] Serialized content
98
+ def self.serialize(node)
99
+ return nil if node.nil?
100
+
101
+ Canon::Diff::NodeSerializer.serialize(node)
102
+ end
103
+
104
+ # Extract attributes from a node as a normalized hash
105
+ #
106
+ # @param node [Object, nil] Node to extract attributes from
107
+ # @return [Hash, nil] Normalized attributes hash
108
+ def self.extract_attributes(node)
109
+ return nil if node.nil?
110
+
111
+ Canon::Diff::NodeSerializer.extract_attributes(node)
112
+ end
113
+ end
114
+ end
115
+ end