makiri 0.12.1 → 0.13.0

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: 6432135ca3963c17f432c152ede49162c3f1be48e96179b914c971f0b2da1b76
4
- data.tar.gz: 2b0478171ee224ea5c29ae19cd0a16a08c3d786e017444241d4bb8f620ecf975
3
+ metadata.gz: 48c6dfd55ed565531aea117967aee1f4b8f3405ff0e36ca8766ffa729a1ee27e
4
+ data.tar.gz: 1866e9eb174ea0a6428a8f1c81f65ed8756d61c2429de935876d60547a4bc6bd
5
5
  SHA512:
6
- metadata.gz: 8447a816ba507fb6a74ed8e6a5a92deaff6da2453ef29ba6cfe24652ec160b440409ec94b58b4aa89d3d28ab05643086f4dd1bba150339eeff27819d2e3d86d7
7
- data.tar.gz: 2a9505ac1b1762cbdbc738645542acbda4bb029e06e5b4befe76f63e39e6e55a9f36a34106dd0fe24d3103d17f020939498373d9c872cb2042d71b23bb0d6513
6
+ metadata.gz: f847d9d772a2865bcd6e3f9b1626f0b67e4215d14d844b529bd194e9d287aa288cc2a555156799f842ac68fcc13405e1cfff317a506c2476b87eb24646f17c46
7
+ data.tar.gz: ee1fe65b9055dc2a9c11cdbbd789544ef72ef1bd2c8fa731ab0eab2f97173125ab3f96637da903d33df76dfa80e221dd0e11abeaa446f5683e1428cc7f6552af
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] - 2026-10-03
4
+
5
+ ### Added
6
+
7
+ * `Document#tree_version` (HTML and XML): an Integer that increases whenever
8
+ a child list in the document changes - nodes added, removed or replaced,
9
+ `inner_html=`, `content=`, and both documents when a node moves between
10
+ them. Attribute changes leave it as it is, so it can key a cache of child
11
+ lists.
12
+ * `Element#attribute_value_ns(ns, local)` and `#attribute_node_ns(ns, local)`
13
+ (HTML and XML): the attribute's value or Attr node by namespace and local
14
+ name, like the DOM's `getAttributeNS` / `getAttributeNodeNS`. `nil` or `""`
15
+ means no namespace.
16
+ * `Element#set_loose_dom_attribute(name, value)` now works on HTML nodes too.
17
+ Like the DOM's `setAttribute`, it sets an attribute in no namespace whose
18
+ name may contain a colon (`v-on:click`).
19
+
20
+ ### Fixed
21
+
22
+ * HTML documents keep namespace URIs as written. `create_element_ns`,
23
+ `set_attribute_ns` and `import_node` lower-cased them (`fooNamespace` read
24
+ back as `foonamespace`), and a differently cased XHTML namespace URI made
25
+ an HTML element.
26
+
3
27
  ## [0.12.1] - 2026-10-02
4
28
 
5
29
  ### Fixed
data/README.md CHANGED
@@ -50,7 +50,7 @@ doc = Makiri::HTML(<<~HTML)
50
50
  </body></html>
51
51
  HTML
52
52
 
53
- # CSS selectors (Lexbor's selector engine)
53
+ # CSS selectors (Lexbor's selector parser, Makiri's own matcher)
54
54
  doc.css("a").map { |a| a["href"] } # => ["/a", "/b"]
55
55
  doc.at_css("p.lead").text # => "Hello"
56
56
 
@@ -184,6 +184,27 @@ and unusually large documents can raise it with `max_bytes:`.
184
184
  Makiri::XML(huge_xml, max_bytes: 512 * 1024 * 1024) # also Makiri::XML::Document.parse(..., max_bytes:)
