makiri 0.10.0.rc2 → 0.10.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/NOKOGIRI_DIFFERENCES.md +7 -0
  4. data/Rakefile +14 -0
  5. data/ext/makiri/rust/clippy.toml +18 -10
  6. data/ext/makiri/rust/fuzz/fuzz_targets/common.rs +1 -1
  7. data/ext/makiri/rust/src/bridge/html.rs +23 -2
  8. data/ext/makiri/rust/src/bridge/node_set.rs +2 -2
  9. data/ext/makiri/rust/src/bridge/typed.rs +47 -2
  10. data/ext/makiri/rust/src/bridge/wrapper.rs +147 -4
  11. data/ext/makiri/rust/src/bridge/xml.rs +113 -25
  12. data/ext/makiri/rust/src/bridge/xml_decode.rs +21 -7
  13. data/ext/makiri/rust/src/bridge/xpath/context_object.rs +2 -2
  14. data/ext/makiri/rust/src/css/build.rs +1 -1
  15. data/ext/makiri/rust/src/falloc/mod.rs +25 -25
  16. data/ext/makiri/rust/src/glue/xml_node/mutate.rs +53 -52
  17. data/ext/makiri/rust/src/glue/xml_node/ns.rs +1 -1
  18. data/ext/makiri/rust/src/glue/xml_node/read.rs +1 -1
  19. data/ext/makiri/rust/src/glue/xml_node/serialize.rs +3 -0
  20. data/ext/makiri/rust/src/glue/xml_node/strings.rs +1 -1
  21. data/ext/makiri/rust/src/lexbor/adapter/cross_import.rs +2 -7
  22. data/ext/makiri/rust/src/lexbor/adapter/source_loc.rs +5 -1
  23. data/ext/makiri/rust/src/lexbor/adapter/text_index.rs +3 -3
  24. data/ext/makiri/rust/src/lexbor/contains_guard.rs +299 -0
  25. data/ext/makiri/rust/src/lexbor/css_engine.rs +28 -2
  26. data/ext/makiri/rust/src/lexbor/fragment.rs +2 -2
  27. data/ext/makiri/rust/src/lexbor/mod.rs +6 -0
  28. data/ext/makiri/rust/src/lexbor/selectors.rs +11 -4
  29. data/ext/makiri/rust/src/lexbor/stylesheet.rs +48 -7
  30. data/ext/makiri/rust/src/lexbor/tests.rs +233 -0
  31. data/ext/makiri/rust/src/lib.rs +1 -1
  32. data/ext/makiri/rust/src/rust_tests.rs +5 -45
  33. data/ext/makiri/rust/src/xml/arena.rs +183 -99
  34. data/ext/makiri/rust/src/xml/chars/expand.rs +159 -0
  35. data/ext/makiri/rust/src/xml/chars/mod.rs +181 -0
  36. data/ext/makiri/rust/src/xml/dom_name.rs +84 -0
  37. data/ext/makiri/rust/src/xml/encoding_sniff.rs +191 -114
  38. data/ext/makiri/rust/src/xml/index.rs +90 -67
  39. data/ext/makiri/rust/src/xml/mod.rs +1 -2
  40. data/ext/makiri/rust/src/xml/model.rs +10 -13
  41. data/ext/makiri/rust/src/xml/mutate/attr.rs +154 -0
  42. data/ext/makiri/rust/src/xml/mutate/copy.rs +206 -0
  43. data/ext/makiri/rust/src/xml/mutate/edit.rs +100 -0
  44. data/ext/makiri/rust/src/xml/mutate/factory.rs +138 -0
  45. data/ext/makiri/rust/src/xml/mutate/insert.rs +488 -0
  46. data/ext/makiri/rust/src/xml/mutate/mod.rs +66 -0
  47. data/ext/makiri/rust/src/xml/mutate/ns.rs +174 -0
  48. data/ext/makiri/rust/src/xml/qname.rs +50 -121
  49. data/ext/makiri/rust/src/xml/selftest.rs +881 -898
  50. data/ext/makiri/rust/src/xml/serialize/c14n.rs +243 -0
  51. data/ext/makiri/rust/src/xml/serialize/mod.rs +110 -0
  52. data/ext/makiri/rust/src/xml/serialize/out.rs +89 -0
  53. data/ext/makiri/rust/src/xml/serialize/xml.rs +511 -0
  54. data/ext/makiri/rust/src/xml/tree/cursor.rs +367 -0
  55. data/ext/makiri/rust/src/xml/tree/decl.rs +102 -0
  56. data/ext/makiri/rust/src/xml/tree/dtd.rs +442 -0
  57. data/ext/makiri/rust/src/xml/tree/mod.rs +710 -0
  58. data/ext/makiri/rust/src/xml/tree/scope.rs +93 -0
  59. data/ext/makiri/rust/src/xml/xpath.rs +5 -3
  60. data/ext/makiri/rust/src/xpath/ctx.rs +2 -2
  61. data/ext/makiri/rust/src/xpath/eval.rs +2 -2
  62. data/ext/makiri/rust/src/xpath/funcs/mod.rs +1 -1
  63. data/ext/makiri/rust/src/xpath/parse.rs +3 -3
  64. data/ext/makiri/rust/src/xpath/str_cache.rs +1 -1
  65. data/ext/makiri/rust/src/xpath/tests.rs +1 -1
  66. data/ext/makiri/rust/src/xpath/value.rs +1 -1
  67. data/lib/makiri/version.rb +1 -1
  68. data/script/check_unsafe_boundaries.rb +40 -7
  69. metadata +22 -7
  70. data/ext/makiri/rust/src/xml/api.rs +0 -18
  71. data/ext/makiri/rust/src/xml/chars.rs +0 -287
  72. data/ext/makiri/rust/src/xml/mutate.rs +0 -1159
  73. data/ext/makiri/rust/src/xml/parse.rs +0 -46
  74. data/ext/makiri/rust/src/xml/serialize.rs +0 -729
  75. data/ext/makiri/rust/src/xml/tree.rs +0 -1467
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7dc5843dd369edd0bcae884c8b4f23dbdb854de335b51c99e1474858c9c49f6
4
- data.tar.gz: ba1aeb6c3363450fc0212f5ce814c971b0e2eaf8dfd3f927703cfa2a667fdc35
3
+ metadata.gz: b6163794fb07f39d4476e91a5d9db367d82b02888e167bc41435bb12de32de49
4
+ data.tar.gz: 4c075f42cbdf69b39b9783e3ad66bd09acb48f380447e8e80a47402dcdd427e5
5
5
  SHA512:
