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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9c5b752aba6a52719fbeb794be70155e0fc52a553889e424b3cfbc0a4afa324
4
- data.tar.gz: ee7a443060fc562236ccfb135c509ae65e51ba3ec930cd8a39cb3a917f5e41ed
3
+ metadata.gz: 627111c496efca4687eabeeb78989d611d3d308adc5ed32443c7058da51c57c9
4
+ data.tar.gz: 86d1cc6c471d07084e0d2e0de9412b286b5486b202f9fe677b7417f514c1fe55
5
5
  SHA512:
6
- metadata.gz: d92bc0324a80312556a369b6b1ce7b1619c13ef23fac2212c3148211deb7c786543b83568ea6a1d312df9e09189f1d8feabf8b5d44840618852124d4c5550dd7
7
- data.tar.gz: 9a0b511e096230051cd1e92ae3e36603b02ddfe2f2c03cfaf6d8072b21defda2e65938c4f9963516d3d3696a44a7689d98cc68244bd60e26672c49e04d292079
6
+ metadata.gz: f1108c98dc8af92913605dd08b84eb50bbfec92a5dd286e15656c3ecd57937ce56bc186c9393a740f6bd8a8316f06fb059db226f20ffc9f0b84cfb433e1a532d
7
+ data.tar.gz: 02d109a32659b30fc34ac5d6d3f01bc0d32d9e1ca6a38cfdc2fe3880a02c779ce5ff41fa2f0eda04adc2a9ccd6702f372d29b4d2a6fe33faeb0456cb8852d26b
@@ -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,74 @@ 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
+ Since 1.9.118/1.9.121 the engine also handles WHATWG foreign
82
+ content — embedded SVG and MathML in HTML parse into the tree
83
+ (preserving `foreignObject` subtrees that Nokogiri drops), the
84
+ source HTML doctype round-trips, and `template`/`colgroup` place
85
+ correctly. Foreign-content camelCase names are currently
86
+ lowercased (engine conformance note leptris/leptris#1013).
87
+
88
+ Adapters without an engine HTML mode (Ox, Oga, REXML, LibXML) raise
89
+ `Moxml::AdapterError`; Nokogiri parses through libxml2's HTML mode.
90
+
91
+ == Streaming iteration — `iterparse`
92
+
93
+ `Context#iterparse(xml, mode:)` / `iterparse_file(path, mode:)`
94
+ expose the engine's incremental parser (libleptris v1.6, #586):
95
+ completed elements yield while parsing, prior subtrees release, and
96
+ memory stays bounded by the largest subtree — `iterparse_file`
97
+ streams the file C-side. `:top_level` yields the root's children;
98
+ `:full_document` yields every element in completion (post-order)
99
+ order.
100
+
101
+ Measured on a 21MB / 108k-element file: **+20MB peak RSS** (full
102
+ parse: +169MB; Nokogiri Reader: +62MB), bare iteration ~97ms on
103
+ leptris >= 1.9.105 (Nokogiri Reader: 179ms) — ~1.8x faster at a
104
+ third of the memory. Attribute reads on yielded elements improved
105
+ ~16x on current builds (the parentless-read cost filed in
106
+ leptris-ruby#152 is fixed).
107
+
108
+ The yielded elements are parentless (`#document` nil) and valid
109
+ only inside the block — the subtree is released when the iterator
110
+ advances. Since binding 1.9.105, access after the iteration raises
111
+ `UseAfterFreeError` instead of crashing (leptris-ruby#152). Adapters without an engine iterator raise
112
+ `Moxml::AdapterError`.
113
+
114
+ [source,ruby]
115
+ ----
116
+ Moxml.new(:leptris).iterparse_file("huge.xml") do |record|
117
+ process(record.at_attrs, record.children.map(&:text))
118
+ end
119
+ ----
120
+
121
+ == Lifecycle
54
122
  == Lifecycle
55
123
 
56
124
  `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
@@ -64,6 +64,14 @@ module Moxml
64
64
  texts << child
65
65
  attachments.set(doc, :document_text, texts)
66
66
  child
67
+ when ::Leptris::XML::Comment
68
+ # The tree model supports document comments (they parse
69
+ # and serialize, libleptris 1.9.3 #578) but the engine
70
+ # has no add entry yet (leptris/leptris#1032).
71
+ raise Moxml::NotImplementedError.new(
72
+ "Adding document-level comments requires an engine entry (leptris/leptris#1032)",
73
+ feature: "add_document_child", adapter: "Leptris",
74
+ )
67
75
  else
68
76
  raise Moxml::DocumentStructureError.new(
69
77
  "Unsupported document child: #{child.class}",
@@ -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
 
@@ -33,13 +33,53 @@ module Moxml
33
33
  # Entity restoration belongs to the wrapper layer
34
34
  # (Node#to_xml runs adapter.restore_entities for every
35
35
  # adapter); doing it here scanned the output a second time.
36
- xml = normalize_serialization(raw_serialize(node, options), options)
36
+ xml = if native_expand?(node, options)
37
+ opts = options.dup
38
+ opts[:__expand_handled_natively] = true
39
+ normalize_serialization(raw_serialize(node, opts), opts)
40
+ else
41
+ normalize_serialization(raw_serialize(node, options), options)
42
+ end
37
43
  # The binding's FFI strings come back binary-tagged; the
38
44
  # engine encoded the bytes per this option, so tag them.
39
45
  xml.force_encoding(options[:encoding]) if options[:encoding]
40
46
  xml
41
47
  end
42
48
 
49
+ # Element-face trailing-newline strip: engine fix landed in
50
+ # 1.9.42; armed only on older floor bindings.
51
+ TRAILING_NL_STRIP_ACTIVE =
52
+ Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.42")
53
+
54
+ # The binding's element face with all-default options — the
55
+ # frozen splat keeps the hot argless path allocation-free.
56
+ # Native expand-empty (engine #882, libleptris 1.9.95,
57
+ # bindings 1.9.144): empty elements emit <a></a> through a
58
+ # C-side ext entry — the Ruby full-output regex rewrite and
59
+ # its "/>" probe scan drop out of the element path. The
60
+ # document face does not expose the option yet; documents
61
+ # keep the Ruby pass.
62
+ EXPAND_EMPTY_NATIVE =
63
+ Gem::Version.new(::Leptris::VERSION) >= Gem::Version.new("1.9.144")
64
+
65
+ ELEMENT_DEFAULT_KWARGS = { indent: 0, no_decl: true, encoding: nil }.freeze
66
+ ELEMENT_EXPAND_KWARGS =
67
+ if EXPAND_EMPTY_NATIVE
68
+ { indent: 0, no_decl: true, encoding: nil, expand_empty: true }.freeze
69
+ else
70
+ ELEMENT_DEFAULT_KWARGS
71
+ end.freeze
72
+
73
+ # Elements (not documents) with expand_empty and NO
74
+ # indent-unit string: the C ext entry handles expansion. The
75
+ # element unit serializer does not take the flag — those keep
76
+ # the Ruby pass.
77
+ def native_expand?(node, options)
78
+ EXPAND_EMPTY_NATIVE && options[:expand_empty] &&
79
+ !node.is_a?(::Leptris::XML::Document) &&
80
+ !options[:indent_text].is_a?(String)
81
+ end
82
+
43
83
  def raw_serialize(node, options)
44
84
  # CDATA must precede Text in this chain: CDATA < Text in the
45
85
  # binding, so a Text branch first would swallow CDATA nodes.
@@ -60,23 +100,33 @@ module Moxml
60
100
  return serialize_document(node, options)
61
101
  end
62
102
 
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/, "")
103
+ # Element serialization never emits a declaration — the C
104
+ # element serializer ignores the flag (verified
105
+ # byte-identical); skipping the declaration fetch avoids a
106
+ # document attachment walk per element serialize. Likewise
107
+ # an unset encoding serializes UTF-8 — byte-identical to an
108
+ # explicit "UTF-8" — and the binding then reuses its shared
109
+ # DEFAULT_OPTIONS instead of rebuilding an options struct
110
+ # per call; the wrapper force-tags the string either way.
111
+ indent = options.fetch(:indent, 0)
112
+ encoding = options[:encoding] == "UTF-8" ? nil : options[:encoding]
113
+ native_expand = native_expand?(node, options)
114
+ xml = if indent.zero? && encoding.nil?
115
+ node.to_xml(**(native_expand ? ELEMENT_EXPAND_KWARGS : ELEMENT_DEFAULT_KWARGS))
116
+ else
117
+ kwargs = { indent: indent, no_decl: true, encoding: encoding }
118
+ kwargs[:expand_empty] = true if native_expand
119
+ if INDENT_UNIT_SUPPORTED && options[:indent_text].is_a?(String)
120
+ kwargs[:indent_text] = options[:indent_text]
121
+ end
122
+ node.to_xml(**kwargs)
123
+ end
124
+ # Element output always ends with the close tag — but older
125
+ # engines append a stray trailing newline when the element's
126
+ # last text child is non-ASCII (fixed engine side in
127
+ # 1.9.42). The strip stays armed only below that version —
128
+ # a regex sub per element serialize is measurable in bulk.
129
+ TRAILING_NL_STRIP_ACTIVE ? xml.sub(/\n+\z/, "") : xml
80
130
  end
81
131
 
82
132
  # A bare ampersand — not starting a named or numeric entity
@@ -102,7 +152,9 @@ module Moxml
102
152
  # The libxml2-layout serializer (>= 1.9.42) keeps attribute
103
153
  # apostrophes literal; older engines escaped them.
104
154
  needs_apos = !LIBXML2_LAYOUT_PARITY && xml.include?("&apos;")
105
- needs_expand = options[:expand_empty] && xml.include?("/>")
155
+ needs_expand = options[:expand_empty] &&
156
+ !options[:__expand_handled_natively] &&
157
+ xml.include?("/>")
106
158
  # Corruption guards: the 1-char ampersand probe is ~1µs
107
159
  # (memchr-class); the raw-< scan runs only on builds that
108
160
  # still carry the parse race (leptris-ruby#131).