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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e052ba548bcbe2090cf0ebb538cc50a56ca64cc30a24e875418dbbe9accd053d
4
- data.tar.gz: 6dfb8f2c9f840d0326271545ffee0ec137f3313469fa78c0cf80c9dc3e14f931
3
+ metadata.gz: d9954ac6056439abc3c5b211ecf17b9af2391ad0b809eb3c239fef55cbdc723a
4
+ data.tar.gz: dbe215e817f160c840769b5bbd0969fe83d315d2b4429cec125de43312ec821a
5
5
  SHA512:
6
- metadata.gz: c041ca7ded4daedf25b1ecc59d6e3be6ebb7df8dfa12f4bf87ae5458fdfb7bdcc2c3aa8bad1eb92f75c365ee91047d075be45cd05694477275ad1f9313b665be
7
- data.tar.gz: 97d0a91b0fb8629cc57315872b2a8c151c2c06b28eca0f6c686c9ba6258b3405e3717fbbc72fa837794ed1129d2412c684955194de8a5e5112831ad4af695bb1
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: [preprocessed_str1, preprocessed_str2],
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: detect_html_version_from_node(node1),
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.normalize_text(node_text(node)).empty?
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
- HTML_PRESERVE_ELEMENTS.map(&:to_sym).freeze
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
- HTML_COLLAPSE_ELEMENTS.map(&:to_sym).freeze
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
- set = Set.new(format_default_preserve_elements(match_opts).map(&:to_s))
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
- if match_opts[:preserve_whitespace_elements]
190
- set |= match_opts[:preserve_whitespace_elements].map(&:to_s)
191
- end
204
+ if match_opts[:preserve_whitespace_elements]
205
+ set |= match_opts[:preserve_whitespace_elements].map(&:to_s)
206
+ end
192
207
 
193
- strip_set = resolved_strip_elements_set(match_opts)
194
- set.reject { |e| strip_set.include?(e) }.to_set
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
- set = Set.new(format_default_collapse_elements(match_opts).map(&:to_s))
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
- if match_opts[:collapse_whitespace_elements]
201
- set |= match_opts[:collapse_whitespace_elements].map(&:to_s)
202
- end
218
+ if match_opts[:collapse_whitespace_elements]
219
+ set |= match_opts[:collapse_whitespace_elements].map(&:to_s)
220
+ end
203
221
 
204
- strip_set = resolved_strip_elements_set(match_opts)
205
- set.reject { |e| strip_set.include?(e) }.to_set
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
- raw = match_opts[:strip_whitespace_elements]
210
- Set.new((raw || []).map(&:to_s))
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.map(&:to_s).include?(name.to_s)
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
- # Create temporary RootNode wrappers
79
- temp_root1 = Canon::Xml::Nodes::RootNode.new
80
- temp_root1.children = children1.dup
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?)
@@ -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
- # HTML attributes are namespace-free; xmlns declarations are
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.13"
4
+ VERSION = "0.3.14"
5
5
  end
@@ -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: node.attribute_nodes.map do |attr|
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
- frames = []
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
- # copies them into pairs before returning.
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, frames, own_namespaces
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
- frames.push([depth, node]) if node
311
+ if node
312
+ frame_depths << depth
313
+ frame_nodes << node
314
+ end
304
315
  end
305
316
 
306
- top = frames.last
307
- return nil unless top
317
+ return nil if frame_nodes.empty?
308
318
 
309
- assign_moxml_namespace_scopes(top[1], nil, own_namespaces)
310
- top[1]
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
- frames, own_namespaces)
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.each_slice(4).to_a,
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
- children << frames.pop[1] while frames.any? && frames.last[0] > depth
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: node.attributes.map do |attr|
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: pos1,
173
- pos2: 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
- match_children(elem1.children, elem2.children, elem_path_with_ns)
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: 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: 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: pos1,
277
- pos2: 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
- match_children(elem1.children, elem2.children, elem_path_with_ns)
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: pos1,
324
- pos2: 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
- match_children(elem1.children, elem2.children, elem_path_with_ns)
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: regular_attrs.map do |attr_name, attr_value|
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` are [name, value, namespace_uri,
67
- # prefix] pairs; `namespace_scope` is a merged scope (or nil for
68
- # no namespace nodes).
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. Index arithmetic instead of subarray slices:
80
- # the scan stays allocation-free on the hot paths.
81
- index = 0
82
- while index < attributes.size
83
- pair = attributes[index]
84
- attr_name = pair[0]
85
- attr_namespace_uri = pair[2]
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 < index
89
- candidate = attributes[prior]
90
- if candidate[0] == attr_name && candidate[2] == attr_namespace_uri
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 += 1
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: pair[1],
101
- namespace_uri: attr_namespace_uri,
102
- prefix: pair[3],
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
- index += 1
123
+ base += 4
106
124
  end
107
125
 
108
126
  element
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.13
4
+ version: 0.3.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.