moxml 0.5.145 → 0.5.147

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 (40) 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 +129 -2
  10. data/lib/moxml/adapter/libxml.rb +5 -0
  11. data/lib/moxml/adapter/oga.rb +10 -0
  12. data/lib/moxml/adapter/rexml.rb +5 -0
  13. data/lib/moxml/document.rb +9 -0
  14. data/lib/moxml/version.rb +1 -1
  15. data/lib/moxml/xpath/compiler.rb +14 -1
  16. data/spec/consistency/divergence_register_spec.rb +109 -0
  17. data/spec/consistency/document_text_spec.rb +30 -0
  18. data/spec/consistency/eol_normalization_spec.rb +8 -8
  19. data/spec/consistency/loud_degradation_spec.rb +44 -0
  20. data/spec/consistency/model_coverage_audit_spec.rb +113 -0
  21. data/spec/consistency/spec_constant_uniqueness_spec.rb +29 -0
  22. data/spec/consistency/xpath_idioms_spec.rb +32 -1
  23. data/spec/moxml/adapter/leptris_contract_reads_spec.rb +133 -0
  24. data/spec/moxml/adapter/leptris_detached_text_spec.rb +34 -0
  25. data/spec/moxml/adapter/leptris_parse_lint_spec.rb +35 -0
  26. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +3 -3
  27. data/spec/moxml/c14n/adapter_delegation_spec.rb +3 -3
  28. data/spec/moxml/c14n/comments_pis_spec.rb +2 -2
  29. data/spec/moxml/c14n/subset_spec.rb +4 -4
  30. data/spec/moxml/c14n/w3c_examples_spec.rb +3 -1
  31. data/spec/moxml/declaration_preservation_spec.rb +2 -2
  32. data/spec/moxml/element_add_element_spec.rb +3 -3
  33. data/spec/moxml/element_attribute_pairs_spec.rb +2 -2
  34. data/spec/moxml/node_spec.rb +1 -1
  35. data/spec/moxml/plan_conformance_spec.rb +3 -3
  36. data/spec/moxml/sax_entity_parity_spec.rb +3 -3
  37. data/spec/moxml/sax_spec.rb +7 -7
  38. data/spec/moxml/struct_plan_spec.rb +2 -2
  39. data/spec/moxml/xpath_capabilities_spec.rb +13 -13
  40. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 042f6cd21672a36e869b19c158819a3624d3a79a53de1fd083e83a377b577442
4
- data.tar.gz: 7cacbfbd44bb9a87aa252a638b8ac9d25fb5cb67b6e3845c8fdf9c5cf943df24
3
+ metadata.gz: 2e5797a825682b992d84b5ab9155d74f81fdeca24293b40ad7b9e58860c407f0
4
+ data.tar.gz: 11accb2da9da7cf656d28ab7e128da698f1702120332d94d06b4a20ea39ad112
5
5
  SHA512:
6
- metadata.gz: 507216b5acdf26ae9cd5b4d69c12d2519b949fbe2017c39a804954a04659780c5432b4fe8b11262642ca1699e09eb453af08e230a653a9b530f55404b6d6f5f1
7
- data.tar.gz: 5d19c7d3d7e1ad18864d13b0b3eaf69f481b5f1a0c68fd652f29cd9bff4e7b9f95f4adf57698d77849371bc635f9ba3ef36c945f12a1b63e2a350e50fc886bbb
6
+ metadata.gz: 2b8980eb872815020ed2b6d582172ffd62c08a426c2fa2104bd891cd5e2f1e69392fd82d9573215383399087cb686852c47fb10e1d93e812c1641f8eb5e4eeef
7
+ data.tar.gz: 924b76844411af051e0a9b02ea55788dbc4f5d0e6ec0be31ade4ded2c17c6c6e58356f687ef42e2d7934a4a8f684720f77b0d7688e108437aa64c68a41b68d06
@@ -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
@@ -463,25 +463,72 @@ module Moxml
463
463
  # instances). The hot bare reads super into the C methods
464
464
  # (exact-name = the bare-name contract).
465
465
  module Identity
466
+ # The C faces are captured before the contract modules
467
+ # shadow them — alias copies bind at this point, so the
468
+ # overrides below plain-dispatch the native reads
469
+ # (leptris-ruby#463 completed the surface: every contract
470
+ # face is a C method at 50-84ns).
466
471
  ELEMENT = Class.new(::Leptris::XML::NativeNode) do
