makiri 0.13.0 → 0.14.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/README.md +6 -0
  4. data/ext/makiri/rust/build.rs +1 -7
  5. data/ext/makiri/rust/src/bridge/doc.rs +30 -8
  6. data/ext/makiri/rust/src/bridge/dom_error.rs +47 -0
  7. data/ext/makiri/rust/src/bridge/fragment.rs +1 -2
  8. data/ext/makiri/rust/src/bridge/gvl.rs +1 -2
  9. data/ext/makiri/rust/src/bridge/html.rs +50 -53
  10. data/ext/makiri/rust/src/bridge/mod.rs +3 -0
  11. data/ext/makiri/rust/src/bridge/ruby.rs +8 -2
  12. data/ext/makiri/rust/src/bridge/string.rs +36 -10
  13. data/ext/makiri/rust/src/bridge/wrapper.rs +171 -49
  14. data/ext/makiri/rust/src/bridge/xml.rs +125 -55
  15. data/ext/makiri/rust/src/bridge/xml_decode.rs +49 -69
  16. data/ext/makiri/rust/src/bridge/xpath/context_object.rs +24 -39
  17. data/ext/makiri/rust/src/bridge/xpath/handler.rs +11 -15
  18. data/ext/makiri/rust/src/bridge/xpath/mod.rs +74 -16
  19. data/ext/makiri/rust/src/dom_rules.rs +18 -0
  20. data/ext/makiri/rust/src/glue/html_doc.rs +20 -0
  21. data/ext/makiri/rust/src/glue/html_node/css.rs +3 -0
  22. data/ext/makiri/rust/src/glue/html_node/mutate.rs +43 -40
  23. data/ext/makiri/rust/src/glue/node.rs +43 -5
  24. data/ext/makiri/rust/src/glue/query.rs +2 -21
  25. data/ext/makiri/rust/src/glue/xml_doc.rs +11 -0
  26. data/ext/makiri/rust/src/glue/xml_node/css.rs +3 -3
  27. data/ext/makiri/rust/src/glue/xml_node/mod.rs +1 -0
  28. data/ext/makiri/rust/src/glue/xml_node/mutate.rs +88 -21
  29. data/ext/makiri/rust/src/glue/xml_node/serialize.rs +6 -0
  30. data/ext/makiri/rust/src/lexbor/abi.rs +11 -18
  31. data/ext/makiri/rust/src/lexbor/adapter/cross_import.rs +56 -14
  32. data/ext/makiri/rust/src/lexbor/adapter/html/attrs.rs +15 -7
  33. data/ext/makiri/rust/src/lexbor/adapter/html/build.rs +12 -4
  34. data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +54 -10
  35. data/ext/makiri/rust/src/lexbor/adapter/html/mutate.rs +16 -13
  36. data/ext/makiri/rust/src/lexbor/adapter/post_parse.rs +31 -17
  37. data/ext/makiri/rust/src/lexbor/adapter/text_index.rs +9 -9
  38. data/ext/makiri/rust/src/lexbor/adapter/tree_guard.rs +31 -24
  39. data/ext/makiri/rust/src/lexbor/css_match/compile.rs +71 -28
  40. data/ext/makiri/rust/src/lexbor/css_match/mod.rs +6 -0
  41. data/ext/makiri/rust/src/lexbor/css_match/query.rs +27 -43
  42. data/ext/makiri/rust/src/lexbor/css_match/scratch.rs +1 -1
  43. data/ext/makiri/rust/src/lexbor/css_match/simple.rs +4 -4
  44. data/ext/makiri/rust/src/lexbor/fragment.rs +22 -24
  45. data/ext/makiri/rust/src/lexbor/stylesheet.rs +50 -23
  46. data/ext/makiri/rust/src/lexbor/tests.rs +21 -9
  47. data/ext/makiri/rust/src/xml/arena.rs +107 -13
  48. data/ext/makiri/rust/src/xml/attr_key.rs +17 -4
  49. data/ext/makiri/rust/src/xml/chars/expand.rs +126 -25
  50. data/ext/makiri/rust/src/xml/chars/mod.rs +1 -1
  51. data/ext/makiri/rust/src/xml/dom_name.rs +4 -3
  52. data/ext/makiri/rust/src/xml/mod.rs +1 -0
  53. data/ext/makiri/rust/src/xml/model.rs +37 -30
  54. data/ext/makiri/rust/src/xml/mutate/attr.rs +58 -31
  55. data/ext/makiri/rust/src/xml/mutate/copy.rs +219 -58
  56. data/ext/makiri/rust/src/xml/mutate/edit.rs +2 -0
  57. data/ext/makiri/rust/src/xml/mutate/factory.rs +70 -16
  58. data/ext/makiri/rust/src/xml/mutate/insert.rs +15 -14
  59. data/ext/makiri/rust/src/xml/mutate/mod.rs +8 -5
  60. data/ext/makiri/rust/src/xml/mutate/ns.rs +87 -138
  61. data/ext/makiri/rust/src/xml/ns_scope.rs +104 -0
  62. data/ext/makiri/rust/src/xml/qname.rs +66 -1
  63. data/ext/makiri/rust/src/xml/selftest.rs +20 -6
  64. data/ext/makiri/rust/src/xml/serialize/c14n.rs +22 -12
  65. data/ext/makiri/rust/src/xml/serialize/mod.rs +20 -15
  66. data/ext/makiri/rust/src/xml/serialize/xml.rs +23 -13
  67. data/ext/makiri/rust/src/xml/tree/dtd.rs +15 -33
  68. data/ext/makiri/rust/src/xml/tree/mod.rs +30 -37
  69. data/ext/makiri/rust/src/xml/xpath.rs +9 -8
  70. data/ext/makiri/rust/src/xpath/order.rs +73 -4
  71. data/ext/makiri/rust/src/xpath/verify.rs +8 -53
  72. data/lib/makiri/html/document.rb +45 -21
  73. data/lib/makiri/version.rb +1 -1
  74. data/lib/makiri/xml/document.rb +10 -16
  75. data/script/check_unsafe_boundaries.rb +69 -41
  76. data/suppressions/ruby.supp +14 -0
  77. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48c6dfd55ed565531aea117967aee1f4b8f3405ff0e36ca8766ffa729a1ee27e