185
185
  ```
186
186
 
187
+ ### Node identity
188
+
189
+ Navigating to the same node always gives the same Ruby object, for as long as
190
+ its document is alive. The document keeps every wrapper it has handed out, so
191
+ instance variables, singleton methods and `freeze` set on a node survive
192
+ garbage collection and are there the next time the node is reached.
193
+
194
+ That makes a node a safe place to keep per-node state, such as a wrapper
195
+ object of your own:
196
+
197
+ ```ruby
198
+ el = doc.at_css("p")
199
+ el.instance_variable_set(:@wrapper, MyElement.new(el))
200
+ doc.at_css("p").instance_variable_get(:@wrapper) # => the same MyElement
201
+ ```
202
+
203
+ A node created by `clone_node` or `import_node` is a new node with no state.
204
+ `pointer_id` is unique only among live nodes: once a document is freed, a node
205
+ of another document may reuse its value. So key a pointer-based cache per
206
+ document, not across documents.
207
+
187
208
  ## Non-goals (v1.0)
188
209
 
189
210
  * XSLT, DTD / Schema / RelaxNG validation, XPointer, XInclude.
@@ -224,6 +245,7 @@ See also [`spec/conformance/README.md`](spec/conformance/README.md).
224
245
  | XPath 1.0 | XML | `Nokogiri::XML` — differential | `conformance:xpath_xml` |
225
246
  | Parsed tree (property-based) | XML | `Nokogiri::XML` — differential | `conformance:xml_pbt` |
226
247
  | CSS selectors | XML | `Nokogiri::XML` — differential | `conformance:css_xml` |
248
+ | XML Builder | Ruby DSL | `Nokogiri::XML::Builder` — differential | `conformance:builder` |
227
249
 
228
250
  ## Requirements
229
251
 
@@ -186,7 +186,13 @@ fn main() {
186
186
  .allowlist_function("lxb_dom_document_type_system_id")
187
187
  .allowlist_function("lxb_dom_processing_instruction_target")
188
188
  .allowlist_function("lxb_ns_by_id")
189
- .allowlist_function("lxb_ns_data_by_link")
189
+ // A namespace URI interned and looked up AS WRITTEN: Lexbor's own
190
+ // `lxb_ns_append` / `lxb_ns_data_by_link` fold ASCII case, where the
191
+ // DOM keeps a namespace an opaque string (`HtmlDoc::intern_ns`).
192
+ .allowlist_function("lexbor_hash_search")
193
+ .allowlist_function("lexbor_hash_insert")
194
+ .allowlist_var("lexbor_hash_search_raw")
195
+ .allowlist_var("lexbor_hash_insert_raw")
190
196
  .allowlist_function("lxb_dom_document_root")
191
197
  .allowlist_type("lxb_html_token_t")
192
198
  .allowlist_type("lxb_html_token_type_t")
@@ -324,10 +324,26 @@ impl<'a> HtmlEdit<'a> {
324
324
  /// not the token's - so a caller keeps the span from here to the change
325
325
  /// to engine calls and checks that call no Ruby (`insert` reads its
326
326
  /// argument's node and frozen flag there, and nothing more).
327
+ ///
328
+ /// It counts as a change to a child list ([`bump_tree_version`]); an
329
+ /// attribute edit takes [`HtmlEdit::node_for_attributes`] instead.
327
330
  pub fn node(self) -> Result<HtmlNodeMut<'a>, Error> {
331
+ self.mutable(true)
332
+ }
333
+
334
+ /// [`HtmlEdit::node`] for an edit of the element's ATTRIBUTES only, which
335
+ /// changes no child list and so leaves the tree version alone.
336
+ pub fn node_for_attributes(self) -> Result<HtmlNodeMut<'a>, Error> {
337
+ self.mutable(false)
338
+ }
339
+
340
+ fn mutable(self, structural: bool) -> Result<HtmlNodeMut<'a>, Error> {
328
341
  crate::bridge::ruby::check_frozen(self.this.value)?;
329
342
  ensure_document_mutable(self.this.document)?;
330
343
  invalidate_indexes(self.this.document);
344
+ if structural {
345
+ bump_tree_version(self.this.document);
346
+ }
331
347
  // SAFETY: the receiver is not frozen and no XPath evaluation is
332
348
  // reading its document - both checked just now.
333
349
  Ok(unsafe { HtmlNodeMut::assume_mutable(self.this.raw().as_node()) })
@@ -369,7 +385,9 @@ fn adopt_release(src: Value) -> Result<(), Error> {
369
385
  release_from_tree(unsafe { HtmlNodeMut::assume_mutable(node) });
370
386
  })?;
371
387
  /* After the borrow `with_arg_node` held: dropping them borrows again. */
372
- invalidate_indexes(keepalive_document(src)?);
388
+ let src_doc = keepalive_document(src)?;
389
+ invalidate_indexes(src_doc);
390
+ bump_tree_version(src_doc);
373
391
  Ok(())
374
392
  }
375
393
 
@@ -322,6 +322,9 @@ pub struct DocData {
322
322
  /// what [`account_growth`] compares against, since the byte count
323
323
  /// itself costs a walk of every chunk.
324
324
  reported_chunks: usize,
325
+ /// `Document#tree_version`: bumped by every edit that can change a child
326
+ /// list of a node this document owns (see [`bump_tree_version`]).
327
+ tree_version: u64,
325
328
  /// One wrapper per node; see [`NodeCache`].
326
329
  ///
327
330
  /// Boxed and optional so a document nobody navigates never allocates a
@@ -590,6 +593,7 @@ impl DocumentShell {
590
593
  errors: QFALSE,
591
594
  reported: 0,
592
595
  reported_chunks: 0,
596
+ tree_version: 0,
593
597
  nodes: None,
594
598
  },
595
599
  |d| d.errors = errors.as_raw(),
@@ -880,6 +884,28 @@ pub fn keepalive_document(rb_node: Value) -> Result<Value, Error> {
880
884
  Ok(unsafe { value(nd.document) })
881
885
  }
882
886
 
887
+ /* ---- the tree version ---- */
888
+
889
+ /// Count a change to a child list of a node `rb_doc` owns - attached,
890
+ /// detached or inside a fragment alike. Called where an edit is handed its
891
+ /// mutable node (`HtmlEdit::node`, `Editing::with_arena`) and for the source
892
+ /// of an adoption, so no structural mutator can miss it; attribute edits take
893
+ /// the paths that skip it, as they change no child list.
894
+ ///
895
+ /// Bumped inside the edit, with no Ruby run between the bump and the change,
896
+ /// so nothing can read the tree in between and cache it under the new number.
897
+ /// An edit that then fails costs a reader a cache refill, never a stale
898
+ /// answer.
899
+ pub fn bump_tree_version(rb_doc: Value) {
900
+ with_doc_data_known(rb_doc, |d| d.tree_version = d.tree_version.wrapping_add(1));
901
+ }
902
+
903
+ /// `Document#tree_version`: how many structural edits the document has seen.
904
+ /// `TypeError` for a non-Document.
905
+ pub fn tree_version(rb_doc: Value) -> Result<u64, Error> {
906
+ Ok(DOC_TYPE.get(&rb_doc)?.tree_version)
907
+ }
908
+
883
909
  /* ---- the document's mutation gate ---- */