472
+ alias_method :native_name, :name
467
473
  include ::Moxml::Node
468
474
  include ::Moxml::Element
475
+
476
+ # Memo form kept: a warm read is an ivar hit (~30ns
477
+ # vs the C call's 84ns); the cold read drops the
478
+ # adapter round-trip and its PI-check bind_call.
479
+ def name
480
+ @name ||= native_name
481
+ end
469
482
  end
470
483
  TEXT = Class.new(::Leptris::XML::NativeNode) do
484
+ alias_method :native_content, :content
471
485
  include ::Moxml::Node
472
486
  include ::Moxml::Text
487
+
488
+ # Text#content's memo + entity restore are the
489
+ # contract; the backing read drops the adapter
490
+ # round-trip (dispatch + is_a? + bind_call) for the
491
+ # C face.
492
+ def raw_content
493
+ native_content
494
+ end
473
495
  end
474
496
  CDATA = Class.new(::Leptris::XML::NativeNode) do
497
+ alias_method :native_content, :content
475
498
  include ::Moxml::Node
476
499
  include ::Moxml::Cdata
500
+
501
+ def content
502
+ native_content
503
+ end
477
504
  end
478
505
  COMMENT = Class.new(::Leptris::XML::NativeNode) do
506
+ alias_method :native_content, :content
479
507
  include ::Moxml::Node
480
508
  include ::Moxml::Comment
509
+
510
+ # The nil bridge stays for floor-admitted bindings
511
+ # where the native face answers nil for comment-kind
512
+ # (leptris-ruby#313).
513
+ def content
514
+ content = native_content
515
+ return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
516
+
517
+ content
518
+ end
481
519
  end
482
520
  PROCESSING_INSTRUCTION = Class.new(::Leptris::XML::NativeNode) do
521
+ alias_method :native_content, :content
483
522
  include ::Moxml::Node
484
523
  include ::Moxml::ProcessingInstruction
524
+
525
+ # Same nil bridge as Comment (leptris-ruby#344).
526
+ def content
527
+ content = native_content
528
+ return ::Moxml::Adapter::Leptris.to_binding(self).content if content.nil?
529
+
530
+ content
531
+ end
485
532
  end
486
533
 
