canon 0.2.8 → 0.2.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/.rspec-opal +7 -0
- data/.rubocop_todo.yml +14 -71
- data/Rakefile +17 -0
- data/lib/canon/cli.rb +1 -1
- data/lib/canon/color_detector.rb +3 -5
- data/lib/canon/comparison/compare_profile.rb +1 -4
- data/lib/canon/comparison/dimensions/attribute_order_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/attribute_presence_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/attribute_values_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/comments_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/element_position_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/structural_whitespace_dimension.rb +2 -6
- data/lib/canon/comparison/dimensions/text_content_dimension.rb +3 -5
- data/lib/canon/comparison/format_detector.rb +29 -20
- data/lib/canon/comparison/html_comparator.rb +18 -29
- data/lib/canon/comparison/html_compare_profile.rb +3 -10
- data/lib/canon/comparison/html_parser.rb +1 -1
- data/lib/canon/comparison/json_comparator.rb +8 -0
- data/lib/canon/comparison/node_inspector.rb +146 -80
- data/lib/canon/comparison/strategies/semantic_tree_match_strategy.rb +6 -8
- data/lib/canon/comparison/whitespace_sensitivity.rb +55 -193
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +5 -10
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +4 -4
- data/lib/canon/comparison/xml_comparator/diff_node_builder.rb +10 -8
- data/lib/canon/comparison/xml_comparator/namespace_comparator.rb +14 -28
- data/lib/canon/comparison/xml_comparator/node_parser.rb +12 -11
- data/lib/canon/comparison/xml_comparator/node_type_comparator.rb +30 -58
- data/lib/canon/comparison/xml_comparator.rb +61 -83
- data/lib/canon/comparison/xml_node_comparison.rb +15 -15
- data/lib/canon/comparison/yaml_comparator.rb +8 -0
- data/lib/canon/comparison.rb +23 -23
- data/lib/canon/config/profile_loader.rb +13 -13
- data/lib/canon/config.rb +29 -5
- data/lib/canon/diff/diff_classifier.rb +7 -41
- data/lib/canon/diff/diff_line.rb +1 -1
- data/lib/canon/diff/diff_node_enricher.rb +22 -24
- data/lib/canon/diff/node_serializer.rb +23 -30
- data/lib/canon/diff/path_builder.rb +24 -37
- data/lib/canon/diff/source_locator.rb +0 -3
- data/lib/canon/diff/xml_serialization_formatter.rb +8 -81
- data/lib/canon/diff_formatter/by_line/base_formatter.rb +7 -7
- data/lib/canon/diff_formatter/by_line/json_formatter.rb +1 -1
- data/lib/canon/diff_formatter/by_line/simple_formatter.rb +1 -1
- data/lib/canon/diff_formatter/by_line/xml_formatter.rb +2 -2
- data/lib/canon/diff_formatter/by_line/yaml_formatter.rb +1 -1
- data/lib/canon/diff_formatter/by_line_formatter.rb +1 -1
- data/lib/canon/diff_formatter/by_object/base_formatter.rb +11 -15
- data/lib/canon/diff_formatter/by_object/xml_formatter.rb +8 -10
- data/lib/canon/diff_formatter/by_object_formatter.rb +1 -1
- data/lib/canon/diff_formatter/debug_output.rb +12 -24
- data/lib/canon/diff_formatter/diff_detail_formatter/color_helper.rb +2 -2
- data/lib/canon/diff_formatter/diff_detail_formatter/node_utils.rb +146 -318
- data/lib/canon/diff_formatter/diff_detail_formatter.rb +28 -20
- data/lib/canon/diff_formatter/legend.rb +2 -2
- data/lib/canon/diff_formatter/pretty_diff_formatter.rb +2 -2
- data/lib/canon/diff_formatter/theme.rb +4 -4
- data/lib/canon/diff_formatter.rb +2 -2
- data/lib/canon/formatters/html_formatter.rb +1 -1
- data/lib/canon/formatters/html_formatter_base.rb +1 -1
- data/lib/canon/formatters/xml_formatter.rb +7 -32
- data/lib/canon/html/data_model.rb +1 -1
- data/lib/canon/pretty_printer/html.rb +1 -1
- data/lib/canon/pretty_printer/xml.rb +16 -7
- data/lib/canon/pretty_printer/xml_normalized.rb +9 -3
- data/lib/canon/rspec_matchers.rb +2 -2
- data/lib/canon/tree_diff/adapters/html_adapter.rb +1 -1
- data/lib/canon/tree_diff/adapters/xml_adapter.rb +1 -1
- data/lib/canon/tree_diff/core/tree_node.rb +1 -3
- data/lib/canon/validators/html_validator.rb +1 -1
- data/lib/canon/validators/xml_validator.rb +1 -1
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +131 -137
- data/lib/canon/xml/namespace_helper.rb +5 -0
- data/lib/canon/xml/node.rb +2 -1
- data/lib/canon/xml/nodes/root_node.rb +4 -0
- data/lib/canon/xml/nodes/text_node.rb +6 -1
- data/lib/canon/xml/sax_builder.rb +4 -6
- data/lib/canon/xml_backend.rb +49 -0
- data/lib/canon/xml_parsing.rb +271 -0
- data/lib/canon.rb +3 -1
- data/lib/tasks/benchmark_runner.rb +1 -1
- data/lib/tasks/performance_helpers.rb +1 -1
- metadata +5 -2
|
@@ -1,444 +1,272 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "cgi"
|
|
4
4
|
require_relative "../../xml/namespace_helper"
|
|
5
5
|
|
|
6
6
|
module Canon
|
|
7
7
|
class DiffFormatter
|
|
8
8
|
module DiffDetailFormatterHelpers
|
|
9
|
-
# Node utility methods
|
|
9
|
+
# Node utility methods for the diff detail formatter.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
11
|
+
# All node queries delegate to NodeInspector / XmlParsing.
|
|
12
|
+
# No respond_to? — types are known at every call site.
|
|
12
13
|
module NodeUtils
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
# @param node [Object] Node to extract attributes from
|
|
16
|
-
# @return [Array<String>] Array of attribute names
|
|
14
|
+
# --- Attribute extraction ---
|
|
15
|
+
|
|
17
16
|
def self.get_attribute_names(node)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
node.attributes
|
|
24
|
-
elsif node.respond_to?(:[]) && node.respond_to?(:each)
|
|
25
|
-
# Hash-like node
|
|
26
|
-
node.keys
|
|
27
|
-
else
|
|
28
|
-
[]
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
return [] unless attrs
|
|
32
|
-
|
|
33
|
-
# Handle different attribute formats
|
|
34
|
-
if attrs.is_a?(Array)
|
|
35
|
-
attrs.map { |attr| attr.respond_to?(:name) ? attr.name : attr.to_s }
|
|
36
|
-
elsif attrs.respond_to?(:keys)
|
|
37
|
-
attrs.keys.map(&:to_s)
|
|
38
|
-
else
|
|
39
|
-
[]
|
|
40
|
-
end
|
|
17
|
+
extract_attribute_names(node)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.get_attribute_names_in_order(node)
|
|
21
|
+
extract_attribute_names(node)
|
|
41
22
|
end
|
|
42
23
|
|
|
43
|
-
# Find all differing attributes between two nodes
|
|
44
|
-
#
|
|
45
|
-
# @param node1 [Object] First node
|
|
46
|
-
# @param node2 [Object] Second node
|
|
47
|
-
# @return [Array<String>] Array of attribute names with different values
|
|
48
24
|
def self.find_all_differing_attributes(node1, node2)
|
|
49
25
|
return [] unless node1 && node2
|
|
50
26
|
|
|
51
27
|
attrs1 = get_attributes_hash(node1)
|
|
52
28
|
attrs2 = get_attributes_hash(node2)
|
|
53
29
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
all_keys.reject do |key|
|
|
30
|
+
(attrs1.keys | attrs2.keys).reject do |key|
|
|
57
31
|
attrs1[key.to_s] == attrs2[key.to_s]
|
|
58
32
|
end
|
|
59
33
|
end
|
|
60
34
|
|
|
61
|
-
# Get attribute names in order from a node
|
|
62
|
-
#
|
|
63
|
-
# @param node [Object] Node to extract from
|
|
64
|
-
# @return [Array<String>] Ordered array of attribute names
|
|
65
|
-
def self.get_attribute_names_in_order(node)
|
|
66
|
-
return [] unless node
|
|
67
|
-
|
|
68
|
-
attrs = if node.respond_to?(:attribute_nodes)
|
|
69
|
-
node.attribute_nodes
|
|
70
|
-
elsif node.respond_to?(:attributes)
|
|
71
|
-
node.attributes
|
|
72
|
-
else
|
|
73
|
-
[]
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
return [] unless attrs
|
|
77
|
-
|
|
78
|
-
if attrs.is_a?(Array)
|
|
79
|
-
attrs.map { |attr| attr.respond_to?(:name) ? attr.name : attr.to_s }
|
|
80
|
-
else
|
|
81
|
-
attrs.keys.map(&:to_s)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Get attributes as a hash
|
|
86
|
-
#
|
|
87
|
-
# @param node [Object] Node to extract from
|
|
88
|
-
# @return [Hash] Attributes hash
|
|
89
35
|
def self.get_attributes_hash(node)
|
|
90
36
|
return {} unless node
|
|
91
37
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{}
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
return {} unless attrs
|
|
101
|
-
|
|
102
|
-
result = {}
|
|
103
|
-
if attrs.is_a?(Array)
|
|
104
|
-
attrs.each do |attr|
|
|
105
|
-
name = attr.respond_to?(:name) ? attr.name : attr.to_s
|
|
106
|
-
value = attr.respond_to?(:value) ? attr.value : attr.to_s
|
|
107
|
-
result[name] = value
|
|
108
|
-
end
|
|
109
|
-
elsif attrs.respond_to?(:each)
|
|
110
|
-
attrs.each do |key, val|
|
|
111
|
-
name = key.to_s
|
|
112
|
-
value = if val.respond_to?(:value)
|
|
113
|
-
val.value
|
|
114
|
-
elsif val.respond_to?(:content)
|
|
115
|
-
val.content
|
|
116
|
-
else
|
|
117
|
-
val.to_s
|
|
118
|
-
end
|
|
119
|
-
result[name] = value
|
|
120
|
-
end
|
|
38
|
+
case node
|
|
39
|
+
when Canon::Xml::Nodes::ElementNode
|
|
40
|
+
node.attribute_nodes.to_h { |a| [a.name.to_s, a.value.to_s] }
|
|
41
|
+
else
|
|
42
|
+
backend_attributes_hash(node)
|
|
121
43
|
end
|
|
122
|
-
|
|
123
|
-
result
|
|
124
44
|
end
|
|
125
45
|
|
|
126
|
-
# Get attribute value from a node
|
|
127
|
-
#
|
|
128
|
-
# @param node [Object] Node to extract from
|
|
129
|
-
# @param attr_name [String] Attribute name
|
|
130
|
-
# @return [String, nil] Attribute value or nil
|
|
131
46
|
def self.get_attribute_value(node, attr_name)
|
|
132
47
|
return nil unless node && attr_name
|
|
133
48
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
elsif value.respond_to?(:to_s)
|
|
141
|
-
value.to_s
|
|
142
|
-
else
|
|
143
|
-
value
|
|
144
|
-
end
|
|
145
|
-
elsif node.respond_to?(:get_attribute)
|
|
146
|
-
attr = node.get_attribute(attr_name)
|
|
147
|
-
attr.respond_to?(:value) ? attr.value : attr
|
|
148
|
-
elsif node.respond_to?(:attribute_nodes)
|
|
149
|
-
attribute_node = node.attribute_nodes.find do |attr|
|
|
150
|
-
attr.name == attr_name.to_s
|
|
151
|
-
end
|
|
152
|
-
attribute_node&.value
|
|
49
|
+
case node
|
|
50
|
+
when Canon::Xml::Nodes::ElementNode
|
|
51
|
+
attr = node.attribute_nodes.find { |a| a.name == attr_name.to_s }
|
|
52
|
+
attr&.value
|
|
53
|
+
else
|
|
54
|
+
XmlParsing.attribute_value(node, attr_name)
|
|
153
55
|
end
|
|
154
56
|
end
|
|
155
57
|
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
# @param node [Object] Node to extract from
|
|
159
|
-
# @return [String] Text content
|
|
58
|
+
# --- Text / name / namespace ---
|
|
59
|
+
|
|
160
60
|
def self.get_node_text(node)
|
|
161
61
|
return "" unless node
|
|
162
62
|
|
|
163
|
-
|
|
164
|
-
node.text
|
|
165
|
-
elsif node.respond_to?(:content)
|
|
166
|
-
node.content
|
|
167
|
-
elsif node.respond_to?(:inner_text)
|
|
168
|
-
node.inner_text
|
|
169
|
-
elsif node.respond_to?(:value)
|
|
170
|
-
node.value
|
|
171
|
-
elsif node.respond_to?(:node_info)
|
|
172
|
-
node.node_info
|
|
173
|
-
elsif node.respond_to?(:to_s)
|
|
174
|
-
node.to_s
|
|
175
|
-
else
|
|
176
|
-
""
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
strip_ascii_whitespace(text.to_s)
|
|
63
|
+
strip_ascii_whitespace(Canon::Comparison::NodeInspector.text_content(node).to_s)
|
|
180
64
|
end
|
|
181
65
|
|
|
182
|
-
|
|
183
|
-
# whitespace like non-breaking space (\u00A0). Ruby's String#strip removes
|
|
184
|
-
# all Unicode whitespace, which destroys meaningful content like \u00A0.
|
|
185
|
-
#
|
|
186
|
-
# @param str [String] String to strip
|
|
187
|
-
# @return [String] String with leading/trailing ASCII whitespace removed
|
|
188
|
-
ASCII_WHITESPACE_BYTES = [32, 9, 13, 10].freeze # ' ', '\t', '\r', '\n'
|
|
66
|
+
ASCII_WHITESPACE_PATTERN = /[ \t\r\n]/
|
|
189
67
|
|
|
190
68
|
def self.strip_ascii_whitespace(str)
|
|
191
69
|
return "" if str.nil?
|
|
192
70
|
return str if str.empty?
|
|
193
71
|
|
|
194
|
-
# Find first non-ASCII-whitespace character position
|
|
195
72
|
first_pos = str.index(/[^ \t\r\n]/)
|
|
196
73
|
return "" unless first_pos
|
|
197
74
|
|
|
198
|
-
# Find last non-ASCII-whitespace character position (from end)
|
|
199
|
-
# Use reverse and index, then convert back to forward position
|
|
200
75
|
reversed_pos = str.reverse.index(/[^ \t\r\n]/)
|
|
201
76
|
last_pos = str.length - 1 - reversed_pos
|
|
202
77
|
|
|
203
78
|
str[first_pos..last_pos]
|
|
204
79
|
end
|
|
205
80
|
|
|
206
|
-
# Get element name for display
|
|
207
|
-
#
|
|
208
|
-
# @param node [Object] Node to get name from
|
|
209
|
-
# @return [String] Element name
|
|
210
81
|
def self.get_element_name_for_display(node)
|
|
211
82
|
return "" unless node
|
|
212
83
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
# Handle CommentNode specially since it doesn't respond to :name
|
|
219
|
-
if node.is_a?(Canon::Xml::Nodes::CommentNode)
|
|
220
|
-
return "comment"
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
if node.respond_to?(:name)
|
|
224
|
-
node.name.to_s
|
|
84
|
+
case node
|
|
85
|
+
when Canon::Xml::Nodes::TextNode
|
|
86
|
+
"text"
|
|
87
|
+
when Canon::Xml::Nodes::CommentNode
|
|
88
|
+
"comment"
|
|
225
89
|
else
|
|
226
|
-
node.
|
|
90
|
+
Canon::Comparison::NodeInspector.name(node).to_s
|
|
227
91
|
end
|
|
228
92
|
end
|
|
229
93
|
|
|
230
|
-
# Get namespace URI for display
|
|
231
|
-
#
|
|
232
|
-
# @param node [Object] Node to get namespace from
|
|
233
|
-
# @return [String] Namespace URI
|
|
234
94
|
def self.get_namespace_uri_for_display(node)
|
|
235
95
|
return "" unless node
|
|
236
96
|
|
|
237
|
-
|
|
238
|
-
node.namespace_uri.to_s
|
|
239
|
-
elsif node.respond_to?(:namespace)
|
|
240
|
-
ns = node.namespace
|
|
241
|
-
ns.respond_to?(:href) ? ns.href.to_s : ""
|
|
242
|
-
else
|
|
243
|
-
""
|
|
244
|
-
end
|
|
97
|
+
Canon::Comparison::Canon::Comparison::NodeInspector.namespace_uri(node).to_s
|
|
245
98
|
end
|
|
246
99
|
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
# @param node [Object] Node to format
|
|
250
|
-
# @return [String] Brief node description
|
|
100
|
+
# --- Display helpers ---
|
|
101
|
+
|
|
251
102
|
def self.format_node_brief(node)
|
|
252
103
|
return "" unless node
|
|
253
104
|
|
|
254
105
|
name = get_element_name_for_display(node)
|
|
255
106
|
text = get_node_text(node)
|
|
256
107
|
|
|
257
|
-
|
|
258
|
-
|
|
108
|
+
text && !text.empty? ? "#{name}(\"#{text}\")" : name
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.node_to_display(node, compact: false)
|
|
112
|
+
if compact && node.is_a?(Canon::Xml::Nodes::ElementNode)
|
|
113
|
+
serialize_node_compact(node)
|
|
259
114
|
else
|
|
260
|
-
|
|
115
|
+
get_node_text(node)
|
|
261
116
|
end
|
|
262
117
|
end
|
|
263
118
|
|
|
264
|
-
#
|
|
265
|
-
|
|
266
|
-
# Produces a human-readable inline XML string without namespace
|
|
267
|
-
# declarations and without indentation — suitable for use in Semantic
|
|
268
|
-
# Diff Report entries. Handles both +Canon::Xml::Nodes+ types and
|
|
269
|
-
# Nokogiri XML/HTML nodes (the html DOM comparison path uses
|
|
270
|
-
# Nokogiri nodes, so element-structure diffs originating there must
|
|
271
|
-
# be rendered structurally too — see issue #120). For any other
|
|
272
|
-
# node type, falls back to +get_node_text+.
|
|
273
|
-
#
|
|
274
|
-
# @param node [Object] Node to serialize
|
|
275
|
-
# @return [String] Compact XML string
|
|
119
|
+
# --- Serialization ---
|
|
120
|
+
|
|
276
121
|
def self.serialize_node_compact(node)
|
|
277
|
-
require "cgi"
|
|
278
122
|
return "" unless node
|
|
279
123
|
|
|
280
124
|
case node
|
|
281
125
|
when Canon::Xml::Nodes::TextNode
|
|
282
126
|
CGI.escapeHTML(node.value.to_s)
|
|
283
|
-
when Canon::Xml::Nodes::ElementNode
|
|
284
|
-
tag = node.name.to_s
|
|
285
|
-
attrs = node.attribute_nodes.map do |attr|
|
|
286
|
-
attr_name = attr.respond_to?(:name) ? attr.name.to_s : attr.to_s
|
|
287
|
-
attr_value = attr.respond_to?(:value) ? attr.value.to_s : ""
|
|
288
|
-
" #{attr_name}=\"#{CGI.escapeHTML(attr_value)}\""
|
|
289
|
-
end.join
|
|
290
|
-
children_xml = node.children.map do |c|
|
|
291
|
-
serialize_node_compact(c)
|
|
292
|
-
end.join
|
|
293
|
-
if children_xml.empty?
|
|
294
|
-
"<#{tag}#{attrs}/>"
|
|
295
|
-
else
|
|
296
|
-
"<#{tag}#{attrs}>#{children_xml}</#{tag}>"
|
|
297
|
-
end
|
|
298
127
|
when Canon::Xml::Nodes::CommentNode
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
CGI.escapeHTML(node.content.to_s)
|
|
303
|
-
when Nokogiri::XML::Comment
|
|
304
|
-
"<!--#{CGI.escapeHTML(node.content.to_s)}-->"
|
|
305
|
-
when Nokogiri::XML::Element
|
|
306
|
-
tag = node.name.to_s
|
|
307
|
-
attrs = node.attribute_nodes.map do |a|
|
|
308
|
-
" #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
|
|
309
|
-
end.join
|
|
310
|
-
children_xml = node.children.map do |c|
|
|
311
|
-
serialize_node_compact(c)
|
|
312
|
-
end.join
|
|
313
|
-
if children_xml.empty?
|
|
314
|
-
"<#{tag}#{attrs}/>"
|
|
315
|
-
else
|
|
316
|
-
"<#{tag}#{attrs}>#{children_xml}</#{tag}>"
|
|
317
|
-
end
|
|
128
|
+
"<!--#{CGI.escapeHTML(node.value.to_s)}-->"
|
|
129
|
+
when Canon::Xml::Nodes::ElementNode
|
|
130
|
+
serialize_element_compact(node)
|
|
318
131
|
else
|
|
319
|
-
|
|
320
|
-
get_node_text(node)
|
|
132
|
+
serialize_backend_node_compact(node)
|
|
321
133
|
end
|
|
322
134
|
end
|
|
323
135
|
|
|
324
|
-
# Serialize a node's open tag only — name + attributes, no children,
|
|
325
|
-
# no closing tag. Used by +format_text_content_one_sided+ to render
|
|
326
|
-
# a brief parent-element context hint (e.g. +<div id="A">+) for a
|
|
327
|
-
# one-sided text diff, instead of the full ancestor subtree that
|
|
328
|
-
# +serialize_node_compact+ would produce. See lutaml/canon#125.
|
|
329
|
-
#
|
|
330
|
-
# @param node [Object] Element node to serialize
|
|
331
|
-
# @return [String] Open-tag string, or "" for non-elements / nil
|
|
332
136
|
def self.serialize_open_tag(node)
|
|
333
|
-
require "cgi"
|
|
334
137
|
return "" unless node
|
|
335
138
|
|
|
336
139
|
case node
|
|
337
140
|
when Canon::Xml::Nodes::ElementNode
|
|
338
|
-
tag = node.name.to_s
|
|
339
|
-
attrs = node.attribute_nodes.map do |attr|
|
|
340
|
-
" #{attr.name}=\"#{CGI.escapeHTML(attr.value.to_s)}\""
|
|
341
|
-
end.join
|
|
342
|
-
"<#{tag}#{attrs}>"
|
|
343
|
-
when Nokogiri::XML::Element
|
|
344
141
|
tag = node.name.to_s
|
|
345
142
|
attrs = node.attribute_nodes.map do |a|
|
|
346
143
|
" #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
|
|
347
144
|
end.join
|
|
348
145
|
"<#{tag}#{attrs}>"
|
|
349
146
|
else
|
|
350
|
-
|
|
147
|
+
serialize_backend_open_tag(node)
|
|
351
148
|
end
|
|
352
149
|
end
|
|
353
150
|
|
|
354
|
-
# Return the raw text content of a text node without stripping
|
|
355
|
-
# whitespace. +get_node_text+ strips ASCII whitespace, which
|
|
356
|
-
# destroys whitespace-only payloads that callers (e.g. one-sided
|
|
357
|
-
# text-content diff rendering) need to display verbatim.
|
|
358
|
-
#
|
|
359
|
-
# @param node [Object] Text node
|
|
360
|
-
# @return [String] Raw text content, or "" if not a text-bearing node
|
|
361
151
|
def self.raw_text_value(node)
|
|
362
152
|
return "" unless node
|
|
363
153
|
|
|
364
|
-
|
|
365
|
-
when Canon::Xml::Node
|
|
366
|
-
node.value.to_s
|
|
367
|
-
when Nokogiri::XML::Node
|
|
368
|
-
node.content.to_s
|
|
369
|
-
else
|
|
370
|
-
""
|
|
371
|
-
end
|
|
154
|
+
Canon::Comparison::NodeInspector.text_content(node).to_s
|
|
372
155
|
end
|
|
373
156
|
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
# When +compact: true+ and the node is a Canon ElementNode, returns a
|
|
377
|
-
# compact XML serialization (e.g. +<strong>Annex</strong>+) instead of
|
|
378
|
-
# the +node_info+ description string that +get_node_text+ would produce.
|
|
379
|
-
# In all other cases, delegates to +get_node_text+.
|
|
380
|
-
#
|
|
381
|
-
# @param node [Object] Node to display
|
|
382
|
-
# @param compact [Boolean] Whether to use compact XML for element nodes
|
|
383
|
-
# @return [String] Display string
|
|
384
|
-
def self.node_to_display(node, compact: false)
|
|
385
|
-
if compact && node.is_a?(Canon::Xml::Nodes::ElementNode)
|
|
386
|
-
serialize_node_compact(node)
|
|
387
|
-
else
|
|
388
|
-
get_node_text(node)
|
|
389
|
-
end
|
|
390
|
-
end
|
|
157
|
+
# --- Traversal ---
|
|
391
158
|
|
|
392
|
-
# Return the parent of a node, or nil, regardless of the node API.
|
|
393
|
-
#
|
|
394
|
-
# Canon::Xml nodes expose +parent+; some Nokogiri-shaped nodes expose
|
|
395
|
-
# +parent_node+. This helper abstracts over both.
|
|
396
|
-
#
|
|
397
|
-
# @param node [Object] Node to query
|
|
398
|
-
# @return [Object, nil] Parent node or nil
|
|
399
159
|
def self.parent_of(node)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if node.respond_to?(:parent)
|
|
403
|
-
node.parent
|
|
404
|
-
elsif node.respond_to?(:parent_node)
|
|
405
|
-
node.parent_node
|
|
406
|
-
end
|
|
160
|
+
Canon::Comparison::NodeInspector.parent(node)
|
|
407
161
|
end
|
|
408
162
|
|
|
409
|
-
# Check if node is inside a preserve-whitespace element
|
|
410
|
-
#
|
|
411
|
-
# @param node [Object] Node to check
|
|
412
|
-
# @return [Boolean] true if inside preserve element
|
|
413
163
|
def self.inside_preserve_element?(node)
|
|
414
164
|
return false unless node
|
|
415
165
|
|
|
416
166
|
preserve_elements = %w[pre code textarea script style]
|
|
417
167
|
|
|
418
|
-
# Check the node itself
|
|
419
|
-
if node.respond_to?(:name) && preserve_elements.include?(node.name.to_s.downcase)
|
|
420
|
-
return true
|
|
421
|
-
end
|
|
422
|
-
|
|
423
|
-
# Check ancestors
|
|
424
168
|
current = node
|
|
425
169
|
while current
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
170
|
+
name = Canon::Comparison::NodeInspector.name(current)
|
|
171
|
+
return true if name && preserve_elements.include?(name.to_s.downcase)
|
|
172
|
+
|
|
173
|
+
parent = Canon::Comparison::NodeInspector.parent(current)
|
|
174
|
+
break if parent.nil? || parent == current
|
|
175
|
+
|
|
176
|
+
current = parent
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class << self
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def extract_attribute_names(node)
|
|
186
|
+
return [] unless node
|
|
187
|
+
|
|
188
|
+
case node
|
|
189
|
+
when Canon::Xml::Nodes::ElementNode
|
|
190
|
+
node.attribute_nodes.map(&:name)
|
|
430
191
|
else
|
|
431
|
-
|
|
192
|
+
attrs = XmlParsing.attributes(node)
|
|
193
|
+
return [] unless attrs
|
|
194
|
+
return attrs.map { |a| a.name.to_s } if attrs.is_a?(Array)
|
|
195
|
+
|
|
196
|
+
attrs.keys.map(&:to_s)
|
|
432
197
|
end
|
|
198
|
+
end
|
|
433
199
|
|
|
434
|
-
|
|
200
|
+
def backend_attributes_hash(node)
|
|
201
|
+
attrs = XmlParsing.attributes(node)
|
|
202
|
+
return {} unless attrs
|
|
435
203
|
|
|
436
|
-
if
|
|
437
|
-
|
|
204
|
+
if attrs.is_a?(Array)
|
|
205
|
+
attrs.each_with_object({}) do |attr, h|
|
|
206
|
+
name = attr.is_a?(Canon::Xml::Nodes::AttributeNode) ? attr.name : XmlParsing.name(attr).to_s
|
|
207
|
+
value = attr.is_a?(Canon::Xml::Nodes::AttributeNode) ? attr.value : XmlParsing.text_content(attr).to_s
|
|
208
|
+
h[name.to_s] = value
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
attrs.each_with_object({}) do |(key, val), h|
|
|
212
|
+
h[key.to_s] =
|
|
213
|
+
val.is_a?(String) ? val : XmlParsing.text_content(val).to_s
|
|
214
|
+
end
|
|
438
215
|
end
|
|
439
216
|
end
|
|
440
217
|
|
|
441
|
-
|
|
218
|
+
def serialize_element_compact(element_node)
|
|
219
|
+
tag = element_node.name.to_s
|
|
220
|
+
attrs = element_node.attribute_nodes.map do |a|
|
|
221
|
+
" #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
|
|
222
|
+
end.join
|
|
223
|
+
children_xml = element_node.children.map do |c|
|
|
224
|
+
serialize_node_compact(c)
|
|
225
|
+
end.join
|
|
226
|
+
children_xml.empty? ? "<#{tag}#{attrs}/>" : "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def serialize_backend_node_compact(node)
|
|
230
|
+
if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Node)
|
|
231
|
+
serialize_nokogiri_node_compact(node)
|
|
232
|
+
elsif node.is_a?(Canon::Xml::Node)
|
|
233
|
+
serialize_node_compact(node)
|
|
234
|
+
else
|
|
235
|
+
get_node_text(node)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def serialize_nokogiri_node_compact(node)
|
|
240
|
+
case node
|
|
241
|
+
when Nokogiri::XML::Text, Nokogiri::XML::CDATA
|
|
242
|
+
CGI.escapeHTML(node.content.to_s)
|
|
243
|
+
when Nokogiri::XML::Comment
|
|
244
|
+
"<!--#{CGI.escapeHTML(node.content.to_s)}-->"
|
|
245
|
+
when Nokogiri::XML::Element
|
|
246
|
+
tag = node.name.to_s
|
|
247
|
+
attrs = node.attribute_nodes.map do |a|
|
|
248
|
+
" #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
|
|
249
|
+
end.join
|
|
250
|
+
children_xml = node.children.map do |c|
|
|
251
|
+
serialize_node_compact(c)
|
|
252
|
+
end.join
|
|
253
|
+
children_xml.empty? ? "<#{tag}#{attrs}/>" : "<#{tag}#{attrs}>#{children_xml}</#{tag}>"
|
|
254
|
+
else
|
|
255
|
+
get_node_text(node)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def serialize_backend_open_tag(node)
|
|
260
|
+
if XmlBackend.nokogiri? && node.is_a?(Nokogiri::XML::Element)
|
|
261
|
+
tag = node.name.to_s
|
|
262
|
+
attrs = node.attribute_nodes.map do |a|
|
|
263
|
+
" #{a.name}=\"#{CGI.escapeHTML(a.value.to_s)}\""
|
|
264
|
+
end.join
|
|
265
|
+
"<#{tag}#{attrs}>"
|
|
266
|
+
else
|
|
267
|
+
""
|
|
268
|
+
end
|
|
269
|
+
end
|
|
442
270
|
end
|
|
443
271
|
end
|
|
444
272
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "rainbow"
|
|
3
|
+
require "rainbow" unless RUBY_ENGINE == "opal"
|
|
4
4
|
require_relative "../xml/namespace_helper"
|
|
5
5
|
# DiffDetailFormatter helper modules
|
|
6
6
|
require_relative "diff_detail_formatter/text_utils"
|
|
@@ -33,11 +33,8 @@ module Canon
|
|
|
33
33
|
return "" if differences.empty?
|
|
34
34
|
|
|
35
35
|
# Group differences by normative status
|
|
36
|
-
normative = differences.
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
informative = differences.select do |diff|
|
|
40
|
-
diff.respond_to?(:normative?) && !diff.normative?
|
|
36
|
+
normative, informative = differences.partition do |diff|
|
|
37
|
+
normative?(diff)
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
# Apply show_diffs filter — same semantics as the line-diff filter
|
|
@@ -100,24 +97,14 @@ compact: false, expand_difference: false)
|
|
|
100
97
|
output = []
|
|
101
98
|
|
|
102
99
|
# Header - handle both DiffNode and Hash
|
|
103
|
-
status = section || (
|
|
104
|
-
diff.normative? ? "NORMATIVE" : "INFORMATIVE"
|
|
105
|
-
else
|
|
106
|
-
"NORMATIVE" # Hash diffs are always normative
|
|
107
|
-
end)
|
|
100
|
+
status = section || (normative?(diff) ? "NORMATIVE" : "INFORMATIVE")
|
|
108
101
|
status_color = status == "NORMATIVE" ? :green : :yellow
|
|
109
102
|
output << colorize("🔍 DIFFERENCE ##{number}/#{total} [#{status}]",
|
|
110
103
|
status_color, use_color, bold: true)
|
|
111
104
|
output << colorize("─" * 70, :cyan, use_color)
|
|
112
105
|
|
|
113
106
|
# Dimension - handle both DiffNode and Hash
|
|
114
|
-
dimension =
|
|
115
|
-
diff.dimension
|
|
116
|
-
elsif diff.is_a?(Hash)
|
|
117
|
-
diff[:diff_code] || diff[:dimension] || "unknown"
|
|
118
|
-
else
|
|
119
|
-
"unknown"
|
|
120
|
-
end
|
|
107
|
+
dimension = diff_dimension(diff)
|
|
121
108
|
output << "#{colorize('Dimension:', :cyan, use_color,
|
|
122
109
|
bold: true)} #{colorize(dimension.to_s,
|
|
123
110
|
:magenta, use_color)}"
|
|
@@ -129,7 +116,7 @@ compact: false, expand_difference: false)
|
|
|
129
116
|
use_color)}"
|
|
130
117
|
|
|
131
118
|
# show reason if available
|
|
132
|
-
if diff
|
|
119
|
+
if diff_reason(diff)
|
|
133
120
|
format_reason_line(output, diff.reason, use_color)
|
|
134
121
|
end
|
|
135
122
|
output << ""
|
|
@@ -157,7 +144,7 @@ compact: false, expand_difference: false)
|
|
|
157
144
|
end
|
|
158
145
|
|
|
159
146
|
dimension = begin
|
|
160
|
-
diff
|
|
147
|
+
diff_dimension(diff)
|
|
161
148
|
rescue StandardError
|
|
162
149
|
"unknown"
|
|
163
150
|
end
|
|
@@ -177,6 +164,27 @@ compact: false, expand_difference: false)
|
|
|
177
164
|
colorize(error_msg, :red, use_color, bold: true)
|
|
178
165
|
end
|
|
179
166
|
|
|
167
|
+
# Protocol helpers — call DiffNode-like methods safely on any object.
|
|
168
|
+
# DiffNode, FakeDiff (tests), and Hash all support these via duck typing.
|
|
169
|
+
# Using rescue avoids respond_to? while remaining polymorphic.
|
|
170
|
+
def normative?(diff)
|
|
171
|
+
diff.normative?
|
|
172
|
+
rescue NoMethodError
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def diff_dimension(diff)
|
|
177
|
+
diff.dimension
|
|
178
|
+
rescue NoMethodError
|
|
179
|
+
diff.is_a?(Hash) ? (diff[:diff_code] || diff[:dimension] || "unknown") : "unknown"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def diff_reason(diff)
|
|
183
|
+
diff.reason
|
|
184
|
+
rescue NoMethodError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
180
188
|
# Format the Reason line. When the reason contains visualized
|
|
181
189
|
# spaces (░), split into two vertically-aligned lines so the
|
|
182
190
|
# before/after text can be compared visually.
|