moxml 0.5.146 → 0.5.148

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +35 -0
  3. data/README.adoc +19 -1
  4. data/docs/_pages/adapter-compatibility.adoc +7 -0
  5. data/docs/_pages/divergences.adoc +90 -0
  6. data/docs/_references/index.adoc +9 -0
  7. data/docs/_references/processing-model.adoc +211 -0
  8. data/docs/index.adoc +9 -0
  9. data/lib/moxml/adapter/leptris.rb +100 -3
  10. data/lib/moxml/adapter/libxml.rb +5 -0
  11. data/lib/moxml/adapter/oga.rb +10 -0
  12. data/lib/moxml/document.rb +27 -4
  13. data/lib/moxml/node.rb +12 -0
  14. data/lib/moxml/version.rb +1 -1
  15. data/spec/consistency/divergence_register_spec.rb +109 -0
  16. data/spec/consistency/document_text_spec.rb +30 -0
  17. data/spec/consistency/eol_normalization_spec.rb +8 -8
  18. data/spec/consistency/loud_degradation_spec.rb +44 -0
  19. data/spec/consistency/model_coverage_audit_spec.rb +113 -0
  20. data/spec/consistency/spec_constant_uniqueness_spec.rb +29 -0
  21. data/spec/consistency/xpath_idioms_spec.rb +1 -1
  22. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +29 -0
  23. data/spec/moxml/adapter/leptris_detached_text_spec.rb +41 -0
  24. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +47 -0
  25. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +3 -3
  26. data/spec/moxml/c14n/adapter_delegation_spec.rb +3 -3
  27. data/spec/moxml/c14n/comments_pis_spec.rb +2 -2
  28. data/spec/moxml/c14n/subset_spec.rb +4 -4
  29. data/spec/moxml/c14n/w3c_examples_spec.rb +3 -1
  30. data/spec/moxml/declaration_preservation_spec.rb +2 -2
  31. data/spec/moxml/element_add_element_spec.rb +3 -3
  32. data/spec/moxml/element_attribute_pairs_spec.rb +2 -2
  33. data/spec/moxml/node_spec.rb +1 -1
  34. data/spec/moxml/plan_conformance_spec.rb +3 -3
  35. data/spec/moxml/sax_entity_parity_spec.rb +3 -3
  36. data/spec/moxml/sax_spec.rb +7 -7
  37. data/spec/moxml/struct_plan_spec.rb +2 -2
  38. data/spec/moxml/xpath_capabilities_spec.rb +13 -13
  39. metadata +11 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b529948a14e67202c6ad6fc9acbe6c06c033ba022f93ada6bc9e4001554ea583
4
- data.tar.gz: 68a76e1a69397aef0d0356e3ddd7bc200737a41333a70d6688cfca34d44e4c27
3
+ metadata.gz: 1b51347e90c3dae3f4b0b41268b4ee8cd92fa91a4210a17b1ea75da85db0f82a
4
+ data.tar.gz: d251705fd512baed6295dad6a56c12e7db531dfa4845b2f77983c9c305bfaffd
5
5
  SHA512:
6
- metadata.gz: 3775a27419325f8c764d6d686c547e88f75bdc073cbe354bdd97d2e0516e68a4f83896d4cd840c25be26f7d6aafdca24a596d3c6aa2893e8596ed95d646c16fa
7
- data.tar.gz: 84c2f0abec2a55d990cdc921f9abac0c9206def8190d609fd298256dd035d586334dedc04bb97af496a98fe9053b2fa3b65f716a11e22e94516f2f8b91837e1b
6
+ metadata.gz: 8ffcc8a80a304b656005d171627c40a982ba1b1594bbd2cb9090f390150f799b69041e338a54d2e79245a3fdf526639406938f4fdd82b290f55092c43ac20f34
7
+ data.tar.gz: 764dd4cd2fa5346a701224b7841ff0fb7cfced542b68ad914f4200acda168c5060813cb737429a5bcfd67eca12e74870b8bc4449bc83214678f5c86beaf3aa8c
@@ -71,3 +71,38 @@ jobs:
71
71
  env:
72
72
  LEPTRIS_BENCH_LOCK: "1"
73
73
  run: bundle exec rake benchmark:plan
