moxml 0.5.30 → 0.5.32

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.
@@ -23,6 +23,58 @@ module Moxml
23
23
  # the adapter no longer carries accommodation paths for them.
24
24
  MINIMUM_BINDING_VERSION = "1.9.32"
25
25
 
26
+ # leptris_parse_html_string shipped in bindings 1.9.80
27
+ # (libleptris 1.9.75, engine #659) as Leptris::XML.parse_html.
28
+ HTML_PARSE_SUPPORTED =
29
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.80")
30
+
31
+ # Attribute-node xpath results carry proper wrappers since
32
+ # 1.9.105 (leptris-ruby#153: ResultAttr with name/value);
33
+ # before that the native gate routed them to the Ruby engine.
34
+ ATTR_RESULT_NATIVE =
35
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.105")
36
+
37
+ # Native C14N delegation probe: the engine's C14N must
38
+ # byte-match the Ruby reference (ported from canon) on a
39
+ # namespace-sorting + attributes + comments shape before
40
+ # Moxml::C14n hands the default path to it. The engine
41
+ # currently emits namespace declarations in document order
42
+ # instead of lexicographic (filed leptris/leptris#881); the
43
+ # probe auto-adopts the native path once a fixed build lands —
44
+ # no moxml release needed.
45
+ # Lazy, not load-time: the probe exercises the wrapper layer
46
+ # (Document/serialize/C14n), which is circular while THIS
47
+ # adapter file is still loading — the load-time form rescued
48
+ # to false on every build and masked a landed engine fix.
49
+ def self.native_c14n_byte_safe?
50
+ return @native_c14n_byte_safe unless @native_c14n_byte_safe.nil?
51
+
52
+ @native_c14n_byte_safe = begin
53
+ probe_xml = %(<?xml version="1.0"?><doc xmlns:p="urn:p" xmlns="urn:d" b="2" a="1"><e p:x="v" z="w">t &amp; u</e><!-- c --></doc>)
54
+ native_doc = ::Leptris::XML::Document.parse(probe_xml)
55
+ native = native_doc.root.canonicalize(
56
+ ::Leptris::XML::FFI::C14N_1_0, nil,
57
+ mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL
58
+ )
59
+ wrapper = Moxml::Document.new(native_doc, Moxml::Context.new(:leptris))
60
+ reference = Moxml::C14n::Inclusive10.new.canonicalize(wrapper.root)
61
+ native == reference
62
+ rescue StandardError
63
+ false
64
+ end
65
+ end
66
+
67
+ # Bumped whenever a document's :entity_markers flag is written
68
+ # (parse, parse_html, entity-reference mint) so wrapper-level
69
+ # entity_bearing? memos invalidate.
70
+ def self.serialize_generation
71
+ @serialize_generation ||= 0
72
+ end
73
+
74
+ def self.bump_serialize_generation
75
+ @serialize_generation = serialize_generation + 1
76
+ end
77
+
26
78
  # leptris-ruby#103: prefixed attribute tests inside predicates
27
79
  # stopped resolving through the document's in-scope declarations
28
80
  # on released 1.9.37–1.9.39; 1.9.40 (engine 1.9.14+) restored
@@ -108,6 +160,39 @@ module Moxml
108
160
  doc.root = element
109
161
  end
110
162
 
163
+ def bare_set_qname_safe?
164
+ true
165
+ end
166
+
167
+ def bare_get_qname_safe?
168
+ true
169
+ end
170
+
171
+ # Expanded-name (URI + local) attribute VALUE lookup on the
172
+ # engine — the same match rule as the resolver (xmlns
173
+ # declarations invisible, no-namespace never matches a
174
+ # prefixed name), without materializing the attribute list.
175
+ def expanded_attr_value(element, uri, local)
176
+ element.attribute_ns(uri, local)
177
+ end
178
+
179
+ # Fast bare-name read: the binding call plus marker
180
+ # restoration when the document carries them (entity-free
181
+ # documents — the common case — skip the scan; parentless
182
+ # iterparse elements are never bearing).
183
+ def bare_attr_value(element, name)
184
+ value = element[name.to_s]
185
+ if value.is_a?(String) && entity_bearing?(element)
186
+ restore_entities(value)
187
+ else
188
+ value
189
+ end
190
+ end
191
+
192
+ def native_identity_stable?
193
+ true
194
+ end
195
+
111
196
  def parse(xml, options = {}, _context = nil)
