canon 0.3.13 → 0.3.14
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/lib/canon/comparison/html_comparator.rb +13 -6
- data/lib/canon/comparison/markup_comparator.rb +1 -1
- data/lib/canon/comparison/match_options.rb +15 -0
- data/lib/canon/comparison/whitespace_sensitivity.rb +43 -17
- data/lib/canon/comparison/xml_comparator/child_comparison.rb +4 -15
- data/lib/canon/comparison.rb +10 -0
- data/lib/canon/html/data_model.rb +10 -7
- data/lib/canon/version.rb +1 -1
- data/lib/canon/xml/data_model.rb +37 -17
- data/lib/canon/xml/element_matcher.rb +67 -29
- data/lib/canon/xml/sax_builder.rb +15 -5
- data/lib/canon/xml/tree_builder.rb +41 -23
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9954ac6056439abc3c5b211ecf17b9af2391ad0b809eb3c239fef55cbdc723a
|
|
4
|
+
data.tar.gz: dbe215e817f160c840769b5bbd0969fe83d315d2b4429cec125de43312ec821a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1552e6f9a7735903f10b8f73902c6896cfab41b61da29b9bf725c6d5a1132483566eec58eeeb5ce2a59714a5e228ba8cb2979fa79f853cf5bd361f5ffcd849f
|
|
7
|
+
data.tar.gz: c2b93d2248033a3680164769ab57163f44935314a0929155fa98e2a488f1ea278bff8dba5bbdc38b19d313db13e9ddc6ffeb8b56cdf7537d1ba5c08098ec9bb2
|
|
@@ -44,6 +44,16 @@ module Canon
|
|
|
44
44
|
# @return [Boolean, Array] true if equivalent, or array of diffs if
|
|
45
45
|
# verbose
|
|
46
46
|
def equivalent?(html1, html2, opts = {}, child_opts = {})
|
|
47
|
+
# FAST PATH: identical inputs never differ under any profile —
|
|
48
|
+
# byte-identical HTML parses to identical DOMs. Comparison's
|
|
49
|
+
# entry point already catches this; it is repeated here for
|
|
50
|
+
# callers invoking the comparator directly. Verbose callers
|
|
51
|
+
# need full metadata, so they take the whole pipeline.
|
|
52
|
+
if !opts[:verbose] && (html1.equal?(html2) ||
|
|
53
|
+
(html1.is_a?(String) && html2.is_a?(String) && html1 == html2))
|
|
54
|
+
return true
|
|
55
|
+
end
|
|
56
|
+
|
|
47
57
|
opts = DEFAULT_OPTS.merge(opts)
|
|
48
58
|
|
|
49
59
|
# Capture original HTML strings for display.
|
|
@@ -94,10 +104,6 @@ module Canon
|
|
|
94
104
|
# Create child_opts with resolved options
|
|
95
105
|
child_opts = opts.merge(child_opts)
|
|
96
106
|
|
|
97
|
-
# Serialize preprocessed nodes for diff display (avoid re-preprocessing)
|
|
98
|
-
preprocessed_str1 = serialize_for_display(node1)
|
|
99
|
-
preprocessed_str2 = serialize_for_display(node2)
|
|
100
|
-
|
|
101
107
|
differences = []
|
|
102
108
|
diff_children = opts[:diff_children] || false
|
|
103
109
|
|
|
@@ -124,10 +130,11 @@ module Canon
|
|
|
124
130
|
if opts[:verbose]
|
|
125
131
|
ComparisonResult.new(
|
|
126
132
|
differences: differences,
|
|
127
|
-
preprocessed_strings: [
|
|
133
|
+
preprocessed_strings: [serialize_for_display(node1),
|
|
134
|
+
serialize_for_display(node2)],
|
|
128
135
|
original_strings: [original_str1, original_str2],
|
|
129
136
|
format: :html,
|
|
130
|
-
html_version:
|
|
137
|
+
html_version: html_version,
|
|
131
138
|
match_options: match_opts_hash,
|
|
132
139
|
algorithm: :dom,
|
|
133
140
|
parse_errors_expected: Comparison.parse_errors_for(node1),
|
|
@@ -93,7 +93,7 @@ module Canon
|
|
|
93
93
|
# Use preserve_whitespace_elements / strip_whitespace_elements to control.
|
|
94
94
|
# Blacklist (strip) > preserve > collapse > format defaults.
|
|
95
95
|
return false unless text_node?(node) && node.parent
|
|
96
|
-
return false unless MatchOptions.
|
|
96
|
+
return false unless MatchOptions.whitespace_only?(node_text(node))
|
|
97
97
|
|
|
98
98
|
# NBSP (U+00A0) is never insignificant whitespace —
|
|
99
99
|
# it always renders as a visible non-breaking space.
|
|
@@ -93,6 +93,21 @@ module Canon
|
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Normalize text preserving Unicode whitespace type distinctions.
|
|
96
|
+
# Fast form of `normalize_text(text).empty?`, called per text
|
|
97
|
+
# node by node_excluded?. Pure-ASCII whitespace (the common
|
|
98
|
+
# case — pretty-print indentation) matches a plain class with
|
|
99
|
+
# no intermediate strings and no \p{} property (Opal's JS
|
|
100
|
+
# regexes do not honor \p{Space}); anything else falls back to
|
|
101
|
+
# normalize_text's exact semantics. NUL is included because
|
|
102
|
+
# String#strip strips nulls too.
|
|
103
|
+
ASCII_WHITESPACE_ONLY = /\A[ \t\r\n\v\f\x00]*\z/
|
|
104
|
+
|
|
105
|
+
def whitespace_only?(text)
|
|
106
|
+
text = text.to_s
|
|
107
|
+
text.empty? || text.match?(ASCII_WHITESPACE_ONLY) ||
|
|
108
|
+
normalize_text(text).empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
96
111
|
def normalize_text_preserving_type(text)
|
|
97
112
|
return "" if text.nil?
|
|
98
113
|
|
|
@@ -24,6 +24,19 @@ module Canon
|
|
|
24
24
|
time tt u var wbr
|
|
25
25
|
].freeze
|
|
26
26
|
|
|
27
|
+
# Precomputed symbol forms of the default lists: format_default_*
|
|
28
|
+
# used to map(&:to_sym) per call — one array per classified node.
|
|
29
|
+
HTML_PRESERVE_SYMBOLS = HTML_PRESERVE_ELEMENTS.map(&:to_sym).freeze
|
|
30
|
+
HTML_COLLAPSE_SYMBOLS = HTML_COLLAPSE_ELEMENTS.map(&:to_sym).freeze
|
|
31
|
+
|
|
32
|
+
# The resolved element sets depend only on match_opts, which is
|
|
33
|
+
# resolved once per comparison — but classification runs per node,
|
|
34
|
+
# so the sets are memoized per match_opts object (identity-keyed;
|
|
35
|
+
# side-flag merges produce distinct objects and their own entries).
|
|
36
|
+
# Bounded: stale entries of finished comparisons cost a few
|
|
37
|
+
# hundred bytes until the next clear.
|
|
38
|
+
RESOLVED_SETS_LIMIT = 1024
|
|
39
|
+
|
|
27
40
|
class << self
|
|
28
41
|
# Classify the whitespace behaviour for an element using ancestor walk.
|
|
29
42
|
def classify_element(element, match_opts)
|
|
@@ -107,7 +120,7 @@ module Canon
|
|
|
107
120
|
format = match_opts[:format] || :xml
|
|
108
121
|
case format
|
|
109
122
|
when :html, :html4, :html5
|
|
110
|
-
|
|
123
|
+
HTML_PRESERVE_SYMBOLS
|
|
111
124
|
else
|
|
112
125
|
[].freeze
|
|
113
126
|
end
|
|
@@ -117,7 +130,7 @@ module Canon
|
|
|
117
130
|
format = match_opts[:format] || :xml
|
|
118
131
|
case format
|
|
119
132
|
when :html, :html4, :html5
|
|
120
|
-
|
|
133
|
+
HTML_COLLAPSE_SYMBOLS
|
|
121
134
|
else
|
|
122
135
|
[].freeze
|
|
123
136
|
end
|
|
@@ -184,30 +197,43 @@ module Canon
|
|
|
184
197
|
end
|
|
185
198
|
|
|
186
199
|
def resolved_preserve_elements_set(match_opts)
|
|
187
|
-
|
|
200
|
+
entry = resolved_sets_entry(match_opts)
|
|
201
|
+
entry[0] ||= begin
|
|
202
|
+
set = Set.new(format_default_preserve_elements(match_opts).map(&:to_s))
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
204
|
+
if match_opts[:preserve_whitespace_elements]
|
|
205
|
+
set |= match_opts[:preserve_whitespace_elements].map(&:to_s)
|
|
206
|
+
end
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
|
|
208
|
+
strip_set = resolved_strip_elements_set(match_opts)
|
|
209
|
+
set.reject { |e| strip_set.include?(e) }.to_set
|
|
210
|
+
end
|
|
195
211
|
end
|
|
196
212
|
|
|
197
213
|
def resolved_collapse_elements_set(match_opts)
|
|
198
|
-
|
|
214
|
+
entry = resolved_sets_entry(match_opts)
|
|
215
|
+
entry[1] ||= begin
|
|
216
|
+
set = Set.new(format_default_collapse_elements(match_opts).map(&:to_s))
|
|
199
217
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
218
|
+
if match_opts[:collapse_whitespace_elements]
|
|
219
|
+
set |= match_opts[:collapse_whitespace_elements].map(&:to_s)
|
|
220
|
+
end
|
|
203
221
|
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
strip_set = resolved_strip_elements_set(match_opts)
|
|
223
|
+
set.reject { |e| strip_set.include?(e) }.to_set
|
|
224
|
+
end
|
|
206
225
|
end
|
|
207
226
|
|
|
208
227
|
def resolved_strip_elements_set(match_opts)
|
|
209
|
-
|
|
210
|
-
Set.new((
|
|
228
|
+
entry = resolved_sets_entry(match_opts)
|
|
229
|
+
entry[2] ||= Set.new((match_opts[:strip_whitespace_elements] || [])
|
|
230
|
+
.map(&:to_s))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def resolved_sets_entry(match_opts)
|
|
234
|
+
cache = (@resolved_sets_cache ||= {}.compare_by_identity)
|
|
235
|
+
cache.clear if cache.size >= RESOLVED_SETS_LIMIT
|
|
236
|
+
cache[match_opts] ||= [nil, nil, nil]
|
|
211
237
|
end
|
|
212
238
|
|
|
213
239
|
def walk_ancestor_classification(element, preserve_set, collapse_set,
|
|
@@ -271,7 +297,7 @@ module Canon
|
|
|
271
297
|
name = node_name(element)
|
|
272
298
|
return false unless name
|
|
273
299
|
|
|
274
|
-
list.
|
|
300
|
+
list.any? { |e| e.to_s == name }
|
|
275
301
|
end
|
|
276
302
|
|
|
277
303
|
def text_node_parent?(node)
|
|
@@ -75,21 +75,10 @@ module Canon
|
|
|
75
75
|
# Use ElementMatcher for semantic comparison
|
|
76
76
|
def use_element_matcher_comparison(children1, children2, parent_node, comparator,
|
|
77
77
|
opts, child_opts, diff_children, differences)
|
|
78
|
-
#
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
temp_root2 = Canon::Xml::Nodes::RootNode.new
|
|
83
|
-
temp_root2.children = children2.dup
|
|
84
|
-
|
|
85
|
-
matcher = Canon::Xml::ElementMatcher.new
|
|
86
|
-
matches = matcher.match_trees(temp_root1, temp_root2)
|
|
87
|
-
|
|
88
|
-
# Filter matches to only include direct children
|
|
89
|
-
matches = matches.select do |m|
|
|
90
|
-
(m.elem1.nil? || children1.include?(m.elem1)) &&
|
|
91
|
-
(m.elem2.nil? || children2.include?(m.elem2))
|
|
92
|
-
end
|
|
78
|
+
# Single-level matching: the comparator itself descends via
|
|
79
|
+
# compare_nodes below, so nested matches would be discarded.
|
|
80
|
+
matches = Canon::Xml::ElementMatcher.new
|
|
81
|
+
.match_children_only(children1, children2)
|
|
93
82
|
|
|
94
83
|
# If no matches and children exist, they're all different
|
|
95
84
|
if matches.empty? && (!children1.empty? || children2.empty?)
|
data/lib/canon/comparison.rb
CHANGED
|
@@ -230,6 +230,16 @@ module Canon
|
|
|
230
230
|
# - :verbose - Return detailed diff array (default: false)
|
|
231
231
|
# @return [Boolean, Array] true if equivalent, or array of diffs if verbose
|
|
232
232
|
def equivalent?(obj1, obj2, opts = {})
|
|
233
|
+
# FAST PATH: identical inputs never differ under any profile —
|
|
234
|
+
# byte-identical strings parse to identical structures, and one
|
|
235
|
+
# object parsed twice yields the same tree. Verbose callers need
|
|
236
|
+
# full metadata (differences list, statistics), so they take the
|
|
237
|
+
# whole pipeline.
|
|
238
|
+
if !opts[:verbose] && (obj1.equal?(obj2) ||
|
|
239
|
+
(obj1.is_a?(String) && obj2.is_a?(String) && obj1 == obj2))
|
|
240
|
+
return true
|
|
241
|
+
end
|
|
242
|
+
|
|
233
243
|
# Normalize: match: { semantic_diff: true } → diff_algorithm: :semantic
|
|
234
244
|
if opts.dig(:match, :semantic_diff) || opts.dig(:match, :semantic_tree)
|
|
235
245
|
opts = opts.merge(diff_algorithm: :semantic)
|
|
@@ -107,17 +107,20 @@ module Canon
|
|
|
107
107
|
inherited_namespaces,
|
|
108
108
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
|
|
109
109
|
)
|
|
110
|
+
# HTML attributes are namespace-free; xmlns declarations are
|
|
111
|
+
# not reported as attributes. Flat stride-4 array
|
|
112
|
+
# (TreeBuilder#element contract).
|
|
113
|
+
flat_attributes = []
|
|
114
|
+
node.attribute_nodes.each do |attr|
|
|
115
|
+
next if attr.name.start_with?("xmlns")
|
|
116
|
+
|
|
117
|
+
flat_attributes << attr.name << attr.value << nil << nil
|
|
118
|
+
end
|
|
110
119
|
element = builder.element(
|
|
111
120
|
name: node.name,
|
|
112
121
|
prefix: node.namespace&.prefix,
|
|
113
122
|
namespace_uri: node.namespace&.href,
|
|
114
|
-
|
|
115
|
-
# not reported as attributes.
|
|
116
|
-
attributes: node.attribute_nodes.filter_map do |attr|
|
|
117
|
-
next if attr.name.start_with?("xmlns")
|
|
118
|
-
|
|
119
|
-
[attr.name, attr.value, nil, nil]
|
|
120
|
-
end,
|
|
123
|
+
attributes: flat_attributes,
|
|
121
124
|
namespace_scope: scope,
|
|
122
125
|
)
|
|
123
126
|
node.children.each do |child|
|
data/lib/canon/version.rb
CHANGED
data/lib/canon/xml/data_model.rb
CHANGED
|
@@ -171,13 +171,16 @@ inherited_namespaces: nil)
|
|
|
171
171
|
inherited_namespaces,
|
|
172
172
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
|
|
173
173
|
)
|
|
174
|
+
flat_attributes = []
|
|
175
|
+
node.attribute_nodes.each do |attr|
|
|
176
|
+
flat_attributes << attr.name << attr.value <<
|
|
177
|
+
attr.namespace&.href << attr.namespace&.prefix
|
|
178
|
+
end
|
|
174
179
|
element = TreeBuilder::DEFAULT.element(
|
|
175
180
|
name: node.name,
|
|
176
181
|
prefix: node.namespace&.prefix,
|
|
177
182
|
namespace_uri: node.namespace&.href,
|
|
178
|
-
attributes:
|
|
179
|
-
[attr.name, attr.value, attr.namespace&.href, attr.namespace&.prefix]
|
|
180
|
-
end,
|
|
183
|
+
attributes: flat_attributes,
|
|
181
184
|
namespace_scope: scope,
|
|
182
185
|
)
|
|
183
186
|
node.children.each do |child|
|
|
@@ -252,13 +255,18 @@ inherited_namespaces: nil)
|
|
|
252
255
|
|
|
253
256
|
def self.build_moxml_subtree_from_records(moxml_element,
|
|
254
257
|
preserve_whitespace: false)
|
|
255
|
-
|
|
258
|
+
# Parallel depth/node stacks: one [depth, node] pair per record
|
|
259
|
+
# cost two Arrays per node; depths are Integers (immediate
|
|
260
|
+
# values), so two parallel arrays allocate nothing per record.
|
|
261
|
+
frame_depths = []
|
|
262
|
+
frame_nodes = []
|
|
256
263
|
own_namespaces = {}.compare_by_identity
|
|
257
264
|
|
|
258
265
|
# materialize_fields: the zero-allocation hot path (moxml#143).
|
|
259
266
|
# Flat reused buffers — attributes stride 4, namespaces stride
|
|
260
267
|
# 2 — valid only inside the block, so the element builder
|
|
261
|
-
#
|
|
268
|
+
# consumes them synchronously before the next record reuses
|
|
269
|
+
# the buffer.
|
|
262
270
|
moxml_element.materialize_fields do |kind, qname, prefix, namespace_uri, namespaces, attributes, text, depth|
|
|
263
271
|
# The record contract is root-subtree-only since moxml 0.5.11
|
|
264
272
|
# (moxml#140). Older 0.5.x releases — still allowed by canon's
|
|
@@ -286,7 +294,7 @@ preserve_whitespace: false)
|
|
|
286
294
|
when :element
|
|
287
295
|
build_moxml_element_from_fields(
|
|
288
296
|
qname, prefix, namespace_uri, namespaces, attributes,
|
|
289
|
-
depth,
|
|
297
|
+
depth, frame_depths, frame_nodes, own_namespaces
|
|
290
298
|
)
|
|
291
299
|
when :text, :cdata
|
|
292
300
|
content = text.to_s
|
|
@@ -300,30 +308,39 @@ preserve_whitespace: false)
|
|
|
300
308
|
TreeBuilder::DEFAULT.processing_instruction(qname, text || "")
|
|
301
309
|
end
|
|
302
310
|
|
|
303
|
-
|
|
311
|
+
if node
|
|
312
|
+
frame_depths << depth
|
|
313
|
+
frame_nodes << node
|
|
314
|
+
end
|
|
304
315
|
end
|
|
305
316
|
|
|
306
|
-
|
|
307
|
-
return nil unless top
|
|
317
|
+
return nil if frame_nodes.empty?
|
|
308
318
|
|
|
309
|
-
|
|
310
|
-
top
|
|
319
|
+
top = frame_nodes.last
|
|
320
|
+
assign_moxml_namespace_scopes(top, nil, own_namespaces)
|
|
321
|
+
top
|
|
311
322
|
end
|
|
312
323
|
|
|
313
324
|
def self.build_moxml_element_from_fields(qname, prefix, namespace_uri,
|
|
314
325
|
namespaces, attributes, depth,
|
|
315
|
-
|
|
326
|
+
frame_depths, frame_nodes,
|
|
327
|
+
own_namespaces)
|
|
328
|
+
# The flat attribute buffer is consumed synchronously — the
|
|
329
|
+
# next record reuses it (no each_slice copy out of the block).
|
|
316
330
|
element = TreeBuilder::DEFAULT.element(
|
|
317
331
|
name: qname,
|
|
318
332
|
prefix: prefix,
|
|
319
333
|
namespace_uri: namespace_uri,
|
|
320
|
-
attributes: attributes
|
|
334
|
+
attributes: attributes,
|
|
321
335
|
)
|
|
322
336
|
|
|
323
337
|
# Adopt completed children: they pop in reverse order; reversing
|
|
324
338
|
# once is O(n) (unshift per child would be O(n^2) on wide trees).
|
|
325
339
|
children = []
|
|
326
|
-
|
|
340
|
+
while frame_depths.any? && frame_depths.last > depth
|
|
341
|
+
frame_depths.pop
|
|
342
|
+
children << frame_nodes.pop
|
|
343
|
+
end
|
|
327
344
|
children.reverse_each { |child| element.add_child(child) }
|
|
328
345
|
|
|
329
346
|
# Copy the declarations out of the reused buffer (most
|
|
@@ -352,13 +369,16 @@ inherited_namespaces: nil)
|
|
|
352
369
|
inherited_namespaces,
|
|
353
370
|
node.namespace_definitions.map { |ns| [ns.prefix, ns.uri] },
|
|
354
371
|
)
|
|
372
|
+
flat_attributes = []
|
|
373
|
+
node.attributes.each do |attr|
|
|
374
|
+
flat_attributes << attr.name << attr.value <<
|
|
375
|
+
attr.namespace&.uri << attr.namespace&.prefix
|
|
376
|
+
end
|
|
355
377
|
element = TreeBuilder::DEFAULT.element(
|
|
356
378
|
name: node.name,
|
|
357
379
|
prefix: node.namespace&.prefix,
|
|
358
380
|
namespace_uri: node.namespace&.uri,
|
|
359
|
-
attributes:
|
|
360
|
-
[attr.name, attr.value, attr.namespace&.uri, attr.namespace&.prefix]
|
|
361
|
-
end,
|
|
381
|
+
attributes: flat_attributes,
|
|
362
382
|
namespace_scope: scope,
|
|
363
383
|
)
|
|
364
384
|
node.children.each do |child|
|
|
@@ -130,6 +130,21 @@ module Canon
|
|
|
130
130
|
@matches
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
+
# Match one level of children only — no descent into matched
|
|
134
|
+
# pairs. The comparator walks descendants itself (compare_nodes →
|
|
135
|
+
# ChildComparison per level), so the nested matches match_trees
|
|
136
|
+
# would produce below this level are discarded by its direct-
|
|
137
|
+
# children filter; matching them is wasted work.
|
|
138
|
+
#
|
|
139
|
+
# @param children1 [Array<Canon::Xml::Node>] First parent's children
|
|
140
|
+
# @param children2 [Array<Canon::Xml::Node>] Second parent's children
|
|
141
|
+
# @return [Array<MatchResult>] Match results for these children only
|
|
142
|
+
def match_children_only(children1, children2)
|
|
143
|
+
@matches = []
|
|
144
|
+
match_level(children1, children2, [], recursive: false)
|
|
145
|
+
@matches
|
|
146
|
+
end
|
|
147
|
+
|
|
133
148
|
private
|
|
134
149
|
|
|
135
150
|
# Match children recursively
|
|
@@ -137,10 +152,25 @@ module Canon
|
|
|
137
152
|
# FAST PATH: Same array object means all children match
|
|
138
153
|
return if children1.equal?(children2)
|
|
139
154
|
|
|
155
|
+
match_level(children1, children2, path, recursive: true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# One matching level: identity attributes, then name+namespace
|
|
159
|
+
# position, then class attribute, then deleted/inserted recording.
|
|
160
|
+
# With +recursive:+ true, matched pairs descend immediately after
|
|
161
|
+
# being recorded (match_trees' ordering contract).
|
|
162
|
+
def match_level(children1, children2, path, recursive:)
|
|
140
163
|
# Filter to only element nodes
|
|
141
164
|
elems1 = children1.select { |n| n.node_type == :element }
|
|
142
165
|
elems2 = children2.select { |n| n.node_type == :element }
|
|
143
166
|
|
|
167
|
+
# Positions by identity: elems.index(elem) was an O(n) scan per
|
|
168
|
+
# recorded match — O(n²) per level on element-heavy parents.
|
|
169
|
+
positions1 = {}
|
|
170
|
+
elems1.each_with_index { |e, i| positions1[e] = i }
|
|
171
|
+
positions2 = {}
|
|
172
|
+
elems2.each_with_index { |e, i| positions2[e] = i }
|
|
173
|
+
|
|
144
174
|
# Build identity maps for quick lookup
|
|
145
175
|
map1 = build_identity_map(elems1)
|
|
146
176
|
map2 = build_identity_map(elems2)
|
|
@@ -160,24 +190,22 @@ module Canon
|
|
|
160
190
|
path + [elem1.name]
|
|
161
191
|
end
|
|
162
192
|
|
|
163
|
-
# Track positions
|
|
164
|
-
pos1 = elems1.index(elem1)
|
|
165
|
-
pos2 = elems2.index(elem2)
|
|
166
|
-
|
|
167
193
|
@matches << MatchResult.new(
|
|
168
194
|
status: :matched,
|
|
169
195
|
elem1: elem1,
|
|
170
196
|
elem2: elem2,
|
|
171
197
|
path: elem_path_with_ns,
|
|
172
|
-
pos1:
|
|
173
|
-
pos2:
|
|
198
|
+
pos1: positions1[elem1],
|
|
199
|
+
pos2: positions2[elem2],
|
|
174
200
|
)
|
|
175
201
|
|
|
176
202
|
matched1.add(elem1)
|
|
177
203
|
matched2.add(elem2)
|
|
178
204
|
|
|
179
205
|
# Recursively match children
|
|
180
|
-
|
|
206
|
+
if recursive
|
|
207
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
208
|
+
end
|
|
181
209
|
end
|
|
182
210
|
end
|
|
183
211
|
|
|
@@ -185,14 +213,15 @@ module Canon
|
|
|
185
213
|
unmatched1 = elems1.reject { |e| matched1.include?(e) }
|
|
186
214
|
unmatched2 = elems2.reject { |e| matched2.include?(e) }
|
|
187
215
|
|
|
188
|
-
match_by_position(unmatched1, unmatched2, path, matched1, matched2
|
|
216
|
+
match_by_position(unmatched1, unmatched2, path, matched1, matched2,
|
|
217
|
+
recursive: recursive)
|
|
189
218
|
|
|
190
219
|
# Fallback: match remaining elements by class attribute
|
|
191
220
|
# This handles insertions that shift positions
|
|
192
221
|
still_unmatched1 = elems1.reject { |e| matched1.include?(e) }
|
|
193
222
|
still_unmatched2 = elems2.reject { |e| matched2.include?(e) }
|
|
194
223
|
match_by_class(still_unmatched1, still_unmatched2, path, matched1,
|
|
195
|
-
matched2)
|
|
224
|
+
matched2, recursive: recursive)
|
|
196
225
|
|
|
197
226
|
# Record unmatched as deleted/inserted
|
|
198
227
|
unmatched1.each do |elem1|
|
|
@@ -203,14 +232,13 @@ module Canon
|
|
|
203
232
|
else
|
|
204
233
|
path + [elem1.name]
|
|
205
234
|
end
|
|
206
|
-
pos1 = elems1.index(elem1)
|
|
207
235
|
|
|
208
236
|
@matches << MatchResult.new(
|
|
209
237
|
status: :deleted,
|
|
210
238
|
elem1: elem1,
|
|
211
239
|
elem2: nil,
|
|
212
240
|
path: elem_path_with_ns,
|
|
213
|
-
pos1:
|
|
241
|
+
pos1: positions1[elem1],
|
|
214
242
|
pos2: nil,
|
|
215
243
|
)
|
|
216
244
|
end
|
|
@@ -223,7 +251,6 @@ module Canon
|
|
|
223
251
|
else
|
|
224
252
|
path + [elem2.name]
|
|
225
253
|
end
|
|
226
|
-
pos2 = elems2.index(elem2)
|
|
227
254
|
|
|
228
255
|
@matches << MatchResult.new(
|
|
229
256
|
status: :inserted,
|
|
@@ -231,17 +258,25 @@ module Canon
|
|
|
231
258
|
elem2: elem2,
|
|
232
259
|
path: elem_path_with_ns,
|
|
233
260
|
pos1: nil,
|
|
234
|
-
pos2:
|
|
261
|
+
pos2: positions2[elem2],
|
|
235
262
|
)
|
|
236
263
|
end
|
|
237
264
|
end
|
|
238
265
|
|
|
239
266
|
# Match remaining elements by name and position
|
|
240
|
-
def match_by_position(elems1, elems2, path, matched1, matched2
|
|
267
|
+
def match_by_position(elems1, elems2, path, matched1, matched2,
|
|
268
|
+
recursive:)
|
|
241
269
|
# Group by element name AND namespace_uri
|
|
242
270
|
by_identity1 = elems1.group_by { |e| [e.name, e.namespace_uri] }
|
|
243
271
|
by_identity2 = elems2.group_by { |e| [e.name, e.namespace_uri] }
|
|
244
272
|
|
|
273
|
+
# Positions within these (already unmatched-filtered) lists —
|
|
274
|
+
# elems.index(elem) was an O(n) scan per recorded match
|
|
275
|
+
subset_positions1 = {}
|
|
276
|
+
elems1.each_with_index { |e, i| subset_positions1[e] = i }
|
|
277
|
+
subset_positions2 = {}
|
|
278
|
+
elems2.each_with_index { |e, i| subset_positions2[e] = i }
|
|
279
|
+
|
|
245
280
|
# For each name+namespace combination, match by position
|
|
246
281
|
by_identity1.each do |identity, list1|
|
|
247
282
|
next unless by_identity2.key?(identity)
|
|
@@ -264,34 +299,39 @@ module Canon
|
|
|
264
299
|
path + [name]
|
|
265
300
|
end
|
|
266
301
|
|
|
267
|
-
# Track positions in original element lists
|
|
268
|
-
pos1 = elems1.index(elem1)
|
|
269
|
-
pos2 = elems2.index(elem2)
|
|
270
|
-
|
|
271
302
|
@matches << MatchResult.new(
|
|
272
303
|
status: :matched,
|
|
273
304
|
elem1: elem1,
|
|
274
305
|
elem2: elem2,
|
|
275
306
|
path: elem_path_with_ns,
|
|
276
|
-
pos1:
|
|
277
|
-
pos2:
|
|
307
|
+
pos1: subset_positions1[elem1],
|
|
308
|
+
pos2: subset_positions2[elem2],
|
|
278
309
|
)
|
|
279
310
|
matched1.add(elem1)
|
|
280
311
|
matched2.add(elem2)
|
|
281
312
|
|
|
282
313
|
# Recursively match children
|
|
283
|
-
|
|
314
|
+
if recursive
|
|
315
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
316
|
+
end
|
|
284
317
|
end
|
|
285
318
|
end
|
|
286
319
|
end
|
|
287
320
|
|
|
288
321
|
# Match remaining elements by class attribute (position-independent)
|
|
289
322
|
# This handles cases where insertions shift positions but elements have class-based identity
|
|
290
|
-
def match_by_class(elems1, elems2, path, matched1, matched2
|
|
323
|
+
def match_by_class(elems1, elems2, path, matched1, matched2,
|
|
324
|
+
recursive:)
|
|
291
325
|
# Build class maps for elements that have class attributes
|
|
292
326
|
class_map1 = build_class_map(elems1)
|
|
293
327
|
class_map2 = build_class_map(elems2)
|
|
294
328
|
|
|
329
|
+
# Positions within these (still-unmatched-filtered) lists
|
|
330
|
+
subset_positions1 = {}
|
|
331
|
+
elems1.each_with_index { |e, i| subset_positions1[e] = i }
|
|
332
|
+
subset_positions2 = {}
|
|
333
|
+
elems2.each_with_index { |e, i| subset_positions2[e] = i }
|
|
334
|
+
|
|
295
335
|
# Match by class attribute
|
|
296
336
|
class_map1.each do |class_value, elem1|
|
|
297
337
|
next if matched1.include?(elem1)
|
|
@@ -311,23 +351,21 @@ module Canon
|
|
|
311
351
|
path + [elem1.name]
|
|
312
352
|
end
|
|
313
353
|
|
|
314
|
-
# Track positions in original element lists
|
|
315
|
-
pos1 = elems1.index(elem1)
|
|
316
|
-
pos2 = elems2.index(elem2)
|
|
317
|
-
|
|
318
354
|
@matches << MatchResult.new(
|
|
319
355
|
status: :matched,
|
|
320
356
|
elem1: elem1,
|
|
321
357
|
elem2: elem2,
|
|
322
358
|
path: elem_path_with_ns,
|
|
323
|
-
pos1:
|
|
324
|
-
pos2:
|
|
359
|
+
pos1: subset_positions1[elem1],
|
|
360
|
+
pos2: subset_positions2[elem2],
|
|
325
361
|
)
|
|
326
362
|
matched1.add(elem1)
|
|
327
363
|
matched2.add(elem2)
|
|
328
364
|
|
|
329
365
|
# Recursively match children
|
|
330
|
-
|
|
366
|
+
if recursive
|
|
367
|
+
match_children(elem1.children, elem2.children, elem_path_with_ns)
|
|
368
|
+
end
|
|
331
369
|
end
|
|
332
370
|
end
|
|
333
371
|
|
|
@@ -138,16 +138,22 @@ strip_doctype: false)
|
|
|
138
138
|
new_scope = ns_hash.empty? ? inherited_scope : inherited_scope.merge(ns_hash)
|
|
139
139
|
@namespace_stack.push(new_scope)
|
|
140
140
|
|
|
141
|
+
# Flat stride-4 attribute array (TreeBuilder#element contract):
|
|
142
|
+
# one array per element instead of one sub-array per attribute.
|
|
143
|
+
flat_attributes = []
|
|
144
|
+
regular_attrs.each do |attr_name, attr_value|
|
|
145
|
+
attr_prefix, attr_local = parse_qname(attr_name)
|
|
146
|
+
attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
|
|
147
|
+
flat_attributes << attr_local <<
|
|
148
|
+
decode_character_references(attr_value || "") << attr_ns_uri << attr_prefix
|
|
149
|
+
end
|
|
150
|
+
|
|
141
151
|
element = TreeBuilder::DEFAULT.element(
|
|
142
152
|
name: local_name,
|
|
143
153
|
prefix: prefix,
|
|
144
154
|
namespace_uri: new_scope[prefix.to_s],
|
|
145
155
|
namespace_scope: new_scope,
|
|
146
|
-
attributes:
|
|
147
|
-
attr_prefix, attr_local = parse_qname(attr_name)
|
|
148
|
-
attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
|
|
149
|
-
[attr_local, decode_character_references(attr_value || ""), attr_ns_uri, attr_prefix]
|
|
150
|
-
end,
|
|
156
|
+
attributes: flat_attributes,
|
|
151
157
|
)
|
|
152
158
|
|
|
153
159
|
parent.add_child(element)
|
|
@@ -324,6 +330,10 @@ strip_doctype: false)
|
|
|
324
330
|
# @param value [String] String potentially containing character references
|
|
325
331
|
# @return [String] String with character references decoded
|
|
326
332
|
def decode_character_references(value)
|
|
333
|
+
# Fast path: no reference opener means nothing to decode —
|
|
334
|
+
# skip the gsub and its string copy on every plain text chunk.
|
|
335
|
+
return value unless value.include?("&")
|
|
336
|
+
|
|
327
337
|
value.gsub(/&#(x?[0-9a-fA-F]+);/) do |match|
|
|
328
338
|
code_str = Regexp.last_match(1)
|
|
329
339
|
code_point = if code_str.start_with?("x")
|
|
@@ -23,6 +23,21 @@ module Canon
|
|
|
23
23
|
XML_NAMESPACE_PREFIX = "xml"
|
|
24
24
|
XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"
|
|
25
25
|
|
|
26
|
+
# Repetitive short strings — element/attribute names, prefixes,
|
|
27
|
+
# namespace URIs — are interned so one frozen instance is shared
|
|
28
|
+
# across every tree canon builds: a thousand `<p>` elements hold
|
|
29
|
+
# one "p". Values (text content, attribute values) are unique and
|
|
30
|
+
# are never interned. Bounded; cleared when full.
|
|
31
|
+
INTERN_LIMIT = 8192
|
|
32
|
+
|
|
33
|
+
def intern(string)
|
|
34
|
+
return string if string.nil?
|
|
35
|
+
|
|
36
|
+
cache = (@string_intern_cache ||= {})
|
|
37
|
+
cache.clear if cache.size >= INTERN_LIMIT
|
|
38
|
+
cache.fetch(string) { cache[string] = string.freeze }
|
|
39
|
+
end
|
|
40
|
+
|
|
26
41
|
# In-scope namespace bindings: the element's own declarations
|
|
27
42
|
# shadow inherited ones; xml is prebound at the base. Declaration
|
|
28
43
|
# pairs are [prefix-or-nil, uri]; nil and "" both mean the default
|
|
@@ -56,53 +71,56 @@ module Canon
|
|
|
56
71
|
nodes = cache[scope]
|
|
57
72
|
if nodes.nil?
|
|
58
73
|
nodes = scope.map do |prefix, uri|
|
|
59
|
-
Nodes::NamespaceNode.new(prefix: prefix, uri: uri)
|
|
74
|
+
Nodes::NamespaceNode.new(prefix: intern(prefix), uri: intern(uri))
|
|
60
75
|
end.freeze
|
|
61
76
|
cache[scope] = nodes
|
|
62
77
|
end
|
|
63
78
|
element.namespace_nodes = nodes
|
|
64
79
|
end
|
|
65
80
|
|
|
66
|
-
# Build an element. `attributes`
|
|
67
|
-
#
|
|
68
|
-
#
|
|
81
|
+
# Build an element. `attributes` is a FLAT stride-4 array —
|
|
82
|
+
# [name, value, namespace_uri, prefix, name, value, ...] — so the
|
|
83
|
+
# moxml records feed hands its reused buffer straight through
|
|
84
|
+
# (read synchronously below) and the tree feeds build one flat
|
|
85
|
+
# array instead of one sub-array per attribute. `namespace_scope`
|
|
86
|
+
# is a merged scope (or nil for no namespace nodes).
|
|
69
87
|
def element(name:, prefix: nil, namespace_uri: nil,
|
|
70
88
|
attributes: NO_ATTRIBUTES, namespace_scope: nil)
|
|
71
89
|
element = Nodes::ElementNode.new(
|
|
72
|
-
name: name,
|
|
73
|
-
namespace_uri: namespace_uri,
|
|
74
|
-
prefix: prefix,
|
|
90
|
+
name: intern(name),
|
|
91
|
+
namespace_uri: intern(namespace_uri),
|
|
92
|
+
prefix: intern(prefix),
|
|
75
93
|
)
|
|
76
94
|
attach_namespace_scope(element, namespace_scope) if namespace_scope
|
|
77
95
|
|
|
78
96
|
# Duplicate (name, namespace) pairs are invalid XML — first
|
|
79
|
-
# occurrence wins.
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
attr_name =
|
|
85
|
-
attr_namespace_uri =
|
|
97
|
+
# occurrence wins. Flat index arithmetic keeps the scan
|
|
98
|
+
# allocation-free.
|
|
99
|
+
base = 0
|
|
100
|
+
limit = attributes.size
|
|
101
|
+
while base < limit
|
|
102
|
+
attr_name = attributes[base]
|
|
103
|
+
attr_namespace_uri = attributes[base + 2]
|
|
86
104
|
duplicate = false
|
|
87
105
|
prior = 0
|
|
88
|
-
while prior <
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
while prior < base
|
|
107
|
+
if attributes[prior] == attr_name &&
|
|
108
|
+
attributes[prior + 2] == attr_namespace_uri
|
|
91
109
|
duplicate = true
|
|
92
110
|
break
|
|
93
111
|
end
|
|
94
|
-
prior +=
|
|
112
|
+
prior += 4
|
|
95
113
|
end
|
|
96
114
|
|
|
97
115
|
unless duplicate
|
|
98
116
|
element.add_attribute(Nodes::AttributeNode.new(
|
|
99
|
-
name: attr_name,
|
|
100
|
-
value:
|
|
101
|
-
namespace_uri: attr_namespace_uri,
|
|
102
|
-
prefix:
|
|
117
|
+
name: intern(attr_name),
|
|
118
|
+
value: attributes[base + 1],
|
|
119
|
+
namespace_uri: intern(attr_namespace_uri),
|
|
120
|
+
prefix: intern(attributes[base + 3]),
|
|
103
121
|
))
|
|
104
122
|
end
|
|
105
|
-
|
|
123
|
+
base += 4
|
|
106
124
|
end
|
|
107
125
|
|
|
108
126
|
element
|