moxml 0.5.29 → 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: df62f6f6a27c7ff63c2893ae7d85547d09645d059b187fd4235111c18ad2467f
4
- data.tar.gz: a9c0e9ee0a3f7f2c8775ba5dfa49563e048ef3e2aac506eaef9135c2a2390139
3
+ metadata.gz: 32f1034201de38bed35a6b1496d10f96a580f0e4c92517c2253ded0631331439
4
+ data.tar.gz: bb3d697a73fbac699e00b51905ef446f9837b9ee649785bb1ed4df2027870e78
5
5
  SHA512:
6
- metadata.gz: 705a551074ea195562d7af4b726a388e8c3517ec4366cf9dff3d3b0828a79f46b6a50d79501d9c5ed1df871855f90bc5d5b69b35e22eb006b558e26c0da5626b
7
- data.tar.gz: 2e5b4b7da9018d41434c23c70ea4ced99e626ef813dc92b6d2e1df8d9f8f81e89ac536dd52f684bd95de9fcb610ba673eaa9914de5048a491ee0f217bdfa1975
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
@@ -86,17 +103,17 @@ module Moxml
86
103
  RAW_AMP_RE = /&(?!#{Entity::NAME_PATTERN};|#\d+;|#x[0-9A-Fa-f]+;)/
87
104
 
88
105
  # A raw "<" in text position — the engine intermittently
89
- # loses the escape when parsing under allocation pressure
90
- # (leptris-ruby#131), so a decoded &#x3c; serializes bare and
91
- # reparsing truncates at it. Valid markup NEVER puts "<"
92
- # before a non-name, non-delimiter character, so this is
93
- # deterministic in markup segments (CDATA is literal and
94
- # excluded by the segment walker). One class-anchored scan:
95
- # multi-char literal probes cost ~30x a single regex pass on
96
- # CRuby (measured include?("< ") at 31µs vs this regex at
97
- # 64µs for 14 probes' worth of coverage on 32KB).
106
+ # lost the escape when parsing under allocation pressure
107
+ # (leptris-ruby#131), so a decoded &#x3c; serialized bare and
108
+ # reparsing truncated at it. Fixed in leptris 1.9.80 (verified
109
+ # 0/200 on the churn repro); the guard stays armed below that
110
+ # version and for any future build that regresses — the
111
+ # parse-under-pressure spec in leptris_spec fails loudly if a
112
+ # fixed-version build corrupts while the guard is stood down.
98
113
  RAW_LT_RE = /<(?![A-Za-z_:\/?!])/
99
114
  RAW_LT_TRIGGER_RE = /<[^A-Za-z_:\/?!]/
115
+ RAW_LT_GUARD_ACTIVE =
116
+ Gem::Version.new(::Leptris::VERSION) < Gem::Version.new("1.9.80")
100
117
 
101
118
  def normalize_serialization(xml, options)
102
119
  # The libxml2-layout serializer (>= 1.9.42) keeps attribute
@@ -104,10 +121,10 @@ module Moxml
104
121
  needs_apos = !LIBXML2_LAYOUT_PARITY && xml.include?("&apos;")
105
122
  needs_expand = options[:expand_empty] && xml.include?("/>")
106
123
  # Corruption guards: the 1-char ampersand probe is ~1µs
107
- # (memchr-class); both regexes only run when their cheap
108
- # necessary condition holds.
124
+ # (memchr-class); the raw-< scan runs only on builds that
125
+ # still carry the parse race (leptris-ruby#131).
109
126
  needs_amp = xml.include?("&") && xml.match?(RAW_AMP_RE)
110
- needs_lt = xml.match?(RAW_LT_TRIGGER_RE)
127
+ needs_lt = RAW_LT_GUARD_ACTIVE && xml.match?(RAW_LT_TRIGGER_RE)
111
128
  return xml unless needs_apos || needs_expand || needs_amp || needs_lt
112
129
 
113
130
  out = +""