112
197
  xml_string = xml.is_a?(IO) || xml.is_a?(StringIO) ? xml.read : xml.to_s
113
198
  # The marker flag rides preprocess's own `&` scan — no
@@ -153,11 +238,59 @@ module Moxml
153
238
 
154
239
  record_source_declaration(native_doc, processed)
155
240
  attachments.set(native_doc, :entity_markers, entity_markers)
241
+ bump_serialize_generation
156
242
  attachments.set(native_doc, :parse_errors, recover_errors) if recover_errors
157
243
 
158
244
  doc
159
245
  end
160
246
 
247
+ # Tolerant HTML4/5 parsing into the standard DOM (engine
248
+ # leptris/leptris#659): implied end tags, void elements,
249
+ # raw-text script/style, case-insensitive lowercased names,
250
+ # the HTML named-entity table, synthesized html/head/body.
251
+ # The engine decodes HTML entities directly into text — no
252
+ # marker pipeline, so the marker split scan is stood down.
253
+ # Incremental parse (libleptris v1.6/#586): yields completed
254
+ # elements as the parse runs; each prior subtree is released.
255
+ # Yielded elements are parentless (document nil) and valid
256
+ # only inside the block — the wrapper mirrors that lifetime.
257
+ def iterparse(xml, mode = :top_level, _context = nil, &block)
258
+ raise ArgumentError, "iterparse requires a block" unless block
259
+
260
+ ctx = _context || Context.new(:leptris)
261
+ ::Leptris::XML::Iterparse.parse(xml, mode: mode) do |element|
262
+ yield(Node.wrap(element, ctx))
263
+ end
264
+ end
265
+
266
+ def iterparse_file(path, mode = :top_level, _context = nil, &block)
267
+ raise ArgumentError, "iterparse_file requires a block" unless block
268
+
269
+ ctx = _context || Context.new(:leptris)
270
+ ::Leptris::XML::Iterparse.parse_file(path, mode: mode) do |element|
271
+ yield(Node.wrap(element, ctx))
272
+ end
273
+ end
274
+
275
+ def parse_html(html, _options = {}, _context = nil)
276
+ unless HTML_PARSE_SUPPORTED
277
+ raise Moxml::AdapterError.new(
278
+ "HTML parsing requires leptris >= 1.9.80 (have #{::Leptris::VERSION})",
279
+ adapter: name, operation: "parse_html",
280
+ )
281
+ end
282
+
283
+ html_string = html.is_a?(IO) || html.is_a?(StringIO) ? html.read : html.to_s
284
+ native_doc = begin
285
+ ::Leptris::XML.parse_html(html_string)
286
+ rescue ::Leptris::XML::ParseError => e
287
+ raise Moxml::ParseError.new(e.message)
288
+ end
289
+ attachments.set(native_doc, :entity_markers, false)
290
+ bump_serialize_generation
291
+ Document.new(native_doc, _context || Context.new(:leptris))
292
+ end
293
+
161
294
  # nil when no parse flag is requested — the binding treats a
162
295
  # nil options hash as plain defaults (matching libxml2/
163
296
  # Nokogiri semantics: blanks kept, no ATTLIST defaults).
@@ -393,7 +526,7 @@ module Moxml
393
526
  when ::Leptris::XML::Element then :element
394
527
  when ::Leptris::XML::CDATA then :cdata
395
528
  when ::Leptris::XML::Text, CustomizedLeptris::TextSegment then :text
396
- when ::Leptris::XML::Attr then :attribute
529
+ when ::Leptris::XML::Attr, ::Leptris::XML::ResultAttr then :attribute
397
530
  when ::Leptris::XML::Comment then :comment
398
531
  when ::Leptris::XML::ProcessingInstruction, CustomizedLeptris::DocumentPI then :processing_instruction
399
532
  when ::Leptris::XML::Document then :document
@@ -455,7 +588,8 @@ module Moxml
455
588
  # dominates cold children cost. Cross-document moves of
456
589
  # marker-bearing text into an entity-free document degrade
457
590
  # to literal text.
458
- return natives if attachments.get(node.document, :entity_markers) == false
591
+ return natives if node.document.nil? ||
592
+ attachments.get(node.document, :entity_markers) == false
459
593
 
460
594
  split_entity_markers(natives, node)
461
595
  end
@@ -561,6 +695,7 @@ module Moxml
561
695
  marker = parent.document.create_text_node("#{Entity::MARKER}#{child.name};")