487
534
  TYPES = {
@@ -537,6 +584,46 @@ module Moxml
537
584
  end
538
585
  ELEMENT.include(Reads)
539
586
  TEXT.include(Reads)
587
+
588
+ # leptris-ruby#503: name_read joins attr_read/text_read as
589
+ # the unshadowed contract faces. With them, the hot reads
590
+ # plain-dispatch the C faces (one Ruby frame over the
591
+ # ~45ns native call) instead of the adapter bind_call
592
+ # crossing. Face-probed — older bindings keep the
593
+ # adapter paths. The memos ride the existing
594
+ # invalidation: name= and clear_native_memo! clear
595
+ # @name, content= clears @content.
596
+ if NN.method_defined?(:name_read)
597
+ module ContractReads
598
+ def name
599
+ @name ||= name_read
600
+ end
601
+ end
602
+ ELEMENT.include(ContractReads)
603
+ end
604
+
605
+ if NN.method_defined?(:text_read)
606
+ module TextContractReads
607
+ # The stamp fast path is inlined (same ivar protocol
608
+ # as Reads#doc_entity_bearing?, which stays the
609
+ # single writer on recompute) — the two frames it
610
+ # saves are ~20% of the cold read.
611
+ def content
612
+ value = @content
613
+ return value if value
614
+
615
+ value = text_read
616
+ stamp = @doc_entity_stamp
617
+ if stamp && (stamp >> 1) == ::Moxml::Adapter::Leptris.serialize_generation
618
+ value = ::Moxml::Adapter::Leptris.restore_entities(value) if stamp.allbits?(1)
619
+ elsif doc_entity_bearing?
620
+ value = ::Moxml::Adapter::Leptris.restore_entities(value)
621
+ end
622
+ @content = value
623
+ end
624
+ end
625
+ TEXT.include(TextContractReads)
626
+ end
540
627
  end
541
628
 
542
629
  # leptris-ruby#246 klass-propagating reads: the read
@@ -1246,10 +1333,28 @@ module Moxml
1246
1333
  native_fragment.to_html
1247
1334
  end
1248
1335
 
1249
- def parse_html(html, _options = {}, _context = nil)
1336
+ # leptris#1771's parse-lint (§13.2.5): the binding's parse_html
1337
+ # accepts collect_errors (and mode) and records the error-token
1338
+ # stream into the document's diagnostic channel, which
1339
+ # parse_diagnostics reads. Face-probed — the zero-dep gem lets
1340
+ # consumers resolve bindings predating the kwarg.
1341
+ COLLECT_ERRORS_NATIVE = ::Leptris::XML
1342
+ .method(:parse_html).parameters.any? do |_, key|
1343
+ key == :collect_errors
1344
+ end
1345
+
1346
+ def parse_html(html, options = {}, _context = nil)
1250
1347
  html_string = html.is_a?(IO) || html.is_a?(StringIO) ? html.read : html.to_s
1251
1348
  native_doc = begin
1252
- ::Leptris::XML.parse_html(html_string)
1349
+ if COLLECT_ERRORS_NATIVE && options[:collect_errors]
1350
+ ::Leptris::XML.parse_html(
1351
+ html_string,
1352
+ mode: options.fetch(:mode, :whatwg),
1353
+ collect_errors: true,
1354
+ )
1355
+ else
1356
+ ::Leptris::XML.parse_html(html_string)
1357
+ end
1253
1358
  rescue ::Leptris::XML::ParseError => e
1254
1359
  raise Moxml::ParseError.new(e.message)
1255
1360
  end
@@ -2743,6 +2848,16 @@ module Moxml
2743
2848
  else
2744
2849
  node.content = content.to_s
2745
2850
  end
2851
+ rescue ::Leptris::XML::Error => e
2852
+ # Detached (parentless) nodes answer the engine's bare
2853
+ # "Invalid argument" (issue #401); created-unattached nodes
2854
+ # work, so only the failing detachment is re-raised, named.
2855
+ raise if node.parent
2856
+
2857
+ raise Moxml::Error,
2858
+ "cannot set text content on a detached (parentless) " \
2859
+ "node — attach it to a document first (issue #401): " \
2860
+ "#{e.message}"
2746
2861
  end
2747
2862
 
2748
2863
  def cdata_content(node)
@@ -2842,6 +2957,7 @@ module Moxml
2842
2957
  PARENT_AXIS_CACHE = XPath::Cache.new(100)
2843
2958
 
2844
2959
  def xpath(node, expression, namespaces = {})
2960
+ namespaces = normalize_xpath_namespaces(namespaces)
2845
2961
  node = to_binding(node) if NATIVE_READ_LAYER
2846
2962
  native = native_xpath(node, expression, namespaces)
2847
2963
  return native unless native.nil?
@@ -2873,6 +2989,7 @@ module Moxml
2873
2989
  end
2874
2990
 
2875
2991
  def at_xpath(node, expression, namespaces = {})
2992
+ namespaces = normalize_xpath_namespaces(namespaces)
2876
2993
  native = native_xpath(node, expression, namespaces, first_only: true)
2877
2994
  return native unless native.nil?
2878
2995
 
@@ -2880,6 +2997,16 @@ module Moxml
2880
2997
  result.is_a?(Array) ? result.first : result
2881
2998
  end
2882
2999
 
3000
+ # Bindings arrive in both spellings (symbol keys are the
3001
+ # common Nokogiri idiom); every seam below — the "xmlns"
3002
+ # reserved-key check and the native pairs set — is
3003
+ # string-keyed (issue #395).
3004
+ def normalize_xpath_namespaces(namespaces)
3005
+ return namespaces unless namespaces.is_a?(Hash)
3006
+
3007
+ namespaces.transform_keys(&:to_s)
3008
+ end
3009
+
2883
3010
  # @return [Array, Object, nil] native results, or nil when the
2884
3011
  # query must run on the Ruby engine
2885
3012
  def native_xpath(node, expression, namespaces, first_only: false)
@@ -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
 
@@ -607,6 +607,11 @@ module Moxml
607
607
  # XPath.match for every expression: get_elements only
608
608
  # handles simple child-name paths — attribute terminals,
609
609
  # absolute paths and predicates silently degrade there.
610
+ # REXML resolves prefixes by string spelling — symbol-keyed
611
+ # bindings (the common Nokogiri idiom) must normalize or
612
+ # namespaced elements never match (issue #395).
613
+ namespaces = namespaces.transform_keys(&:to_s) if
614
+ namespaces.is_a?(Hash)
610
615
  if namespaces && !namespaces.empty?
611
616
  ::REXML::XPath.match(node, expression, namespaces).to_a
612
617
  else