4
- data.tar.gz: 1866e9eb174ea0a6428a8f1c81f65ed8756d61c2429de935876d60547a4bc6bd
3
+ metadata.gz: baaa765e2bd6208137962299994628a55a36ab68e29f9de9921e686679c69bf2
4
+ data.tar.gz: cd74ab123b1f8384d7aa14c448f1f12ee78371335061b60728c06424ccc5973b
5
5
  SHA512:
6
- metadata.gz: f847d9d772a2865bcd6e3f9b1626f0b67e4215d14d844b529bd194e9d287aa288cc2a555156799f842ac68fcc13405e1cfff317a506c2476b87eb24646f17c46
7
- data.tar.gz: ee1fe65b9055dc2a9c11cdbbd789544ef72ef1bd2c8fa731ab0eab2f97173125ab3f96637da903d33df76dfa80e221dd0e11abeaa446f5683e1428cc7f6552af
6
+ metadata.gz: 9ce8aa721e0e6848e2f7015f924f510599064f309c590181abef990e9b81121a1675aade8917515fdc35577bb65bb5edcf0d72ce4582df686eb0156d461d5e7b
7
+ data.tar.gz: 692a2a400e1be9ecc12fcd67cf317f5d7e51eef38c2846f25ab3bc23dd6626acdf02d860309e4444bf1b2b646461e8a32934804ebc8d1cc3f9a8593d729a79d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-10-04
4
+
5
+ ### Added
6
+
7
+ * `Document#attribute_version` (HTML and XML): an Integer that increases
8
+ whenever an attribute is added, removed or set (to the same value too),
9
+ including through an Attr node's `content=` or `remove`. Child-list and
10
+ text edits leave it as it is, so it can key a cache of attributes.
11
+ * `Makiri::XML::Document#create_element_ns(namespace_uri, qualified_name)`,
12
+ like the HTML Document's and the DOM's `createElementNS`. The element keeps
13
+ its namespace wherever it is inserted; a name that is not an XML QName
14
+ (`f}oo`) makes an element that `to_xml` refuses.
15
+ * `Makiri::HTML::Document.new`: an empty HTML document in no-quirks mode.
16
+ * `Makiri::HTML::Document#quirks_mode?` and `#compat_mode` (`"BackCompat"` /
17
+ `"CSS1Compat"`, like the DOM's `compatMode`).
18
+ * HTML `Attr#remove` / `#unlink` remove the attribute from its element, as on
19
+ XML. They raised "use delete(name) to remove an attribute".
20
+
21
+ ### Changed
22
+
23
+ * `tree_version` counts child-list changes only. A Text, Comment, CDATA or
24
+ PI node's `content=` no longer moves it, and an HTML Attr node's
25
+ `content=` moves `attribute_version` instead.
26
+ * `tree_version` and `attribute_version` are documented as cache keys: an
27
+ unchanged version means nothing it covers changed, while a changed one may
28
+ be conservative (an edit refused part-way can still move it).
29
+ * `namespace_uri` and `prefix` are documented as `nil`, never `""`, when
30
+ there is no namespace or prefix, in HTML and XML.
31
+ * `Document#dup` copies the tree node by node instead of re-parsing its
32
+ serialization. An HTML copy keeps a tree with no `<html>` root as it is and
33
+ keeps the quirks mode, and `Node#line` on it is `nil`. An XML copy keeps
34
+ every name and namespace as written (a re-parse invented `ns1:` prefixes),
35
+ copies data XML cannot write, and keeps the original's `max_bytes`.
36
+ * XML `create_processing_instruction` accepts the target `xml` (in any case),
37
+ as the DOM does; `to_xml` and `canonicalize` refuse a tree holding one.
38
+ * HTML `create_processing_instruction` raises `ArgumentError`, as XML does,
39
+ for data containing `?>`. It raised `Makiri::Error`.
40
+ * A refused XML insertion names the DOM rule it breaks, in the same words as
41
+ HTML. It said "invalid placement".
42
+
43
+ ### Fixed
44
+
45
+ * An Attr's `content=` and `remove` raise `FrozenError` when the element
46
+ that owns it is frozen, in HTML and XML.
47
+ * XML `Attr#content=` sets the attribute's value. It raised "operation
48
+ unsupported".
49
+ * HTML `title=` and `meta_encoding=` do nothing on a document with no
50
+ `<head>`, as the DOM's title setter does. They inserted the element directly
51
+ under the root element.
52
+ * HTML `Document#root` and CSS `:root` return the document element, or `nil`.
53
+ Without an `<html>` element they returned the first child of any kind, such
54
+ as a comment, and `title=` raised on such a document.
55
+ * An edit refused for its arguments - a frozen or non-node argument to an
56
+ HTML insertion, an invalid name given to XML `[]=`, `set_attribute_ns` or
57
+ `set_loose_dom_attribute`, a refused HTML `Attr#remove` - no longer moves a
58
+ version.
59
+ * An XML copy that runs out of the document's byte budget (`import_node`,
60
+ `clone_node`, an insertion from another document) gives back what it used.
61
+ The document could not be edited any more after one.
62
+ * Importing HTML into an XML document stores each namespace URI once instead
63
+ of once per element, which used up the byte budget faster.
64
+ * An HTML Attr imported on its own from another HTML document keeps its
65
+ namespace URI as written. It was lower-cased.
66
+ * `to_xml` and `canonicalize` write an element in the XML namespace as
67
+ `xml:local`. They wrote a declaration that does not parse. An element in
68
+ the XMLNS namespace is refused with `Makiri::Error`.
69
+ * XML XPath: a prefixed attribute on a detached element, whose namespace is
70
+ not decided yet, no longer matches an unprefixed name test.
71
+ * XML: references are read by one grammar everywhere. The internal subset
72
+ accepted a character reference to a character XML does not allow
73
+ (`<!ENTITY x "&#0;">`), and a malformed reference next to an entity
74
+ reference was reported as an unsupported DTD construct instead of as
75
+ malformed XML.
76
+
3
77
  ## [0.13.0] - 2026-10-03