562
696
  parent.add_child(marker)
563
697
  attachments.set(parent.document, :entity_markers, true)
698
+ bump_serialize_generation
564
699
  return child
565
700
  end
566
701
  child = parent.document.create_text_node(child) if child.is_a?(String)
@@ -772,8 +907,10 @@ module Moxml
772
907
  end
773
908
  case result
774
909
  when ::Leptris::XML::NodeSet
775
- nodes = result.to_a
776
- first_only ? nodes.first : nodes
910
+ # The binding's #to_a mints one Ruby wrapper per result
911
+ # node; hand the native set through so .size/.first stay
912
+ # native-side (LazyNodeSet holds it unmaterialized).
913
+ first_only ? result.first : result.extend(Moxml::LazyNodeSet)
777
914
  else
778
915
  result
779
916
  end
@@ -785,6 +922,10 @@ module Moxml
785
922
  end
786
923
 
787
924
  def native_context_node?(node)
925
+ # Parentless elements (iterparse yields) have no document
926
+ # handle for the compiled eval — the Ruby engine owns them.
927
+ return false if node.is_a?(::Leptris::XML::Element) && node.document.nil?
928
+
788
929
  node.is_a?(::Leptris::XML::Document) ||
789
930
  node.is_a?(::Leptris::XML::Element)
790
931
  end
@@ -815,6 +956,12 @@ module Moxml
815
956
  next false if uses_xmlns_prefix?(ast)
816
957
  next false if !PREFIXED_ATTR_PREDICATES_NATIVE && prefixed_attribute_test?(ast)
817
958
 
959
+ # Attribute-node results are native since 1.9.105
960
+ # (leptris-ruby#153: ResultAttr wrappers with name/value);
961
+ # older bindings returned generic Node wrappers whose
962
+ # #name raised, so the Ruby engine owned them.
963
+ next true if selects_attribute_results?(ast) && ATTR_RESULT_NATIVE
964
+
818
965
  !selects_attribute_results?(ast)
819
966
  end
820
967
  rescue XPath::SyntaxError
@@ -923,12 +923,23 @@ module Moxml
923
923
  native.name
924
924
  end
925
925
 
926
+ # libxml-ruby 5.x exposes no external/system ID accessor on
927
+ # the DTD node — the DOCTYPE source text is the only source,
928
+ # so re-derive from it (same DOCTYPE_RE as the parse path).
926
929
  def doctype_external_id(native)
927
- native.external_id
930
+ if native.is_a?(DoctypeWrapper)
931
+ native.external_id
932
+ else
933
+ native.to_s[DOCTYPE_RE, 2]
934
+ end
928
935
  end
929
936
 
930
937
  def doctype_system_id(native)
931
- native.system_id
938
+ if native.is_a?(DoctypeWrapper)
939
+ native.system_id
940
+ else
941
+ native.to_s[DOCTYPE_RE, 3] || native.to_s[DOCTYPE_RE, 4]
942
+ end
932
943
  end
933
944
 
934
945
  def xpath(node, expression, namespaces = nil)
@@ -12,12 +12,50 @@ module Moxml
12
12
  @attachments ||= Moxml::NativeAttachment.new
13
13
  end
14
14
 
15
+ def bare_set_qname_safe?
16
+ true
17
+ end
18
+
19
+ # Qualified-name reads are clean on this engine (verified by
20
+ # the expanded-name specs); markers restore in-line when the
21
+ # parse recorded them.
22
+ def bare_get_qname_safe?
23
+ true
24
+ end
25
+
26
+ # Fast bare-name read for Element#[]: the native call plus,
27
+ # only when the parse recorded entity markers, their
28
+ # restoration (the resolver path's other real semantic).
29
+ # The marker flag is constant per document — a WeakMap on
30
+ # the adapter beats the per-read document fetch + attachment
31
+ # hash chain.
32
+ def doc_entity_markers?(doc)
33
+ cache = (@doc_markers ||= ObjectSpace::WeakMap.new)
34
+ cached = cache[doc]
35
+ return cached unless cached.nil?
36
+
37
+ cache[doc] = attachments.get(doc, :entity_markers) == true
38
+ end
39
+
40
+ def bare_attr_value(element, name)
41
+ value = element[name.to_s]
42
+ if value.is_a?(String) && doc_entity_markers?(element.document)
43
+ restore_entities(value)
44
+ else
45
+ value
46
+ end
47
+ end
48
+
49
+ def native_identity_stable?
50
+ true
51
+ end
52
+
15
53
  def set_root(doc, element)
