moxml 0.5.12 → 0.5.14

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +9 -0
  3. data/docs/_pages/adapter-protocol.adoc +117 -0
  4. data/docs/_pages/adapters/index.adoc +11 -5
  5. data/docs/_pages/adapters/leptris.adoc +74 -0
  6. data/docs/_pages/adapters/libxml.adoc +1 -1
  7. data/docs/_pages/adapters/nokogiri.adoc +1 -1
  8. data/docs/_pages/conversion-apis.adoc +103 -0
  9. data/docs/_pages/index.adoc +6 -0
  10. data/lib/compat/opal/moxml_boot.rb +1 -0
  11. data/lib/moxml/adapter/base.rb +18 -4
  12. data/lib/moxml/adapter/leptris/document_parts.rb +208 -0
  13. data/lib/moxml/adapter/leptris/markers.rb +51 -0
  14. data/lib/moxml/adapter/leptris/materialize.rb +140 -0
  15. data/lib/moxml/adapter/leptris/sax_bridge.rb +56 -0
  16. data/lib/moxml/adapter/leptris/serialize.rb +135 -0
  17. data/lib/moxml/adapter/leptris.rb +58 -523
  18. data/lib/moxml/adapter/libxml/serialize.rb +497 -0
  19. data/lib/moxml/adapter/libxml.rb +20 -462
  20. data/lib/moxml/adapter/nokogiri.rb +7 -0
  21. data/lib/moxml/context.rb +8 -0
  22. data/lib/moxml/document.rb +18 -0
  23. data/lib/moxml/entity_registry.rb +0 -1
  24. data/lib/moxml/materializer.rb +108 -52
  25. data/lib/moxml/node.rb +8 -0
  26. data/lib/moxml/version.rb +1 -1
  27. data/lib/moxml.rb +3 -0
  28. data/spec/integration/shared_examples/node_wrappers/declaration_behavior.rb +3 -4
  29. data/spec/integration/shared_examples/node_wrappers/document_behavior.rb +4 -0
  30. data/spec/moxml/adapter/leptris_spec.rb +54 -11
  31. data/spec/moxml/adapter/nokogiri_spec.rb +8 -0
  32. data/spec/moxml/builder_spec.rb +8 -1
  33. data/spec/moxml/materializer_spec.rb +51 -0
  34. data/spec/moxml/signature/algorithms_spec.rb +1 -1
  35. data/spec/moxml/xpath/conversion_spec.rb +2 -6
  36. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f702d6c51c5df49bdb2bf0ac5189d3fe0716d94093ced7decdb743dcf87fa5d
4
- data.tar.gz: 56316b016c8865d6290033873477d9dcc92b04e1e16ae77716626398bd976ad3
3
+ metadata.gz: 8302b1a98c08b34619df4a30d5b84733fa9125eebeeaf1b39b5ba82d0e8fa625
4
+ data.tar.gz: e96034377b072b1ee9058ced4a4dd0347e819f6354011810831b07a06e88e138
5
5
  SHA512:
6
- metadata.gz: a1e503460e2b5d645cd4147d4164d2a67c254e8e2174b145fbf4f17dab222d1eae03216b7a35dca18a800684c3c904daf9633140f262195f212d9dd235ccc675
7
- data.tar.gz: 4cf7957e370339230fc4d184a7fd6b9312bd3f5e8f9e843984ed4e56967e33597d18abef6479de69cde47af781a65703427349cdaff16eaf317cdb39ba8cd135
6
+ metadata.gz: bbe5b80c8b6a656e14b45307bf9b41d1ba1a384c33906ed11fc7d861c2f8fbfad33fb08f2b091ea8b295608c025b5045bc6d0ff73927420e07a23635750fec38
7
+ data.tar.gz: 994ca51d9e27649ef6d70d2f225408dafdd73e35d5948903e33c978b75c5523d5c11ce889c361ded3c7af74473fe75b107168df5f6ab88ec4af5841f5f530ebf
data/.rubocop.yml CHANGED
@@ -18,3 +18,12 @@ AllCops:
18
18
  - 'tmp/**/*'