4
78
 
5
79
  ### Added
data/README.md CHANGED
@@ -184,6 +184,12 @@ 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
+ The limit covers the document's whole life, not only the parse: nodes and
188
+ values made by editing it count too, and a replaced value or a removed node
189
+ keeps its bytes until the document is gone. A document edited for a long time
190
+ can be compacted with `dup`, which copies only what is in it now, under the
191
+ same limit.
192
+
187
193
  ### Node identity
188
194
 
189
195
  Navigating to the same node always gives the same Ruby object, for as long as
@@ -193,7 +193,6 @@ fn main() {
193
193
  .allowlist_function("lexbor_hash_insert")
194
194
  .allowlist_var("lexbor_hash_search_raw")
195
195
  .allowlist_var("lexbor_hash_insert_raw")
196
- .allowlist_function("lxb_dom_document_root")
197
196
  .allowlist_type("lxb_html_token_t")
198
197
  .allowlist_type("lxb_html_token_type_t")
199
198
  // The token-type FLAGS are in `enum lxb_html_token_type` - no trailing
@@ -283,6 +282,7 @@ fn main() {
283
282
  .allowlist_function("lxb_dom_document_type_create")
284
283
  .allowlist_function("lxb_dom_document_import_node")
285
284
  .allowlist_function("lexbor_str_init")
285
+ .allowlist_function("lxb_html_document_create")
286
286
  .allowlist_function("lxb_html_document_destroy")
287
287
  // The document title reader, generated rather than hand-declared like
288
288
  // the rest of this list.
@@ -416,12 +416,6 @@ const UNDECLARED_EXPORTS: &[(&str, &str, &str, &str)] = &[
416
416
  "lxb_dom_element_t *element, const lxb_char_t *prefix, size_t prefix_len, \
417
417
  const lxb_char_t *lname, size_t lname_len",
418
418
  ),
419
- (
420
- "lexbor/ns/ns.c",
421
- "LXB_API const lxb_ns_data_t *",
422
- "lxb_ns_append",
423
- "lexbor_hash_t *hash, const lxb_char_t *link, size_t length",
424
- ),
425
419
  ];
426
420
 
427
421
  fn check_undeclared_exports(include: &std::path::Path) {
@@ -34,8 +34,8 @@ use crate::bridge::xml::xml_node_document;
34
34
  use crate::bridge::xml::{xml_mut_result, xml_node_unwrap};
35
35
  use crate::lexbor::adapter::cross_import::cross_xml_to_html;
36
36
  use crate::lexbor::adapter::html::{RawDoc, RawNode};
37
- use crate::lexbor::adapter::post_parse::{parse_html, HtmlParseError};
38
- use crate::lexbor::adapter::tree_guard::{DepthLimit, MAX_SELECT_OPTIONS};
37
+ use crate::lexbor::adapter::post_parse::{empty_html_document, parse_html, HtmlParseError};
38
+ use crate::lexbor::adapter::tree_guard::{DepthLimit, GuardStop, MAX_SELECT_OPTIONS};
39
39
 
40
40
  /* ------------------------------------------------------------------ *
41
41
  * parsing *
@@ -49,13 +49,24 @@ pub fn select_options_error() -> Error {
49
49
  ))
50
50
  }
51
51
 
52
+ /// The error for a parse the guard refused (`GuardStop`), for a document and
53
+ /// a fragment alike: the one place that maps a stop to its exception.
54
+ pub fn guard_error(stop: GuardStop, limit: DepthLimit) -> Error {
55
+ match stop {
56
+ GuardStop::TooDeep => tree_depth_error(limit),
57
+ GuardStop::TooManyOptions => select_options_error(),
58
+ }
59
+ }
60
+
52
61
  /// The error for a parse the tree-depth limit refused: `Makiri::Error`, naming
53
62
  /// the limit, for a document and a fragment alike.
54
63
  pub fn tree_depth_error(limit: DepthLimit) -> Error {
55
64
  match limit.max_depth() {
56
65
  Some(n) => makiri_error(format!("document tree depth limit exceeded ({n})")),
57
- /* Unreachable: an unlimited parse is never refused for depth. */
58
- None => makiri_error("document tree depth limit exceeded"),
66
+ /* An unlimited parse is never refused for depth. */
67
+ None => crate::bridge::ruby::internal_error(
68
+ "a parse with no tree-depth limit was refused for depth",
69
+ ),
59
70
  }
60
71
  }