74
+
75
+ # Order-flake tripwire (TODO.model/06 checker integrity): the same
76
+ # example order every run - a red here reproduces locally with
77
+ # `rspec --bisect --order rand:424242`. The per-PR matrix legs keep
78
+ # their random seeds for breadth; this one is the deterministic twin.
79
+ battery-fixed-seed:
80
+ runs-on: ubuntu-latest
81
+ steps:
82
+ - uses: actions/checkout@v6
83
+ with:
84
+ submodules: "recursive"
85
+
86
+ - name: Set up Ruby
87
+ uses: ruby/setup-ruby@v1
88
+ with:
89
+ ruby-version: "3.4"
90
+ bundler-cache: false
91
+
92
+ - name: Install ragel
93
+ run: sudo apt-get update && sudo apt-get install -y ragel
94
+
95
+ - name: Install ruby-ll (for rake vendor:prepare)
96
+ run: gem install ruby-ll --no-document
97
+
98
+ - name: Generate ragel / ruby-ll outputs in vendored forks
99
+ run: rake vendor:prepare
100
+
101
+ - name: Install dependencies
102
+ run: bundle install
103
+
104
+ - name: Compile liboga / libll native extensions
105
+ run: rake vendor:compile
106
+
107
+ - name: Full battery at the fixed seed
108
+ run: bundle exec rspec --order rand:424242
data/README.adoc CHANGED
@@ -15,10 +15,28 @@ It offers a consistent API that abstracts away the underlying XML implementation
15
15
  details while maintaining high performance through efficient node mapping and
16
16
  native XPath querying.
17
17
 
18
+ Moxml's mission: **make "which XML engine am I running?" a deployment decision
19
+ instead of an API decision.** Libraries write once against a single XML
20
+ processing model — one object model, one name/namespace resolution model, one
21
+ XPath, one serialization and canonicalization contract — and the engine
22
+ underneath (Nokogiri, Leptris, REXML, Oga, Ox, LibXML) is a configuration
23
+ choice.
24
+
25
+ The invariant that makes this work: any program written against the facade
26
+ produces the same observable outcome on every adapter. Performance may vary
27
+ across engines — semantics may not. Where an engine cannot deliver a
28
+ capability, it fails loudly (a named error); where an engine genuinely
29
+ diverges, the divergence is registered and pinned — never silent. The model is
30
+ specified normatively in the
31
+ https://lutaml.github.io/moxml/_references/processing-model[Processing Model]
32
+ document, pinned as executable expectations in the consistency suite, and its
33
+ known engine differences are tracked in the
34
+ https://lutaml.github.io/moxml/_pages/divergences[divergence register].
35
+
18
36
  Key features:
19
37
 
20
38
  * Intuitive, Ruby-idiomatic API for XML manipulation
21
- * Consistent interface across different XML libraries
39
+ * One processing model, consistently implemented across different XML libraries
22
40
  * Efficient node mapping for XPath queries
23
41
  * Support for all XML node types and features
24
42
  * Easy switching between XML processing engines
@@ -8,6 +8,13 @@ toc::[]
8
8
 
9
9
  This document provides detailed compatibility information for all supported XML adapters in Moxml, explaining which features are fully supported, partially supported, or not supported by each adapter.
10
10
 
11
+ The authoritative sources for cross-adapter semantics are the
12
+ link:../_references/processing-model[Processing Model] (the normative
13
+ single model every adapter implements) and the
14
+ link:divergences[divergence register] (every known, pinned engine
15
+ difference, D-01..D-12). A capability an adapter cannot deliver raises
16
+ a named error — it never answers a divergent result silently.
17
+
11
18
  == Test suite results
12
19
 
