moxml 0.5.12 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f702d6c51c5df49bdb2bf0ac5189d3fe0716d94093ced7decdb743dcf87fa5d
4
- data.tar.gz: 56316b016c8865d6290033873477d9dcc92b04e1e16ae77716626398bd976ad3
3
+ metadata.gz: 6f890c98078986e7be37d59762c08c6387cb173a7260212be428c705e043bd1a
4
+ data.tar.gz: 6a5dae862bb917c2906581412c33f0cd9f4506112ba5f24a146feed9f2dc0371
5
5
  SHA512:
6
- metadata.gz: a1e503460e2b5d645cd4147d4164d2a67c254e8e2174b145fbf4f17dab222d1eae03216b7a35dca18a800684c3c904daf9633140f262195f212d9dd235ccc675
7
- data.tar.gz: 4cf7957e370339230fc4d184a7fd6b9312bd3f5e8f9e843984ed4e56967e33597d18abef6479de69cde47af781a65703427349cdaff16eaf317cdb39ba8cd135
6
+ metadata.gz: d8acaee20452b9d74ba026cc4fce32d6a8911af51afee523bf474e831786d9e71c31e089360d023c17a62c8610081a8ad4d55b774261279e56966942ab26b040
7
+ data.tar.gz: c9e1d227bca2d9f7f5771604de905b5efec5a61451e01b69919ec315484673cafadbe65345423fd65633fcdc1d8e8197de58d6822f5bc42a6fa6fe253ec4b7f3
@@ -0,0 +1,113 @@
1
+ ---
2
+ title: Adapter protocol
3
+ nav_order: 46
4
+ ---
5
+
6
+ = The adapter protocol
7
+
8
+ The seam between moxml's wrapper layer (`Moxml::Node` and friends) and the
9
+ engine adapters. Every adapter is a class under `Moxml::Adapter` implementing
10
+ class methods over its library's native nodes; wrappers dispatch through
11
+ `context.config.adapter` and never touch a native directly.
12
+
13
+ The contract is pinned by the shared example groups under
14
+ `spec/moxml/adapter/shared_examples/` (the adapter contract) and the
15
+ consistency suite — behavior there is authoritative when this page and the
16
+ code disagree.
17
+
18
+ == Method groups
19
+
20
+ Parse and document lifecycle::
21
+ `parse(xml, options, context)`, `create_document`, `create` factories
22
+ (`create_native_element`, `create_native_text`, `create_native_cdata`,
23
+ `create_native_comment`, `create_native_processing_instruction`,
24
+ `create_native_declaration`, `create_native_doctype`,
25
+ `create_native_entity_reference`, `create_native_namespace`),
26
+ `free_document` (#134; no-op on GC-managed engines).
27
+
28
+ Tree navigation::
29
+ `children`, `parent`, `next_sibling`, `previous_sibling`, `root`,
30
+ `set_root`, `document`, `duplicate_node`. `children` on a document
31
+ lists document-level parts in document order where the engine exposes
32
+ them (Nokogiri-shaped contract).
33
+
34
+ Node typing and names::
35
+ `node_type`, `node_name`, `set_node_name`, `attribute_name`.
36
+
37
+ Attributes::
38
+ `get_attribute` / `get_attribute_value` (the raw layer beneath
39
+ `Moxml::AttributeResolver` — expanded-name resolution lives above the
40
+ adapter), `set_attribute_value`, `remove_attribute`,
41
+ `patch_node`/`unpatch_node` for engines needing wrapper natives
42
+ (libxml, ox).
43
+
44
+ Namespaces::
45
+ `namespace`, `namespace_definitions` (the element's own declarations),
46
+ `in_scope_namespaces`, `set_namespace`, `remove_namespace`.
47
+
48
+ Mutation::
49
+ `add_child`, `add_next_sibling`, `add_previous_sibling`, `remove`,
50
+ `replace`, `replace_children`. Return contract: methods that can swap
51
+ the native (`set_attribute_name`, `set_namespace`, adoptions) return
52
+ the tracked native; wrappers re-key through `refresh_native!`.
53
+
54
+ Serialization::
55
+ `serialize(node, options)`. Options: `indent`, `encoding`,
56
+ `declaration`, `no_declaration`, `expand_empty`, `line_ending`.
57
+ Entity-marker restoration is owned by the wrapper layer
58
+ (`Node#to_xml`), guarded by `entity_bearing?`.
59
+
60
+ XPath::
61
+ `xpath(expression, namespaces)`. Engines without a native engine use
62
+ moxml's pure-Ruby XPath 1.0 engine (`Moxml::XPath`).
63
+
64
+ == Capability predicates
65
+
66
+ The wrapper layer probes adapters rather than assuming:
67
+
68
+ `patches_children?`::
69
+ True when `children` results need per-child `patch_node` rewriting
70
+ (libxml, ox). Others skip the identity map over every child list.
71
+
72
+ `wrappers_recyclable?`::
73
+ False for engines that mint fresh Ruby objects per access (libxml) —
74
+ the context's wrapper identity map opts out.
75
+
76
+ `entity_bearing?(native)`::
77
+ Whether the subtree can contain entity markers; gates the
78
+ post-serialize restore scan.
79
+
80
+ `bulk_materialize?`::
81
+ Whether the adapter offers a bulk path for `Moxml::Materializer`;
82
+ `materialize_records` yields flattened records, returning nil falls
83
+ back to the generic wrapper walk.
84
+
85
+ Constants like `DOC_NODE_SUPPORTED` / `DTDATTR_SUPPORTED` /
86
+ `TRAVERSE_SUBTREE_BOUNDED` (Leptris) follow the same idea: one probe
87
+ per engine capability, computed at load.
88
+
89
+ == Return shapes
90
+
91
+ * Mutations return the tracked native (or nil when not applicable) —
92
+ never a wrapper.
93
+ * `children` returns an array of natives; the wrapper layer builds the
94
+ `NodeSet`.
95
+ * `xpath` returns `Array<native>` or a scalar for non-node results.
96
+ * Marker-bearing text (leptris) is split by the adapter into
97
+ text/entity-reference pseudo-natives before the wrapper sees it.
98
+
99
+ == Writing an adapter
100
+
101
+ Subclass `Moxml::Adapter::Base`, implement the groups above, and
102
+ include the shared examples:
103
+
104
+ [source,ruby]
105
+ ----
106
+ RSpec.describe Moxml::Adapter::Mine do
107
+ it_behaves_like "xml adapter"
108
+ it_behaves_like "adapter contract"
109
+ end
110
+ ----
111
+
112
+ The contract suite is the test surface: if it passes, the wrapper
113
+ layer's behavior is covered.
@@ -14,15 +14,21 @@ how to choose the right adapter for your needs.
14
14
  === What are adapters?
15
15
 
16
16
  Moxml uses an adapter pattern to provide a unified interface over different
17
- XML processing libraries. Each adapter wraps a specific XML library (Nokogiri,
18
- LibXML, Oga, REXML, or Ox) and provides consistent behavior through Moxml's
19
- API.
17
+ XML processing libraries. Each adapter wraps a specific XML library (Leptris,
18
+ Nokogiri, LibXML, Oga, REXML, or Ox) and provides consistent behavior through
19
+ Moxml's API. See link:../adapter-protocol[the adapter protocol] for the seam
20
+ itself.
20
21
 
21
22
  === Available adapters
22
23
 
24
+ link:leptris[Leptris adapter]::
25
+ Pure-C99 XML engine, the default on CRuby when the installed binding
26
+ supports programmatic documents — fastest on every measured operation,
27
+ with native XPath 1.0, SAX, and deterministic memory release.
28
+
23
29
  link:nokogiri[Nokogiri adapter]::
24
- Industry standard XML library with excellent performance and full XPath 1.0
25
- support. Recommended for most use cases.
30
+ The industry-standard XML library and the fallback default when Leptris
31
+ is not installed. Excellent performance with full XPath 1.0 support.
26
32
 
27
33
  link:libxml[LibXML adapter]::
28
34
  Alternative to Nokogiri using native libxml2 bindings. Excellent performance
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: Leptris
3
+ parent: Adapters
4
+ nav_order: 1
5
+ ---
6
+
7
+ == Leptris adapter
8
+
9
+ Leptris is a pure-C99 XML 1.0 parser with an FFI binding (the
10
+ https://github.com/leptris/leptris-ruby[leptris-ruby] gem). It is the
11
+ preferred default on CRuby: fastest on every measured moxml operation,
12
+ with a native XPath 1.0 engine, SAX, and canonicalization.
13
+
14
+ == Default resolution
15
+
16
+ Leptris becomes the default when the installed binding supports
17
+ programmatic document construction (`Leptris::XML::Document.create`,
18
+ leptris >= 1.3). Otherwise the default falls back to Nokogiri. Under
19
+ Opal the default is rexml (stock oga requires its C extension and
20
+ libleptris does not build for Opal).
21
+
22
+ [source,ruby]
23
+ ----
24
+ Moxml.new # => Leptris context when capable
25
+ ctx.config.adapter_name # => :leptris / :nokogiri / :rexml (Opal)
26
+ ----
27
+
28
+ == Parse options
29
+
30
+ `readonly: true`::
31
+ The binding memoizes every read and refuses mutations — the
32
+ parse-and-read lifecycle for multi-pass consumers (comparison, diff,
33
+ signatures). Repeat attribute reads on a large element drop by
34
+ orders of magnitude. Builder documents stay read-write.
35
+
36
+ `dtdattr: true`::
37
+ Materialize DTD `<!ATTLIST>` defaults. Off by default, matching
38
+ libxml2/Nokogiri/REXML semantics (libleptris >= 1.9.8).
39
+
40
+ `strict: true`::
41
+ Raise `Moxml::ParseError` on malformed input instead of returning an
42
+ empty document.
43
+
44
+ == Lifecycle
45
+
46
+ `Document#free` releases the C tree deterministically — batch
47
+ workloads parsing thousands of documents otherwise hold native memory
48
+ until GC finalizers run. Access after `#free` raises the binding's
49
+ use-after-free error.
50
+
51
+ == Document model
52
+
53
+ Documents expose the libxml2-shaped document node: prolog PIs and
54
+ comments, the root element, epilog PIs and comments, in document
55
+ order (leptris >= 1.9.26). The DOCTYPE round-trips with its internal
56
+ subset. See link:../conversion-apis[Conversion and memory lifecycle APIs]
57
+ for `materialize` — the flattened-record conversion path with a bulk
58
+ implementation over one `leptris_node_traverse` call.
59
+
60
+ == Known limitations (tracked upstream)
61
+
62
+ * Tab indentation (`indent_text`) needs a serialize-time parameter in
63
+ libleptris; post-hoc rewriting would corrupt mixed content.
64
+ * Document-level PIs are read-only natives (`target=`/`data=`/
65
+ `unlink` rejected); write-through and removal are pending C
66
+ surface (leptris/leptris#612).
67
+ * A PI preceding the DOCTYPE reorders on serialization — libleptris
68
+ normalizes DOCTYPE-first and the document node does not record its
69
+ relative position.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: LibXML
3
3
  parent: Adapters
4
- nav_order: 2
4
+ nav_order: 4
5
5
  ---
6
6
 
7
7
  == LibXML adapter
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Nokogiri
3
3
  parent: Adapters
4
- nav_order: 1
4
+ nav_order: 3
5
5
  ---
6
6
 
7
7
  == Nokogiri adapter
@@ -0,0 +1,78 @@
1
+ ---
2
+ title: Conversion and memory lifecycle APIs
3
+ nav_order: 45
4
+ ---
5
+
6
+ = Conversion and Memory Lifecycle APIs
7
+
8
+ Scanned streaming (`materialize`), deterministic release (`Document#free`), and parse-lifecycle options (`readonly:`, `dtdattr:`) — the seams for conversion-heavy and batch workloads.
9
+
10
+ == Bulk materialization — `materialize` (issue #132)
11
+
12
+ Flattened, post-order records for a subtree with no `Moxml::Node` allocation — the conversion path for consumers that build their own tree from a document:
13
+
14
+ [source,ruby]
15
+ ----
16
+ context.materialize(xml) { |record| ... } # parse + flatten
17
+ document.materialize.each # Enumerator, root subtree
18
+ any_element.materialize # any subtree
19
+ ----
20
+
21
+ Record shape (all seven keys on every record):
22
+
23
+ [source,ruby]
24
+ ----
25
+ { kind: :element|:text|:cdata|:comment|:processing_instruction|:entity_reference,
26
+ qname: "tag"|"pi-target"|nil,
27
+ prefix: "p"|nil,
28
+ namespace_uri: "urn:x"|nil,
29
+ namespaces: [[prefix, uri], ...], # element's OWN declarations; nil prefix = default
30
+ attributes: [[name, value, namespace_uri, prefix], ...],
31
+ text: String|nil,
32
+ depth: Integer }
33
+ ----
34
+
35
+ * Post-order: children arrive before their parent — build with a stack.
36
+ * `Document#materialize` yields exactly the root subtree (issue #140); document-level parts (prolog/epilog PIs and comments, DOCTYPE) stay enumerable via `children`.
37
+ * The leptris adapter has a bulk path (one `leptris_node_traverse` call); every other adapter uses the generic wrapper walk. Both emit identical streams — the parity is spec-pinned.
38
+ * Entity-bearing documents fall back to the generic walk so entity references still split.
39
+
40
+ == Deterministic release — `Document#free` (issue #134)
41
+
42
+ Adapters backed by C trees hold native memory until a GC finalizer runs. Batch workloads (parse → convert → discard, thousands of documents) release it deterministically:
43
+
44
+ [source,ruby]
45
+ ----
46
+ doc = context.parse(xml)
47
+ doc.free # C tree released now; attachments swept
48
+ doc.root # => raises the engine's use-after-free error (leptris)
49
+ ----
50
+
51
+ `#free` is a no-op on GC-managed engines (nokogiri, oga, ox, rexml). Normally-scoped documents keep working via the finalizer either way. Measured with GC disabled, 1000 × ~115KB parses: +1.2GB RSS without `#free` vs +121MB with it.
52
+
53
+ == Parse-lifecycle options
54
+
55
+ Both options pass through `Context#parse` to the engine and are no-ops where the concept does not apply:
56
+
57
+ * `readonly: true` (issue #133, leptris) — memoized reads, refused mutations. The parse-and-read lifecycle for multi-pass consumers (comparison, diff, signature): repeat attribute reads on a 100-attribute element drop from ~99µs to ~0.15µs (680×). Builder documents stay read-write.
58
+
59
+ * `dtdattr: true` (libleptris 1.9.8+, leptris/leptris#606) — materialize DTD `<!ATTLIST>` defaults. Off by default, matching libxml2/Nokogiri/REXML semantics:
60
+
61
+ [source,ruby]
62
+ ----
63
+ context.parse(dtd_xml) # plain: ATTLIST defaults excluded
64
+ context.parse(dtd_xml, dtdattr: true) # opt-in: attr="default" materialized
65
+ ----
66
+
67
+ == Pretty-print parity (issue #129)
68
+
69
+ Through the leptris adapter, byte-identical output to raw Nokogiri:
70
+
71
+ [source,ruby]
72
+ ----
73
+ context.parse(src).to_xml(
74
+ indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8",
75
+ ) == Nokogiri::XML(src).to_xml(indent: 2, encoding: "UTF-8") # => true
76
+ ----
77
+
78
+ Known residual divergences (engine-level, tracked upstream): tab indentation needs a serialize-time parameter in libleptris, and a PI preceding the DOCTYPE reorders (the engine normalizes DOCTYPE-first).
@@ -28,6 +28,12 @@ Compare features, performance, and compatibility across adapters.
28
28
  link:configuration[Configuration]::
29
29
  Configure Moxml for your application's requirements.
30
30
 
31
+ link:conversion-apis[Conversion and memory lifecycle APIs]::
32
+ Bulk materialization, deterministic document release, and parse-lifecycle options for conversion-heavy workloads.
33
+
34
+ link:adapter-protocol[The adapter protocol]::
35
+ The seam between the wrapper layer and engine adapters — method groups, capability predicates, return shapes, and how to add an adapter.
36
+
31
37
  link:error-handling[Error handling]::
32
38
  Comprehensive guide to Moxml's error classes and debugging.
33
39
 
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ module DocumentParts
7
+ # Issue #134: deterministic release of the C tree. The binding
8
+ # clears its wrapper cache and raises UseAfterFreeError on
9
+ # later access; moxml-side attachments for the document are
10
+ # swept too (the context wrapper identity map self-cleans via
11
+ # its size valve).
12
+ DOCUMENT_ATTACHMENT_KEYS = %i[
13
+ entity_markers doc_pi_nodes declaration doctype
14
+ had_source_declaration document_text
15
+ ].freeze
16
+
17
+ def free_document(native)
18
+ DOCUMENT_ATTACHMENT_KEYS.each { |key| attachments.delete(native, key) }
19
+ native.free
20
+ nil
21
+ end
22
+
23
+ def assemble_document_children(doc)
24
+ children = []
25
+
26
+ native_doctype = doc.doctype
27
+ children << native_doctype if native_doctype
28
+
29
+ doctype_wrapper = attachments.get(doc, :doctype)
30
+ children << doctype_wrapper if doctype_wrapper
31
+
32
+ if DOC_NODE_SUPPORTED
33
+ # The libxml2-model document node lists prolog PIs/comments,
34
+ # the root, and epilog PIs/comments in document order —
35
+ # the Nokogiri-shaped contract, epilog anchoring included
36
+ # (issue #130). Built (programmatic) documents are not yet
37
+ # fully reflected by the node (binding gap: an attached
38
+ # root does not appear); document_node_children answers
39
+ # nil there for the legacy parts path.
40
+ doc_children = document_node_children(doc)
41
+ if doc_children
42
+ children.concat(doc_children)
43
+ else
44
+ children.concat(document_pi_nodes(doc))
45
+ children << doc.root if doc.root
46
+ end
47
+ else
48
+ # Legacy path: document-level PIs live outside the element
49
+ # tree in a flat pre-root list (libleptris < 1.9.7 C
50
+ # model); epilog anchoring is not representable there.
51
+ children.concat(document_pi_nodes(doc))
52
+
53
+ children << doc.root if doc.root
54
+ end
55
+
56
+ texts = attachments.get(doc, :document_text)
57
+ children.concat(texts) if texts
58
+ children
59
+ end
60
+
61
+ # Document-level PI pseudo-nodes, materialized once from the C
62
+ # list and cached per document: children and serialization read
63
+ # the same objects, so wrapper mutations round-trip. Build the
64
+ # cache BEFORE appending a PI with add_pi, or the C-side
65
+ # addition would be double-counted.
66
+ # The document node's children, or nil when the node does not
67
+ # reflect reality: parsed documents always list the root
68
+ # element among their children, but programmatically built
69
+ # ones do not (binding gap) — those keep the legacy parts
70
+ # path.
71
+ def document_node_children(doc)
72
+ doc_children = doc.children.to_a
73
+ has_root = doc_children.any?(::Leptris::XML::Element)
74
+ return doc_children if has_root
75
+ return doc_children if doc.root.nil?
76
+
77
+ nil
78
+ end
79
+
80
+ def document_pi_nodes(doc)
81
+ attachments.get(doc, :doc_pi_nodes) || begin
82
+ nodes = doc.processing_instructions.map do |(target, data)|
83
+ CustomizedLeptris::DocumentPI.new(target, data, doc)
84
+ end
85
+ attachments.set(doc, :doc_pi_nodes, nodes)
86
+ nodes
87
+ end
88
+ end
89
+
90
+ def add_document_child(doc, child)
91
+ case child
92
+ when CustomizedLeptris::Declaration
93
+ child.parent_doc = doc
94
+ attachments.set(doc, :declaration, child)
95
+ when CustomizedLeptris::Doctype
96
+ child.parent_doc = doc
97
+ attachments.set(doc, :doctype, child)
98
+ when ::Leptris::XML::DocType
99
+ raise Moxml::DocumentStructureError.new(
100
+ "libleptris does not support attaching a native DocType to a document",
101
+ )
102
+ when ::Leptris::XML::Element
103
+ doc.root = child
104
+ when ::Leptris::XML::ProcessingInstruction
105
+ document_pi_nodes(doc)
106
+ doc.add_pi(child.target, child.content.to_s)
107
+ document_pi_nodes(doc) << CustomizedLeptris::DocumentPI.new(
108
+ child.target, child.content.to_s, doc
109
+ )
110
+ when CustomizedLeptris::DocumentPI
111
+ document_pi_nodes(doc)
112
+ doc.add_pi(child.target, child.data)
113
+ document_pi_nodes(doc) << child
114
+ when ::Leptris::XML::Text
115
+ texts = attachments.get(doc, :document_text) || []
116
+ texts << child
117
+ attachments.set(doc, :document_text, texts)
118
+ child
119
+ else
120
+ raise Moxml::DocumentStructureError.new(
121
+ "Unsupported document child: #{child.class}",
122
+ )
123
+ end
124
+ child
125
+ end
126
+
127
+ # Documents compose from their parts: the native serializer
128
+ # only walks the root subtree, so declaration, DOCTYPE, PIs and
129
+ # document-level text are assembled around it explicitly.
130
+ def serialize_document(doc, options)
131
+ # Nokogiri's document shape: every top-level part is
132
+ # newline-terminated, at any indent — declaration, DOCTYPE,
133
+ # document PIs, the root element, trailing newline after it.
134
+ # Document-level text is content, not structure: no added
135
+ # newline.
136
+ parts = []
137
+
138
+ include_decl = !options[:no_declaration] && options.fetch(:declaration) do
139
+ document_has_declaration?(doc)
140
+ end
141
+ if include_decl
142
+ declaration = attachments.get(doc, :declaration)
143
+ parts << (declaration ? declaration.to_xml : default_declaration_xml(doc, options)) << "\n"
144
+ end
145
+
146
+ doctype = attachments.get(doc, :doctype)
147
+ parts << doctype.to_xml << "\n" if doctype
148
+
149
+ native = native_doctype_xml(doc)
150
+ parts << native << "\n" if native
151
+
152
+ if DOC_NODE_SUPPORTED
153
+ # The libxml2-model document node: prolog PIs/comments,
154
+ # the root, epilog PIs/comments — in document order, so
155
+ # epilog parts serialize after the root (issue #130).
156
+ doc_children = document_node_children(doc)
157
+ if doc_children
158
+ doc_children.each { |child| parts << raw_serialize(child, options) << "\n" }
159
+ else
160
+ document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
161
+ parts << raw_serialize(doc.root, options) << "\n" if doc.root
162
+ end
163
+ else
164
+ document_pi_nodes(doc).each { |pi| parts << pi.to_xml << "\n" }
165
+
166
+ parts << raw_serialize(doc.root, options) << "\n" if doc.root
167
+ end
168
+
169
+ texts = attachments.get(doc, :document_text)
170
+ texts&.each { |text| parts << XmlEmitter.escape_text(text.content.to_s) }
171
+
172
+ parts.join
173
+ end
174
+
175
+ def native_doctype_xml(doc)
176
+ dt = doc.doctype
177
+ return nil unless dt
178
+
179
+ subset = dt.internal_subset if dt.class.method_defined?(:internal_subset)
180
+ XmlEmitter.doctype_xml(dt.root_name, dt.public_id, dt.system_id, subset)
181
+ end
182
+
183
+ def default_declaration_xml(doc, options)
184
+ encoding = options[:encoding] || doc.encoding
185
+ encoding = "UTF-8" if encoding.to_s.empty?
186
+ XmlEmitter.declaration_xml("1.0", encoding, nil)
187
+ end
188
+
189
+ def document_has_declaration?(native)
190
+ return false unless native.is_a?(::Leptris::XML::Document)
191
+
192
+ return true if attachments.get(native, :declaration)
193
+
194
+ attachments.get(native, :had_source_declaration) ? true : false
195
+ end
196
+
197
+ def marker_text_for(parent, name)
198
+ return nil unless parent.is_a?(::Leptris::XML::Element)
199
+
200
+ marker = "#{Entity::MARKER}#{name};"
201
+ parent.children.to_a.find do |child|
202
+ child.is_a?(::Leptris::XML::Text) && child.content == marker
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moxml
4
+ module Adapter
5
+ class Leptris
6
+ module Markers
7
+ # Marker presence is a document-level fact: parse records it,
8
+ # the ER builder path flips it, and the split/restore scans
9
+ # consult it. Customized natives only exist as split products
10
+ # of marker-bearing text, so they always report true.
11
+ def entity_bearing?(native)
12
+ case native
13
+ when CustomizedLeptris::Declaration, CustomizedLeptris::Doctype,
14
+ CustomizedLeptris::EntityReference, CustomizedLeptris::TextSegment,
15
+ CustomizedLeptris::DocumentPI
16
+ true
17
+ else
18
+ doc = native.document
19
+ doc.nil? || attachments.get(doc, :entity_markers) != false
20
+ end
21
+ end
22
+
23
+ # Expand marker-bearing text nodes into the child sequence the
24
+ # moxml contract exposes: text, EntityReference, text, ...
25
+ def split_entity_markers(natives, parent)
26
+ result = []
27
+ natives.each do |child|
28
+ # FFI Text#content returns a fresh unfrozen BINARY string:
29
+ # retag in place (dup would be a throwaway allocation), but
30
+ # before include? — BINARY.include? with the UTF-8 marker raises
31
+ if child.is_a?(::Leptris::XML::Text)
32
+ content = child.content
33
+ content.force_encoding("UTF-8")
34
+ end
35
+ if content&.include?(Entity::MARKER)
36
+ content.scan(/([^#{Entity::MARKER}]*)(?:#{Entity::MARKER}([\w.:-]+);)?/o) do
37
+ text_part = Regexp.last_match(1)
38
+ name = Regexp.last_match(2)
39
+ result << CustomizedLeptris::TextSegment.new(text_part, parent) unless text_part.empty?
40
+ result << CustomizedLeptris::EntityReference.new(name) if name
41
+ end
42
+ else
43
+ result << child
44
+ end
45
+ end
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end