61
72
 
@@ -91,8 +102,7 @@ pub fn parse_document(source: Value, limit: DepthLimit) -> Result<Value, Error>
91
102
  drop(owned);
92
103
 
93
104
  let parsed = result.map_err(|e| match e {
94
- HtmlParseError::TooDeep => tree_depth_error(limit),
95
- HtmlParseError::TooManyOptions => select_options_error(),
105
+ HtmlParseError::Guard(stop) => guard_error(stop, limit),
96
106
  HtmlParseError::Failed => makiri_error("failed to parse HTML document"),
97
107
  })?;
98
108
  /* The GC learns the arena's size in `install`; `owned` is already gone, so
@@ -100,12 +110,24 @@ pub fn parse_document(source: Value, limit: DepthLimit) -> Result<Value, Error>
100
110
  Ok(shell.install_html(parsed))
101
111
  }
102
112
 
113
+ /// An empty HTML document - no children - in `compat_mode` (Lexbor's
114
+ /// numbering): `Makiri::HTML::Document.new` (no-quirks) and the copy
115
+ /// `Document#dup` fills.
116
+ pub fn new_empty_document(compat_mode: u32) -> Result<Value, Error> {
117
+ /* The wrapper first, while nothing needs freeing - see DocumentShell. */
118
+ let shell = DocumentShell::new(DocKind::Html);
119
+ let parsed = empty_html_document(compat_mode)
120
+ .map_err(|_| makiri_error("failed to create HTML document"))?;
121
+ Ok(shell.install_html(parsed))
122
+ }
123
+
103
124
  /* ------------------------------------------------------------------ *
104
125
  * read-only accessors *
105
126
  * ------------------------------------------------------------------ */
106
127
 