13
20
  **Overall:** 2,182 examples, 0 failures, 97 pending
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: Divergences
3
+ nav_order: 4
4
+ ---
5
+
6
+ == Divergence register
7
+
8
+ Every known cross-adapter divergence from the
9
+ link:../_references/processing-model[processing model] is registered
10
+ here. Contract:
11
+
12
+ * A divergence is either **fixed**, **loud** (a named raise), or
13
+ **SPEC'D** (documented here and pinned with a skip in the
14
+ consistency suite, citing the entry ID).
15
+ * Consistency-suite exemptions must cite a register ID. An
16
+ undocumented divergence is a bug — report it against this register.
17
+
18
+ [cols="2,4,6"]
19
+ |===
20
+ | ID | Adapter(s) | Divergence
21
+
22
+ | D-01
23
+ | oga
24
+ | The `xmlns:` default-namespace XPath idiom is unsupported by oga's
25
+ own engine (explicit bindings work). Pinned in
26
+ `spec/consistency/xpath_idioms_spec.rb`.
27
+
28
+ | D-02
29
+ | oga
30
+ | Raw CR preserved in text content — §2.11 EOL normalization not
31
+ applied at parse. Pinned in `spec/consistency/eol_normalization_spec.rb`.
32
+
33
+ | D-03
34
+ | ox, headed_ox
35
+ | `
` character references collapse to LF. Pinned in
36
+ `spec/consistency/eol_normalization_spec.rb`.
37
+
38
+ | D-04
39
+ | oga
40
+ | The XML declaration surfaces as a `<?xml?>` PI; PI content keeps its
41
+ separator space. Pinned in `spec/moxml/c14n/adapter_delegation_spec.rb`.
42
+
43
+ | D-05
44
+ | ox, headed_ox
45
+ | PI content dropped at parse. Pinned in
46
+ `spec/moxml/c14n/adapter_delegation_spec.rb`.
47
+
48
+ | D-06
49
+ | ox, headed_ox
50
+ | Comment content padding stripped at parse. Pinned in
51
+ `spec/moxml/c14n/subset_spec.rb`.
52
+
53
+ | D-07
54
+ | rexml
55
+ | `text()` node tests return no text nodes (adapter XPath capability
56
+ gap, both axes). Pinned in `spec/moxml/c14n/subset_spec.rb`.
57
+
58
+ | D-08
59
+ | c14n subsystem
60
+ | Document-level PI/comment rendering not yet implemented (all
61
+ adapters; a subsystem TODO, not an engine divergence). Tracked in
62
+ `TODO.c14n/09-comments-pis-tests.md`.
63
+
64
+ | D-09
65
+ | oga, ox, headed_ox, libxml, leptris
66
+ | Element-level `to_xml(indent:)` silently ignored (libxml emits a
67
+ partial, unindented newline form). Document-level HTML/XHTML
68
+ indentation is honored on leptris/nokogiri and raises on incapable
69
+ engines. Pinned in
70
+ `spec/moxml/adapter/shared_examples/adapter_contract.rb`.
71
+
72
+ | D-10
73
+ | ox, headed_ox
74
+ | SAX emits no separate CDATA/comment/processing-instruction events
75
+ (sequences fold into text or are dropped). Pinned in
76
+ `spec/moxml/sax_spec.rb`.
77
+
78
+ | D-11
79
+ | oga
80
+ | SAX parse is more lenient with malformed input than the model's
81
+ error authority. Pinned in `spec/moxml/sax_spec.rb`.
82
+
83
+ | D-12
84
+ | ox, headed_ox
85
+ | The XPath translation covers a subset: no positional predicates,
86
+ attribute-value predicates, namespace/default-namespace tests,
87
+ `position()`/`count()`, `text()`, boolean expressions, `child::`/
88
+ `descendant::` axes, or unions. Pinned in
89
+ `spec/moxml/xpath_capabilities_spec.rb` and `spec/moxml/node_spec.rb`.
90
+ |===
@@ -7,6 +7,15 @@ nav_order: 1
7
7
 
8
8
  Detailed technical specifications and API documentation for Moxml.
9
9
 
10
+ === Processing model
11
+
12
+ link:processing-model[Processing Model]::
13
+ The normative statement of Moxml's single XML processing model — the
14
+ semantics every adapter implements.
15
+
16
+ link:../_pages/divergences[Divergences]::
17
+ The register of known, pinned cross-adapter divergences.
18
+
10
19
  === API reference
11
20
 
12
21
  link:document-api[Document API]::
