canon 0.3.12 → 0.3.13

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: e052ba548bcbe2090cf0ebb538cc50a56ca64cc30a24e875418dbbe9accd053d
4
+ data.tar.gz: 6dfb8f2c9f840d0326271545ffee0ec137f3313469fa78c0cf80c9dc3e14f931
5
5
  SHA512:
6
- metadata.gz: a28e38753f0ac126563f37bdea72adec9f605885c25320f09407b4367495ee17c72b60f014615bc7bad6c352640eda067ece20ef4e04361632ae7fd8323f644d
7
- data.tar.gz: 6db620f6c3ccdb828102c7b80489b302396e8bdda96c07b0a11abb65b4e2cc09b08ea7ea221cb9bfced71eaaaad829ccc37c1e47d0a88036e2f8c25e5d6b15c4
6
+ metadata.gz: c041ca7ded4daedf25b1ecc59d6e3be6ebb7df8dfa12f4bf87ae5458fdfb7bdcc2c3aa8bad1eb92f75c365ee91047d075be45cd05694477275ad1f9313b665be
7
+ data.tar.gz: 97d0a91b0fb8629cc57315872b2a8c151c2c06b28eca0f6c686c9ba6258b3405e3717fbbc72fa837794ed1129d2412c684955194de8a5e5112831ad4af695bb1
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.13"
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
 
@@ -320,8 +326,9 @@ preserve_whitespace: false)
320
326
  children << frames.pop[1] while frames.any? && frames.last[0] > depth
321
327
  children.reverse_each { |child| element.add_child(child) }
322
328
 
323
- # Copy the declarations out of the reused buffer.
324
- own_namespaces[element] = namespaces.each_slice(2).to_a
329
+ # Copy the declarations out of the reused buffer (most
330
+ # elements declare none — stash nothing for those).
331
+ own_namespaces[element] = namespaces.each_slice(2).to_a unless namespaces.empty?
325
332
  element
326
333
  end
327
334
 
@@ -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,8 +129,13 @@ 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
 
136
141
  element = TreeBuilder::DEFAULT.element(
@@ -26,23 +26,41 @@ module Canon
26
26
  # In-scope namespace bindings: the element's own declarations
27
27
  # shadow inherited ones; xml is prebound at the base. Declaration
28
28
  # pairs are [prefix-or-nil, uri]; nil and "" both mean the default
29
- # namespace.
29
+ # namespace. Elements with no declarations return the inherited
30
+ # scope unchanged — most elements — so equal scopes share one
31
+ # object (and one namespace-node array, see attach). The xml
32
+ # binding is materialized at the root even for undeclaring
33
+ # elements: it is in scope on every element per the XPath data
34
+ # model (and the SAX feed's initial stack carries it too).
30
35
  def merge_namespace_scope(inherited, declaration_pairs)
36
+ return inherited if declaration_pairs.empty? && inherited
37
+
31
38
  scope = inherited ? inherited.dup : { XML_NAMESPACE_PREFIX => XML_NAMESPACE_URI }
32
39
  declaration_pairs.each do |prefix, uri|
33
40
  scope[prefix || ""] = uri
34
41
  end
35
- scope
42
+ scope.freeze
36
43
  end
37
44
 
38
- # Attach an in-scope scope to an element as namespace nodes.
45
+ # Attach an in-scope scope to an element as namespace nodes. The
46
+ # node array is cached per scope object: elements that introduced
47
+ # no declarations share their parent's array instead of
48
+ # re-materializing one NamespaceNode per prefix per element.
49
+ # NamespaceNode#parent is never read, so shared nodes carry the
50
+ # first creator as parent by convention. The cache is identity
51
+ # keyed and bounded — stale scopes of dead trees cost a few
52
+ # hundred bytes until the next clear.
39
53
  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
- ))
54
+ cache = (@namespace_node_cache ||= {}.compare_by_identity)
55
+ cache.clear if cache.size >= 4096
56
+ nodes = cache[scope]
57
+ if nodes.nil?
58
+ nodes = scope.map do |prefix, uri|
59
+ Nodes::NamespaceNode.new(prefix: prefix, uri: uri)
60
+ end.freeze
61
+ cache[scope] = nodes
45
62
  end
63
+ element.namespace_nodes = nodes
46
64
  end
47
65
 
48
66
  # Build an element. `attributes` are [name, value, namespace_uri,
@@ -58,21 +76,33 @@ module Canon
58
76
  attach_namespace_scope(element, namespace_scope) if namespace_scope
59
77
 
60
78
  # 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
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]
86
+ duplicate = false
87
+ prior = 0
88
+ while prior < index
89
+ candidate = attributes[prior]
90
+ if candidate[0] == attr_name && candidate[2] == attr_namespace_uri
91
+ duplicate = true
92
+ break
93
+ end
94
+ prior += 1
67
95
  end
68
- next if duplicate
69
96
 
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
- ))
97
+ unless duplicate
98
+ element.add_attribute(Nodes::AttributeNode.new(
99
+ name: attr_name,
100
+ value: pair[1],
101
+ namespace_uri: attr_namespace_uri,
102
+ prefix: pair[3],
103
+ ))
104
+ end
105
+ index += 1
76
106
  end
77
107
 
78
108
  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.13
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