107
- /// `Document#root`: the root Element node, or nil (unreachable today - the HTML
108
- /// parser inserts html/head/body even for empty input).
128
+ /// `Document#root`: the root Element node, or nil - for `Document.new`, or once
129
+ /// the root is removed (the HTML parser inserts html/head/body even for empty
130
+ /// input).
109
131
  pub fn document_root(rb_doc: Value) -> Result<Option<Value>, Error> {
110
132
  let Some(root) = html_doc(&rb_doc).as_node().document_root() else {
111
133
  return Ok(None);
@@ -0,0 +1,47 @@
1
+ //! The errors the DOM's insertion rules raise, worded once for HTML and XML.
2
+ //!
3
+ //! Both representations keep a refusal's reason as a
4
+ //! [`PreInsertError`](crate::dom_rules::PreInsertError) all the way here, so
5
+ //! the same broken rule reads the same in either - XML used to fold every rule
6
+ //! but two into one sentence.
7
+
8
+ #![forbid(unsafe_code)]
9
+
10
+ use crate::bridge::ruby::makiri_error;
11
+ use crate::dom_rules::{Hierarchy as H, PreInsertError, Violation};
12
+ use magnus::Error;
13
+
14
+ /// The `Makiri::Error` an insertion `e` refused raises.
15
+ pub fn pre_insert_error(e: PreInsertError) -> Error {
16
+ makiri_error(message(e))
17
+ }
18
+
19
+ fn message(e: PreInsertError) -> &'static str {
20
+ match e {
21
+ PreInsertError::NoParent { replacing: true } => "cannot replace a node with no parent",
22
+ PreInsertError::NoParent { replacing: false } => {
23
+ "cannot add a sibling to a node with no parent"
24
+ }
25
+ /* Unreachable through either representation's insertion, which takes
26
+ * the reference child from the parent it names; worded all the same. */
27
+ PreInsertError::Rule(Violation::NotFound) => {
28
+ "the reference node is not a child of the parent"
29
+ }
30
+ PreInsertError::Rule(Violation::HierarchyRequest(h)) => match h {
31
+ H::ParentNotContainer => {
32
+ "only a document, a document fragment or an element can have children"
33
+ }
34
+ H::Ancestor => "cannot insert a node into its own subtree",
35
+ H::AttributeNode => "an attribute node cannot be inserted into the tree",
36
+ H::DocumentNode => "a document node cannot be inserted into the tree",
37
+ H::UnsupportedNode => "this kind of node cannot be inserted into the tree",
38
+ H::DoctypeParent => "a doctype node can only be a child of the document",
39
+ H::DuplicateDoctype => "the document already has a doctype",
40
+ H::DoctypeAfterElement | H::ElementBeforeDoctype => {
41
+ "a doctype must precede the document element"
42
+ }
43
+ H::SecondDocumentElement => "the document already has a root element",
44
+ H::TextUnderDocument => "text cannot be a child of the document",
45
+ },
46
+ }
47
+ }
@@ -28,8 +28,7 @@ use crate::lexbor::fragment::{FragmentContext, FragmentError, TransientFragment}
28
28
  /// A fragment-parse failure as `Makiri::Error`.
29
29
  fn fragment_error(e: FragmentError, limit: DepthLimit) -> Error {
30
30
  match e {
31
- FragmentError::TooDeep => crate::bridge::doc::tree_depth_error(limit),
32
- FragmentError::TooManyOptions => crate::bridge::doc::select_options_error(),
31
+ FragmentError::Guard(stop) => crate::bridge::doc::guard_error(stop, limit),
33
32
  _ => makiri_error(e.message()),
34
33
  }
35
34
  }
@@ -107,8 +107,7 @@ pub fn without_gvl<F: FnOnce() -> R + Send, R>(f: F) -> Result<R, magnus::Error>
107
107
  }
108
108
  if slot.f.is_none() {
109
109
  /* It ran and neither answered nor panicked - which `run` cannot do. */
110
- return Err(magnus::Error::new(
111
- crate::init::EXC_INTERNAL_ERROR.exception(),
110
+ return Err(crate::bridge::ruby::internal_error(
112
111
  "the GVL-released body ran without a result",
113
112
  ));
114
113
  }
@@ -17,8 +17,7 @@ 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, PreInsertError,
21
- RawDoc, RawNode,
20
+ ForeignNode, HtmlNode, HtmlNodeKey, HtmlNodeMut, Insertion, NodeType, Place, RawDoc, RawNode,
22
21
  };
23
22
  use crate::lexbor::fragment::import_with_fixup;
24
23
 
@@ -282,7 +281,8 @@ pub(in crate::bridge) fn with_html_node<R>(
282
281
 
283
282
  /// The receiver cleared for an edit - not frozen, its document not under
284
283
  /// evaluation - and the PROOF of it: [`edit`] is the only way to build one and
285
- /// [`HtmlEdit::node`] the only way to spend it. The XML side's `Editing`.
284
+ /// [`HtmlEdit::node`] (or its attribute and data twins) the only way to spend
285
+ /// it, ONCE - they take `self`. The XML side's `Editing`, spent the same way.
286
286
  ///
287
287
  /// The checks and the index drop are two steps on purpose. The checks come
288
288
  /// first, so a frozen receiver is reported before a bad argument. The drop
@@ -298,12 +298,26 @@ pub struct HtmlEdit<'a> {
298
298
  /// before [`HtmlEdit::node`].
299
299
  pub fn edit(this: &HtmlSelf) -> Result<HtmlEdit<'_>, Error> {
300
300
  crate::bridge::ruby::check_frozen(this.value)?;
301
+ check_attr_owner_frozen(this)?;
301
302
  ensure_document_mutable(this.document)?;
302
303
  /* Before any argument is converted: see `account_growth`. */
303
304
  crate::bridge::wrapper::account_growth(this.document);
304
305
  Ok(HtmlEdit { this })
305
306
  }
306
307
 
308
+ /// An Attr receiver's edit changes its OWNER's attribute list, so a frozen
309
+ /// owner refuses it as it refuses `delete` - checked with the receiver's own
310
+ /// frozen flag, before and after the arguments are converted.
311
+ fn check_attr_owner_frozen(this: &HtmlSelf) -> Result<(), Error> {
312
+ let node = this.node();
313
+ match (node.node_type(), node.parent()) {
314
+ (NodeType::Attribute, Some(owner)) => {
315
+ crate::bridge::wrapper::check_node_frozen(this.document, RawNode::from(owner))
316
+ }
317
+ _ => Ok(()),
318
+ }
319
+ }
320
+
307
321
  impl<'a> HtmlEdit<'a> {
308
322
  /// The receiver's node, read-only, for a check that no argument can
309
323
  /// change (its node type).
@@ -325,25 +339,34 @@ impl<'a> HtmlEdit<'a> {
325
339
  /// to engine calls and checks that call no Ruby (`insert` reads its
326
340
  /// argument's node and frozen flag there, and nothing more).
327
341
  ///
328
- /// It counts as a change to a child list ([`bump_tree_version`]); an
329
- /// attribute edit takes [`HtmlEdit::node_for_attributes`] instead.
342
+ /// It counts as a change to a child list ([`record_edit`]); an
343
+ /// attribute edit takes [`HtmlEdit::node_for_attributes`] instead, and a
344
+ /// character-data edit [`HtmlEdit::node_for_data`].
330
345
  pub fn node(self) -> Result<HtmlNodeMut<'a>, Error> {
331
- self.mutable(true)
346
+ self.mutable(EditKind::ChildList)
332
347
  }
333
348
 
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.
349
+ /// [`HtmlEdit::node`] for an edit of ATTRIBUTES only - an element's, or an
350
+ /// Attr node's value - which changes no child list: it counts towards the
351
+ /// attribute version ([`record_edit`]) instead of the tree
352
+ /// version.
336
353
  pub fn node_for_attributes(self) -> Result<HtmlNodeMut<'a>, Error> {
337
- self.mutable(false)
354
+ self.mutable(EditKind::Attributes)
338
355
  }
339
356
 
340
- fn mutable(self, structural: bool) -> Result<HtmlNodeMut<'a>, Error> {
357
+ /// [`HtmlEdit::node`] for an edit of a Text, Comment, CDATA or PI node's
358
+ /// DATA, which changes no child list and no attribute: no version counts
359
+ /// it (see [`EditKind::CharacterData`]).
360
+ pub fn node_for_data(self) -> Result<HtmlNodeMut<'a>, Error> {
361
+ self.mutable(EditKind::CharacterData)
362
+ }
363
+
364
+ fn mutable(self, kind: EditKind) -> Result<HtmlNodeMut<'a>, Error> {
341
365
  crate::bridge::ruby::check_frozen(self.this.value)?;