6
- metadata.gz: f1699b0cdd955116e52a2b575abfa5dbfbbdaecd43d3217844020828d18af341505e2df25539a8c5086ad619c85fe099f679054a1cf562067d6ec159977aee73
7
- data.tar.gz: 666692b6b9597640c6092a8407594a380fa6dfd6682844c1fecc4ce85ea5381e5dc49877210e3684ac3a10ab09f9b30a49cef4939b7f113fdc8d6e359b608156
6
+ metadata.gz: d6f1d154116c49d5b42e57e08d584781af42c2d0a02a6d3a1d6073659437cd7b0920a30a42451fedc70b173de242c9738a34ea4fd8d582dd3d199094bbfe10ab
7
+ data.tar.gz: 7278778cf2e98166e03ef79364ef9a4b0feec3b410949c2e4889f82f899594da8ba492ac00a6d75de2ee7a553430e5dc69a9af52a41292ab86249af830dea7fe
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-09-22
4
+
5
+ ### Fixed
6
+
7
+ * A frozen node now raises `FrozenError` when it is the ARGUMENT of a tree
8
+ mutation, not only the receiver: `b.add_child(a)` relinks `a` exactly as
9
+ `a.remove` does. Both `Makiri::XML` and `Makiri::HTML`. A fragment argument
10
+ splices its children, which have no wrapper of their own to check.
11
+ * A `Makiri::XML` mutation that exceeds the document's own `max_bytes` /
12
+ `max_nodes` now raises `Makiri::XML::LimitExceeded` instead of reporting the
13
+ refusal as out of memory.
14
+ * Inserting a `DocumentFragment` is all or nothing on `add_child`, `before` and
15
+ `after`, as it already was on `replace`: a child the rules refuse no longer
16
+ leaves the earlier ones linked in a document the caller was told had not
17
+ changed.
18
+ * A rejected `Makiri::XML::Document#fragment` no longer charges the document for
19
+ the nodes it discarded. 100k rejected fragments grew a `<r/>` document to
20
+ 77 MB; it is now 516 bytes.
21
+ * A failed `Makiri::XML` parse reports the FIRST failure for all four kinds
22
+ (`syntax` was sticky while `limit` and `unsupported` overwrote each other).
23
+ * `Makiri::XML#to_xml`'s serializer allocates fallibly again, so running out of
24
+ memory raises instead of aborting the process.
25
+ * `:lexbor-contains()` now rejects an argument the bundled CSS parser does not
26
+ take, the way any unknown pseudo-class is rejected: `Makiri::CSS::SyntaxError`
27
+ from `#css` / `#at_css` / `#matches?`, and a `:bad_style` rule from
28
+ `Makiri::Lexbor::CSS.parse_stylesheet`. Well-formed uses are unchanged.
29
+
30
+ ### Performance
31
+
32
+ * `Makiri::XML#to_xml` plans namespaces from a binding stack instead of
33
+ re-walking each ancestor's attribute list, which cost O(depth^2 x attributes).
34
+ 403 KB of nested prefixed attributes took 4.88s and now takes 0.001s. A
35
+ crafted document fails closed with `Makiri::Error` ("namespace planning
36
+ exceeded its step budget") rather than running on.
37
+ * Setting an attribute on a `Makiri::XML` element walks the attribute list once
38
+ instead of twice (4096 attributes: 47ms -> 27ms).
39
+
3
40
  ## [0.10.0.rc2] - 2026-09-20
4
41
 
5
42
  ### Added
@@ -61,6 +61,13 @@ what browsers do - rather than libxml2. Detailed, test-backed notes live in
61
61
  * `create_processing_instruction("a:b", ...)` succeeds, as DOM
62
62
  `createProcessingInstruction` does, but `#to_xml` / `#canonicalize` then raise,
63
63
  as DOM Parsing's well-formed serializer does. Nokogiri writes `<?a:b ...?>`.
64
+ * `#freeze` on a node is ENFORCED: a frozen node's mutators raise `FrozenError`,
65
+ and so does passing a frozen node as the argument of an insertion, which
66
+ relinks it. Nokogiri reports `frozen?` but every mutator still mutates. The
67
+ check reaches the nodes the caller named; a fragment argument splices its
68
+ children, and those cannot be checked, because frozen-ness is a property of a
69
+ Ruby object and the arena keeps no map from a node back to its wrapper.
70
+
64
71
  * A node's namespace URI is its identity, not something re-derived from the
65
72
  declarations around it - the WHATWG DOM model, measured against Chrome 152
66
73
  (`DOMParser` + `XMLSerializer`).
data/Rakefile CHANGED
@@ -74,6 +74,20 @@ namespace :unsafe do
74
74
  end
75
75
  end
76
76
 
77
+ # The lint gate, with CI's OWN arguments. It exists because a narrower spelling
78
+ # looks like it passes and is not: `cargo clippy --no-default-features --features
79
+ # lexbor` never compiles `bridge/` or `glue/` at all, so five `clippy.toml`
80
+ # violations there went unseen locally and turned CI red. `--all-features` is
81
+ # every configuration the crate has, which is why CI uses it - and why this task
82
+ # does, rather than leaving the arguments to be remembered.
83
+ desc "Clippy and rustfmt exactly as CI runs them (--all-features, warnings are errors)"
84
+ task :lint do
85
+ Dir.chdir("ext/makiri/rust") do
86
+ sh "cargo", "clippy", "--all-features", "--", "-D", "warnings"
87
+ sh "cargo", "fmt", "--all", "--", "--check"
88
+ end
89
+ end
90
+
77
91
  # `rake spec:valgrind` - run the spec suite under Valgrind memcheck via
78
92
  # ruby_memcheck (Linux CI; see .github/workflows/valgrind.yml). The gem ships
79
93
  # Ruby's own Valgrind suppression files (matched by Ruby version) and filters
@@ -19,17 +19,25 @@
19
19
  # call, and review is what catches a site that skipped them.
20
20
  disallowed-methods = [
21
21
  { path = "std::boxed::Box::new", reason = "aborts on OOM; use falloc::try_box" },
22
+ # `to_vec` is the same allocation as `Vec::from` under a friendlier name, and
23
+ # it was one of the holes this list had. Its sibling `ToOwned::to_owned` is NOT
24
+ # banned here, because it cannot be: on a `&str` it builds the String of a Ruby
25
+ # exception message, which the glue is right to allocate infallibly on a path
26
+ # that is already raising. The rule is per-LAYER and clippy.toml is crate-wide,
27
+ # so the layer half lives in `script/check_unsafe_boundaries.rb` ("infallible
28
+ # allocation inside an engine layer"), beside the other per-layer gates.
29
+ { path = "slice::to_vec", reason = "aborts on OOM; use falloc::try_to_vec" },
22
30
  { path = "alloc::vec::Vec::with_capacity", reason = "aborts on OOM; use falloc::try_vec_with_capacity" },
23
- { path = "alloc::vec::Vec::reserve", reason = "aborts on OOM; use Reserve::mkr_reserve" },
24
- { path = "alloc::vec::Vec::reserve_exact", reason = "aborts on OOM; use Reserve::mkr_reserve_exact" },
25
- { path = "alloc::vec::Vec::try_reserve", reason = "not injectable; use Reserve::mkr_reserve" },
26
- { path = "alloc::vec::Vec::try_reserve_exact", reason = "not injectable; use Reserve::mkr_reserve_exact" },
27
- { path = "std::collections::HashMap::reserve", reason = "aborts on OOM; use Reserve::mkr_reserve" },
28
- { path = "std::collections::HashMap::try_reserve", reason = "not injectable; use Reserve::mkr_reserve" },
29
- { path = "std::collections::HashMap::with_capacity", reason = "aborts on OOM; use Reserve::mkr_reserve" },
30
- { path = "std::collections::HashSet::reserve", reason = "aborts on OOM; use Reserve::mkr_reserve" },
31
- { path = "std::collections::HashSet::try_reserve", reason = "not injectable; use Reserve::mkr_reserve" },
32
- { path = "std::collections::HashSet::with_capacity", reason = "aborts on OOM; use Reserve::mkr_reserve" },
31
+ { path = "alloc::vec::Vec::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
32
+ { path = "alloc::vec::Vec::reserve_exact", reason = "aborts on OOM; use Reserve::falloc_reserve_exact" },
33
+ { path = "alloc::vec::Vec::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
34
+ { path = "alloc::vec::Vec::try_reserve_exact", reason = "not injectable; use Reserve::falloc_reserve_exact" },
35
+ { path = "std::collections::HashMap::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
36
+ { path = "std::collections::HashMap::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
37
+ { path = "std::collections::HashMap::with_capacity", reason = "aborts on OOM; use Reserve::falloc_reserve" },
38
+ { path = "std::collections::HashSet::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
39
+ { path = "std::collections::HashSet::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
40
+ { path = "std::collections::HashSet::with_capacity", reason = "aborts on OOM; use Reserve::falloc_reserve" },
33
41
  # std's STABLE sorts take a scratch buffer from the global allocator and abort
34
42
  # the process when it cannot be had - outside falloc, so `rake oom` never
35
43
  # fails them. The unstable sorts sort in place and allocate nothing.
@@ -12,7 +12,7 @@ use core::ffi::c_void;
12
12
 
13
13
  pub use makiri::text::VerifiedText;
14
14
  pub use makiri::token::Token;
15
- pub use makiri::xml::parse::xml_parse;
15
+ pub use makiri::xml::tree::parse as xml_parse;
16
16
  pub use makiri::xml::Document;
17
17
  pub use makiri::xpath::ast::Ast;
18
18
  pub use makiri::xpath::ctx::{Context, XPathValue};
@@ -118,15 +118,28 @@ pub fn wrap_html_node(node: RawNode, document: Value) -> Value {
118
118
  _ => CLASS_HTML_NODE.raw(),
119
119
  };
120
120
 
121
+ /* One wrapper per node: navigating here twice must give the SAME object, or
122
+ * everything that lives on a Ruby object is silently lost - `equal?`, an
123
+ * instance variable, a singleton method, `freeze`. The Document above is
124
+ * already its own wrapper, which is why it needs no entry. */
125
+ let token = node.as_ptr() as usize;
126
+ if let Some(cached) = crate::bridge::wrapper::cached_node(document, token) {
127
+ return cached;
128
+ }
129
+
121
130
  /* The Document is stored after the wrap: see `TypedType::wrap`. */
122
131
  // SAFETY: a fresh wrapper; the store closure only moves a live VALUE in.
123
- unsafe {
132
+ let fresh = unsafe {
124
133
  value(HTML_NODE_TYPE.wrap(
125
134
  klass,
126
135
  |nd| nd.node = node.as_ptr(),
127
136
  |nd| nd.document = document.as_raw(),
128
137
  ))
129
- }
138
+ };
139
+ /* After the wrap, so the VALUE exists; `fresh` is on the stack, where the
140
+ * conservative scan pins it across the cache's own allocation. */
141
+ crate::bridge::wrapper::cache_node(document, token, fresh);
142
+ fresh
130
143
  }
131
144
 
132
145
  /// The HTML node handle behind an HTML node or HTML Document.
@@ -297,6 +310,14 @@ fn release_from_tree(node: HtmlNodeMut<'_>) {
297
310
  pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value, Error> {
298
311
  let target = edit(this)?;
299
312
  let incoming = arg_node(&rb_incoming)?;
313
+ /* The argument is relinked too - `place` changes its parent and siblings, and
314
+ * an adoption removes it from its own document - so a frozen argument is a
315
+ * frozen node being modified. The receiver check alone let it through, which
316
+ * made `a.remove` raise and `span.add_child(a)` not, for the same effect on
317
+ * `a`. Reaches the nodes the caller NAMED; a fragment's children cannot be
318
+ * checked, because frozenness lives on the Ruby object and there is no map
319
+ * from a node back to its wrapper. */
320
+ crate::bridge::ruby::check_frozen(rb_incoming)?;
300
321
  Insertion::new(target.node(), place, incoming)
301
322
  .and_then(|i| i.check())
302
323
  .map_err(|e| refused(e, place))?;
@@ -613,7 +613,7 @@ impl Index {
613
613
  return Index::Linear;
614
614
  }
615
615
  let mut set = PtrSet::default();
616
- if set.mkr_reserve(nodes.len()).is_err() {
616
+ if set.falloc_reserve(nodes.len()).is_err() {
617
617
  return Index::Linear;
618
618
  }
619
619
  set.extend(nodes.iter().copied());
@@ -626,7 +626,7 @@ impl Index {
626
626
  return Index::Linear;
627
627
  }
628
628
  let mut set = PtrSet::default();
629
- if set.mkr_reserve(expected).is_err() {
629
+ if set.falloc_reserve(expected).is_err() {
630
630
  return Index::Linear;
631
631
  }
632
632
  Index::Hashed(set)
@@ -26,12 +26,40 @@ use super::ruby::protect;
26
26
  pub struct Marker(());
27
27
 
28
28
  impl Marker {
29
- /// Mark a stored `VALUE` as reachable.
29
+ /// Mark a stored `VALUE` as reachable, PINNING it where it is.
30
+ ///
31
+ /// For a field the object holds once. A table of many - a node cache - uses
32
+ /// [`Marker::mark_movable`] instead, so 50,000 pinned objects do not stop
33
+ /// compaction from doing its job.
30
34
  #[inline]
31
35
  pub fn mark(&self, v: VALUE) {
32
36
  // SAFETY: called from Ruby's mark phase, with the GVL held.
33
37
  unsafe { rb_sys::rb_gc_mark(v) };
34
38
  }
39
+
40
+ /// Mark a stored `VALUE` that compaction MAY move.
41
+ ///
42
+ /// Paired with [`Relocator::location`] in [`Hooks::compact`]: whatever is
43
+ /// marked movable must be re-read there, or the stored copy is left pointing
44
+ /// at where the object used to be.
45
+ #[inline]
46
+ pub fn mark_movable(&self, v: VALUE) {
47
+ // SAFETY: as `mark`; `Hooks::compact` updates every VALUE marked here.
48
+ unsafe { rb_sys::rb_gc_mark_movable(v) };
49
+ }
50
+ }
51
+
52
+ /// The compaction phase's handle, for [`Hooks::compact`].
53
+ pub struct Relocator(());
54
+
55
+ impl Relocator {
56
+ /// Where `v` is now, after compaction may have moved it.
57
+ #[inline]
58
+ pub fn location(&self, v: VALUE) -> VALUE {
59
+ // SAFETY: called from Ruby's compaction phase, with the GVL held, on a
60
+ // VALUE this object marked movable.
61
+ unsafe { rb_sys::rb_gc_location(v) }
62
+ }
35
63
  }
36
64
 
37
65
  /// A Rust value owned by a Ruby object.
@@ -44,6 +72,15 @@ pub trait Hooks: Sized {
44
72
  /// Mark every `VALUE` this object holds.
45
73
  fn mark(&self, marker: &Marker);
46
74
 
75
+ /// Re-read every `VALUE` marked with [`Marker::mark_movable`].
76
+ ///
77
+ /// Empty by default, which is correct for an object that only ever marks
78
+ /// with [`Marker::mark`] - those are pinned and cannot move. An object that
79
+ /// marks movable MUST implement this; Ruby's `GC.auto_compact` (which
80
+ /// `GC_COMPACT_STRESS=1` turns on for the whole suite) is what catches one
81
+ /// that does not.
82
+ fn compact(&mut self, _relocator: &Relocator) {}
83
+
47
84
  /// The bytes this object owns, reported to Ruby's GC.
48
85
  fn memsize(&self) -> usize {
49
86
  core::mem::size_of::<Self>()
@@ -82,6 +119,12 @@ unsafe extern "C" fn memsize_cb<T: Hooks>(ptr: *const c_void) -> rb_sys::size_t
82
119
  unsafe { (*(ptr as *const T)).memsize() as rb_sys::size_t }
83
120
  }
84
121
 
122
+ unsafe extern "C" fn compact_cb<T: Hooks>(ptr: *mut c_void) {
123
+ // SAFETY: as `mark_cb`; compaction has finished moving and is now fixing up
124
+ // the references to what it moved.
125
+ unsafe { (*(ptr as *mut T)).compact(&Relocator(())) };
126
+ }
127
+
85
128
  /// A `rb_data_type_t` for `T`: its GC callbacks drive [`Hooks`] for `T`, and
86
129
  /// every way to make or read an object of this type goes through it.
87
130
  ///
@@ -105,6 +148,7 @@ impl<T: Hooks> TypedType<T> {
105
148
  Some(mark_cb::<T>),
106
149
  Some(free_cb::<T>),
107
150
  Some(memsize_cb::<T>),
151
+ Some(compact_cb::<T>),
108
152
  ),
109
153
  _t: PhantomData,
110
154
  }
@@ -190,6 +234,7 @@ impl DataType {
190
234
  dmark: rb_sys::RUBY_DATA_FUNC,
191
235
  dfree: rb_sys::RUBY_DATA_FUNC,
192
236
  dsize: Option<unsafe extern "C" fn(*const core::ffi::c_void) -> rb_sys::size_t>,
237
+ dcompact: rb_sys::RUBY_DATA_FUNC,
193
238
  ) -> DataType {
194
239
  DataType(rb_sys::rb_data_type_t {
195
240
  wrap_struct_name: name,
@@ -197,7 +242,7 @@ impl DataType {
197
242
  dmark,
198
243
  dfree,
199
244
  dsize,
200
- dcompact: None,
245
+ dcompact,
201
246
  reserved: [core::ptr::null_mut(); 1],
202
247
  },
203
248
  parent,
@@ -18,11 +18,14 @@ use crate::bridge::ruby::makiri_error;
18
18
  use magnus::{prelude::*, Error, Value};
19
19
 
20
20
  use crate::bridge::ruby::{value, VALUE};
21
- use crate::bridge::typed::{Hooks, Marker, TypedType};
21
+ use crate::bridge::typed::{Hooks, Marker, Relocator, TypedType};
22
+ use crate::falloc::MapInsert;
22
23
  use crate::init::CLASS_DOCUMENT;
23
24
  use crate::lexbor::adapter::html::{HtmlDoc, RawDoc};
24
25
  use crate::lexbor::adapter::post_parse::HtmlParsed;
25
- use crate::xml::model::Doc as XmlDoc;
26
+ use crate::xml::model::Document as XmlDoc;
27
+ use core::hash::BuildHasherDefault;
28
+ use std::collections::HashMap;
26
29
 
27
30
  /* ------------------------------------------------------------------ *
28
31
  * the node wrapper *
@@ -97,6 +100,86 @@ impl Content {
97
100
  }
98
101
  }
99
102
 
103
+ /// One Ruby wrapper per node, so navigating to the same node twice gives the
104
+ /// SAME object.
105
+ ///
106
+ /// Without it every navigation allocated a fresh wrapper, and everything that
107
+ /// lives on a Ruby object was silently lost: `equal?` was false for one node,
108
+ /// an instance variable set through one wrapper was gone through the next, a
109
+ /// singleton method vanished, and `freeze` protected only the object you
110
+ /// happened to be holding. `==`/`eql?`/`hash` were unaffected, because those
111
+ /// are node identity, which is why it went unnoticed.
112
+ ///
113
+ /// The cost is Nokogiri's, and it is the same cost for the same reason: a
114
+ /// wrapper stays alive while its document does. Measured on a 50,000-node
115
+ /// document, minor GC after wrapping N nodes and dropping every reference:
116
+ /// N=1,000 costs nothing (0.30 ms, the same as N=0), N=50,000 costs +2.2 ms.
117
+ /// Nokogiri's figures for the same experiment are 0.31 ms and +2.24 ms.
118
+ ///
119
+ /// Keyed by the node TOKEN - a `NodeId` for XML, a node pointer for HTML - which
120
+ /// is stable for the document's life in both: XML never recycles an arena slot,
121
+ /// and HTML detaches without destroying, so a node is never freed.
122
+ struct NodeCache {
123
+ /// Empty until the first navigation, so a document nobody walks pays
124
+ /// nothing.
125
+ map: HashMap<usize, VALUE, BuildHasherDefault<TokenHasher>>,
126
+ }
127
+
128
+ /// The token is already a well-distributed integer - a slot index or an aligned
129
+ /// pointer - so it only needs mixing, not hashing. `ptr_table::mix64` is the
130
+ /// crate's one place for that.
131
+ #[derive(Default)]
132
+ struct TokenHasher(u64);
133
+
134
+ impl core::hash::Hasher for TokenHasher {
135
+ fn finish(&self) -> u64 {
136
+ self.0
137
+ }
138
+ fn write(&mut self, bytes: &[u8]) {
139
+ for &b in bytes {
140
+ self.0 = crate::ptr_table::mix64(self.0 ^ b as u64);
141
+ }
142
+ }
143
+ fn write_usize(&mut self, n: usize) {
144
+ self.0 = crate::ptr_table::mix64(n as u64);
145
+ }
146
+ }
147
+
148
+ impl NodeCache {
149
+ fn get(&self, token: usize) -> Option<VALUE> {
150
+ self.map.get(&token).copied()
151
+ }
152
+
153
+ /// Remember `wrapper` as the one wrapper for `token`.
154
+ ///
155
+ /// A failed insert leaves the node uncached, so the next navigation builds
156
+ /// another wrapper and identity is lost for it. That only happens when the
157
+ /// allocator is refusing, where the process is already failing; the
158
+ /// alternative is raising out of a wrap that has no error path.
159
+ fn insert(&mut self, token: usize, wrapper: VALUE) {
160
+ let _ = self.map.falloc_insert(token, wrapper);
161
+ }
162
+
163
+ /// MOVABLE, not pinned: a document walked end to end holds one entry per
164
+ /// node, and pinning them all would stop compaction doing its job. Paired
165
+ /// with [`NodeCache::compact`].
166
+ fn mark(&self, marker: &Marker) {
167
+ for v in self.map.values() {
168
+ marker.mark_movable(*v);
169
+ }
170
+ }
171
+
172
+ fn compact(&mut self, relocator: &Relocator) {
173
+ for v in self.map.values_mut() {
174
+ *v = relocator.location(*v);
175
+ }
176
+ }
177
+
178
+ fn memsize(&self) -> usize {
179
+ self.map.capacity() * (core::mem::size_of::<usize>() + core::mem::size_of::<VALUE>())
180
+ }
181
+ }
182
+
100
183
  /// A Document wrapper's data: the parsed content (owned - GC frees it), the
101
184
  /// mutation gate's count, and the reserved errors Array.
102
185
  pub struct DocData {
@@ -110,9 +193,38 @@ pub struct DocData {
110
193
  /// The external bytes this wrapper has told the GC about, so `release`
111
194
  /// takes back exactly what [`account_document`] reported.
112
195
  reported: usize,
196
+ /// One wrapper per node; see [`NodeCache`].
197
+ ///
198
+ /// Boxed and optional because a `DocData` is born from `ruby_xcalloc` - all
199
+ /// zero bytes - and a zeroed `HashMap` is not an empty one: hashbrown's empty
200
+ /// table points at a static, not at null. `None` IS all-zero (Box is
201
+ /// non-null, so the niche is the null pointer), which makes the zeroed state
202
+ /// both valid and the right one: a document nobody navigates never allocates
203
+ /// a cache.
204
+ nodes: Option<Box<NodeCache>>,
113
205
  }
114
206
 
115
207
  impl DocData {
208
+ /// The one wrapper for `token`, or None until something navigates to it.
209
+ fn cached(&self, token: usize) -> Option<VALUE> {
210
+ self.nodes.as_ref()?.get(token)
211
+ }
212
+
213
+ /// Remember `wrapper` for `token`, allocating the cache on first use.
214
+ fn cache(&mut self, token: usize, wrapper: VALUE) {
215
+ if self.nodes.is_none() {
216
+ let Ok(fresh) = crate::falloc::try_box(NodeCache {
217
+ map: HashMap::with_hasher(BuildHasherDefault::default()),
218
+ }) else {
219
+ return; /* see NodeCache::insert on a refusing allocator */
220
+ };
221
+ self.nodes = Some(fresh);
222
+ }
223
+ if let Some(cache) = self.nodes.as_mut() {
224
+ cache.insert(token, wrapper);
225
+ }
226
+ }
227
+
116
228
  /// The Document's parse-warning Array.
117
229
  pub fn errors(&self) -> Value {
118
230
  // SAFETY: the live Array this wrapper marks.
@@ -126,7 +238,7 @@ impl DocData {
126
238
  match self.content {
127
239
  Content::Empty => 0,
128
240
  Content::Html(p) => p.as_ref().external_bytes(),
129
- Content::Xml(d) => crate::xml::api::xml_doc_memsize(d.as_ref()),
241
+ Content::Xml(d) => d.as_ref().memsize(),
130
242
  }
131
243
  }
132
244
  }
@@ -135,10 +247,21 @@ impl DocData {
135
247
  impl Hooks for DocData {
136
248
  fn mark(&self, marker: &Marker) {
137
249
  marker.mark(self.errors);
250
+ if let Some(cache) = self.nodes.as_ref() {
251
+ cache.mark(marker);
252
+ }
253
+ }
254
+
255
+ fn compact(&mut self, relocator: &Relocator) {
256
+ if let Some(cache) = self.nodes.as_mut() {
257
+ cache.compact(relocator);
258
+ }
138
259
  }
139
260
 
140
261
  fn memsize(&self) -> usize {
141
- core::mem::size_of::<DocData>().saturating_add(self.external_bytes())
262
+ core::mem::size_of::<DocData>()
263
+ .saturating_add(self.external_bytes())
264
+ .saturating_add(self.nodes.as_ref().map_or(0, |c| c.memsize()))
142
265
  }
143
266
 
144
267
  fn release(&mut self) {
@@ -151,6 +274,10 @@ impl Hooks for DocData {
151
274
  }
152
275
  }
153
276
  self.content = Content::Empty;
277
+ /* The cache is Rust-owned heap in a struct Ruby frees with `xfree`, which
278
+ * does NOT run Drop - so it has to be dropped here, like the content
279
+ * above. `rake leaks` is what caught this one when it was missing. */
280
+ drop(self.nodes.take());
154
281
  /* Balance the report, or the GC keeps counting freed arenas as live
155
282
  * and collects ever more eagerly. A plain C call, as this hook has to
156
283
  * be: it only subtracts, and Ruby's own `xfree` does the same from
@@ -356,6 +483,22 @@ pub(in crate::bridge) fn with_html_parsed_known<R>(
356
483
  unsafe { f(p.as_mut()) }
357
484
  }
358
485
 
486
+ /// The one wrapper for `token` under `rb_doc`, or None the first time.
487
+ ///
488
+ /// `rb_doc` must be a live Document; the two `wrap_*_node` functions are the
489
+ /// only callers and both already hold one.
490
+ pub fn cached_node(rb_doc: Value, token: usize) -> Option<Value> {
491
+ // SAFETY: as `with_doc_data_known`.
492
+ let v = with_doc_data_known(rb_doc, |d| d.cached(token));
493
+ // SAFETY: a VALUE this document marks, so it is live.
494
+ v.map(|v| unsafe { value(v) })
495
+ }
496
+
497
+ /// Remember `wrapper` as the one wrapper for `token` under `rb_doc`.
498
+ pub fn cache_node(rb_doc: Value, token: usize, wrapper: Value) {
499
+ with_doc_data_known(rb_doc, |d| d.cache(token, wrapper.as_raw()));
500
+ }
501
+
359
502
  /// Run `f` over a Document's wrapper data, for the fields that are the
360
503
  /// wrapper's own rather than the content's (the evaluation count).
361
504
  fn with_doc_data_known<R>(rb_doc: Value, f: impl FnOnce(&mut DocData) -> R) -> R {