@@ -0,0 +1,211 @@
1
+ ---
2
+ title: Processing Model
3
+ nav_order: 3
4
+ ---
5
+
6
+ == The processing model (normative)
7
+
8
+ This document is the **single normative statement of Moxml's XML
9
+ processing model**. Every adapter implements this model; no adapter's
10
+ native semantics leak through it. When implementations disagree, the
11
+ model wins — engines converge to it, never the reverse.
12
+
13
+ The model's executable form is the consistency suite
14
+ (`spec/consistency/`). Known, unfixable engine differences are
15
+ registered in link:../_pages/divergences[the divergence register] and
16
+ skip with their entry ID — an undocumented divergence is a bug.
17
+
18
+ === Status and precedence
19
+
20
+ 1. This document — the model.
21
+ 2. `spec/consistency/` — the model pinned as executable expectations.
22
+ 3. Adapter implementations and capability gates — representations.
23
+
24
+ A semantics change amends this document first (see
25
+ `TODO.model/05-model-evolution-protocol.md`).
26
+
27
+ === Mission and invariant
28
+
29
+ Moxml exists to make "which XML engine am I running?" a **deployment
30
+ decision instead of an API decision**. The invariant that enables it:
31
+ any program written against the facade produces the same observable
32
+ outcome on every adapter. The engine is unobservable except through
33
+ declared channels: capability gates (loud raises), registered
34
+ divergences, and performance.
35
+
36
+ * Performance may vary across engines — semantics may not.
37
+ * Absence of capability is legal (a named raise). Absence of
38
+ equivalence is not (a different answer).
39
+
40
+ === Object model
41
+
42
+ Node kinds:: `Document`, `Element`, `Attribute`, `Text`, `Comment`,
43
+ `Cdata`, `Declaration`, `Doctype`, `ProcessingInstruction`,
44
+ `Namespace`, `EntityReference`.
45
+
46
+ Identity:: Parsing and traversal may mint distinct wrapper objects for
47
+ the same underlying node; node identity is a property of the
48
+ underlying tree, not of wrapper object identity. Comparisons use node
49
+ equality semantics, never `equal?` on wrappers.
50
+
51
+ Ownership:: The `Document` owns the tree's lifetime; nodes are valid
52
+ within it. Deterministic release is available via `Document#free`.
53
+
54
+ Declarations are state:: `Declaration` (and the doctype token) are
55
+ document *state*, not child nodes: they never appear in `children`
56
+ listings or serialization child walks.
57
+
58
+ === Document order and traversal
59
+
60
+ * `children` lists nodes in document order.
61
+ * Walks (`each_node`, `materialize`) visit depth-first, pre-order.
62
+ * `Document#children` and `#root` never include the declaration;
63
+ doctype exposure follows the Nokogiri contract.
64
+ * `NodeSet` preserves document order for axis results; unions preserve
65
+ first-encounter document order.
66
+
67
+ === Names and namespaces
68
+
69
+ * Names are **expanded names**: a name test matches by namespace *URI*,
70
+ never by prefix spelling.
71
+ * Unprefixed element name tests match **no-namespace** elements only
72
+ (XPath 1.0). Unprefixed attribute name tests likewise match
73
+ no-namespace attributes.
74
+ * Reserved prefixes: `xml` is bound to the XML namespace always;
75
+ `xmlns:` in an expression addresses the **default namespace** (the
76
+ libxml2 idiom, adopted model-wide).
77
+ * Bindings hashes accept both string and symbol keys; explicit
78
+ bindings **win** over in-scope document declarations.
79
+ * `Element#name` returns the bare tag name; non-element DOM names are
80
+ `"text"` / `"comment"`; a PI's name is its target.
81
+
82
+ === Access semantics
83
+
84
+ `text`:: Concatenated descendant text (elements); a text node's own
85
+ content; entity references restored when the document is
86
+ entity-bearing.
87
+
88
+ `content`:: The node's content per its kind; on text-like nodes, with
89
+ entity-marker restoration; memoized against mutation.
90
+
91
+ `raw_content`:: The backing text **without** entity restoration.
92
+
93
+ `[]` / `attributes` / `attribute_pairs`:: `[]` reads an attribute by
94
+ bare name (no-namespace) or prefixed name (resolved by URI);
95
+ values are entity-restored when the document bears markers.
96
+ `attribute_pairs` is the read-only listing ([name, value] pairs,
97
+ document order, duplicates included). `remove_attribute` returns the
98
+ removed attribute or nil — including for namespace-prefixed targets
99
+ with an unambiguous local name.
100
+
101
+ `remove`/mutation returns:: Mutators return the contract object
102
+ (removed node, new node) — never a silent no-op self.
103
+
104
+ === Parsing
105
+
106
+ * Modes: strict (errors raise) and recover (errors surface via
107
+ `parse_diagnostics` / `parse_errors`, both `[{kind:, message:}]`).
108
+ * HUGE mode: over-cap text (> 10 MB) parses completely.
109
+ * EOL normalization (XML 1.0 §2.11): CRLF → LF; bare CR → LF in text,
110
+ CDATA, and parsed content; attribute-value CRLF collapses to one
111
+ space; `&#xD;` survives as a literal CR.
112
+ * Entity modes: `:expand` (default — built-ins resolved at parse),
113
+ `:keep` (reference nodes preserved with marker machinery),
114
+ `:disabled`, `:custom` (provider). Restoration of marker-bearing
115
+ values is marker-driven and generation-stamped.
116
+
117
+ === XPath evaluation
118
+
119
+ * XPath 1.0 semantics on every adapter. Engines without an evaluator
120
+ (Ox, HeadedOx; leptris for shapes its engine cannot evaluate) run
121
+ **moxml's own pure-Ruby engine** — the model's semantics do not
122
+ depend on the engine shipping one.
123
+ * Document-context **relative name-test paths** resolve laxly —
124
+ descendant-anchored at the root (`doc.at("a/b")` finds `//a/b`),
125
+ matching Nokogiri's `Document#at`/`#search`. Axes, node types, `.`
126
+ and `..` keep strict document context.
127
+ * Declared idioms (pinned in `spec/consistency/xpath_idioms_spec.rb`):
128
+ `xmlns:` default-namespace matching; attribute terminals in every
129
+ position (`//a/@id`, `//@id`, `r/a/@id`); symbol- and string-keyed
130
+ bindings resolve identically.
131
+
132
+ === Serialization
133
+
134
+ [cols="1,3"]
135
+ |===
136
+ | Shape | Contract
137
+
138
+ | `to_xml`
139
+ | Declaration per configuration; empty elements per `expand_empty`;
140
+ namespace declarations preserved.
141
+
142
+ | `to_html`
143
+ | HTML method: void elements unclosed (`<br>`), raw text
144
+ (script/style) verbatim, no XML declaration, DOCTYPE first.
145
+
146
+ | `to_xhtml`
147
+ | XML method on HTML trees: void elements self-closed (`<br/>`),
148
+ non-void elements paired, raw text XML-escaped, no declaration
149
+ unless asked. Also selected by `to_html(xhtml: true)`.
150
+
151
+ | Indentation
152
+ | Document-level `to_html`/`to_xhtml` indentation composes with the
153
+ `indent:`/`indent_text:` knobs and raises `Moxml::AdapterError` on
154
+ incapable engines. Element-level `to_xml(indent:)` is honored on
155
+ nokogiri/rexml; other engines currently ignore it — registered as
156
+ link:../_pages/divergences[D-09], not silently blessed.
157
+
158
+ | Line endings
159
+ | `line_ending:` normalizes output newlines on every shape.
160
+ |===
161
+
162
+ === Canonicalization
163
+
164
+ * Algorithms: Inclusive 1.0, Exclusive 1.0, Inclusive 1.1, plus
165
+ XPath-driven subset canonicalization (enveloped-signature oracle).
166
+ * Native delegations (leptris engine, libxml2) answer **byte-identical**
167
+ output to the Ruby reference path (byte-safety probed before
168
+ engagement).
169
+ * `Moxml::C14n.equivalent?` is the algorithm-aware comparison.
170
+
171
+ === XSLT
172
+
173
+ XSLT 1.0 through engines that ship a processor (libxslt; leptris
174
+ 1.0–3.0). Compilation and transform failures raise named
175
+ `Moxml::XsltError`s. Engines without a processor raise
176
+ `Moxml::NotImplementedError` — the face is never silently absent.
177
+
178
+ === SAX
179
+
180
+ * The callback stream (`on_start_element`, `on_characters`, `on_error`,
181
+ …) is the authoritative event sequence.
182
+ * The record-drain fast path (`on_sax_records`) replays **exactly** the
183
+ callback stream (spec-pinned equality). Documents whose parse would
184
+ emit an ERROR event are outside the drainable subset — the drain
185
+ refuses them so `on_error` retains authority.
186
+
187
+ === Materialization
188
+
189
+ `materialize`, `materialize_fields`, `Plan`, and `StructPlan` produce
190
+ **identical records across all adapters** (conformance spec). Plan name
191
+ matching is global; unmatched subtrees are dropped per the pinned
192
+ semantics.
193
+
194
+ === Errors and loud degradation
195
+
196
+ * `Moxml::Error` hierarchy; `Moxml::XPathError`, `Moxml::XsltError`,
197
+ `Moxml::AdapterError` (e.g. schema-handle/adapter misroute),
198
+ `Moxml::NotImplementedError` for unsupported capability contracts.
199
+ * Every gated surface answers the contract or raises the named error —
200
+ never returns a divergent answer.
201
+ * Adapter degradation is loud: a present-but-unusable preferred adapter
202
+ warns at configuration and misroutes fail at the wrap, not deep in
203
+ a call.
204
+
205
+ === Performance: a declared, non-semantic channel
206
+
207
+ Acceleration (native read layers, C minting, record drains, plan
208
+ streams) always lands behind version + face gates and degrades to the
209
+ spec-pinned fallback. Cross-adapter performance variance is sanctioned
210
+ exactly one place: the benchmark parity locks. Semantics never ride a
211
+ gate.
data/docs/index.adoc CHANGED
@@ -14,6 +14,15 @@ applications. It offers a consistent API that abstracts away the underlying
14
14
  XML implementation details while maintaining high performance through
