makiri 0.15.0 → 0.16.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: 383a2feea9ae25aca8e66985862eb3dfbbb64682771fc4578d9f0f6c11100d43
4
- data.tar.gz: 774f2089e9b581b757cf0e142e937f892a87ccd71bb7c60f74666d9007bcac5c
3
+ metadata.gz: 80c74b7d335f5f5f93f8476a8f99a8918560df8cf75f43a5a71d9b132e892873
4
+ data.tar.gz: d6593459917f22ceb301093d17b981a597d8be7d975d1fab1e4142f6cf17fcbc
5
5
  SHA512:
6
- metadata.gz: 575cdf673cf1c0f2f76bc641c978a5c946e99a19ebc9bd92d761aa23ac9e7d9a72d980120c22b7bc344a95d0236bcfff6887fc09e28836f64739ca87a18c7e8d
7
- data.tar.gz: 8b5f74af6c317ce0928c2b700d4061828621c4115685b80c3b49c47bf65f521f46431e4cf22ce064eadf0c30dc2f68611f84b3e4e4ead2b162ef5369c5544b7a
6
+ metadata.gz: efa98c3ca322d6a3ee141428c1f5e9c3e5b46fba9dde4d7ac75d7dcfdf27d4dd083ef46eed5fb8db21685396d86caeb58e1755f9dd5014d396674cc149378df9
7
+ data.tar.gz: 836d97359577dfcd4bd60a8de0dfcf355c596b95fa7fffd5e56a0a355bb4267f3141bf8786f4f905596d79bef44721eeb3bfe4f25fa97e489c5aa3319c9f7655
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.0] - 2026-10-10
4
+
5
+ ### Added
6
+
7
+ * `Makiri::NATIVE_VERSION` and `Makiri::NATIVE_RUBY_API_VERSION`: the gem
8
+ version and Ruby `MAJOR.MINOR` the native extension was built as.
9
+ `require "makiri"` raises `LoadError` for an extension built for another
10
+ Ruby (refused by the extension itself, before it defines anything) or as
11
+ another makiri version (e.g. a stale `lib/makiri/makiri.bundle` in a
12
+ checkout).
13
+ * `Node#child_count`, `#child_at(i)`, `#element_child_count` and
14
+ `#element_child_at(i)` (HTML and XML): count and index a child list without
15
+ building `#children`. A loop over the indexes in order costs O(1) a step,
16
+ also while other lists are read in between. Any child-list edit in the
17
+ document resets that, so a loop that edits as it indexes walks from the
18
+ nearer end after each edit. `child_at` returns nil for a negative index or
19
+ one past the end.
20
+ * `Node#first_child` (an alias of `#child`) and `#last_child`.
21
+ * `Node#root_node`, DOM `getRootNode()`: the Document, a DocumentFragment, or
22
+ the top of a detached subtree. An Attr is its own root.
23
+
24
+ ### Changed
25
+
26
+ * `#local_name`, `#prefix`, `#namespace_uri` and `#tag_name` return frozen,
27
+ interned Strings: every read of one name returns the same object. `#name`
28
+ still returns a new, mutable String.
29
+
3
30
  ## [0.15.0] - 2026-10-09
4
31
 
5
32
  ### Added
@@ -41,6 +41,10 @@ fn main() {
41
41
  println!("cargo:rerun-if-changed=build.rs");
42
42
  println!("cargo:rerun-if-env-changed=MAKIRI_LEXBOR_INCLUDE");
43
43
 
44
+ if std::env::var_os("CARGO_FEATURE_RUBY").is_some() {
45
+ gem_version();
46
+ }
47
+
44
48
  // Only a build that reads Lexbor's layout pays for this. Kani builds with
45
49
  // `--no-default-features` and has no Lexbor headers to point at, which is
46
50
  // the whole reason `rake kani` needs no `rake compile` first.
@@ -472,3 +476,36 @@ fn definition(text: &str, name: &str) -> Option<(String, String)> {
472
476
  }
473
477
  None
474
478
  }
479
+
480
+ /// Embed the gem's version (`lib/makiri/version.rb`, the one place it is
481
+ /// written) as `MAKIRI_GEM_VERSION`, which `init` publishes as
482
+ /// `Makiri::NATIVE_VERSION` for `lib/makiri.rb` to check the loaded binary
483
+ /// against. Read from the source rather than passed in by extconf, so every
484
+ /// cargo build of the extension carries it and none can carry a stale one.
485
+ fn gem_version() {
486
+ let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
487
+ let path = manifest.join("../../../lib/makiri/version.rb");
488
+ println!("cargo:rerun-if-changed={}", path.display());
489
+ let source = std::fs::read_to_string(&path)
490
+ .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
491
+ /* `VERSION = "x"` or `VERSION = 'x'`, a trailing `.freeze` or comment
492
+ * allowed: the literal is what is between the first quote and its match. */
493
+ let version = source
494
+ .lines()
495
+ .find_map(|line| {
496
+ let rest = line.trim().strip_prefix("VERSION")?.trim_start();
497
+ let rest = rest.strip_prefix('=')?.trim_start();
498
+ let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?;
499
+ let body = &rest[1..];
500
+ body.find(quote).map(|end| &body[..end])
501
+ })
502
+ .filter(|v| !v.is_empty())
503
+ .unwrap_or_else(|| {
504
+ panic!(
505
+ "build.rs reads the gem version from a `VERSION = \"x.y.z\"` line in {}, \
506
+ and found none",
507
+ path.display()
508
+ )
509
+ });
510
+ println!("cargo:rustc-env=MAKIRI_GEM_VERSION={version}");
511
+ }
@@ -17,12 +17,14 @@ use crate::init::{
17
17
  CLASS_HTML_PROCESSING_INSTRUCTION, CLASS_HTML_TEXT, CLASS_XML_DOCUMENT,
18
18
  };
19
19
  use crate::lexbor::adapter::html::{
20
- ForeignNode, HtmlNode, HtmlNodeKey, HtmlNodeMut, Insertion, NodeType, Place, RawDoc, RawNode,
20
+ ForeignNode, HtmlNode, HtmlNodeKey, HtmlNodeMut, HtmlTree, Insertion, NodeType, Place, RawDoc,
21
+ RawNode,
21
22
  };
22
23
  use crate::lexbor::fragment::import_with_fixup;
23
24
 
24
25
  use crate::bridge::string::{RubyData, RubyText};
25
26
  use crate::bridge::wrapper::*;
27
+ use crate::child_index::{ChildList, Children, TokenTree};
26
28
  use crate::lexbor::adapter::html::{HtmlDoc, HtmlElementMut};
27
29
  use crate::lexbor::adapter::AdapterOom;
28
30
 
@@ -241,6 +243,50 @@ pub fn wrap_node(node: Option<HtmlNode<'_>>, document: Value) -> Result<Option<V
241
243
  .transpose()
242
244
  }
243
245
 
246
+ /* ------------------------------------------------------------------ *
247
+ * indexing a child list *
248
+ * ------------------------------------------------------------------ */
249
+
250
+ /// The Lexbor tree for [`crate::child_index`]: a node's pointer is its token.
251
+ /// The links are [`HtmlTree`]'s, the raw ones `#children` follows, so the two
252
+ /// agree - a `<template>`'s own (empty) child list included.
253
+ impl<'d> TokenTree for HtmlTree<'d> {
254
+ fn token(&self, n: HtmlNode<'d>) -> usize {
255
+ RawNode::from(n).as_ptr() as usize
256
+ }
257
+ fn node_of(&self, token: usize) -> Option<HtmlNode<'d>> {
258
+ let raw = RawNode::from_ptr(token as *mut core::ffi::c_void)?;
259
+ // SAFETY: `child_index` asks only for a token it recorded for a list
260
+ // of this document, so by `ChildPositionMemo`'s invariant it names a
261
+ // node that is still a child there - in the document the receiver
262
+ // keeps alive for `'d`. Lexbor detaches rather than frees, so it was
263
+ // never released.
264
+ Some(unsafe { raw.as_node() })
265
+ }
266
+ }
267
+
268
+ /// The tree every HTML receiver lends its child lists, as `&'static`: a
269
+ /// `const` item is what may be borrowed for that long (the type holds
270
+ /// nothing).
271
+ const HTML_TREE: HtmlTree<'static> = HtmlTree::new();
272
+
273
+ impl ChildListHost for HtmlSelf {
274
+ type Tree<'a> = HtmlTree<'a>;
275
+ fn document(&self) -> Value {
276
+ self.document
277
+ }
278
+ fn children(&self, list: ChildList) -> Children<'_, HtmlTree<'_>> {
279
+ Children {
280
+ tree: &HTML_TREE,
281
+ parent: self.node(),
282
+ list,
283
+ }
284
+ }
285
+ fn wrap_child(&self, n: Option<HtmlNode<'_>>) -> Result<Option<Value>, Error> {
286
+ wrap_node(n, self.document)
287
+ }
288
+ }
289
+
244
290
  /* ------------------------------------------------------------------ *
245
291
  * resolving a long-lived key *
246
292
  * ------------------------------------------------------------------ */
@@ -20,6 +20,47 @@ pub use rb_sys::{ID, VALUE};
20
20
 
21
21
  /* The two conveniences every bridge and glue module shares. */
22
22
 
23
+ /// The `MAJOR.MINOR` of the Ruby whose headers this extension was compiled
24
+ /// against - its ABI, since a teeny release keeps it. Published as
25
+ /// `Makiri::NATIVE_RUBY_API_VERSION`.
26
+ pub fn ruby_api_version() -> String {
27
+ format!(
28
+ "{}.{}",
29
+ rb_sys::RUBY_API_VERSION_MAJOR,
30
+ rb_sys::RUBY_API_VERSION_MINOR
31
+ )
32
+ }
33
+
34
+ /// `Err(LoadError)` when the running Ruby's API `MAJOR.MINOR` is not the one
35
+ /// this extension was compiled for.
36
+ ///
37
+ /// The FIRST thing `Init_makiri` does. A release Ruby does not check
38
+ /// `ruby_abi_version` itself, and under macOS's `-undefined dynamic_lookup`
39
+ /// a binary built for another Ruby loads and then misreads the interpreter's
40
+ /// structs - defining one class could already corrupt it, so the check
41
+ /// cannot wait for `lib/makiri.rb`. It reads the running Ruby's exported
42
+ /// `ruby_api_version` (three ints, whose layout no Ruby changes) and nothing
43
+ /// else; only a mismatch goes on to build the exception.
44
+ pub fn check_running_ruby_api(ruby: &Ruby) -> Result<(), Error> {
45
+ // SAFETY: an immutable `const int[3]` libruby exports, read by value.
46
+ let running = unsafe { (rb_sys::ruby_api_version[0], rb_sys::ruby_api_version[1]) };
47
+ let built = (
48
+ rb_sys::RUBY_API_VERSION_MAJOR as core::ffi::c_int,
49
+ rb_sys::RUBY_API_VERSION_MINOR as core::ffi::c_int,
50
+ );
51
+ if running == built {
52
+ return Ok(());
53
+ }
54
+ Err(Error::new(
55
+ ruby.exception_load_error(),
56
+ format!(
57
+ "makiri's native extension was built for Ruby {}.{}, but this is Ruby {}.{}; \
58
+ rebuild it (`bundle exec rake clean compile` in a checkout, or reinstall the gem)",
59
+ built.0, built.1, running.0, running.1
60
+ ),
61
+ ))
62
+ }
63
+
23
64
  /// `Makiri::Error` - the one definition; the modules that each kept a private
24
65
  /// copy of this now import it.
25
66
  pub fn error_class() -> magnus::ExceptionClass {
@@ -411,6 +411,30 @@ pub unsafe fn ruby_str_from_utf8(bytes: &[u8]) -> VALUE {
411
411
  rb_sys::rb_utf8_str_new(bytes.as_ptr() as *const c_char, bytes.len() as c_long)
412
412
  }
413
413
 
414
+ /// A DOM name as a Ruby String: frozen and deduplicated, tagged UTF-8 -
415
+ /// Ruby's own `-"div"`, so every read of one name answers the same object and
416
+ /// allocates nothing once Ruby has it.
417
+ ///
418
+ /// THE contract for the name readers of both representations: `local_name`,
419
+ /// `prefix`, `namespace_uri` and `tag_name` answer through this, and only
420
+ /// `#name` (Nokogiri's, which callers may edit) answers a fresh String.
421
+ /// Character data - text, attribute values - is never interned.
422
+ ///
423
+ /// Safe to call with any bytes: `rb_enc_interned_str` copies them, and bytes
424
+ /// that were not UTF-8 would make a wrong String, never a memory error. Every
425
+ /// caller passes a document's name, which is UTF-8 by the text-input contract.
426
+ pub fn dom_name_str(bytes: &[u8]) -> Value {
427
+ // SAFETY: a slice's pointer and length, read for the call; the encoding
428
+ // is Ruby's static UTF-8 one. The result is a live String.
429
+ unsafe {
430
+ crate::bridge::ruby::value(rb_sys::rb_enc_interned_str(
431
+ bytes.as_ptr() as *const c_char,
432
+ bytes.len() as c_long,
433
+ rb_sys::rb_utf8_encoding(),
434
+ ))
435
+ }
436
+ }
437
+
414
438
  /* ---- the strict text contract ---- */
415
439
 
416
440
  /// Check `bytes` against the strict contract, returning the specific
@@ -18,6 +18,8 @@ use magnus::{Error, Value};
18
18
 
19
19
  use crate::bridge::ruby::{value, VALUE};
20
20
  use crate::bridge::typed::{Hooks, Marker, Relocator, TypedType};
21
+ use crate::child_index::{ChildList, ChildPositionMemo, Children, TokenTree};
22
+ use crate::dom_rules::Tree;
21
23
  use crate::falloc::{MapInsert, Reserve};
22
24
  use crate::init::{RbConst, CLASS_DOCUMENT};
23
25
  use crate::lexbor::adapter::html::{HtmlDoc, HtmlNodeKey, RawDoc, RawNode};
@@ -387,6 +389,9 @@ pub struct DocData {
387
389
  /// Boxed and optional so a document nobody navigates never allocates a
388
390
  /// cache, and its wrapper stays one pointer wide.
389
391
  nodes: Option<Box<NodeCache>>,
392
+ /// Where `child_count` / `child_at` last were ([`ChildPositionMemo`]).
393
+ /// Allocates nothing until the first of them records.
394
+ child_memo: ChildPositionMemo,
390
395
  }
391
396
 
392
397
  impl DocData {
@@ -482,6 +487,7 @@ impl Hooks for DocData {
482
487
  core::mem::size_of::<DocData>()
483
488
  .saturating_add(self.external_bytes())
484
489
  .saturating_add(self.nodes.as_ref().map_or(0, |c| c.memsize()))
490
+ .saturating_add(self.child_memo.memsize())
485
491
  }
486
492
  }
487
493
 
@@ -642,6 +648,7 @@ impl DocumentShell {
642
648
  errors: QFALSE,
643
649
  report: ExternalReport::default(),
644
650
  nodes: None,
651
+ child_memo: ChildPositionMemo::default(),
645
652
  },
646
653
  |d| d.errors = errors.as_raw(),
647
654
  )
@@ -1028,6 +1035,52 @@ pub fn attribute_version(rb_doc: Value) -> Result<u64, Error> {
1028
1035
  Ok(DOC_TYPE.get(&rb_doc)?.edits.attribute_version)
1029
1036
  }
1030
1037
 
1038
+ /* ---- the child-position memo ---- */
1039
+
1040
+ /// Run `f` over the document's [`ChildPositionMemo`] and its current
1041
+ /// `tree_version`, which every use of the memo must pass it - the memo keeps
1042
+ /// its own invariant from that (`crate::child_index`).
1043
+ ///
1044
+ /// `f` runs inside the document's borrow, so it must not run Ruby; a walk of
1045
+ /// the child links does not.
1046
+ fn with_child_memo<R>(rb_doc: Value, f: impl FnOnce(&mut ChildPositionMemo, u64) -> R) -> R {
1047
+ with_doc_data_known(rb_doc, |d| f(&mut d.child_memo, d.edits.tree_version))
1048
+ }
1049
+
1050
+ /// A receiver whose child lists `#child_count` / `#child_at` and their
1051
+ /// element twins read - an HTML or XML node or Document. A representation
1052
+ /// says which tree and node it is and how a child is wrapped; counting and
1053
+ /// indexing are written once, here, and the four Ruby methods once, over
1054
+ /// this, in `glue::node`.
1055
+ pub trait ChildListHost: magnus::TryConvert {
1056
+ type Tree<'a>: TokenTree
1057
+ where
1058
+ Self: 'a;
1059
+
1060
+ /// The Document whose memo the lists use.
1061
+ fn document(&self) -> Value;
1062
+ /// The receiver's `list`.
1063
+ fn children(&self, list: ChildList) -> Children<'_, Self::Tree<'_>>;
1064
+ /// A child of the receiver as its Ruby wrapper; None is nil.
1065
+ fn wrap_child(&self, n: Option<<Self::Tree<'_> as Tree>::Node>)
1066
+ -> Result<Option<Value>, Error>;
1067
+
1068
+ /// How many children are in `list`.
1069
+ fn child_count(&self, list: ChildList) -> usize {
1070
+ with_child_memo(self.document(), |memo, version| {
1071
+ self.children(list).count(memo, version)
1072
+ })
1073
+ }
1074
+
1075
+ /// The wrapped child at `index` in `list`, or nil past the end.
1076
+ fn child_at(&self, list: ChildList, index: usize) -> Result<Option<Value>, Error> {
1077
+ let found = with_child_memo(self.document(), |memo, version| {
1078
+ self.children(list).at(memo, version, index)
1079
+ });
1080
+ self.wrap_child(found)
1081
+ }
1082
+ }
1083
+
1031
1084
  /* ---- the document's mutation gate ---- */
1032
1085
 
1033
1086
  /// `Err(Makiri::Error)` while an evaluation with a handler is reading `rb_doc`.
@@ -29,6 +29,7 @@ use crate::bridge::wrapper::{
29
29
  ensure_document_mutable, node_repr, DocKind, DocumentShell, NodeRepr,
30
30
  };
31
31
  use crate::bridge::xml_decode::xml_decode_input_value;
32
+ use crate::child_index::{ChildList, Children, TokenTree};
32
33
  use crate::init::{CLASS_NODE, CLASS_XML_DOCUMENT, EXC_XML_LIMIT_EXCEEDED, EXC_XML_SYNTAX_ERROR};
33
34
  use crate::init::{
34
35
  CLASS_XML_ATTR, CLASS_XML_CDATA_SECTION, CLASS_XML_COMMENT, CLASS_XML_DOCUMENT_FRAGMENT,
@@ -86,6 +87,35 @@ impl crate::bridge::wrapper::NodeHandleSource for NodeId {
86
87
  }
87
88
  }
88
89
 
90
+ /// The arena for [`crate::child_index`]: a node's token is its `NodeId`'s,
91
+ /// which the arena never reuses. The links are the arena's `Tree` ones,
92
+ /// which `#children` follows (attributes are not on them).
93
+ impl TokenTree for XmlDoc {
94
+ fn token(&self, n: NodeId) -> usize {
95
+ n.to_token()
96
+ }
97
+ fn node_of(&self, token: usize) -> Option<NodeId> {
98
+ NodeId::from_token(token)
99
+ }
100
+ }
101
+
102
+ impl ChildListHost for XmlSelf {
103
+ type Tree<'a> = XmlDoc;
104
+ fn document(&self) -> Value {
105
+ self.document
106
+ }
107
+ fn children(&self, list: ChildList) -> Children<'_, XmlDoc> {
108
+ Children {
109
+ tree: self.doc_ref(),
110
+ parent: self.id,
111
+ list,
112
+ }
113
+ }
114
+ fn wrap_child(&self, n: Option<NodeId>) -> Result<Option<Value>, Error> {
115
+ n.map(|id| wrap_xml_node(id, self.document)).transpose()
116
+ }
117
+ }
118
+
89
119
  /// The arena node behind a wrapper.
90
120
  ///
91
121
  /// An XML Document resolves to its arena's DOCUMENT node. Anything else goes