canon 0.3.12 → 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: cb5f47fd1b5759edefe57276d19baec565e015cebc47c683125875dad10122b2
4
- data.tar.gz: e21fe66a481474c041add458fdbf2e7bcb39dfc910d5fe58f3c6394f33929426
3
+ metadata.gz: d9954ac6056439abc3c5b211ecf17b9af2391ad0b809eb3c239fef55cbdc723a
4
+ data.tar.gz: dbe215e817f160c840769b5bbd0969fe83d315d2b4429cec125de43312ec821a
5
5
  SHA512:
6
- metadata.gz: a28e38753f0ac126563f37bdea72adec9f605885c25320f09407b4367495ee17c72b60f014615bc7bad6c352640eda067ece20ef4e04361632ae7fd8323f644d
7
- data.tar.gz: 6db620f6c3ccdb828102c7b80489b302396e8bdda96c07b0a11abb65b4e2cc09b08ea7ea221cb9bfced71eaaaad829ccc37c1e47d0a88036e2f8c25e5d6b15c4
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.12"
4
+ VERSION = "0.3.14"
5
5
  end
@@ -80,8 +80,14 @@ module Canon
80
80
  end
81
81
 
82
82
  def self.extract_xml_encoding(xml_string)
83
- binary_string = xml_string.dup.force_encoding("BINARY")
84
- if binary_string =~ /\A\s*<\?xml[^>]*\bencoding\s*=\s*["']([^"']+)["'][^>]*\?>/i
83
+ # A valid UTF-8 string matches directly (the regex is
84
+ # ASCII-only); anything else still needs the BINARY view so a
85
+ # broken byte sequence cannot raise mid-probe. Skipping the
86
+ # dup avoids a full-document copy on every parse.
87
+ unless xml_string.encoding.name == "UTF-8" && xml_string.valid_encoding?
88
+ xml_string = xml_string.dup.force_encoding("BINARY")
89
+ end
90
+ if xml_string =~ /\A\s*<\?xml[^>]*\bencoding\s*=\s*["']([^"']+)["'][^>]*\?>/i
85
91
  return Regexp.last_match(1)
86
92
  end
87
93
 
@@ -165,13 +171,16 @@ inherited_namespaces: nil)
165
171
  inherited_namespaces,
166
172
  node.namespace_definitions.map { |ns| [ns.prefix, ns.href] },
167
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
168
179
  element = TreeBuilder::DEFAULT.element(
169
180
  name: node.name,
170
181
  prefix: node.namespace&.prefix,
171
182
  namespace_uri: node.namespace&.href,
172
- attributes: node.attribute_nodes.map do |attr|
173
- [attr.name, attr.value, attr.namespace&.href, attr.namespace&.prefix]
174
- end,
183
+ attributes: flat_attributes,
175
184
  namespace_scope: scope,
176
185
  )
177
186
  node.children.each do |child|
@@ -246,13 +255,18 @@ inherited_namespaces: nil)
246
255
 
247
256
  def self.build_moxml_subtree_from_records(moxml_element,
248
257
  preserve_whitespace: false)
249
- 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 = []
250
263
  own_namespaces = {}.compare_by_identity
251
264
 
252
265
  # materialize_fields: the zero-allocation hot path (moxml#143).
253
266
  # Flat reused buffers — attributes stride 4, namespaces stride
254
267
  # 2 — valid only inside the block, so the element builder
255
- # copies them into pairs before returning.
268
+ # consumes them synchronously before the next record reuses
269
+ # the buffer.
256
270
  moxml_element.materialize_fields do |kind, qname, prefix, namespace_uri, namespaces, attributes, text, depth|
257
271
  # The record contract is root-subtree-only since moxml 0.5.11
258
272
  # (moxml#140). Older 0.5.x releases — still allowed by canon's
@@ -280,7 +294,7 @@ preserve_whitespace: false)
280
294
  when :element