16
54
  doc.root = element
17
55
  end
18
56
 
19
57
  def parse(xml, options = {}, _context = nil)
20
- processed_xml = preprocess_entities(xml)
58
+ processed_xml, entity_markers = Entity.preprocess_with_marker_flag(xml)
21
59
 
22
60
  # preprocess_entities always returns UTF-8, so tell Nokogiri to
23
61
  # parse as UTF-8 regardless of any original encoding option.
@@ -42,9 +80,20 @@ module Moxml
42
80
 
43
81
  # Use provided context if available, otherwise create new one
44
82
  ctx = _context || Context.new(:nokogiri)
83
+ attachments.set(native_doc, :entity_markers, entity_markers)
45
84
  Document.new(native_doc, ctx)
46
85
  end
47
86
 
87
+ # Tolerant HTML parsing through libxml2's HTML mode: implied
88
+ # html/head/body structure, void elements, lowercased names,
89
+ # the HTML named-entity table. Recovery is inherent —
90
+ # malformed input never raises.
91
+ def parse_html(html, _options = {}, _context = nil)
92
+ html_string = html.is_a?(IO) || html.is_a?(StringIO) ? html.read : html.to_s
93
+ native_doc = ::Nokogiri::HTML(html_string)
94
+ Document.new(native_doc, _context || Context.new(:nokogiri))
95
+ end
96
+
48
97
  # Nokogiri parses with `config.recover` unless strict, so
49
98
  # recoverable syntax errors land on `doc.errors` instead of
50
99
  # raising (issue #147).
@@ -386,9 +435,11 @@ module Moxml
386
435
 
387
436
  def xpath(node, expression, namespaces = nil)
388
437
  result = node.xpath(expression, namespaces)
389
- # Adapter contract: Array<native> | scalar (count(),
390
- # string-length(), boolean functions return scalars).
391
- result.is_a?(Enumerable) ? result.to_a : result
438
+ # Adapter contract: Array<native> | LazyNodeSet | scalar
439
+ # (count(), string-length(), boolean functions return
440
+ # scalars). The native node-set passes through lazily —
441
+ # to_a would materialize every result wrapper up front.
442
+ result.is_a?(Enumerable) ? result.extend(Moxml::LazyNodeSet) : result
392
443
  rescue ::Nokogiri::XML::XPath::SyntaxError => e