884
910
 
885
911
  /// `Err(Makiri::Error)` while an evaluation with a handler is reading `rb_doc`.
@@ -330,13 +330,36 @@ impl Editing {
330
330
  /// it indexes. Not in `begin_edit`: the argument conversion between the two
331
331
  /// runs `#to_s`, and a query there rebuilt the index from the tree about to
332
332
  /// change - `//a` then kept finding an element renamed to `b`.
333
+ ///
334
+ /// It counts as a change to a child list ([`bump_tree_version`]); an
335
+ /// attribute edit takes [`Editing::with_attributes`] instead.
333
336
  pub fn with_arena<R>(&self, f: impl FnOnce(&mut XmlDoc, NodeId) -> R) -> Result<R, Error> {
337
+ self.lend(true, f)
338
+ }
339
+
340
+ /// [`Editing::with_arena`] for an edit of the element's ATTRIBUTES only,
341
+ /// which changes no child list and so leaves the tree version alone.
342
+ pub fn with_attributes<R>(&self, f: impl FnOnce(&mut XmlDoc, NodeId) -> R) -> Result<R, Error> {
343
+ self.lend(false, f)
344
+ }
345
+
346
+ fn lend<R>(
347
+ &self,
348
+ structural: bool,
349
+ f: impl FnOnce(&mut XmlDoc, NodeId) -> R,
350
+ ) -> Result<R, Error> {
334
351
  let id = self.id;
335
352
  check_frozen(self.receiver)?;
336
- with_arena_for_new_node(self.document, |d| {
353
+ let r = with_arena_for_new_node(self.document, |d| {
337
354
  d.invalidate_name_index();
338
355
  f(d, id)
339
- })
356
+ });
357
+ /* After the arena call, which refuses an evaluated document first -
358
+ * and that, like the frozen check, leaves the tree as it was. */
359
+ if structural && r.is_ok() {
360
+ bump_tree_version(self.document);
361
+ }
362
+ r
340
363
  }
341
364
  }
342
365
 
@@ -559,6 +582,25 @@ pub fn find_attribute(this: XmlSelf, name: Value) -> Result<Option<NodeId>, Erro
559
582
  Ok(find_attribute_bytes(this.doc_ref(), id, bytes))
560
583
  }
561
584
 
585
+ /// The attribute of `el` in namespace `ns` (nil or "" for none) with local
586
+ /// name `local` - DOM "get an attribute by namespace and local name", the key
587
+ /// `remove_attribute_ns` removes by. Converted before the arena is borrowed,
588
+ /// as [`find_attribute`].
589
+ pub fn find_attribute_ns(this: XmlSelf, ns: Value, local: Value) -> Result<Option<NodeId>, Error> {
590
+ let id = this.id;
591
+ if this.doc_ref().type_(id) != Some(ArenaKind::Element) {
592
+ return Ok(None);
593
+ }
594
+ let lv = ruby_verified_text(local, "attribute local name")?;
595
+ let nv = crate::bridge::string::namespace_arg(ns, "namespace")?;
596
+ let key = crate::xml::attr_key::AttrKey::Ns {
597
+ ns: nv.as_ref().map_or(&b""[..], |n| n.as_bytes()),
598
+ local: lv.as_bytes(),
599
+ };
600
+ let d = this.doc_ref();
601
+ Ok(d.attributes(id).find(|&a| key.matches(d, a)))
602
+ }
603
+
562
604
  /// The attribute of `el` whose qualified name is `name`.
563
605
  ///
564
606
  /// Namespace declarations included: in the DOM an `xmlns` / `xmlns:p` is an
@@ -585,6 +627,8 @@ fn find_attribute_bytes(d: &XmlDoc, el: NodeId, name: &[u8]) -> Option<NodeId> {
585
627
  /// cannot fail - there is nothing left to look up.
586
628
  pub struct Adoption {
587
629
  src_doc: *mut XmlDoc,
630
+ /// The source's Document, whose tree version the removal bumps.
631
+ src_document: Value,
588
632
  src: NodeId,
589
633
  /// The source node's wrapper, which keeps its document - and so
590
634
  /// `src_doc` - alive until the adoption is finished.
@@ -606,6 +650,7 @@ impl Adoption {
606
650
  remove_node(sdoc, self.src);
607
651
  }
608
652
  sdoc.invalidate_name_index();
653
+ bump_tree_version(self.src_document);
609
654
  }
610
655
  }
611
656
 