281
295
  build_moxml_element_from_fields(
282
296
  qname, prefix, namespace_uri, namespaces, attributes,
283
- depth, frames, own_namespaces
297
+ depth, frame_depths, frame_nodes, own_namespaces
284
298
  )
285
299
  when :text, :cdata
286
300
  content = text.to_s
@@ -294,34 +308,44 @@ preserve_whitespace: false)
294
308
  TreeBuilder::DEFAULT.processing_instruction(qname, text || "")
295
309
  end
296
310
 
297
- frames.push([depth, node]) if node
311
+ if node
312
+ frame_depths << depth
313
+ frame_nodes << node
314
+ end
298
315
  end
299
316
 
300
- top = frames.last
301
- return nil unless top
317
+ return nil if frame_nodes.empty?
302
318
 
303
- assign_moxml_namespace_scopes(top[1], nil, own_namespaces)
304
- top[1]
319
+ top = frame_nodes.last
320
+ assign_moxml_namespace_scopes(top, nil, own_namespaces)
321
+ top
305
322
  end
306
323
 
307
324
  def self.build_moxml_element_from_fields(qname, prefix, namespace_uri,
308
325
  namespaces, attributes, depth,
309
- 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).
310
330
  element = TreeBuilder::DEFAULT.element(
311
331
  name: qname,
312
332
  prefix: prefix,
313
333
  namespace_uri: namespace_uri,
314
- attributes: attributes.each_slice(4).to_a,
334
+ attributes: attributes,
315
335
  )
316
336
 
317
337
  # Adopt completed children: they pop in reverse order; reversing
318
338
  # once is O(n) (unshift per child would be O(n^2) on wide trees).
319
339
  children = []
320
- 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
321
344
  children.reverse_each { |child| element.add_child(child) }
322
345
 
323
- # Copy the declarations out of the reused buffer.
324
- own_namespaces[element] = namespaces.each_slice(2).to_a
346
+ # Copy the declarations out of the reused buffer (most
347
+ # elements declare none — stash nothing for those).
348
+ own_namespaces[element] = namespaces.each_slice(2).to_a unless namespaces.empty?
325
349
  element
326
350
  end
327
351
 
@@ -345,13 +369,16 @@ inherited_namespaces: nil)
345
369
  inherited_namespaces,
346
370
  node.namespace_definitions.map { |ns| [ns.prefix, ns.uri] },
347
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
348
377
  element = TreeBuilder::DEFAULT.element(
349
378
  name: node.name,
350
379
  prefix: node.namespace&.prefix,
351
380
  namespace_uri: node.namespace&.uri,
352
- attributes: node.attributes.map do |attr|
353
- [attr.name, attr.value, attr.namespace&.uri, attr.namespace&.prefix]
354
- end,
381
+ attributes: flat_attributes,
355
382
  namespace_scope: scope,
356
383
  )
357
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
 
@@ -4,17 +4,29 @@ module Canon
4
4
  module Xml
5
5
  # Base class for all XPath data model nodes
6
6
  class Node
7
- attr_reader :parent, :children
7
+ attr_reader :parent
8
8
 
9
9
  def initialize
10
10
  @parent = nil
11
- @children = []
11
+ @children = nil
12
12
  @in_node_set = true
13
13
  end
14
14
 
15
+ # Leaf nodes (text, attributes, namespaces, comments, PIs) never
16
+ # gain children; allocating the array eagerly cost one Array per
17
+ # node — the single largest retained-allocation source in a built
18
+ # tree — so it materializes on first add or read.
19
+ def children
20
+ @children ||= []
21
+ end
22
+
23
+ def children=(new_children)
24
+ @children = new_children
25
+ end
26
+
15
27
  def add_child(child)
16
28
  child.parent = self
17
- @children << child
29
+ children << child
18
30
  end
19
31
 
20
32
  def in_node_set?
@@ -3,18 +3,32 @@
3
3
  module Canon
4
4
  module Xml
5
5
  module Nodes
6
- # Element node in the XPath data model
7
6
  class ElementNode < Node
8
- attr_reader :name, :namespace_uri, :prefix, :namespace_nodes,
9
- :attribute_nodes
7
+ attr_reader :name, :namespace_uri, :prefix
10
8
 
11
9
  def initialize(name:, namespace_uri: nil, prefix: nil)
12
10
  super()
13
11
  @name = name
14
12
  @namespace_uri = namespace_uri
15
13
  @prefix = prefix
16
- @namespace_nodes = []
17
- @attribute_nodes = []
14
+ @namespace_nodes = nil
15
+ @attribute_nodes = nil
16
+ end
17
+
18
+ # Lazy: most elements carry no attributes, and inherited
19
+ # namespace nodes arrive as one shared frozen array (see
20
+ # TreeBuilder#attach_namespace_scope) — eager per-element arrays
21
+ # were the largest retained-allocation source in a built tree.
22
+ def namespace_nodes
23
+ @namespace_nodes ||= []
24
+ end
25
+
26
+ def namespace_nodes=(nodes)
27
+ @namespace_nodes = nodes
28
+ end
29
+
30
+ def attribute_nodes
31
+ @attribute_nodes ||= []
18
32
  end
19
33
 
20
34
  def node_type
@@ -27,24 +41,22 @@ module Canon
27
41
 
28
42
  def add_namespace(namespace_node)
29
43
  namespace_node.parent = self
30
- @namespace_nodes << namespace_node
44
+ namespace_nodes << namespace_node
31
45
  end
32
46
 
33
47
  def add_attribute(attribute_node)
34
48
  attribute_node.parent = self
35
- @attribute_nodes << attribute_node
49
+ attribute_nodes << attribute_node
36
50
  end
37
51
 
38
52
  # Get namespace nodes in sorted order (lexicographically by local name)
39
53
  def sorted_namespace_nodes
40
- @namespace_nodes.sort_by do |ns|
41
- ns.local_name.to_s
42
- end
54
+ namespace_nodes.sort_by(&:local_name)
43
55
  end
44
56
 
45
57
  # Get attribute nodes in sorted order (by namespace URI then local name)
46
58
  def sorted_attribute_nodes
47
- @attribute_nodes.sort_by do |attr|
59
+ attribute_nodes.sort_by do |attr|
48
60
  [attr.namespace_uri.to_s, attr.local_name]
49
61
  end
50
62
  end
@@ -12,10 +12,6 @@ module Canon
12
12
  def node_type
13
13
  :root
14
14
  end
15
-
16
- def children=(new_children)
17
- @children = new_children
18
- end
19
15
  end
20
16
  end
21
17
  end
@@ -129,20 +129,31 @@ strip_doctype: false)
129
129
  end
130
130
 
131
131
  # Push new namespace scope with declarations (own shadows
132
- # inherited — the same merge the TreeBuilder scope kernel applies)
133
- new_scope = @namespace_stack.last.merge(ns_hash)
132
+ # inherited — the same merge the TreeBuilder scope kernel
133
+ # applies). Elements that declare nothing push the inherited
134
+ # scope object itself, so scopes — and their cached
135
+ # namespace-node arrays in TreeBuilder — are shared down runs
136
+ # of undeclaring elements instead of re-merged per element.
137
+ inherited_scope = @namespace_stack.last
138
+ new_scope = ns_hash.empty? ? inherited_scope : inherited_scope.merge(ns_hash)
134
139
  @namespace_stack.push(new_scope)
135
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
+
136
151
  element = TreeBuilder::DEFAULT.element(
137
152
  name: local_name,
138
153
  prefix: prefix,
139
154
  namespace_uri: new_scope[prefix.to_s],
140
155
  namespace_scope: new_scope,
141
- attributes: regular_attrs.map do |attr_name, attr_value|
142
- attr_prefix, attr_local = parse_qname(attr_name)
143
- attr_ns_uri = attr_prefix ? new_scope[attr_prefix] : nil
144
- [attr_local, decode_character_references(attr_value || ""), attr_ns_uri, attr_prefix]
145
- end,
156
+ attributes: flat_attributes,
146
157
  )
147
158
 
148
159
  parent.add_child(element)
@@ -319,6 +330,10 @@ strip_doctype: false)
319
330
  # @param value [String] String potentially containing character references
320
331
  # @return [String] String with character references decoded
321
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
+
322
337
  value.gsub(/&#(x?[0-9a-fA-F]+);/) do |match|
323
338
  code_str = Regexp.last_match(1)
324
339
  code_point = if code_str.start_with?("x")
@@ -23,56 +23,104 @@ 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
29
- # namespace.
44
+ # namespace. Elements with no declarations return the inherited
45
+ # scope unchanged — most elements — so equal scopes share one
46
+ # object (and one namespace-node array, see attach). The xml
47
+ # binding is materialized at the root even for undeclaring
48
+ # elements: it is in scope on every element per the XPath data
49
+ # model (and the SAX feed's initial stack carries it too).
30
50
  def merge_namespace_scope(inherited, declaration_pairs)
51
+ return inherited if declaration_pairs.empty? && inherited
52
+
31
53
  scope = inherited ? inherited.dup : { XML_NAMESPACE_PREFIX => XML_NAMESPACE_URI }
32
54
  declaration_pairs.each do |prefix, uri|
33
55
  scope[prefix || ""] = uri
34
56
  end
35
- scope
57
+ scope.freeze
36
58
  end
37
59
 
38
- # Attach an in-scope scope to an element as namespace nodes.
60
+ # Attach an in-scope scope to an element as namespace nodes. The
61
+ # node array is cached per scope object: elements that introduced
62
+ # no declarations share their parent's array instead of
63
+ # re-materializing one NamespaceNode per prefix per element.
64
+ # NamespaceNode#parent is never read, so shared nodes carry the
65
+ # first creator as parent by convention. The cache is identity
66
+ # keyed and bounded — stale scopes of dead trees cost a few
67
+ # hundred bytes until the next clear.
39
68
  def attach_namespace_scope(element, scope)
40
- scope.each do |prefix, uri|
41
- element.add_namespace(Nodes::NamespaceNode.new(
42
- prefix: prefix,
43
- uri: uri,
44
- ))
69
+ cache = (@namespace_node_cache ||= {}.compare_by_identity)
70
+ cache.clear if cache.size >= 4096
71
+ nodes = cache[scope]
72
+ if nodes.nil?
73
+ nodes = scope.map do |prefix, uri|
74
+ Nodes::NamespaceNode.new(prefix: intern(prefix), uri: intern(uri))
75
+ end.freeze
76
+ cache[scope] = nodes
45
77
  end
78
+ element.namespace_nodes = nodes
46
79
  end
47
80
 
48
- # Build an element. `attributes` are [name, value, namespace_uri,
49
- # prefix] pairs; `namespace_scope` is a merged scope (or nil for
50
- # 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).
51
87
  def element(name:, prefix: nil, namespace_uri: nil,
52
88
  attributes: NO_ATTRIBUTES, namespace_scope: nil)
53
89
  element = Nodes::ElementNode.new(
54
- name: name,
55
- namespace_uri: namespace_uri,
56
- prefix: prefix,
90
+ name: intern(name),
91
+ namespace_uri: intern(namespace_uri),
92
+ prefix: intern(prefix),
57
93
  )
58
94
  attach_namespace_scope(element, namespace_scope) if namespace_scope
59
95
 
60
96
  # Duplicate (name, namespace) pairs are invalid XML — first
61
- # occurrence wins. Attributes per element are few, so a pairwise
62
- # scan beats a per-element seen-hash on allocation (this is the
63
- # hot SAX path).
64
- attributes.each_with_index do |(attr_name, value, attr_namespace_uri, attr_prefix), index|
65
- duplicate = attributes[0...index].any? do |prior_name, _v, prior_ns, _p|
66
- prior_name == attr_name && prior_ns == 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]
104
+ duplicate = false
105
+ prior = 0
106
+ while prior < base
107
+ if attributes[prior] == attr_name &&
108
+ attributes[prior + 2] == attr_namespace_uri
109
+ duplicate = true
110
+ break
111
+ end
112
+ prior += 4
113
+ end
114
+
115
+ unless duplicate
116
+ element.add_attribute(Nodes::AttributeNode.new(
117
+ name: intern(attr_name),
118
+ value: attributes[base + 1],
119
+ namespace_uri: intern(attr_namespace_uri),
120
+ prefix: intern(attributes[base + 3]),
121
+ ))
67
122
  end
68
- next if duplicate
69
-
70
- element.add_attribute(Nodes::AttributeNode.new(
71
- name: attr_name,
72
- value: value,
73
- namespace_uri: attr_namespace_uri,
74
- prefix: attr_prefix,
75
- ))
123
+ base += 4
76
124
  end
77
125
 
78
126
  element
@@ -14,6 +14,13 @@ module Canon
14
14
  # text; engines that report it (libxml2 does not, libleptris 1.9.38+
15
15
  # does) stay byte-compatible through here.
16
16
  module WhitespacePolicy
17
+ # Zero-allocation forms of the `content.strip.empty?` /
18
+ # `content.gsub(...).empty?` checks these policies used to run
19
+ # per text node. STRIP_ONLY is exactly String#strip's set
20
+ # (ASCII whitespace plus null); SAX drops space/tab/CR/LF runs.
21
+ STRIP_ONLY = /\A[\0\t\n\v\f\r ]*\z/
22
+ SAX_DROPPED = /\A[ \t\r\n]*\z/
23
+
17
24
  module_function
18
25
 
19
26
  # DOM conversion rule: whitespace-only text is dropped unless
@@ -27,11 +34,11 @@ element_parent: true)
27
34
  # The XPath data model has no root text children — drop
28
35
  # whitespace-only document-level text even when preserving
29
36
  # (engines that report it stay byte-compatible with libxml2).
30
- return false if !element_parent && content.strip.empty?
37
+ return false if !element_parent && content.match?(STRIP_ONLY)
31
38
 
32
39
  return true if preserve_whitespace
33
40
 
34
- !content.strip.empty?
41
+ !content.match?(STRIP_ONLY)
35
42
  end
36
43
 
37
44
  # SAX rule: same shape, plus CR-bearing content is always kept
@@ -39,12 +46,12 @@ element_parent: true)
39
46
  # whitespace (space, tab, CR, LF) are dropped when not preserving.
40
47
  def keep_sax_text?(content, preserve_whitespace:,
41
48
  element_parent: true)
42
- return false if !element_parent && content.strip.empty?
49
+ return false if !element_parent && content.match?(STRIP_ONLY)
43
50
 
44
51
  return true if preserve_whitespace
45
52
  return true if content.include?("\r")
46
53
 
47
- !content.gsub(/[ \t\r\n]/, "").empty?
54
+ !content.match?(SAX_DROPPED)
48
55
  end
49
56
 
50
57
  # HTML conversion rule: whitespace-only text is dropped except in
@@ -55,7 +62,7 @@ element_parent: true)
55
62
  HTML_WHITESPACE_SENSITIVE_TAGS = %w[pre code textarea script style].freeze
56
63
 
57
64
  def keep_html_text?(content, parent_name:, inline_significant: false)
58
- return true unless content.strip.empty?
65
+ return true unless content.match?(STRIP_ONLY)
59
66
  return true if content.include?(" ")
60
67
 
61
68
  parent_name = parent_name.to_s.downcase
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: canon
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.12
4
+ version: 0.3.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-03 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: diff-lcs