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,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
|