@@ -644,6 +689,7 @@ pub fn incoming_node(target_doc: Value, arg: Value) -> Result<(NodeId, Option<Ad
644
689
  copy,
645
690
  Some(Adoption {
646
691
  src_doc,
692
+ src_document,
647
693
  src,
648
694
  _keep: arg,
649
695
  }),
@@ -167,6 +167,10 @@ pub fn init_html_doc() -> Result<(), Error> {
167
167
  html_doc.define_method("quirks_mode", method!(doc_quirks_mode, 0))?;
168
168
  html_doc.define_method("fragment", method!(doc_fragment, -1))?;
169
169
  html_doc.define_method("import_node", method!(doc_import_node, -1))?;
170
+ html_doc.define_method(
171
+ "tree_version",
172
+ method!(crate::glue::node::document_tree_version, 0),
173
+ )?;
170
174
 
171
175
  let frag = crate::init::CLASS_DOCUMENT_FRAGMENT.defined()?;
172
176
  frag.define_singleton_method("parse", method!(frag_s_parse, -1))?;
@@ -110,6 +110,8 @@ fn init_read() -> Result<(), Error> {
110
110
  "attribute_value_by_qualified_name",
111
111
  method!(read::attribute_value_by_qualified_name, 1),
112
112
  )?;
113
+ m.define_method("attribute_value_ns", method!(read::attribute_value_ns, 2))?;
114
+ m.define_method("attribute_node_ns", method!(read::attribute_node_ns, 2))?;
113
115
  m.define_method("value", method!(read::value, 0))?;
114
116
  m.define_method("line", method!(read::line, 0))?;
115
117
 
@@ -158,7 +160,12 @@ fn init_mutate() -> Result<(), Error> {
158
160
  m.define_method("inner_html=", method!(mutate::set_inner_html, 1))?;
159
161
  m.define_method("outer_html=", method!(mutate::set_outer_html, 1))?;
160
162
 
161
- m.define_method("[]=", method!(mutate::aset, 2))?;
163
+ /* `[]=` is the DOM's setAttribute already (no-namespace, the whole name as
164
+ * the local name, lower-cased on an HTML element); the XML name for that
165
+ * is `set_loose_dom_attribute`, so it answers here too. */
166
+ for name in ["[]=", "set_loose_dom_attribute"] {
167
+ m.define_method(name, method!(mutate::aset, 2))?;
168
+ }
162
169
  m.define_method("set_attribute_ns", method!(mutate::set_attribute_ns, 3))?;
163
170
  m.define_method(
164
171
  "remove_attribute_ns",
@@ -75,8 +75,9 @@ fn extract<'q>(
75
75
  /// The receiver as an element, once every argument is converted. Its node type
76
76
  /// was checked before the conversion (an argument cannot change it), so the
77
77
  /// `None` arm is unreachable - it answers `refusal` rather than assuming so.
78
+ /// For the attribute mutators only: it leaves the tree version alone.
78
79
  fn element_of<'a>(edit: HtmlEdit<'a>, refusal: &'static str) -> Result<HtmlElementMut<'a>, Error> {
79
- edit.node()?
80
+ edit.node_for_attributes()?
80
81
  .element_mut()
81
82
  .ok_or_else(|| makiri_error(refusal))
82
83
  }
@@ -23,7 +23,7 @@ use crate::bridge::node_set::node_set_with_fill;
23
23
  use crate::bridge::ruby::is_kind_of;
24
24
  use crate::bridge::string::ruby_verified_text;
25
25
  use crate::init::{CLASS_NODE, CLASS_XML_DOCUMENT};
26
- use crate::lexbor::adapter::html::{HtmlNode, NodeType, RawNode};
26
+ use crate::lexbor::adapter::html::{HtmlAttr, HtmlElement, HtmlNode, NodeType, RawNode};
27
27
 
28
28
  /* ------------------------------------------------------------------ *
29
29
  * small helpers *
@@ -469,6 +469,63 @@ pub fn attribute_value_by_qualified_name(
469
469
  })
470
470
  }
471
471
 
472
+ /// The attribute of `el` in namespace `rb_ns` (nil or "" for none) with local
473
+ /// name `rb_local` - DOM "get an attribute by namespace and local name", the
474
+ /// key `remove_attribute_ns` removes by. The namespace compares exactly.
475
+ fn attr_by_ns_arg<'d>(
476
+ el: HtmlElement<'d>,
477
+ rb_ns: Value,
478
+ rb_local: Value,
479
+ ) -> Result<Option<HtmlAttr<'d>>, Error> {
480
+ let lv = ruby_verified_text(rb_local, "attribute local name")?;
481
+ let nv = crate::bridge::string::namespace_arg(rb_ns, "namespace")?;
482
+ /* Looked up, not interned: a namespace the document never interned is
483
+ * one no attribute here carries. */
484
+ let want_ns = match nv.as_ref() {
485
+ Some(uri) => match el.node().owner_document().lookup_ns(uri.as_bytes()) {
486
+ Some(id) => Some(id),
487
+ None => return Ok(None),
488
+ },
489
+ None => None,
490
+ };
491
+ Ok(el.attr_by_ns(want_ns, lv.as_bytes()))
492
+ }
493
+
494
+ /// `#attribute_value_ns(ns, local)` (DOM `getAttributeNS`): the value of the
495
+ /// attribute in namespace `ns` (nil or "" for none) with local name `local`,
496
+ /// or nil - for a non-element too.
497
+ pub fn attribute_value_ns(
498
+ _ruby: &Ruby,
499
+ this: super::HtmlSelf,
500
+ rb_ns: Value,
501
+ rb_local: Value,
502
+ ) -> Result<Option<Value>, Error> {
503
+ crate::bridge::ruby::entry(|| {
504
+ let Some(el) = this.node().element() else {
505
+ return Ok(None);
506
+ };
507
+ let found = attr_by_ns_arg(el, rb_ns, rb_local)?;
508
+ Ok(found.map(|at| dom_str(at.value())))
509
+ })
510
+ }
511
+
512
+ /// `#attribute_node_ns(ns, local)` (DOM `getAttributeNodeNS`): the Attr
513
+ /// [`attribute_value_ns`] reads, or nil.
514
+ pub fn attribute_node_ns(
515
+ _ruby: &Ruby,
516
+ this: super::HtmlSelf,
517
+ rb_ns: Value,
518
+ rb_local: Value,
519
+ ) -> Result<Option<Value>, Error> {
520
+ crate::bridge::ruby::entry(|| {
521
+ let Some(el) = this.node().element() else {
522
+ return Ok(None);
523
+ };
524
+ let found = attr_by_ns_arg(el, rb_ns, rb_local)?;
525
+ wrap_node(found.map(|at| at.node()), this.document)
526
+ })
527
+ }
528
+
472
529
  /// `attr.value`. For a non-attribute node this falls back to text content,
473
530
  /// matching the loose Nokogiri-ish meaning of `#value`.
474
531
  pub fn value(ruby: &Ruby, this: super::HtmlSelf) -> Result<Value, Error> {
@@ -40,3 +40,12 @@ pub fn node_pointer_id(ruby: &Ruby, rb_self: Value) -> Result<Integer, magnus::E
40
40
  pub fn node_hash(ruby: &Ruby, rb_self: Value) -> Result<Integer, magnus::Error> {
41
41
  crate::bridge::ruby::entry(|| node_pointer_id(ruby, rb_self))
42
42
  }
43
+
44
+ /// `Document#tree_version`: an Integer that grows with every edit that can
45
+ /// change a child list of a node the document owns (attached, detached or in
46
+ /// a fragment) - add, remove, replace, `inner_html=`, `content=` and the
47
+ /// like, and both documents of a move between them. Attribute edits leave it
48
+ /// alone. A reader caching a child list keys it by this.
49
+ pub fn document_tree_version(rb_self: Value) -> Result<u64, magnus::Error> {
50
+ crate::bridge::ruby::entry(|| crate::bridge::wrapper::tree_version(rb_self))
51
+ }
@@ -140,6 +140,10 @@ pub fn init_xml_doc() -> Result<(), Error> {
140
140
  doc.define_method("root", method!(doc_root, 0))?;
141
141
  doc.define_method("internal_subset", method!(doc_internal_subset, 0))?;
142
142
  doc.define_method("fragment", method!(doc_fragment, 1))?;
143
+ doc.define_method(
144
+ "tree_version",
145
+ method!(crate::glue::node::document_tree_version, 0),
146
+ )?;
143
147
 
144
148
  CLASS_XML_DOCUMENT_FRAGMENT
145
149
  .defined()?
@@ -83,6 +83,8 @@ fn init_read() -> Result<(), Error> {
83
83
  "attribute_by_qualified_name",
84
84
  method!(read::attribute_by_qualified_name, 1),
85
85
  )?;
86
+ m.define_method("attribute_value_ns", method!(read::attribute_value_ns, 2))?;
87
+ m.define_method("attribute_node_ns", method!(read::attribute_node_ns, 2))?;
86
88
 
87
89
  /* Node identity by the underlying pointer, so #path, NodeSet dedup, Set and
88
90
  * Hash all work - the same contract HTML nodes have, from the same code. */
@@ -65,7 +65,7 @@ pub fn aset(_ruby: &Ruby, this: XmlSelf, name: Value, val: Value) -> Result<Valu
65
65
  let nv = verified_name(name, "attribute name")?;
66
66
  let vv = verified_data(val, "attribute value")?;
67
67
  let (name, value) = (nv.as_bytes(), vv.as_bytes());
68
- xml_mut_result(edit.with_arena(|d, n| mutate::set_attribute(d, n, name, value))?)?;
68
+ xml_mut_result(edit.with_attributes(|d, n| mutate::set_attribute(d, n, name, value))?)?;
69
69
  Ok(val)
70
70
  })
71
71
  }
@@ -88,7 +88,9 @@ pub fn set_attribute_ns(
88
88
  qv.as_bytes(),
89
89
  vv.as_bytes(),
90
90
  );
91
- xml_mut_result(edit.with_arena(|d, n| mutate::set_attribute_ns(d, n, ns, qname, value))?)?;
91
+ xml_mut_result(
92
+ edit.with_attributes(|d, n| mutate::set_attribute_ns(d, n, ns, qname, value))?,
93
+ )?;
92
94
  Ok(val)
93
95
  })
