moxml 0.5.30 → 0.5.31

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: b9c5b752aba6a52719fbeb794be70155e0fc52a553889e424b3cfbc0a4afa324
4
- data.tar.gz: ee7a443060fc562236ccfb135c509ae65e51ba3ec930cd8a39cb3a917f5e41ed
3
+ metadata.gz: 32f1034201de38bed35a6b1496d10f96a580f0e4c92517c2253ded0631331439
4
+ data.tar.gz: bb3d697a73fbac699e00b51905ef446f9837b9ee649785bb1ed4df2027870e78
5
5
  SHA512:
6
- metadata.gz: d92bc0324a80312556a369b6b1ce7b1619c13ef23fac2212c3148211deb7c786543b83568ea6a1d312df9e09189f1d8feabf8b5d44840618852124d4c5550dd7
7
- data.tar.gz: 9a0b511e096230051cd1e92ae3e36603b02ddfe2f2c03cfaf6d8072b21defda2e65938c4f9963516d3d3696a44a7689d98cc68244bd60e26672c49e04d292079
6
+ metadata.gz: 3a854ab201001d88494c37d79b163bbf4246880702f5c7d61cf52fd41128459be2a1e8c4601c894852b6aa73e33efca3c471ebfdbf9a126120afa698ab28aff1
7
+ data.tar.gz: 531a60b0d52e84f4f0df5a026d51b5eeed850b56da1c1eea23297c2c2fb331efa4047a9dc73ef21a430998035aad7a58fe8adb7905047f2001f5b941d07353fc
@@ -29,6 +29,13 @@ whitespace-only text nodes where the engine has the flag
29
29
  (#147; recover-mode diagnostics — `[]` on clean parses; engines with
30
30
  an error channel map it here, e.g. Nokogiri's `doc.errors`).
31
31
 
32
+ HTML mode::
33
+ `parse_html(html, options, context)` — tolerant HTML4/5 parsing into
34
+ the standard DOM, where the engine offers it (leptris >= 1.9.80 via
35
+ engine #659; Nokogiri via libxml2's HTML parser). The base class
36
+ raises `Moxml::AdapterError` — adapters without an HTML mode keep
37
+ that default.
38
+
32
39
  Tree navigation::
33
40
  `children`, `parent`, `next_sibling`, `previous_sibling`, `root`,
34
41
  `set_root`, `document`, `duplicate_node`. `children` on a document
@@ -51,6 +51,67 @@ libxml2/Nokogiri/REXML semantics (libleptris >= 1.9.8).
51
51
  Raise `Moxml::ParseError` on malformed input instead of returning an
52
52
  empty document.
53
53
 
54
+ == HTML parsing
55
+
56
+ `Context#parse_html(html)` (leptris >= 1.9.80; engine
57
+ leptris/leptris/#659) parses tolerant HTML4/5 into the standard XML
58
+ DOM: implied end tags (`<li>a<li>b`), void elements (`<br>`, `<img>`),
59
+ raw-text script/style, case-insensitive lowercased names and
60
+ attributes, minimized/boolean attributes, and the HTML named-entity
61
+ table. `html`/`head`/`body` are synthesized when the input implies
62
+ them (no empty head for headless fragments); tables get no implied
63
+ `tbody`. Malformed input degrades to text — the parse never fails;
64
+ only an entirely empty result raises `Moxml::ParseError`.
65
+
66
+ [source,ruby]
67
+ ----
68
+ doc = Moxml.new(:leptris).parse_html(%(<ul><li>a<li>b</ul>))
69
+ doc.xpath("//li").map(&:text) # => ["a", "b"]
70
+ ----
71
+
72
+ Serialization is XML-shaped: text content is escaped (`a &lt; b`),
73
+ so script bodies with markup characters round-trip through a strict
74
+ XML reparse. The adapter stands the entity-marker pipeline down for
75
+ HTML parses — the engine decodes HTML entities directly into text.
76
+
77
+ Measured on 1.9.105 (named-entity decoding fixed, engine #848):
78
+ parse 2.5-3.5x, serialize 4.1x, xpath 6-29x faster than Nokogiri —
79
+ uniformly, including entity-laden pages.
80
+
81
+ Adapters without an engine HTML mode (Ox, Oga, REXML, LibXML) raise
82
+ `Moxml::AdapterError`; Nokogiri parses through libxml2's HTML mode.
83
+
84
+ == Streaming iteration — `iterparse`
85
+
86
+ `Context#iterparse(xml, mode:)` / `iterparse_file(path, mode:)`
87
+ expose the engine's incremental parser (libleptris v1.6, #586):
88
+ completed elements yield while parsing, prior subtrees release, and
89
+ memory stays bounded by the largest subtree — `iterparse_file`
90
+ streams the file C-side. `:top_level` yields the root's children;
91
+ `:full_document` yields every element in completion (post-order)
92
+ order.
93
+
94
+ Measured on a 21MB / 108k-element file: **+20MB peak RSS** (full
95
+ parse: +169MB; Nokogiri Reader: +62MB), bare iteration ~97ms on
96
+ leptris >= 1.9.105 (Nokogiri Reader: 179ms) — ~1.8x faster at a
97
+ third of the memory. Attribute reads on yielded elements improved
98
+ ~16x on current builds (the parentless-read cost filed in
99
+ leptris-ruby#152 is fixed).
100
+
101
+ The yielded elements are parentless (`#document` nil) and valid
102
+ only inside the block — the subtree is released when the iterator
103
+ advances. Since binding 1.9.105, access after the iteration raises
104
+ `UseAfterFreeError` instead of crashing (leptris-ruby#152). Adapters without an engine iterator raise
105
+ `Moxml::AdapterError`.
106
+
107
+ [source,ruby]
108
+ ----
109
+ Moxml.new(:leptris).iterparse_file("huge.xml") do |record|
110
+ process(record.at_attrs, record.children.map(&:text))
111
+ end
112
+ ----
113
+
114
+ == Lifecycle
54
115
  == Lifecycle
55
116
 
56
117
  `Document#free` releases the C tree deterministically — batch
@@ -89,6 +89,24 @@ context.parse(dtd_xml) # plain: ATTLIST defaults excluded
89
89
  context.parse(dtd_xml, dtdattr: true) # opt-in: attr="default" materialized
90
90
  ----
91
91
 
92
+ == HTML input — `parse_html`
93
+
94
+ Web-scraped and HTML-fragment inputs parse tolerantly into the
95
+ standard DOM (leptris >= 1.9.80 via engine #659; Nokogiri via
96
+ libxml2's HTML mode; other adapters raise `Moxml::AdapterError`):
97
+
98
+ [source,ruby]
99
+ ----
100
+ doc = context.parse_html(%(<ul><li>a<li>b</ul>))
101
+ doc.xpath("//li").map(&:text) # => ["a", "b"]
102
+ ----
103
+
104
+ Implied end tags, void elements, lowercased names, boolean
105
+ attributes, and HTML named entities decode at parse; serialization
106
+ is XML-shaped. See the leptris adapter page for the semantics
107
+ matrix. On leptris >= 1.9.105 every HTML operation — including
108
+ entity-laden pages — measures 2.5-4x faster than Nokogiri.
109
+
92
110
  == Pretty-print parity (issue #129)
93
111
 
94
112
  Through the leptris adapter, byte-identical output to raw Nokogiri:
@@ -56,6 +56,148 @@ Consumers on CRuby 3.3+ (canon's pretty-print pipeline, batch
56
56
  conversion) should treat it as the default; moxml's own CI
57
57
  performance gates run with YJIT enabled.
58
58
 
59
+ === Wrapper retention — the identity map is weak
60
+
61
+ `Context` keeps a native→wrapper identity map so repeated traversals
62
+ hand back the same wrapper. Since 0.5.31 it is an
63
+ `ObjectSpace::WeakMap`: entries die with their native, so
64
+ parse-and-drop workloads release wrappers, natives, and (once the
65
+ binding's finalizer runs) the C subtrees. Measured walking and
66
+ dropping 220 documents (~600 elements each) with one long-lived
67
+ Context: **+1.9MB RSS growth vs +41MB before**, zero wrapper
68
+ retention beyond the binding's own constant one-document cache —
69
+ and no identity loss, which the previous 65,536-entry wholesale
70
+ clear traded away when it fired.
71
+
72
+ === Measuring moxml — methodology and traps
73
+
74
+ Every number in this page follows the same discipline; contributors
75
+ benchmarking moxml should too. The traps below all produced wrong
76
+ conclusions at least once.
77
+
78
+ *Timing:*
79
+
80
+ - min-of-N (>= 10 samples) around loops, never single-shot averages;
81
+ `Process.clock_gettime(Process::CLOCK_MONOTONIC)`; `GC.start`
82
+ before each round.
83
+ - Time LOOPS, not single calls: single sub-microsecond operations
84
+ read as 0 on some clocks (macOS CLOCK_MONOTONIC granularity).
85
+ - Gate on load average — shared machines under build load (load 30+)
86
+ swing identical runs 2x. Wait for a calm window (< 5).
87
+ - One heavy benchmark at a time; concurrent suites and benchmarks
88
+ have crashed this machine.
89
+ - dup benchmarks must free each copy — native trees accumulate
90
+ otherwise and the numbers degrade monotonically (that drift is a
91
+ leak signal, not noise).
92
+
93
+ *Allocation counts (GC.stat(:total_allocated_objects)) are
94
+ load-immune — use them when the machine is busy:*
95
+
96
+ - WARM THE PATH FIRST: the first call through any code path pays
97
+ autoloads (adapter, xpath engine, serialize modules — dozens of
98
+ classes and regexps). A cold first run charges those to whichever
99
+ variant ran first and fabricates "superadditive" overhead.
100
+ - Keep the fixture shape identical across compared cells: parent
101
+ vs leaf elements allocate differently in every engine.
102
+ - Negative deltas are impossible — if you see one, the harness is
103
+ broken, not the code.
104
+
105
+ *Correctness under load:* race repros and corruption checks need
106
+ hundreds of iterations — spot checks of 30 have missed 5-30% races
107
+ on this codebase.
108
+
109
+ === leptris 1.9.105 numbers (reference table)
110
+
111
+ XML parse 2.4-2.8x, document serialize 2.4x, element queries 32x,
112
+ attribute-node queries 1.4x (native since 1.9.105), HTML parse
113
+ 2.5-3.5x including entity-laden pages (engine #848 fixed), HTML
114
+ serialize 4.1x, streaming iteration ~97ms/21MB at +21MB RSS (~1.8x Nokogiri
115
+ Reader), C14n 1.0 native on leptris >= 1.9.121 (engine #919 fixed)
116
+ at 1.8x+ Nokogiri through the full document path.
117
+
118
+ SAX (calm-machine, verified): the raw binding parses 1.19x faster
119
+ than Nokogiri; through moxml's unified handler the API contract
120
+ (per-event attribute Hash + namespace separation) nets 0.74x interp
121
+ / 0.93x YJIT.
122
+
123
+ === Element serialization
124
+
125
+ Bulk `element.to_xml` (per-element, no options) was wrapper-tax
126
+ bound — the raw engine serializes 3x faster than Nokogiri but the
127
+ Ruby option/guard layers spent 4.6x the C call's cost per element.
128
+ The argless path is now allocation-free (frozen prebuilt defaults),
129
+ the element face skips the declaration fetch, UTF-8 requests ride
130
+ the binding's shared default options, the trailing-newline strip is
131
+ version-gated (engine fixed in 1.9.42), and `entity_bearing?` memoizes
132
+ against an adapter generation counter. Measured per element:
133
+ 3-child **0.74x -> 1.0x** (parity — fixed guard tax on tiny output),
134
+ 21-child **1.7x**; document-level serialize stays 2.4x.
135
+
136
+ === Held-document footprint
137
+
138
+ Four lifecycles, measured (RSS, isolated processes; leptris 1.9.80
139
+ vs Nokogiri/libxml2):
140
+
141
+ | lifecycle | moxml(leptris) | nokogiri | |
142
+ |---|---|---|---|
143
+ | bare tree (parse+hold, unaccessed) | **203 B/node** | 408 B/node | engine tree 2.0x smaller |
144
+ | parse + walk + drop | leak-free (weak registry) | — | `#free` for deterministic release |
145
+ | peak transient, 3.6MB doc (parse+pretty-print) | +48.8MB | +55.6MB | **0.88x smaller peak** |
146
+ | held + walked (37KB doc) | 1186 kB/doc | 754 kB/doc | 1.57x larger — binding wrapper layer |
147
+
148
+ The one regression is held-and-walked: the binding's per-node Ruby
149
+ wrappers (element 80 B + a dedicated 88 B `FFI::Pointer` + cache
150
+ slot ≈ 170+ B/node) outweigh even the C tree; upstream
151
+ leptris-ruby#147 tracks it with a TypedData fix path. Guidance for
152
+ held workloads: keep documents you will not re-traverse in the
153
+ parse-and-read lifecycle (`readonly:`, `#free`), and prefer
154
+ `materialize` for one-shot conversion.
155
+
156
+ === Programmatic construction
157
+
158
+ Building documents node-by-node is the one surface where the C
159
+ engine's lead does not carry through: each create/attach/attribute
160
+ write crosses the FFI seam, and Nokogiri's C-extension calls are
161
+ cheaper per crossing. Measured end to end:
162
+
163
+ | builder shape | interp | YJIT |
164
+ |---|---|---|
165
+ | node churn (1052 tiny nodes — worst case) | 0.40x | 0.78x |
166
+ | payload-realistic (attrs + long text) | 0.58x | **~1.0x (parity)** |
167
+
168
+ Run builders with YJIT (`RUBY_YJIT_ENABLE=1`, see above): it
169
+ compiles moxml's wrapper layer away (-47% on the churn shape) while
170
+ leaving Nokogiri's thin C layer unchanged. The remaining churn-shape
171
+ gap is the binding's own floor — even a zero-overhead wrapper
172
+ measures 0.90x under YJIT (leptris-ruby#149 tracks it, with a
173
+ batch-create proposal to collapse the crossings).
174
+
175
+ === Bulk construction — `append_xml`
176
+
177
+ `element.append_xml(fragment)` appends a raw fragment's top-level
178
+ nodes by parsing it and attaching the subtrees — the engine's C
179
+ parser does the node construction in one crossing instead of an FFI
180
+ round trip per create/attach/attribute call:
181
+
182
+ | 300-node subtree | time | vs per-node |
183
+ |---|---|---|
184
+ | per-node build (leptris) | 2065µs | — |
185
+ | **append_xml (leptris)** | **1019µs** | **2.0x** |
186
+ | per-node build (nokogiri) | 1746µs | append_xml is 1.7x faster |
187
+
188
+ The fragment must be namespace-self-contained and well-formed as
189
+ the content of one wrapper element. Supported on leptris, nokogiri,
190
+ oga, and rexml; the Ox adapter raises (its customized node wrappers
191
+ do not survive cross-document attachment).
192
+
193
+ === Lazy result sets
194
+
195
+ `xpath` results and `children` node sets allocate wrapper slots on
196
+ first access, not at construction — `.size`/`.empty?`/`.first` on a
197
+ 1000-node result no longer allocate the 1000-slot wrapper array
198
+ (that path also skips the binding's per-node wrapper materialization
199
+ entirely; see `LazyNodeSet` under the leptris adapter).
200
+
59
201
  === Prefer bulk paths for per-node conversion
60
202
 
61
203
  Per-node Ruby iteration pays the wrapper tax per element. When the
@@ -12,6 +12,7 @@ require "moxml/xml_utils"
12
12
  require "moxml/xml_utils/encoder"
13
13
  require "moxml/node"
14
14
  require "moxml/node_set"
15
+ require "moxml/lazy_node_set"
15
16
  require "moxml/document"
16
17
  require "moxml/element"
17
18
  require "moxml/attribute"
@@ -28,6 +28,35 @@ module Moxml
28
28
  )
29
29
  end
30
30
 
31
+ # Streaming incremental parse (leptris engine): yields each
32
+ # completed element while the parse runs, releasing prior
33
+ # subtrees — memory bounded by the largest subtree, not the
34
+ # document. Adapters without an engine iterator raise.
35
+ def iterparse(_xml, _mode = :top_level, _context = nil)
36
+ raise Moxml::AdapterError.new(
37
+ "Streaming iteration is not supported by the #{name.split('::').last} adapter",
38
+ adapter: name, operation: "iterparse",
39
+ )
40
+ end
41
+
42
+ def iterparse_file(_path, _mode = :top_level, _context = nil)
43
+ raise Moxml::AdapterError.new(
44
+ "Streaming file iteration is not supported by the #{name.split('::').last} adapter",
45
+ adapter: name, operation: "iterparse_file",
46
+ )
47
+ end
48
+
49
+ # Tolerant HTML4/5 parsing into the standard DOM (engine
50
+ # issue leptris/leptris#659): implied end tags, void elements,
51
+ # case-insensitive lowercased names, the HTML named-entity
52
+ # table. Adapters whose engine has an HTML mode override this.
53
+ def parse_html(_html, _options = {}, _context = nil)
54
+ raise Moxml::AdapterError.new(
55
+ "HTML parsing is not supported by the #{name.split('::').last} adapter",
56
+ adapter: name, operation: "parse_html",
57
+ )
58
+ end
59
+
31
60
  def parse(_xml, _options = {}, _context = nil)
32
61
  raise Moxml::NotImplementedError.new(
33
62
  "parse not implemented",
@@ -181,6 +210,41 @@ namespace_validation_mode: :strict)
181
210
  false
182
211
  end
183
212
 
213
+ # Whether add_child can keep tracking the same native —
214
+ # adapters that may recreate the node on attach (libxml's
215
+ # doc.root=) override to false so the wrapper refresh path
216
+ # stays armed.
217
+ def native_identity_stable?
218
+ false
219
+ end
220
+
221
+ # Whether a BARE-name attribute READ addresses only the
222
+ # no-namespace attribute (qualified-name semantics) — the
223
+ # gate for Element#[]'s fast path (bare_attr_value).
224
+ # Differs per engine: rexml's bare read returns a namespaced
225
+ # sibling's value; oga's raw values need resolver-only
226
+ # marker restoration.
227
+ def bare_get_qname_safe?
228
+ false
229
+ end
230
+
231
+ # Whether set_attribute with a BARE name behaves as a
232
+ # qualified-name write: replaces only the no-namespace
233
+ # attribute and never touches a namespaced p:<local> sibling.
234
+ # Verified per engine; oga's repeated bare writes diverge, so
235
+ # it stays false there and assign keeps the full resolve.
236
+ def bare_set_qname_safe?
237
+ false
238
+ end
239
+
240
+ # Generation of adapter-level state that cached serialize
241
+ # decisions depend on (leptris: the entity-marker document
242
+ # flag). Bumping invalidates wrapper-level memos; adapters
243
+ # whose answers are static keep the constant zero.
244
+ def serialize_generation
245
+ 0
246
+ end
247
+
184
248
  # Whether the subtree at native can contain entity markers.
185
249
  # Marker-tracking adapters override this so the post-serialize
186
250
  # restore can skip its full-output scans on marker-free
@@ -15,8 +15,11 @@ module Moxml
15
15
  CustomizedLeptris::DocumentPI
16
16
  true
17
17
  else
18
+ # Parentless elements come only from Iterparse —
19
+ # engine parsed, outside the marker pipeline: never
20
+ # bearing.
18
21
  doc = native.document
19
- doc.nil? || attachments.get(doc, :entity_markers) != false
22
+ doc.nil? ? false : attachments.get(doc, :entity_markers) != false
20
23
  end
21
24
  end
22
25
 
@@ -40,6 +40,15 @@ module Moxml
40
40
  xml
41
41
  end
42
42
 
43
+ # Element-face trailing-newline strip: engine fix landed in
44
+ # 1.9.42; armed only on older floor bindings.
45
+ TRAILING_NL_STRIP_ACTIVE =
46
+ Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.42")
47
+
48
+ # The binding's element face with all-default options — the
49
+ # frozen splat keeps the hot argless path allocation-free.
50
+ ELEMENT_DEFAULT_KWARGS = { indent: 0, no_decl: true, encoding: nil }.freeze
51
+
43
52
  def raw_serialize(node, options)
44
53
  # CDATA must precede Text in this chain: CDATA < Text in the
45
54
  # binding, so a Text branch first would swallow CDATA nodes.
@@ -60,23 +69,31 @@ module Moxml
60
69
  return serialize_document(node, options)
61
70
  end
62
71
 
63
- include_decl = options.fetch(:declaration) do
64
- options[:no_declaration] ? false : document_has_declaration?(node)
65
- end
66
- kwargs = {
67
- indent: options.fetch(:indent, 0),
68
- no_decl: !include_decl,
69
- encoding: options[:encoding],
70
- }
71
- if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
72
- kwargs[:indent_text] = options[:indent_text]
73
- end
74
- xml = node.to_xml(**kwargs)
75
- # Element output always ends with the close tag — but the
76
- # engine's serializer appends a stray trailing newline when
77
- # the element's last text child is non-ASCII (fixed engine
78
- # side in 1.9.42; kept for older floor bindings).
79
- xml.sub(/\n+\z/, "")
72
+ # Element serialization never emits a declaration — the C
73
+ # element serializer ignores the flag (verified
74
+ # byte-identical); skipping the declaration fetch avoids a
75
+ # document attachment walk per element serialize. Likewise
76
+ # an unset encoding serializes UTF-8 — byte-identical to an
77
+ # explicit "UTF-8" — and the binding then reuses its shared
78
+ # DEFAULT_OPTIONS instead of rebuilding an options struct
79
+ # per call; the wrapper force-tags the string either way.
80
+ indent = options.fetch(:indent, 0)
81
+ encoding = options[:encoding] == "UTF-8" ? nil : options[:encoding]
82
+ xml = if indent.zero? && encoding.nil?
83
+ node.to_xml(**ELEMENT_DEFAULT_KWARGS)
84
+ else
85
+ kwargs = { indent: indent, no_decl: true, encoding: encoding }
86
+ if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
87
+ kwargs[:indent_text] = options[:indent_text]
88
+ end
89
+ node.to_xml(**kwargs)
90
+ end
91
+ # Element output always ends with the close tag — but older
92
+ # engines append a stray trailing newline when the element's
93
+ # last text child is non-ASCII (fixed engine side in
94
+ # 1.9.42). The strip stays armed only below that version —
95
+ # a regex sub per element serialize is measurable in bulk.
96
+ TRAILING_NL_STRIP_ACTIVE ? xml.sub(/\n+\z/, "") : xml
80
97
  end
81
98
 
82
99
  # A bare ampersand — not starting a named or numeric entity
@@ -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