366
+ check_attr_owner_frozen(self.this)?;
342
367
  ensure_document_mutable(self.this.document)?;
343
368
  invalidate_indexes(self.this.document);
344
- if structural {
345
- bump_tree_version(self.this.document);
346
- }
369
+ record_edit(self.this.document, kind);
347
370
  // SAFETY: the receiver is not frozen and no XPath evaluation is
348
371
  // reading its document - both checked just now.
349
372
  Ok(unsafe { HtmlNodeMut::assume_mutable(self.this.raw().as_node()) })
@@ -379,16 +402,16 @@ fn adopt_copy<'d>(doc: RawDoc, node: HtmlNode<'_>) -> Result<HtmlNode<'d>, Error
379
402
  /// document's indexes, which still list it. A structural change to a document
380
403
  /// invalidates ITS indexes; this is one, made from another document's method.
381
404
  fn adopt_release(src: Value) -> Result<(), Error> {
405
+ /* Invalidated and recorded before the release (`record_edit`); the node
406
+ * borrow below is taken after, as dropping the indexes borrows too. */
407
+ let src_doc = keepalive_document(src)?;
408
+ invalidate_indexes(src_doc);
409
+ record_edit(src_doc, EditKind::ChildList);
382
410
  with_arg_node(src, |node| {
383
411
  /* SAFETY: the source document was cleared for editing by
384
412
  * `take_incoming` before anything was copied out of it. */
385
413
  release_from_tree(unsafe { HtmlNodeMut::assume_mutable(node) });
386
- })?;
387
- /* After the borrow `with_arg_node` held: dropping them borrows again. */
388
- let src_doc = keepalive_document(src)?;
389
- invalidate_indexes(src_doc);
390
- bump_tree_version(src_doc);
391
- Ok(())
414
+ })
392
415
  }
393
416
 