94
96
  }
@@ -110,7 +112,7 @@ pub fn set_loose_dom_attribute(
110
112
  let vv = verified_data(val, "attribute value")?;
111
113
  let (name, value) = (nv.as_bytes(), vv.as_bytes());
112
114
  xml_mut_result(
113
- edit.with_arena(|d, n| mutate::set_loose_dom_attribute(d, n, name, value))?,
115
+ edit.with_attributes(|d, n| mutate::set_loose_dom_attribute(d, n, name, value))?,
114
116
  )?;
115
117
  Ok(val)
116
118
  })
@@ -133,7 +135,7 @@ pub fn remove_attribute_ns(
133
135
  let nv = namespace_arg(ns, "namespace")?;
134
136
  let ns = nv.as_ref().map_or(&b""[..], |n| n.as_bytes());
135
137
  let local = lv.as_bytes();
136
- edit.with_arena(|d, n| mutate::remove_attribute_ns(d, n, ns, local))?;
138
+ edit.with_attributes(|d, n| mutate::remove_attribute_ns(d, n, ns, local))?;
137
139
  Ok(rb_self)
138
140
  })
139
141
  }
@@ -148,7 +150,7 @@ pub fn delete(_ruby: &Ruby, this: XmlSelf, name: Value) -> Result<Value, Error>
148
150
  }