15
15
  efficient node mapping and native XPath querying.
16
16
 
17
+ Moxml's mission: **make "which XML engine am I running?" a deployment
18
+ decision instead of an API decision.** Libraries write once against a single
19
+ XML processing model; the engine underneath is a configuration choice. Any
20
+ program written against the facade produces the same observable outcome on
21
+ every adapter — performance may vary, semantics may not. The model is
22
+ specified normatively in the
23
+ link:_references/processing-model[Processing Model] document; known engine
24
+ differences are tracked in the link:_pages/divergences[divergence register].
25
+
17
26
  == Key features
18
27
 
19
28
  * **Unified interface** - Consistent API across different XML libraries
@@ -105,6 +105,9 @@ module Moxml
105
105
  ::Leptris::XML::Document.method(:parse)
106
106
  .parameters.any? { |_, key| key == :strict }
107
107
 
108
+ ADD_CHILD_NATIVE =
109
+ NATIVE_READ_LAYER && NN.method_defined?(:add_child_native)
110
+
108
111
  if NATIVE_READ_LAYER
109
112
  NN_ATTRIBUTE = NN.instance_method(:attribute)
110
113
  NN_NAME = NN.instance_method(:name)
@@ -624,6 +627,31 @@ module Moxml
624
627
  end
625
628
  TEXT.include(TextContractReads)