393
444
  raise Moxml::XPathError.new(
394
445
  e.message,
@@ -15,6 +15,14 @@ module Moxml
15
15
  @attachments ||= Moxml::NativeAttachment.new
16
16
  end
17
17
 
18
+ def bare_set_qname_safe?
19
+ true
20
+ end
21
+
22
+ def native_identity_stable?
23
+ true
24
+ end
25
+
18
26
  def set_root(doc, element)
19
27
  existing_root = root(doc)
20
28
  element.parent = doc if element.is_a?(::Ox::Node)
@@ -14,6 +14,14 @@ module Moxml
14
14
  @attachments ||= Moxml::NativeAttachment.new
15
15
  end
16
16
 
17
+ def bare_set_qname_safe?
18
+ true
19
+ end
20
+
21
+ def native_identity_stable?
22
+ true
23
+ end
24
+
17
25
  def parse(xml, options = {}, _context = nil)
18
26
  xml = "" if xml.nil?
19
27
 
@@ -29,6 +29,27 @@ module Moxml
29
29
  module_function
30
30
 
31
31
  # @return [Moxml::Attribute, nil] the matching attribute wrapper
32
+ # Value-only prefixed resolution for Element#[]: prefix
33
+ # resolution is the resolver's real work; the match step goes to
34
+ # the engine's expanded-name lookup where the adapter offers it,
35
+ # skipping the attribute-list materialization and per-attr URI
36
+ # probes. Bare names take Element#'s adapter fast path instead.
37
+ def resolve_value(element, name)
38
+ prefix, local = name.split(":", 2)
39
+ return nil if prefix == "xmlns"
40
+
41
+ uri = prefix_uri(element, prefix)
42
+ return nil if uri.nil?
43
+
44
+ adapter = element.context.config.adapter
45
+ if adapter.is_a?(Moxml::Adapter::Leptris)
46
+ return adapter.expanded_attr_value(element.native, uri, local)
47
+ end
48
+
49
+ attr = resolve(element, name)
50
+ attr&.value
51
+ end
52
+
32
53
  def resolve(element, name)
33
54
  name = name.to_s
34
55
  if name.include?(":")
@@ -91,6 +112,20 @@ module Moxml
91
112
  return value
92
113
  end
93
114
 
115
+ # Fast path: every adapter's set_attribute writes by qualified
116
+ # name, and XML cannot namespace an unprefixed attribute — so
117
+ # a bare-name set replaces only the no-namespace attribute and
118
+ # can never touch a namespaced p:<local> sibling. The full
119
+ # expanded-name resolve (wrap every attribute, resolve each
120
+ # prefix against in-scope namespaces) is only needed for
121
+ # prefixed names; it dominated programmatic-builder attribute
122
+ # writes.
123
+ if !name.include?(":") && adapter.bare_set_qname_safe?
124
+ adapter.set_attribute(element.native, name, value)
125
+ element.invalidate_attribute_cache!
126
+ return value
127
+ end
128
+
94
129
  existing = resolve(element, name)
95
130
  if existing
96
131
  existing.value = value
data/lib/moxml/c14n.rb CHANGED
@@ -39,6 +39,11 @@ module Moxml
39
39
  # :exclusive10 Exclusive C14N 1.0 (W3C REC-xml-exc-c14n-20020718)
40
40
  def self.canonicalize(node_or_xml, with_comments: false,
41
41
  algorithm: :inclusive10, inclusive_namespaces: [])
42
+ if (native = native_inclusive10(node_or_xml, with_comments,
43
+ inclusive_namespaces))
44
+ return native
45
+ end
46
+
42
47
  engine_for(algorithm).canonicalize(
43
48
  node_or_xml,
44
49
  with_comments: with_comments,
@@ -46,6 +51,31 @@ module Moxml
46
51
  )
47
52
  end
48
53
 
54
+ # The leptris engine canonicalizes in C — 23x the Ruby reference.
55
+ # Delegated only for the exact default shape (Inclusive 1.0, no
56
+ # comments, no InclusiveNamespace prefix list) on wrappers whose
57
+ # adapter passed the NATIVE_C14N_BYTE_SAFE load probe; every
58
+ # other combination keeps the Ruby engine (node-set subsets,
59
+ # xml:base fixup, exclusive, 1.1 stay on the ported
60
+ # implementation regardless).
61
+ def self.native_inclusive10(node_or_xml, with_comments, inclusive_namespaces)
62
+ return nil if with_comments || !inclusive_namespaces.empty?
63
+ return nil unless algorithm_shape_inclusive10?(node_or_xml)
64
+
65
+ adapter = node_or_xml.context.config.adapter
66
+ return nil unless adapter == Moxml::Adapter::Leptris &&
67
+ Moxml::Adapter::Leptris.native_c14n_byte_safe?
68
+
69
+ node_or_xml.native.canonicalize(
70
+ ::Leptris::XML::FFI::C14N_1_0, nil,
71
+ mode: ::Leptris::XML::FFI::C14N_MODE_CANONICAL
72
+ )
73
+ end
74
+
75
+ def self.algorithm_shape_inclusive10?(node_or_xml)
76
+ node_or_xml.is_a?(Moxml::Element) || node_or_xml.is_a?(Moxml::Document)
77
+ end
78
+
49
79
  def self.canonicalize_inclusive10(node_or_xml, with_comments: false)
50
80
  Inclusive10.new.canonicalize(node_or_xml, with_comments: with_comments)
51
81
  end
data/lib/moxml/context.rb CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Moxml
4
4
  class Context
5
+ # Opal's default runtime excludes ObjectSpace entirely; the weak
6
+ # registry needs WeakMap, so the strong Hash (with its
7
+ # wholesale-clear valve) covers that platform.
8
+ WEAK_WRAPPERS = !defined?(ObjectSpace::WeakMap).nil?
5
9
  attr_reader :config
6
10
 
7
11
  def initialize(adapter = nil)
@@ -14,7 +18,12 @@ module Moxml
14
18
  # the same wrapper instead of allocating a fresh one per
15
19
  # access. Keyed by object identity; re-keyed by
16
20
  # Node#refresh_native! when an adapter swaps a native.
17
- @wrappers = {}.compare_by_identity
21
+ # WeakMap where available: entries die with their native —
22
+ # parse-and-drop workloads release wrappers, natives, and (via
23
+ # the binding's finalizer) the C subtrees, instead of pinning
24
+ # up to the old 65,536 wholesale-clear valve which also
25
+ # destroyed identity for live wrappers when it fired.
26
+ @wrappers = WEAK_WRAPPERS ? ObjectSpace::WeakMap.new : {}.compare_by_identity
18
27
  end
19
28
 
20
29
  def wrapper_for(native)
@@ -22,18 +31,26 @@ module Moxml
22
31
  end
23
32
 
24
33
  def register_wrapper(native, wrapper)
25
- # Safety valve + adapter opt-in. Adapters whose natives are
26
- # recreated per access (libxml mints fresh Ruby objects for the
27
- # same C node) opt out so the map does not accumulate dead
28
- # entries; the default is opt-in.
34
+ # Adapter opt-in. Adapters whose natives are recreated per
35
+ # access (libxml mints fresh Ruby objects for the same C node)
36
+ # opt out so the map does not accumulate dead entries; the
37
+ # default is opt-in.
29
38
  return if @config&.adapter&.wrappers_recyclable? == false
30
39
 
31
- @wrappers.clear if @wrappers.size >= 65_536
40
+ # The strong fallback (Opal) needs the wholesale-clear valve;
41
+ # the WeakMap registry is self-cleaning.
42
+ @wrappers.clear if @wrappers.is_a?(Hash) && @wrappers.size >= 65_536
32
43
  @wrappers[native] = wrapper
33
44
  end
34
45
 
46
+ # WeakMap has no delete — a nil value tombstones the entry (reads
47
+ # as a miss) and dies with the native like any other.
35
48
  def unregister_wrapper(native)
36
- @wrappers.delete(native)
49
+ if WEAK_WRAPPERS
50
+ @wrappers[native] = nil
51
+ else
52
+ @wrappers.delete(native)
53
+ end
37
54
  end
38
55
 
39
56
  def namespace_scope_generation
@@ -74,6 +91,29 @@ module Moxml
74
91
  doc
75
92
  end
76
93
 
94
+ # Tolerant HTML4/5 parsing into the standard DOM: implied end
95
+ # tags, void elements, case-insensitive lowercased names, the
96
+ # HTML named-entity table, synthesized html/head/body. Supported
97
+ # by adapters with an engine HTML mode (leptris >= 1.9.80,
98
+ # nokogiri); others raise Moxml::AdapterError.
99
+ def parse_html(html, options = {})
100
+ config.adapter.parse_html(html, options, self)
101
+ end
102
+
103
+ # Streaming incremental parse (leptris): yields each completed
104
+ # element while parsing — :top_level yields the root's children,
105
+ # :full_document every element in completion (post-order) order.
106
+ # Memory stays bounded by the largest subtree, not the document
107
+ # (iterparse_file streams the file C-side). Yielded elements are
108
+ # parentless and valid only inside the block.
109
+ def iterparse(xml, mode: :top_level, &block)
110
+ config.adapter.iterparse(xml, mode, self, &block)
111
+ end
112
+
113
+ def iterparse_file(path, mode: :top_level, &block)
114
+ config.adapter.iterparse_file(path, mode, self, &block)
115
+ end
116
+
77
117
  # Parse then flatten in one call — see Moxml::Materializer
78
118
  # (issue #132). Yields records; returns an Enumerator when no
79
119
  # block is given.
@@ -128,6 +168,26 @@ module Moxml
128
168
  Builder.new(self).build(&block)
129
169
  end
130
170
 
171
+ # Frozen serialization defaults, memoized per context — Node#to_xml
172
+ # merged these from live config reads on every call (bulk element
173
+ # serialization pays a hash build + 4 chain derefs per element).
174
+ def default_serialize_options
175
+ @default_serialize_options ||= {
176
+ encoding: config.default_encoding,
177
+ indent: config.default_indent,
178
+ line_ending: config.default_line_ending,
179
+ expand_empty: true,
180
+ }.freeze
181
+ end
182
+
183
+ # The argless element.to_xml form resolves every option statically
184
+ # (no declaration for non-document nodes, context defaults for the
185
+ # rest) — this prebuilt frozen hash makes that path allocation-free.
186
+ def default_element_serialize_options
187
+ @default_element_serialize_options ||=
188
+ default_serialize_options.merge(no_declaration: true).freeze
189
+ end
190
+
131
191
  private
132
192
 
133
193
  def build_entity_registry
@@ -131,11 +131,11 @@ module Moxml
131
131
  # Handle different result types:
132
132
  # - Scalar values (from functions): return directly
133
133
  # - NodeSet: already wrapped, return directly
134
- # - Array: wrap in NodeSet
134
+ # - Array / LazyNodeSet: wrap in NodeSet
135
135
  case result
136
136
  when NodeSet, Float, String, TrueClass, FalseClass, NilClass
137
137
  result
138
- when Array
138
+ when Array, LazyNodeSet
139
139
  NodeSet.new(result, context)
140
140
  else
141
141
  # For other types, try to wrap in NodeSet
data/lib/moxml/element.rb CHANGED
@@ -46,7 +46,9 @@ module Moxml
46
46
  # attributes only; prefixed names replace by namespace URI and
47
47
  # require a declared prefix. Cache coherence is the resolver's.
48
48
  def []=(name, value)
49
- context.bump_namespace_scope_generation
49
+ # Every assign path (native set or value=) ends in
50
+ # invalidate_attribute_cache!, which bumps the generation —
51
+ # a leading bump here was one per write.
50
52
  Moxml::AttributeResolver.assign(self, name, normalize_xml_value(value))
51
53
  end
52
54
 
@@ -54,12 +56,25 @@ module Moxml
54
56
  # bare names match only no-namespace attributes; prefixed names
55
57
  # resolve through in-scope declarations to expanded names.
56
58
  def [](name)
57
- cache = attribute_read_cache
58
59
  key = name.to_s
60
+ # Bare names on qualified-name engines: one adapter call with
61
+ # the adapter's own marker restoration (bare_attr_value). The
62
+ # resolver's expanded-name semantics only pay for prefixed
63
+ # names, where resolution is real work.
64
+ adapter = self.adapter
65
+ if !key.include?(":") && adapter.bare_get_qname_safe?
66
+ return adapter.bare_attr_value(@native, key)
67
+ end
68
+
69
+ cache = attribute_read_cache
59
70
  unless cache.key?(key)
60
- cache[key] = Moxml::AttributeResolver.resolve(self, key)
71
+ cache[key] = if key.include?(":")
72
+ Moxml::AttributeResolver.resolve_value(self, key)
73
+ else
74
+ Moxml::AttributeResolver.resolve(self, key)&.value
75
+ end
61
76
  end
62
- cache[key]&.value
77
+ cache[key]
63
78
  end
64
79
 
65
80
  def attribute(name)
@@ -92,11 +107,41 @@ module Moxml
92
107
  end
93
108
 
94
109
  def remove_attribute(name)
95
- context.bump_namespace_scope_generation
110
+ # Both remove paths end in invalidate_attribute_cache!, which
111
+ # bumps the generation.
96
112
  Moxml::AttributeResolver.remove(self, name)
97
113
  self
98
114
  end
99
115
 
116
+ # Append a raw XML fragment's top-level nodes as children.
117
+ #
118
+ # Bulk construction rides the engine's parser instead of
119
+ # per-node create/attach calls — measured 2.0x faster than
120
+ # per-node building through the same adapter, and faster than
121
+ # per-node building on Nokogiri (the parse is C; the per-node
122
+ # path pays an FFI crossing per node).
123
+ #
124
+ # The fragment must be namespace-self-contained (declarations
125
+ # inside it ride along with the moved subtrees; prefixes relying
126
+ # on THIS element's scope must be spelled out in the fragment)
127
+ # and well-formed as the content of a single wrapper element —
128
+ # declarations and doctypes raise ParseError.
129
+ #
130
+ # @return [Moxml::Element] self
131
+ def append_xml(fragment)
132
+ if context.config.adapter_name == :ox
133
+ raise Moxml::AdapterError.new(
134
+ "append_xml is not supported by the Ox adapter (its customized node wrappers do not survive cross-document attachment)",
135
+ adapter: "Ox", operation: "append_xml",
136
+ )
137
+ end
138
+
139
+ wrapper = context.parse("<m>#{fragment}</m>")
140
+ children = wrapper.root.children.to_a
141
+ children.each { |child| add_child(child) }
142
+ self
143
+ end
144
+
100
145
  def add_namespace(prefix, uri)
101
146
  adapter.create_namespace(@native, prefix, uri,
102
147
  namespace_validation_mode: context.config.namespace_validation_mode)