19
19
  - 'spec/consistency/round_trip_spec.rb'
20
20
  - 'lib/compat/**/*'
21
+
22
+ # materialize_fields yields the eight record fields positionally —
23
+ # the zero-allocation streaming form (issue #143). One parameter per
24
+ # record key is the interface; a shorter list would need a wrapper
25
+ # object, which is the allocation the API exists to avoid.
26
+ Metrics/ParameterLists:
27
+ Exclude:
28
+ - 'lib/moxml/materializer.rb'
29
+ - 'spec/moxml/materializer_spec.rb'
@@ -0,0 +1,117 @@
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), `parse_errors`
27
+ (#147; recover-mode diagnostics — `[]` on clean parses; engines with
28
+ an error channel map it here, e.g. Nokogiri's `doc.errors`).
29
+
30
+ Tree navigation::
31
+ `children`, `parent`, `next_sibling`, `previous_sibling`, `root`,
32
+ `set_root`, `document`, `duplicate_node`. `children` on a document
33
+ lists document-level parts in document order where the engine exposes
34
+ them (Nokogiri-shaped contract).
35
+
36
+ Node typing and names::
37
+ `node_type`, `node_name`, `set_node_name`, `attribute_name`.
38
+
39
+ Attributes::
40
+ `get_attribute` / `get_attribute_value` (the raw layer beneath
41
+ `Moxml::AttributeResolver` — expanded-name resolution lives above the
42
+ adapter), `set_attribute_value`, `remove_attribute`,
43
+ `patch_node`/`unpatch_node` for engines needing wrapper natives
44
+ (libxml, ox).
45
+
46
+ Namespaces::
47
+ `namespace`, `namespace_definitions` (the element's own declarations),
48
+ `in_scope_namespaces`, `set_namespace`, `remove_namespace`.
49
+
50
+ Mutation::
51
+ `add_child`, `add_next_sibling`, `add_previous_sibling`, `remove`,
52
+ `replace`, `replace_children`. Return contract: methods that can swap
53
+ the native (`set_attribute_name`, `set_namespace`, adoptions) return
54
+ the tracked native; wrappers re-key through `refresh_native!`.
55
+
56
+ Serialization::
57
+ `serialize(node, options)`. Options: `indent`, `encoding`,
58
+ `declaration`, `no_declaration`, `expand_empty`, `line_ending`.
59
+ Entity-marker restoration is owned by the wrapper layer
60
+ (`Node#to_xml`), guarded by `entity_bearing?`.
61
+
62
+ XPath::
63
+ `xpath(expression, namespaces)`. Engines without a native engine use
64
+ moxml's pure-Ruby XPath 1.0 engine (`Moxml::XPath`).
65
+
66
+ == Capability predicates
67
+
68
+ The wrapper layer probes adapters rather than assuming:
69
+
70
+ `patches_children?`::
71
+ True when `children` results need per-child `patch_node` rewriting
72
+ (libxml, ox). Others skip the identity map over every child list.
73
+
74
+ `wrappers_recyclable?`::
75
+ False for engines that mint fresh Ruby objects per access (libxml) —
76
+ the context's wrapper identity map opts out.
77
+
78
+ `entity_bearing?(native)`::
79
+ Whether the subtree can contain entity markers; gates the
80
+ post-serialize restore scan.
81
+
82
+ `bulk_materialize?`::
83
+ Whether the adapter offers a bulk path for `Moxml::Materializer`;
84
+ `materialize_fields` fills the reused flat buffers and yields the
85
+ eight record fields per node, returning nil falls back to the
86
+ generic wrapper walk. Leptris walks raw C pointers with the batch
87
+ `leptris_node_children` call — no wrappers, no per-node callback.
88
+
89
+ Constants like `DOC_NODE_SUPPORTED` / `DTDATTR_SUPPORTED` /
90
+ `BULK_FIELD_READS` (Leptris) follow the same idea: one probe
91
+ per engine capability, computed at load.
92
+
93
+ == Return shapes
94
+
95
+ * Mutations return the tracked native (or nil when not applicable) —
96
+ never a wrapper.
97
+ * `children` returns an array of natives; the wrapper layer builds the
98
+ `NodeSet`.
99
+ * `xpath` returns `Array<native>` or a scalar for non-node results.
100
+ * Marker-bearing text (leptris) is split by the adapter into
101
+ text/entity-reference pseudo-natives before the wrapper sees it.
102
+
103
+ == Writing an adapter
104
+
105
+ Subclass `Moxml::Adapter::Base`, implement the groups above, and
106
+ include the shared examples:
107
+
108
+ [source,ruby]
109
+ ----
110
+ RSpec.describe Moxml::Adapter::Mine do
111
+ it_behaves_like "xml adapter"
112
+ it_behaves_like "adapter contract"
113
+ end
114
+ ----
115
+
116
+ The contract suite is the test surface: if it passes, the wrapper
117
+ 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,74 @@
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
+ * Child PIs serialize inline (engine emits `<r>\n<?pi x?> <e/>`
65
+ rather than libxml2's one-PI-per-indented-line layout); safe
66
+ post-hoc rewriting is impossible in mixed content.
67
+ * DOCTYPE internal subsets serialize inline; libxml2 separates the
68
+ declarations with newlines.
69
+ * Document-level PIs are read-only natives (`target=`/`data=`/
70
+ `unlink` rejected); write-through and removal are pending C
71
+ surface (leptris/leptris#612).
72
+ * A PI preceding the DOCTYPE reorders on serialization — libleptris
73
+ normalizes DOCTYPE-first and the document node does not record its
74
+ 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,103 @@
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`
13
+ allocation — the conversion path for consumers that build their own
14
+ tree from a document:
15
+
16
+ [source,ruby]
17
+ ----
18
+ context.materialize(xml) { |record| ... } # parse + flatten
19
+ document.materialize.each # Enumerator, root subtree
20
+ any_element.materialize # any subtree
21
+ ----
22
+
23
+ Record shape (all eight keys on every record):
24
+
25
+ [source,ruby]
26
+ ----
27
+ { kind: :element|:text|:cdata|:comment|:processing_instruction|:entity_reference,
28
+ qname: "tag"|"pi-target"|nil,
29
+ prefix: "p"|nil,
30
+ namespace_uri: "urn:x"|nil,
31
+ namespaces: [[prefix, uri], ...], # element's OWN declarations; nil prefix = default
32
+ attributes: [[name, value, namespace_uri, prefix], ...],
33
+ text: String|nil,
34
+ depth: Integer }
35
+ ----
36
+
37
+ * Post-order: children arrive before their parent — build with a stack.
38
+ * `Document#materialize` yields exactly the root subtree (issue #140); document-level parts (prolog/epilog PIs and comments, DOCTYPE) stay enumerable via `children`.
39
+ * Every record is an independent Hash snapshot — safe to retain.
40
+ * Entity-bearing documents fall back to the generic walk so entity references still split.
41
+
42
+ === The streaming form — `materialize_fields` (issue #143)
43
+
44
+ The conversion hot path: the same walk with zero per-record
45
+ allocation. The block receives the eight fields positionally, and the
46
+ two list arguments are flat, reused buffers:
47
+
48
+ [source,ruby]
49
+ ----
50
+ context.materialize_fields(xml) do |kind, qname, prefix, uri,
51
+ namespaces, attributes, text, depth|
52
+ # namespaces: flat [prefix, uri, ...] (stride 2)
53
+ # attributes: flat [name, value, uri, prefix, ...] (stride 4)
54
+ end
55
+ ----
56
+
57
+ The buffers are refilled per element record — valid only inside the
58
+ block. Copy what you keep, or use `materialize` (which layers a fresh
59
+ Hash snapshot on this same stream). On CRuby + leptris the walk runs
60
+ over raw C pointers with the batch `leptris_node_children` call — no
61
+ wrapper objects, no per-node FFI callback, no depth bookkeeping;
62
+ measured ~1.2x faster than an equivalent raw-Nokogiri DOM walk.
63
+ Requires a block (raises `ArgumentError` otherwise).
64
+
65
+ == Deterministic release — `Document#free` (issue #134)
66
+
67
+ Adapters backed by C trees hold native memory until a GC finalizer runs. Batch workloads (parse → convert → discard, thousands of documents) release it deterministically:
68
+
69
+ [source,ruby]
70
+ ----
71
+ doc = context.parse(xml)
72
+ doc.free # C tree released now; attachments swept
73
+ doc.root # => raises the engine's use-after-free error (leptris)
74
+ ----
75
+
76
+ `#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.
77
+
78
+ == Parse-lifecycle options
79
+
80
+ Both options pass through `Context#parse` to the engine and are no-ops where the concept does not apply:
81
+
82
+ * `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.
83
+
84
+ * `dtdattr: true` (libleptris 1.9.8+, leptris/leptris#606) — materialize DTD `<!ATTLIST>` defaults. Off by default, matching libxml2/Nokogiri/REXML semantics:
85
+
86
+ [source,ruby]
87
+ ----
88
+ context.parse(dtd_xml) # plain: ATTLIST defaults excluded
89
+ context.parse(dtd_xml, dtdattr: true) # opt-in: attr="default" materialized
90
+ ----
91
+
92
+ == Pretty-print parity (issue #129)
93
+
94
+ Through the leptris adapter, byte-identical output to raw Nokogiri:
95
+
96
+ [source,ruby]
97
+ ----
98
+ context.parse(src).to_xml(
99
+ indent: 2, declaration: true, expand_empty: false, encoding: "UTF-8",
100
+ ) == Nokogiri::XML(src).to_xml(indent: 2, encoding: "UTF-8") # => true
101
+ ----
102
+
103
+ 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
 
@@ -24,6 +24,7 @@ require "moxml/namespace"
24
24
  require "moxml/doctype"
25
25
  require "moxml/entity_reference"
26
26
  require "moxml/entity_registry"
27
+ require "moxml/entity_registry_opal_data"
27
28
  require "moxml/entity"
28
29
  require "moxml/entity/restorer"
29
30
  require "moxml/entity/reference"
@@ -190,14 +190,19 @@ namespace_validation_mode: :strict)
190
190
  end
191
191
 
192
192
  # Whether the engine offers a bulk materialization path for
193
- # Materializer (issue #132). When true, materialize_records
194
- # yields flattened records for the subtree; returning nil
195
- # (e.g. for document shapes the bulk path cannot express)
196
- # falls back to the generic wrapper walk.
193
+ # Materializer (issue #132). When true, the adapter gets
194
+ # #materialize_fields(native, buffers, &block) fill the
195
+ # reused flat buffers and yield the eight record fields per
196
+ # node. Returning nil (e.g. for document shapes the bulk path
197
+ # cannot express) falls back to the generic wrapper walk.
197
198
  def bulk_materialize?
198
199
  false
199
200
  end
200
201
 
202
+ def materialize_fields(_native, _buffers)
203
+ nil
204
+ end
205
+
201
206
  # Deterministic native-memory release for adapters backed by
202
207
  # C trees (issue #134). GC-managed engines no-op; released
203
208
  # documents raise the engine's use-after-free error on
@@ -206,6 +211,15 @@ namespace_validation_mode: :strict)
206
211
  nil
207
212
  end
208
213
 
214
+ # Recover-mode parse diagnostics (issue #147): the error
215
+ # messages the engine recorded while parsing, [] when the
216
+ # parse was clean. Engines with a native recover channel
217
+ # (Nokogiri's `doc.errors`) or a non-strict path that loses
218
+ # the raised error (leptris) override this.
219
+ def parse_errors(_native_doc)
220
+ []
221
+ end
222
+
209
223
  # Check if the native document has an XML declaration
210
224
  # @param native_doc the native document object
211
225
  # @param wrapper [Moxml::Document] the wrapper with has_xml_declaration flag
@@ -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