394
417
  fn release_from_tree(node: HtmlNodeMut<'_>) {
@@ -412,7 +435,7 @@ fn release_from_tree(node: HtmlNodeMut<'_>) {
412
435
  /// [`Insertion::check`], the shared `crate::dom_rules`); after that only the adoption copy can fail, and it
413
436
  /// too runs before a link is touched.
414
437
  pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value, Error> {
415
- let target = edit(this)?.node()?;
438
+ let edit = edit(this)?;
416
439
  let (key, incoming_doc) = html_node_key(rb_incoming)?;
417
440
  /* The argument is relinked too - `place` changes its parent and siblings, and
418
441
  * an adoption removes it from its own document - so a frozen argument is a
@@ -422,6 +445,12 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
422
445
  * checked, because frozenness lives on the Ruby object and there is no map
423
446
  * from a node back to its wrapper. */
424
447
  crate::bridge::ruby::check_frozen(rb_incoming)?;
448
+ /* An adoption changes the argument's own document too (see
449
+ * `take_incoming`, which checks again). Refused here as well, so every
450
+ * refusal about the argument comes before `node` records the edit, as the
451
+ * XML side's does. */
452
+ ensure_document_mutable(incoming_doc)?;
453
+ let target = edit.node()?;
425
454
  /* The argument, resolved against its own Document, for the checks, the
426
455
  * copy or move, and the placing - none of which runs Ruby or wraps a
427
456
  * node. Releasing an adopted original and wrapping its copy borrow a
@@ -429,7 +458,7 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
429
458
  let (placed, adopted) = with_html_node(incoming_doc, key, |incoming| {
430
459
  Insertion::new(target.node(), place, incoming)
431
460
  .and_then(|i| i.check())
432
- .map_err(|e| refused(e, place))?;
461
+ .map_err(crate::bridge::dom_error::pre_insert_error)?;
433
462
  let (node, adopted) = take_incoming(target, incoming_doc, incoming)?;
434
463
  target.place(node, place);
435
464
  Ok::<_, Error>((RawNode::from(node.node()), adopted))
@@ -441,38 +470,6 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
441
470
  wrap_html_node(placed, this.document)
442
471
  }
443
472
 
444
- /// A refused insertion, worded. The one place these messages live.
445
- fn refused(e: PreInsertError, place: Place) -> Error {
446
- use crate::dom_rules::{Hierarchy as H, Violation};
447
- makiri_error(match e {
448
- PreInsertError::NoParent if place == Place::Replace => {
449
- "cannot replace a node with no parent"
450
- }
451
- PreInsertError::NoParent => "cannot add a sibling to a node with no parent",
452
- /* Unreachable through `Insertion::new`, which takes the reference child
453
- * from the parent it names; worded all the same. */
454
- PreInsertError::Rule(Violation::NotFound) => {
455
- "the reference node is not a child of the parent"
456
- }
457
- PreInsertError::Rule(Violation::HierarchyRequest(h)) => match h {
458
- H::ParentNotContainer => {
459
- "only a document, a document fragment or an element can have children"
460
- }
461
- H::Ancestor => "cannot insert a node into its own subtree",
462
- H::AttributeNode => "an attribute node cannot be inserted into the tree",
463
- H::DocumentNode => "a document node cannot be inserted into the tree",
464
- H::UnsupportedNode => "this kind of node cannot be inserted into the tree",
465
- H::DoctypeParent => "a doctype node can only be a child of the document",
466
- H::DuplicateDoctype => "the document already has a doctype",
467
- H::DoctypeAfterElement | H::ElementBeforeDoctype => {
468
- "a doctype must precede the document element"
469
- }
470
- H::SecondDocumentElement => "the document already has a root element",
471
- H::TextUnderDocument => "text cannot be a child of the document",
472
- },
473
- })
474
- }
475
-
476
473
  /// The node to put in the tree for `incoming`: itself, taken out of where it
477
474
  /// was, or - from another document - a copy made in `target`'s, with `true`
478
475
  /// for "release the original once the copy is in" (see [`adopt_release`]).
@@ -59,6 +59,9 @@ pub mod node_set;
59
59
  #[cfg(feature = "lexbor")]
60
60
  pub mod node_wrap;
61
61
 
62
+ /// The errors the DOM's insertion rules raise, for HTML and XML alike.
63
+ pub mod dom_error;
64
+
62
65
  /// The Ruby <-> XPath engine seam: which backend a query runs on, and
63
66
  /// building the engine context for a Ruby node or document.
64
67
  #[cfg(feature = "lexbor")]
@@ -33,6 +33,13 @@ pub fn makiri_error(msg: impl Into<std::borrow::Cow<'static, str>>) -> Error {
33
33
  Error::new(error_class(), msg)
34
34
  }
35
35
 
36
+ /// A `Makiri::InternalError` carrying `msg`: a broken invariant of Makiri
37
+ /// itself, which a bare `rescue` (StandardError) passes through - see
38
+ /// [`entry`].
39
+ pub fn internal_error(msg: impl Into<std::borrow::Cow<'static, str>>) -> Error {
40
+ Error::new(crate::init::EXC_INTERNAL_ERROR.exception(), msg)
41
+ }
42
+
36
43
  /// Is `v` an instance of the class in `klass`? `false` before `Init_makiri`,
37
44
  /// when no class of ours exists for it to be an instance of.
38
45
  #[inline]