626
629
  end
630
+
631
+ # leptris-ruby#507: children_read joins the alias set. The
632
+ # generation-checked memo STAYS — re-walks of the same
633
+ # document are the common harness/consumer shape and the
634
+ # memo serves them allocation-free (dropping it regressed
635
+ # the walk 2x in GC). Only the MISS gets cheaper: the
636
+ # klass-children face plain-dispatches and returns the
637
+ # already-minted wrappers, so the adapter crossing
638
+ # (bind_call + to_a + keep-refs probe) drops out, and
639
+ # entity-marker documents take super for the split.
640
+ if NN.method_defined?(:children_read)
641
+ module ChildReads
642
+ def children
643
+ @children = nil if @children_gen != context.children_generation
644
+ @children_gen = context.children_generation
645
+ @children ||= if doc_entity_bearing?
646
+ super
647
+ else
648
+ NodeSet.new(children_read, context, self)
649
+ end
650
+ end
651
+ end
652
+ ELEMENT.include(ChildReads)
653
+ TEXT.include(ChildReads)
654
+ end
627
655
  end
628
656
 
629
657
  # leptris-ruby#246 klass-propagating reads: the read
@@ -1333,10 +1361,28 @@ module Moxml
1333
1361
  native_fragment.to_html
1334
1362
  end
1335
1363
 
1336
- def parse_html(html, _options = {}, _context = nil)
1364
+ # leptris#1771's parse-lint (§13.2.5): the binding's parse_html
1365
+ # accepts collect_errors (and mode) and records the error-token
1366
+ # stream into the document's diagnostic channel, which
1367
+ # parse_diagnostics reads. Face-probed — the zero-dep gem lets
1368
+ # consumers resolve bindings predating the kwarg.
1369
+ COLLECT_ERRORS_NATIVE = ::Leptris::XML
1370
+ .method(:parse_html).parameters.any? do |_, key|
1371
+ key == :collect_errors
1372
+ end
1373
+
1374
+ def parse_html(html, options = {}, _context = nil)
1337
1375
  html_string = html.is_a?(IO) || html.is_a?(StringIO) ? html.read : html.to_s