149
151
  let nv = verified_text(name, "attribute name")?;
150
152
  let name = nv.as_bytes();
151
- edit.with_arena(|d, n| mutate::remove_attribute(d, n, name))?;
153
+ edit.with_attributes(|d, n| mutate::remove_attribute(d, n, name))?;
152
154
  Ok(rb_self)
153
155
  })
154
156
  }
@@ -281,6 +281,34 @@ pub fn aref(ruby: &Ruby, this: XmlSelf, rb_name: Value) -> Result<Option<Value>,
281
281
  })
282
282
  }
283
283
 
284
+ /// `#attribute_value_ns(ns, local)` (DOM `getAttributeNS`): the value of the
285
+ /// attribute in namespace `ns` (nil or "" for none) with local name `local`,
286
+ /// or nil - for a non-element too.
287
+ pub fn attribute_value_ns(
288
+ ruby: &Ruby,
289
+ this: XmlSelf,
290
+ rb_ns: Value,
291
+ rb_local: Value,
292
+ ) -> Result<Option<Value>, Error> {
293
+ crate::bridge::ruby::entry(|| {
294
+ let found = crate::bridge::xml::find_attribute_ns(this, rb_ns, rb_local)?;
295
+ Ok(str_or_nil(ruby, found.map(|at| this.doc_ref().value(at))))
296
+ })
297
+ }
298
+
299
+ /// `#attribute_node_ns(ns, local)` (DOM `getAttributeNodeNS`): the Attr
300
+ /// [`attribute_value_ns`] reads, or nil.
301
+ pub fn attribute_node_ns(
302
+ this: XmlSelf,
303
+ rb_ns: Value,
304
+ rb_local: Value,
305
+ ) -> Result<Option<Value>, Error> {
306
+ crate::bridge::ruby::entry(|| {
307
+ let found = crate::bridge::xml::find_attribute_ns(this, rb_ns, rb_local)?;
308
+ wrap_rel(this, found)
309
+ })
310
+ }
311
+
284
312
  /// The Attr NODE with that qualified name.