@@ -280,8 +287,7 @@ pub fn bool_value(v: VALUE) -> Option<bool> {
280
287
  pub fn entry<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
281
288
  match std::panic::catch_unwind(core::panic::AssertUnwindSafe(f)) {
282
289
  Ok(out) => out,
283
- Err(payload) => Err(Error::new(
284
- crate::init::EXC_INTERNAL_ERROR.exception(),
290
+ Err(payload) => Err(internal_error(
285
291
  crate::caught::message(payload.as_ref()).to_owned(),
286
292
  )),
287
293
  }
@@ -541,22 +541,46 @@ pub struct Encoding(*mut rb_sys::rb_encoding);
541
541
 
542
542
  impl Encoding {
543
543
  /// The encoding `str` is tagged with.
544
- fn of(str: RString) -> Encoding {
544
+ pub(super) fn of(str: RString) -> Encoding {
545
545
  // SAFETY: a live String, by type; reading its tag runs no Ruby.
546
546
  Encoding(unsafe { rb_sys::rb_enc_get(str.as_raw()) })
547
547
  }
548
548
 
549
- fn utf8() -> Encoding {
549
+ pub(super) fn utf8() -> Encoding {
550
550
  // SAFETY: Ruby's immutable global encoding.
551
551
  Encoding(unsafe { rb_sys::rb_utf8_encoding() })
552
552
  }
553
553
 
554
- fn is_usascii(self) -> bool {
554
+ /// The encoding Ruby knows by `name`, or None for a name it does not
555
+ /// know - not an error. MAY AUTOLOAD the encoding, a Ruby allocation and so
556
+ /// a GC point: call it with no borrow of a String's bytes held.
557
+ ///
558
+ /// `rb_enc_find` wants a C string and the name is bytes, so the NUL is
559
+ /// added here. A name too long to fit, or holding a NUL, is not one Ruby
560
+ /// knows, so it reads as None rather than being truncated at the NUL.
561
+ pub(super) fn find(name: &[u8]) -> Option<Encoding> {
562
+ let mut buf = [0u8; 64];
563
+ if name.is_empty() || name.len() >= buf.len() || name.contains(&0) {
564
+ return None;
565
+ }
566
+ buf[..name.len()].copy_from_slice(name);
567
+ let c = core::ffi::CStr::from_bytes_with_nul(&buf[..name.len() + 1]).ok()?;
568
+ // SAFETY: a NUL-terminated name; the lookup raises nothing.
569
+ let enc = unsafe { rb_sys::rb_enc_find(c.as_ptr()) };
570
+ (!enc.is_null()).then_some(Encoding(enc))
571
+ }
572
+
573
+ /// The raw encoding, for a C call in the bridge that takes one.
574
+ pub(super) fn as_raw(self) -> *mut rb_sys::rb_encoding {
575
+ self.0
576
+ }
577
+
578
+ pub(super) fn is_usascii(self) -> bool {
555
579
  // SAFETY: Ruby's immutable global encoding.
556
580
  self.0 == unsafe { rb_sys::rb_usascii_encoding() }
557
581
  }
558
582
 
559
- fn is_ascii8bit(self) -> bool {
583
+ pub(super) fn is_ascii8bit(self) -> bool {
560
584
  // SAFETY: Ruby's immutable global encoding.
561
585
  self.0 == unsafe { rb_sys::rb_ascii8bit_encoding() }
562
586
  }
@@ -573,10 +597,12 @@ impl Encoding {
573
597
  !self.is_utf8_compatible()
574
598
  }
575
599
 
576
- /// Whether HTML input in this encoding is parsed as it is: UTF-8, US-ASCII,
577
- /// or ASCII-8BIT - deliberately raw bytes, which the parser decodes
578
- /// leniently. Anything else is transcoded to UTF-8 first.
579
- fn parses_as_is(self) -> bool {
600
+ /// Whether input in this encoding is read as UTF-8 bytes with no
601
+ /// transcode: UTF-8, US-ASCII, or ASCII-8BIT - deliberately raw bytes.
602
+ /// Anything else is transcoded to UTF-8 first. How the bytes are then
603
+ /// checked is each reader's own: HTML repairs invalid UTF-8 to U+FFFD
604
+ /// (`ruby_to_utf8`), XML refuses it (`xml_decode`).
605
+ pub(super) fn reads_as_utf8_bytes(self) -> bool {
580
606
  self.is_utf8_compatible() || self.is_ascii8bit()
581
607
  }
582
608
 
@@ -631,7 +657,7 @@ pub fn to_encoding(v: Value) -> Result<Encoding, Error> {
631
657
  /// [`ruby_to_utf8_value`]. Called bare, that raise would `longjmp` over the
632
658
  /// Rust frames above it.
633
659
  unsafe fn ruby_to_utf8(str: RString) -> VALUE {
634
- if Encoding::of(str).parses_as_is() {
660
+ if Encoding::of(str).reads_as_utf8_bytes() {
635
661
  return str.as_raw();
636
662
  }
637
663
  const REPLACE: c_int = rb_sys::ruby_econv_flag_type::RUBY_ECONV_INVALID_REPLACE as c_int
@@ -652,7 +678,7 @@ pub fn ruby_to_utf8_value(s: RString) -> Result<RString, Error> {
652
678
  let raw = protect(|| unsafe { ruby_to_utf8(s) })?;
653
679
  // SAFETY: `rb_str_encode` returns a live value; checked to be a String.
654
680
  RString::from_value(unsafe { crate::bridge::ruby::value(raw) })
655
- .ok_or_else(|| makiri_error("transcoding returned a non-String"))
681
+ .ok_or_else(|| crate::bridge::ruby::internal_error("transcoding returned a non-String"))
656
682
  }
657
683
 
658
684
  /// A Ruby String as HTML parser input, under the text-input contract: its