1338
1376
  native_doc = begin
1339
- ::Leptris::XML.parse_html(html_string)
1377
+ if COLLECT_ERRORS_NATIVE && options[:collect_errors]
1378
+ ::Leptris::XML.parse_html(
1379
+ html_string,
1380
+ mode: options.fetch(:mode, :whatwg),
1381
+ collect_errors: true,
1382
+ )
1383
+ else
1384
+ ::Leptris::XML.parse_html(html_string)
1385
+ end
1340
1386
  rescue ::Leptris::XML::ParseError => e
1341
1387
  raise Moxml::ParseError.new(e.message)
1342
1388
  end
@@ -1446,6 +1492,14 @@ module Moxml
1446
1492
  count = ffi.leptris_document_parse_diag_count(doc)
1447
1493
  return [] if count.zero?
1448
1494
 
1495
+ # leptris#1771: one face carries the diag's machine-readable
1496
+ # code plus line/column with the entry (nil when the engine
1497
+ # has no token code — pre-#1771 records). The face probe
1498
+ # keeps older bindings on kind+message (issue #408).
1499
+ lint_code = if ffi.respond_to?(:leptris_document_parse_diag_code)
1500
+ ffi.method(:leptris_document_parse_diag_code)
1501
+ end
1502
+
1449
1503
  kind_ptr = ::FFI::MemoryPointer.new(:int)
1450
1504
  diagnostics = []
1451
1505
  count.times do |i|
@@ -1455,7 +1509,19 @@ module Moxml
1455
1509
  ) == 1
1456
1510
 
1457
1511
  kind = DIAG_KINDS[kind_ptr.read_int] || :unknown
1458
- diagnostics << { kind: kind, message: buffer.read_string }
1512
+ diag = { kind: kind, message: buffer.read_string }
1513
+ if lint_code
1514
+ code_buf = ::FFI::MemoryPointer.new(:char, 128)
1515
+ line_ptr = ::FFI::MemoryPointer.new(:int)
1516
+ column_ptr = ::FFI::MemoryPointer.new(:int)
1517
+ lint_code.call(doc, i, code_buf, 128, line_ptr, column_ptr)
1518
+ code = code_buf.read_string
1519
+ diag[:code] = code.empty? ? nil : code
1520
+ diag[:line] = line_ptr.read_int.zero? ? nil : line_ptr.read_int
1521
+ diag[:column] =
1522
+ column_ptr.read_int.zero? ? nil : column_ptr.read_int
1523
+ end
1524
+ diagnostics << diag
1459
1525
  end
1460
1526
  diagnostics
1461
1527
  end
@@ -2478,6 +2544,27 @@ module Moxml
2478
2544
  end
2479
2545
 
2480
2546
  def add_child(parent, child)
2547
+ # Same-document Identity append — the builder hot path. One
2548
+ # document pair decides the verdict: the pin, the absorb
2549
+ # checks and the binding bridges only exist for
2550
+ # cross-document work, and the plain C append (version bump
2551
+ # inside) is the whole job. bind_call, not dispatch — the
2552
+ # contract modules shadow add_child on Identity receivers
2553
+ # (moxml#2560 note below).
2554
+ if NATIVE_MUTATIONS_COHERENT && parent.is_a?(NN) && child.is_a?(NN) &&
2555
+ !child.is_a?(CustomizedLeptris::EntityReference)
2556
+ parent_doc = NN_DOCUMENT.bind_call(parent)
2557
+ if parent_doc
2558
+ child_doc = NN_DOCUMENT.bind_call(child)
2559
+ if child_doc && parent_doc.equal?(child_doc)
2560
+ # add_child_native (leptris-ruby#509) plain-dispatches
2561
+ # on Identity receivers — one frame, no bind_call.
2562
+ return parent.add_child_native(child) if ADD_CHILD_NATIVE
2563
+
2564
+ return NN_ADD_CHILD.bind_call(parent, child)
2565
+ end
2566
+ end
2567
+ end
2481
2568
  # 1.9.311: binding-family children (created PIs, texts...) —
2482
2569
  # a DIRECT absorb makes the binding add below move the node
2483
2570
  # by reference. A source already owned elsewhere would need
@@ -2830,6 +2917,16 @@ module Moxml
2830
2917
  else
2831
2918
  node.content = content.to_s
2832
2919
  end