285
313
  pub fn attribute_by_qualified_name(this: XmlSelf, rb_name: Value) -> Result<Option<Value>, Error> {
286
314
  crate::bridge::ruby::entry(|| {
@@ -209,21 +209,31 @@ impl<'doc> HtmlElement<'doc> {
209
209
  value: &[u8],
210
210
  lower: bool,
211
211
  ) -> Result<HtmlAttr<'doc>, AdapterOom> {
212
+ /* Interned as written first: Lexbor's set_name_ns below interns the
213
+ * URI too, case-folded, and its id is replaced by this one. */
214
+ let ns_id = match ns {
215
+ Some(uri) => Some(self.node().owner_document().intern_ns(uri).or_oom()?),
216
+ None => None,
217
+ };
212
218
  // SAFETY: a live element of a live document; every slice is read and
213
219
  // copied by Lexbor, and the attribute is one nothing else holds.
214
220
  unsafe {
215
221
  let at = lxb::lxb_dom_attr_interface_create(self.node().owner_document().as_raw());
216
222
  let at = HtmlAttr::link(at).or_oom()?;
217
- let named = match ns {
218
- Some(uri) => lxb::lxb_dom_attr_set_name_ns(
219
- at.raw(),
220
- uri.as_ptr(),
221
- uri.len(),
222
- qname.as_ptr(),
223
- qname.len(),
224
- false,
225
- ),
226
- None => {
223
+ let named = match (ns, ns_id) {
224
+ (Some(uri), Some(id)) => {
225
+ let st = lxb::lxb_dom_attr_set_name_ns(
226
+ at.raw(),
227
+ uri.as_ptr(),
228
+ uri.len(),
229
+ qname.as_ptr(),
230
+ qname.len(),
231
+ false,
232
+ );
233
+ (*at.raw()).node.ns = id.raw();
234
+ st
235
+ }
236
+ _ => {
227
237
  (*at.raw()).node.ns = (*self.raw()).node.ns;
228
238
  lxb::lxb_dom_attr_set_name(at.raw(), qname.as_ptr(), qname.len(), lower)
229
239
  }
@@ -431,6 +441,21 @@ unsafe fn repair_element(
431
441
  unsafe { de.link_attr(copy, None) };
432
442
  }
433
443
  }
444
+ /* Lexbor's copy re-interns a namespace past the built-in ones in this
445
+ * document case-folded (`lxb_dom_node_interface_copy`), so `fooNamespace`
446
+ * would arrive as `foonamespace`: such a copy gets its source's URI as
447
+ * written, element and attributes alike. An attribute with no namespace
448
+ * of its own carries its element's, and gets the same id the element
449
+ * does. */
450
+ if se.node().owner_document() != doc {
451
+ // SAFETY: the copy is unshared, per the contract.
452
+ unsafe {
453
+ restore_ns(doc, se.node(), de.node())?;
454
+ for (sa, da) in se.attrs().zip(de.attrs()) {
455
+ restore_ns(doc, sa.node(), da.node())?;
456
+ }
457
+ }
458
+ }
434
459
  /* The lists now match one for one, so each shortcut goes to the copy of
435
460
  * the attribute that holds it in the source - which the source keeps as
436
461
  * the DOM's ID / class attribute. */
@@ -450,3 +475,23 @@ unsafe fn repair_element(
450
475
  }
451
476
  Ok(())
452
477
  }
478
+
479
+ /// Give `dst`, a copy of `src` from another document, `src`'s namespace as
480
+ /// written - for one past the built-in ones, whose id Lexbor's copy re-interned
481
+ /// case-folded. A built-in id is the same number in every document.
482
+ ///
483
+ /// # Safety
484
+ /// `dst` is a node of a copy nothing but the caller holds, in `doc`.
485
+ unsafe fn restore_ns(
486
+ doc: HtmlDoc<'_>,
487
+ src: HtmlNode<'_>,
488
+ dst: HtmlNode<'_>,
489
+ ) -> Result<(), AdapterOom> {
490
+ if src.ns_id().is_none_or(|id| id.is_static()) {
491
+ return Ok(());
492
+ }
493
+ let id = doc.intern_ns(src.ns_uri().or_oom()?).or_oom()?;
494
+ // SAFETY: per the contract; `id` is interned in `doc`'s table.
495
+ unsafe { (*dst.as_raw()).ns = id.raw() };
496
+ Ok(())
497
+ }
@@ -47,6 +47,22 @@ impl<'doc> HtmlDoc<'doc> {
47
47
  if has_ascii_uppercase(local) && self.lookup_ns(ns) == Some(NsId::HTML) {
48
48
  return self.create_html_element_as_written(local, prefix);
49
49
  }
50
+ /* The namespace is interned here, as written, and only a built-in
51
+ * one is named to Lexbor - by its exact URI, which its case-folding
52
+ * lookup maps back to the same id. Any other is created in no
53
+ * namespace and given its id after: handed to Lexbor, `fooNamespace`
54
+ * would be interned lower-cased, and `HTTP://WWW.W3.ORG/1999/XHTML`
55
+ * would make an HTML element. In no namespace and in a namespace past
56
+ * the built-in ones, Lexbor builds the same plain element struct. */
57
+ let ns_id = if ns.is_empty() {
58
+ None
59
+ } else {
60
+ Some(self.intern_ns(ns)?)
61
+ };
62
+ let lexbor_ns: &[u8] = match ns_id {
63
+ Some(id) if id.is_static() => ns,
64
+ _ => &[],
65
+ };
50
66
  let or_null = |s: &[u8]| {
51
67
  if s.is_empty() {
52
68
  core::ptr::null()
@@ -66,8 +82,8 @@ impl<'doc> HtmlDoc<'doc> {
66
82
  self.as_raw(),
67
83
  local.as_ptr(),
68
84
  local.len(),
69
- or_null(ns),
70
- ns.len(),
85
+ or_null(lexbor_ns),
86
+ lexbor_ns.len(),
71
87
  core::ptr::null(),
72
88
  0,
73
89
  core::ptr::null(),
@@ -75,6 +91,11 @@ impl<'doc> HtmlDoc<'doc> {
75
91
  false,
76
92
  ))
77
93
  }?;
94
+ if let Some(id) = ns_id.filter(|id| !id.is_static()) {
95
+ // SAFETY: an element just made in this document, in no tree; `id`
96
+ // is interned in its namespace table.
97
+ unsafe { (*el.0.raw()).node.ns = id.raw() };
98
+ }
78
99
  let (p, p_len) = if prefix.is_empty() {
79
100
  if !has_ascii_uppercase(local) {
80
101
  return Some(el);
@@ -75,6 +75,20 @@ const fn nonzero(v: usize) -> NonZeroUsize {
75
75
  }
76
76
  }
77
77
 
78
+ /// The built-in namespace whose URI is exactly `uri`, as Lexbor spells it.
79
+ /// Byte for byte: Lexbor's own static lookup folds ASCII case.
80
+ fn static_ns(uri: &[u8]) -> Option<NsId> {
81
+ /* From HTML: `UNDEF` and `ANY` have no URI. */
82
+ (lxb::lxb_ns_id_enum_t_LXB_NS_HTML as usize..lxb::lxb_ns_id_enum_t_LXB_NS__LAST_ENTRY as usize)
83
+ .find_map(|id| {
84
+ let mut len = 0;
85
+ // SAFETY: a static id, which `lxb_ns_by_id` answers from Lexbor's
86
+ // static table without reading the (here null) document table.
87
+ let link = unsafe { seen(lxb::lxb_ns_by_id(core::ptr::null_mut(), id, &mut len), len) };
88
+ (link == uri).then(|| NsId::from_raw(id)).flatten()
89
+ })
90
+ }
91
+
78
92
  /// An interned namespace id of a document's namespace table - never
79
93
  /// `LXB_NS__UNDEF`, which reads as `None`.
80
94
  ///
@@ -589,19 +603,31 @@ impl<'doc> HtmlDoc<'doc> {
589
603
  /// interning it. `None` for an empty URI and for one never interned - no
590
604
  /// node or attribute can carry a namespace the table does not hold.
591
605
  ///
592
- /// Lexbor folds ASCII case in namespace URIs (it stores and looks them up
593
- /// lower-cased), so `URN:X` finds what `urn:x` interned.
606
+ /// Exact, as the DOM compares namespaces: `fooNamespace` is not
607
+ /// `foonamespace`, and `HTTP://WWW.W3.ORG/1999/XHTML` is not the HTML
608
+ /// namespace. Lexbor's own `lxb_ns_data_by_link` folds ASCII case in both,
609
+ /// so neither it nor `lxb_ns_append` is called with a URI from outside;
610
+ /// see [`intern_ns`](Self::intern_ns).
594
611
  pub fn lookup_ns(self, uri: &[u8]) -> Option<NsId> {
595
612
  if uri.is_empty() {
596
613
  return None;
597
614
  }
598
- // SAFETY: a live document; the table and the URI are only read.
615
+ if let Some(id) = static_ns(uri) {
616
+ return Some(id);
617
+ }
618
+ // SAFETY: a live document; the table and the URI are only read, and
619
+ // every entry of the namespace table is an `lxb_ns_data_t`.
599
620
  let id = unsafe {
600
621
  let table = (*self.as_raw()).ns;
601
622
  if table.is_null() {
602
623
  return None;
603
624
  }
604
- let d = lxb::lxb_ns_data_by_link(table, uri.as_ptr(), uri.len());
625
+ let d = lxb::lexbor_hash_search(
626
+ table,
627
+ lxb::lexbor_hash_search_raw,
628
+ uri.as_ptr(),
629
+ uri.len(),
630
+ ) as *const lxb::lxb_ns_data_t;
605
631
  if d.is_null() {
606
632
  return None;
607
633
  }
@@ -610,24 +636,42 @@ impl<'doc> HtmlDoc<'doc> {
610
636
  NsId::from_raw(id)
611
637
  }
612
638
 
613
- /// The id of `uri` in this document's namespace table, interning it if
614
- /// the table does not hold it yet. `None` for an empty URI or when Lexbor
615
- /// could not intern it. Case is folded as in [`lookup_ns`](Self::lookup_ns).
639
+ /// The id of `uri` in this document's namespace table, interning it AS
640
+ /// WRITTEN if the table does not hold it yet. `None` for an empty URI or
641
+ /// when Lexbor could not intern it.
642
+ ///
643
+ /// A built-in namespace is its static id only for its exact URI; any other
644
+ /// URI gets an entry of its own, keyed case-sensitively - what
645
+ /// `lxb_ns_append` makes, but without its case folding. A URI equal to one
646
+ /// Lexbor interned lower-cased (all its entries are) finds that entry, so
647
+ /// the two kinds share one table without colliding.
616
648
  pub fn intern_ns(self, uri: &[u8]) -> Option<NsId> {
649
+ if let Some(id) = self.lookup_ns(uri) {
650
+ return Some(id);
651
+ }
617
652
  if uri.is_empty() {
618
653
  return None;
619
654
  }
620
655
  // SAFETY: a live document; Lexbor copies the URI into its own table,
621
- // which keeps the entry for the document's lifetime.
656
+ // which keeps the entry for the document's lifetime, and the table was
657
+ // made with `lxb_ns_data_t` entries (`lxb_dom_document_init`).
622
658
  let id = unsafe {
623
659
  let table = (*self.as_raw()).ns;
624
660
  if table.is_null() {
625
661
  return None;
626
662
  }
627
- let d = lxb::lxb_ns_append(table, uri.as_ptr(), uri.len());
628
- if d.is_null() {
663
+ let d = lxb::lexbor_hash_insert(
664
+ table,
665
+ lxb::lexbor_hash_insert_raw,
666
+ uri.as_ptr(),
667
+ uri.len(),
668
+ ) as *mut lxb::lxb_ns_data_t;
669
+ /* The same guard `lxb_ns_append` applies: an id must lie past the
670
+ * static range, which a real allocation always does. */
671
+ if (d as usize) <= lxb::lxb_ns_id_enum_t_LXB_NS__LAST_ENTRY as usize {
629
672
  return None;
630
673
  }
674
+ (*d).ns_id = d as usize;
631
675
  (*d).ns_id
632
676
  };
633
677
  NsId::from_raw(id)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Makiri
4
- VERSION = "0.12.1"
4
+ VERSION = "0.13.0"
5
5
  end
@@ -55,9 +55,9 @@ UNSAFE_ISLANDS = {
55
55
  "lexbor/abi.rs" => 4,
56
56
  "lexbor/adapter/arena_bytes.rs" => 14,
57
57
  "lexbor/adapter/cross_import.rs" => 2,
58
- "lexbor/adapter/html/attrs.rs" => 20,
59
- "lexbor/adapter/html/build.rs" => 24,
60
- "lexbor/adapter/html/mod.rs" => 62,
58
+ "lexbor/adapter/html/attrs.rs" => 23,
59
+ "lexbor/adapter/html/build.rs" => 25,
60
+ "lexbor/adapter/html/mod.rs" => 63,
61
61
  "lexbor/adapter/html/mutate.rs" => 9,
62
62
  "lexbor/adapter/html/serialize.rs" => 18,
63
63
  "lexbor/adapter/post_parse.rs" => 13,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: makiri
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim