canon 0.3.14 → 0.3.16

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: d9954ac6056439abc3c5b211ecf17b9af2391ad0b809eb3c239fef55cbdc723a
4
- data.tar.gz: dbe215e817f160c840769b5bbd0969fe83d315d2b4429cec125de43312ec821a
3
+ metadata.gz: 133da0e86ad37cf095f738d15a37ebcc3b35ae92240327d459ed1d1bbedc3a5b
4
+ data.tar.gz: 6c7c2cf6e60712058a6f49f28cf9181316f9e410da5a3982b72fee7fdebde115
5
5
  SHA512:
6
- metadata.gz: b1552e6f9a7735903f10b8f73902c6896cfab41b61da29b9bf725c6d5a1132483566eec58eeeb5ce2a59714a5e228ba8cb2979fa79f853cf5bd361f5ffcd849f
7
- data.tar.gz: c2b93d2248033a3680164769ab57163f44935314a0929155fa98e2a488f1ea278bff8dba5bbdc38b19d313db13e9ddc6ffeb8b56cdf7537d1ba5c08098ec9bb2
6
+ metadata.gz: fd39028ce4e945d04ac83d44f9a0da8b516dadf8a454bb634147aabfa480f896ed25f7cf048b39d6b0173a6a1b6aab2881a607b6e8588ff124d4616f95fb944c
7
+ data.tar.gz: 20eedf19bb73922d482537bfe8411bf8b8816001a4df6d59130f08558d80e14d2bfd968c9029d176cae056b0a4d9b1c36d16fb4012329073cceb490846462f7a
@@ -10,10 +10,21 @@ module Canon
10
10
  # attributes), and whitespace visualization — previously duplicated
11
11
  # across MarkupComparator and XmlComparator.
12
12
  class DiffNodeBuilder
13
- # Build an enriched DiffNode.
14
- def self.build(node1:, node2:, diff1:, diff2:, dimension:, **_opts)
13
+ # Build a DiffNode. Verbose callers get the fully enriched form —
14
+ # reason, path, serialized content, attributes; non-verbose
15
+ # callers only ever ask normative?, and DiffClassifier reads node
16
+ # refs and dimension alone, so reason building and metadata
17
+ # enrichment (five display operations per difference) are
18
+ # skipped entirely.
19
+ def self.build(node1:, node2:, diff1:, diff2:, dimension:,
20
+ verbose: false, **_opts)
15
21
  raise ArgumentError, "dimension required for DiffNode" if dimension.nil?
16
22
 
23
+ unless verbose
24
+ return Canon::Diff::DiffNode.new(node1: node1, node2: node2,
25
+ dimension: dimension)
26
+ end
27
+
17
28
  reason = build_reason(node1, node2, diff1, diff2, dimension)
18
29
  metadata = enrich_metadata(node1, node2)
19
30
 
@@ -198,7 +198,7 @@ module Canon
198
198
 
199
199
  return Comparison::EQUIVALENT if children1.empty? && children2.empty?
200
200
 
201
- emitter = html_diff_emitter(differences)
201
+ emitter = html_diff_emitter(differences, opts)
202
202
  ChildRealignment.walk(children1, children2, emitter,
203
203
  emit_structural_orphans: true) do |c1, c2|
204
204
  XmlNodeComparison.compare_nodes(c1, c2, opts, child_opts,
@@ -208,10 +208,12 @@ module Canon
208
208
 
209
209
  # Build a diff emitter for the HTML comparator path that
210
210
  # creates DiffNode objects via DiffNodeBuilder.
211
- def html_diff_emitter(differences)
211
+ def html_diff_emitter(differences, opts)
212
+ verbose = opts[:verbose]
212
213
  proc do |n1, n2, d1, d2, dim|
213
214
  differences << Canon::Comparison::DiffNodeBuilder.build(
214
215
  node1: n1, node2: n2, diff1: d1, diff2: d2, dimension: dim,
216
+ verbose: verbose
215
217
  )
216
218
  end
217
219
  end
@@ -16,11 +16,11 @@ module Canon
16
16
  #
17
17
  # Delegates to DiffNodeBuilder, the single DiffNode factory for
18
18
  # the DOM comparison path.
19
- def add_difference(node1, node2, diff1, diff2, dimension, _opts,
19
+ def add_difference(node1, node2, diff1, diff2, dimension, opts,
20
20
  differences)
21
21
  differences << Canon::Comparison::DiffNodeBuilder.build(
22
22
  node1: node1, node2: node2, diff1: diff1, diff2: diff2,
23
- dimension: dimension
23
+ dimension: dimension, verbose: opts[:verbose]
24
24
  )
25
25
  end
26
26
 
@@ -53,6 +53,19 @@ module Canon
53
53
  # @param opts [Hash] Comparison options
54
54
  # @return [Array] Filtered array of children
55
55
  def filter_children(children, opts)
56
+ # Fast path: with no ignore_nodes list, only text/comment
57
+ # children can ever be excluded (node_excluded? returns false
58
+ # for every other kind) — an all-element child list passes
59
+ # through untouched, with no reject copy and no per-child
60
+ # exclusion checks.
61
+ ignore_nodes = opts[:ignore_nodes]
62
+ if (ignore_nodes.nil? || ignore_nodes.empty?) && !children.empty?
63
+ excludable = children.any? do |child|
64
+ text_node?(child) || comment_node?(child)
65
+ end
66
+ return children unless excludable
67
+ end
68
+
56
69
  children.reject do |child|
57
70
  node_excluded?(child, opts)
58
71
  end
@@ -70,6 +70,10 @@ module Canon
70
70
  when :strict
71
71
  text1 == text2
72
72
  when :normalize
73
+ # Identical strings normalize identically — skip the two
74
+ # gsub+strip chains.
75
+ return true if text1 == text2
76
+
73
77
  if whitespace_type == :normalize
74
78
  normalize_text(text1) == normalize_text(text2)
75
79
  else
@@ -92,22 +96,34 @@ module Canon
92
96
  .strip # Remove leading/trailing whitespace
93
97
  end
94
98
 
95
- # Normalize text preserving Unicode whitespace type distinctions.
96
99
  # Fast form of `normalize_text(text).empty?`, called per text
97
100
  # node by node_excluded?. Pure-ASCII whitespace (the common
98
101
  # case — pretty-print indentation) matches a plain class with
99
102
  # 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
+ # regexes do not honor \p{Space}); a pure-ASCII miss is
104
+ # conclusively non-whitespace; only non-ASCII text falls back
105
+ # to normalize_text's exact Unicode semantics. NUL is included
106
+ # because String#strip strips nulls too.
103
107
  ASCII_WHITESPACE_ONLY = /\A[ \t\r\n\v\f\x00]*\z/
108
+ ASCII_ONLY = /\A[\x00-\x7f]*\z/
104
109
 
105
110
  def whitespace_only?(text)
106
111
  text = text.to_s
107
- text.empty? || text.match?(ASCII_WHITESPACE_ONLY) ||
108
- normalize_text(text).empty?
112
+ return true if text.empty?
113
+ return true if text.match?(ASCII_WHITESPACE_ONLY)
114
+ # A pure-ASCII string that failed the class contains an ASCII
115
+ # non-whitespace character, which survives both the collapse
116
+ # and the strip — conclusively not whitespace-only, with no
117
+ # intermediate strings. Only non-ASCII text (NBSP, U+3000,
118
+ # ...) needs normalize_text's exact Unicode semantics — and
119
+ # no \p{} classes appear in the fast paths, which Opal's JS
120
+ # regexes would not honor anyway.
121
+ return false if text.match?(ASCII_ONLY)
122
+
123
+ normalize_text(text).empty?
109
124
  end
110
125
 
126
+ # Normalize text preserving Unicode whitespace type distinctions.
111
127
  def normalize_text_preserving_type(text)
112
128
  return "" if text.nil?
113
129
 
@@ -26,6 +26,14 @@ module Canon
26
26
  match_opts = opts[:match_opts]
27
27
  attribute_order_behavior = match_opts[:attribute_order] || :strict
28
28
 
29
+ # FAST PATH: identical filtered attributes in identical key
30
+ # order — exact equality implies behavioral equality for every
31
+ # value behavior, and no order difference exists. Most element
32
+ # pairs hit this and skip the sort/hash/value work entirely.
33
+ if attrs1 == attrs2 && attrs1.keys == attrs2.keys
34
+ return Comparison::EQUIVALENT
35
+ end
36
+
29
37
  # Check attribute order if not ignored
30
38
  keys1 = attrs1.keys.map(&:to_s)
31
39
  keys2 = attrs2.keys.map(&:to_s)
@@ -157,14 +165,14 @@ differences)
157
165
  # @param opts [Hash] Options
158
166
  # @param differences [Array] Array to append difference to
159
167
  def self.add_attribute_difference(n1:, n2:, diff1:, diff2:,
160
- dimension:, differences:, **opts)
168
+ dimension:, differences:, opts: {})
161
169
  diff_node = Canon::Comparison::DiffNodeBuilder.build(
162
170
  node1: n1,
163
171
  node2: n2,
164
172
  diff1: diff1,
165
173
  diff2: diff2,
166
174
  dimension: dimension,
167
- **opts,
175
+ verbose: opts[:verbose],
168
176
  )
169
177
  differences << diff_node if diff_node
170
178
  end
@@ -17,6 +17,9 @@ module Canon
17
17
  ns_decls1 = extract_declarations(node1)
18
18
  ns_decls2 = extract_declarations(node2)
19
19
 
20
+ # Most element pairs declare nothing — skip the set algebra.
21
+ return Comparison::EQUIVALENT if ns_decls1.empty? && ns_decls2.empty?
22
+
20
23
  # Find missing, extra, and changed namespace declarations
21
24
  missing = ns_decls1.keys - ns_decls2.keys # In node1 but not node2
22
25
  extra = ns_decls2.keys - ns_decls1.keys # In node2 but not node1
@@ -298,14 +298,18 @@ module Canon
298
298
  ns2 = Canon::XmlParsing.namespace_uri(n2)
299
299
 
300
300
  unless ns1 == ns2
301
- diff_node = Canon::Comparison::DiffNodeBuilder.build(
302
- node1: n1,
303
- node2: n2,
304
- diff1: Comparison::UNEQUAL_ELEMENTS,
305
- diff2: Comparison::UNEQUAL_ELEMENTS,
306
- dimension: :namespace_uri,
307
- )
308
- differences << diff_node if opts[:verbose]
301
+ # The DiffNode is only ever appended in verbose mode — skip
302
+ # building it entirely otherwise.
303
+ if opts[:verbose]
304
+ differences << Canon::Comparison::DiffNodeBuilder.build(
305
+ node1: n1,
306
+ node2: n2,
307
+ diff1: Comparison::UNEQUAL_ELEMENTS,
308
+ diff2: Comparison::UNEQUAL_ELEMENTS,
309
+ dimension: :namespace_uri,
310
+ verbose: true,
311
+ )
312
+ end
309
313
  return Comparison::UNEQUAL_ELEMENTS
310
314
  end
311
315
 
@@ -34,7 +34,7 @@ module Canon
34
34
  # @param serialized_after [String, nil] Optional serialized content for display
35
35
  # @param attributes_before [Hash, nil] Optional normalized attributes hash
36
36
  # @param attributes_after [Hash, nil] Optional normalized attributes hash
37
- def initialize(node1:, node2:, dimension:, reason:,
37
+ def initialize(node1:, node2:, dimension:, reason: nil,
38
38
  path: nil, serialized_before: nil, serialized_after: nil,
39
39
  attributes_before: nil, attributes_after: nil)
40
40
  @node1 = node1
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.14"
4
+ VERSION = "0.3.16"
5
5
  end
@@ -4,6 +4,13 @@ module Canon
4
4
  module Xml
5
5
  # Base class for all XPath data model nodes
6
6
  class Node
7
+ # Shared by every childless node: leaves (text, attributes,
8
+ # namespaces, comments, PIs) and empty elements read `children`
9
+ # without materializing a private array. Mutating the returned
10
+ # array is a bug — writers go through add_child/children=, which
11
+ # install a private array first.
12
+ EMPTY_CHILDREN = [].freeze
13
+
7
14
  attr_reader :parent
8
15
 
9
16
  def initialize
@@ -12,12 +19,8 @@ module Canon
12
19
  @in_node_set = true
13
20
  end
14
21
 
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
22
  def children
20
- @children ||= []
23
+ @children || EMPTY_CHILDREN
21
24
  end
22
25
 
23
26
  def children=(new_children)
@@ -26,7 +29,7 @@ module Canon
26
29
 
27
30
  def add_child(child)
28
31
  child.parent = self
29
- children << child
32
+ (@children ||= []) << child
30
33
  end
31
34
 
32
35
  def in_node_set?
@@ -15,12 +15,16 @@ module Canon
15
15
  @attribute_nodes = nil
16
16
  end
17
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.
18
+ # Lazy: attribute-free elements (common in real documents) share
19
+ # one frozen empty array on read instead of materializing one
20
+ # per element; writers install a private array first. Inherited
21
+ # namespace nodes arrive as one shared frozen array from
22
+ # TreeBuilder#attach_namespace_scope.
23
+ EMPTY_ATTRIBUTE_NODES = [].freeze
24
+ EMPTY_NAMESPACE_NODES = [].freeze
25
+
22
26
  def namespace_nodes
23
- @namespace_nodes ||= []
27
+ @namespace_nodes || EMPTY_NAMESPACE_NODES
24
28
  end
25
29
 
26
30
  def namespace_nodes=(nodes)
@@ -28,7 +32,7 @@ module Canon
28
32
  end
29
33
 
30
34
  def attribute_nodes
31
- @attribute_nodes ||= []
35
+ @attribute_nodes || EMPTY_ATTRIBUTE_NODES
32
36
  end
33
37
 
34
38
  def node_type
@@ -41,12 +45,12 @@ module Canon
41
45
 
42
46
  def add_namespace(namespace_node)
43
47
  namespace_node.parent = self
44
- namespace_nodes << namespace_node
48
+ (@namespace_nodes ||= []) << namespace_node
45
49
  end
46
50
 
47
51
  def add_attribute(attribute_node)
48
52
  attribute_node.parent = self
49
- attribute_nodes << attribute_node
53
+ (@attribute_nodes ||= []) << attribute_node
50
54
  end
51
55
 
52
56
  # Get namespace nodes in sorted order (lexicographically by local name)
@@ -22,6 +22,10 @@ module Canon
22
22
  # root = SaxBuilder.parse(xml_string, strip_doctype: true)
23
23
  #
24
24
  class SaxBuilder
25
+ # Shared empties for the no-declaration common case: both
26
+ # consumers only iterate them.
27
+ NO_NAMESPACE_DECLS = [].freeze
28
+ EMPTY_NS_HASH = {}.freeze
25
29
  # Parse XML string and return Canon::Xml::Node tree
26
30
  #
27
31
  # @param xml_string [String] XML content to parse
@@ -110,6 +114,31 @@ strip_doctype: false)
110
114
  def start_element(name, attrs = [])
111
115
  parent = @stack.last
112
116
 
117
+ # Fast path: attribute-less elements (the bulk of most
118
+ # documents) declare no namespaces — no separation, no hash,
119
+ # no qname pair array; the inherited scope object is pushed
120
+ # as-is (shared scope → cached namespace-node array).
121
+ if attrs.empty?
122
+ if (colon = name.index(":"))
123
+ prefix = name[0...colon]
124
+ local_name = name[(colon + 1)..]
125
+ else
126
+ prefix = nil
127
+ local_name = name
128
+ end
129
+ new_scope = @namespace_stack.last
130
+ @namespace_stack.push(new_scope)
131
+ element = TreeBuilder::DEFAULT.element(
132
+ name: local_name,
133
+ prefix: prefix,
134
+ namespace_uri: new_scope[prefix.to_s],
135
+ namespace_scope: new_scope,
136
+ )
137
+ parent.add_child(element)
138
+ @stack.push(element)
139
+ return
140
+ end
141
+
113
142
  # Parse namespace from name (prefix:localname or just localname)
114
143
  prefix, local_name = parse_qname(name)
115
144
 
@@ -280,6 +309,8 @@ strip_doctype: false)
280
309
  # @param ns_decls [Array] Array of [name, value] pairs for namespace declarations
281
310
  # @return [Hash] Namespace prefix => URI mapping
282
311
  def build_ns_hash(ns_decls)
312
+ return EMPTY_NS_HASH if ns_decls.empty?
313
+
283
314
  result = {}
284
315
  ns_decls.each do |name, uri|
285
316
  # xmlns="..." for default namespace, xmlns:prefix="..." for prefixed
@@ -311,6 +342,12 @@ strip_doctype: false)
311
342
  # @param attrs [Array] Array of [name, value] pairs
312
343
  # @return [Array] Two arrays: [namespace_decls, regular_attrs]
313
344
  def separate_namespaces(attrs)
345
+ # Fast path: no xmlns declarations — the attribute pairs pass
346
+ # through untouched and nothing is copied.
347
+ unless attrs.any? { |name, _| name == "xmlns" || name.start_with?("xmlns:") }
348
+ return [NO_NAMESPACE_DECLS, attrs]
349
+ end
350
+
314
351
  ns_decls = []
315
352
  regular_attrs = []
316
353
 
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.14
4
+ version: 0.3.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.