2920
+ rescue ::Leptris::XML::Error => e
2921
+ # Detached (parentless) nodes answer the engine's bare
2922
+ # "Invalid argument" (issue #401); created-unattached nodes
2923
+ # work, so only the failing detachment is re-raised, named.
2924
+ raise if node.parent
2925
+
2926
+ raise Moxml::Error,
2927
+ "cannot set text content on a detached (parentless) " \
2928
+ "node — attach it to a document first (issue #401): " \
2929
+ "#{e.message}"
2833
2930
  end
2834
2931
 
2835
2932
  def cdata_content(node)
@@ -768,6 +768,11 @@ module Moxml
768
768
 
769
769
  native_node = unpatch_node(node)
770
770
  return nil unless native_node
771
+ # Documents are container nodes (issue #400): their text is
772
+ # the root's concatenated descendant text; LibXML::XML::
773
+ # Document carries no content face of its own.
774
+ return native_node.root&.content.to_s if
775
+ native_node.is_a?(::LibXML::XML::Document)
771
776
 
772
777
  native_node.content
773
778
  end
@@ -452,6 +452,16 @@ module Moxml
452
452
  end
453
453
 
454
454
  def text_content(node)
455
+ # Documents are container nodes (issue #400): their text is
456
+ # the whole tree's text; Oga documents carry no text face
457
+ # (root_node answers the document itself), so fold the
458
+ # element children.
459
+ if node.is_a?(::Oga::XML::Document)
460
+ return node.children
461
+ .filter_map { |c| c.text if c.is_a?(::Oga::XML::Element) }
462
+ .join
463
+ end
464
+
455
465
  node.text
456
466
  end
457
467
 
@@ -73,6 +73,15 @@ module Moxml
73
73
  root_element ? Moxml::Node.wrap(root_element, context) : nil
74
74
  end
75
75
 
76
+ # Documents are container nodes: #text concatenates descendant
77
+ # text, Nokogiri's Document#text contract (issue #400) — the base
78
+ # Node#text answered "" and silently emptied consumers'
79
+ # content-preservation checks.
80
+ def text
81
+ val = adapter.text_content(@native)
82
+ entity_bearing? ? adapter.restore_entities(val) : val
83
+ end
84
+
76
85
  # Materialize the root subtree — see Moxml::Materializer.
77
86
  def materialize(&block)
78
87
  return to_enum(:materialize) unless block
@@ -154,11 +163,21 @@ module Moxml
154
163
  end
155
164
 
156
165
  def create_element(name)
157
- Wrappers::Element.new(adapter.create_element(name, owner_doc: @native), context)
166
+ native = adapter.create_element(name, owner_doc: @native)
167
+ # leptris-ruby#508 mints registered klasses at create: the node
168
+ # IS a contract-carrying identity (@native is self) — prime it
169
+ # in place instead of shelling a second wrapper (a raw mint
170
+ # reaching ==/adapter dies on the nil @context).
171
+ return native.prime_contract(native, context, adapter) if native.is_a?(Moxml::Node)
172
+
173
+ Wrappers::Element.new(native, context)
158
174
  end
159
175
 
160
176
  def create_text(content)
161
- Wrappers::Text.new(adapter.create_text(content, owner_doc: @native), context)
177
+ native = adapter.create_text(content, owner_doc: @native)
178
+ return native.prime_contract(native, context, adapter) if native.is_a?(Moxml::Node)
179
+
180
+ Wrappers::Text.new(native, context)
162
181
  end
163
182
 
164
183
  def create_cdata(content)
@@ -218,9 +237,13 @@ module Moxml
218
237
  raise Error, "Document already has a root element"
219
238
  else
220
239
  adapter.add_child(@native, node.native)
221
- # Refresh native for adapters where identity changes (e.g., LibXML doc.root=)
240
+ # Refresh native for adapters where identity changes (e.g., LibXML doc.root=).
241
+ # Native equality via the adapter, not wrapper !=: re-mints can
242
+ # arrive without context primed (1.9.373 actual_native), and
243
+ # wrapper == dispatches through the adapter (issue #409-class).
222
244
  refreshed = adapter.actual_native(node.native, @native)
223
- node.refresh_native!(refreshed) if refreshed && refreshed != node.native
245
+ node.refresh_native!(refreshed) if refreshed &&
246
+ !adapter.same_node?(refreshed, node.native)
224
247
  end
225
248
  node.parent_node = self
226
249
  invalidate_children_cache!