makiri 0.12.1 → 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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -0
  3. data/README.md +29 -1
  4. data/ext/makiri/rust/build.rs +8 -8
  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 +56 -41
  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 +179 -31
  14. data/ext/makiri/rust/src/bridge/xml.rs +147 -31
  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 +24 -0
  21. data/ext/makiri/rust/src/glue/html_node/css.rs +3 -0
  22. data/ext/makiri/rust/src/glue/html_node/mod.rs +8 -1
  23. data/ext/makiri/rust/src/glue/html_node/mutate.rs +44 -40
  24. data/ext/makiri/rust/src/glue/html_node/read.rs +58 -1
  25. data/ext/makiri/rust/src/glue/node.rs +49 -2
  26. data/ext/makiri/rust/src/glue/query.rs +2 -21
  27. data/ext/makiri/rust/src/glue/xml_doc.rs +15 -0
  28. data/ext/makiri/rust/src/glue/xml_node/css.rs +3 -3
  29. data/ext/makiri/rust/src/glue/xml_node/mod.rs +3 -0
  30. data/ext/makiri/rust/src/glue/xml_node/mutate.rs +95 -26
  31. data/ext/makiri/rust/src/glue/xml_node/read.rs +28 -0
  32. data/ext/makiri/rust/src/glue/xml_node/serialize.rs +6 -0
  33. data/ext/makiri/rust/src/lexbor/abi.rs +11 -18
  34. data/ext/makiri/rust/src/lexbor/adapter/cross_import.rs +56 -14
  35. data/ext/makiri/rust/src/lexbor/adapter/html/attrs.rs +66 -13
  36. data/ext/makiri/rust/src/lexbor/adapter/html/build.rs +34 -5
  37. data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +108 -20
  38. data/ext/makiri/rust/src/lexbor/adapter/html/mutate.rs +16 -13
  39. data/ext/makiri/rust/src/lexbor/adapter/post_parse.rs +31 -17
  40. data/ext/makiri/rust/src/lexbor/adapter/text_index.rs +9 -9
  41. data/ext/makiri/rust/src/lexbor/adapter/tree_guard.rs +31 -24
  42. data/ext/makiri/rust/src/lexbor/css_match/compile.rs +71 -28
  43. data/ext/makiri/rust/src/lexbor/css_match/mod.rs +6 -0
  44. data/ext/makiri/rust/src/lexbor/css_match/query.rs +27 -43
  45. data/ext/makiri/rust/src/lexbor/css_match/scratch.rs +1 -1
  46. data/ext/makiri/rust/src/lexbor/css_match/simple.rs +4 -4
  47. data/ext/makiri/rust/src/lexbor/fragment.rs +22 -24
  48. data/ext/makiri/rust/src/lexbor/stylesheet.rs +50 -23
  49. data/ext/makiri/rust/src/lexbor/tests.rs +21 -9
  50. data/ext/makiri/rust/src/xml/arena.rs +107 -13
  51. data/ext/makiri/rust/src/xml/attr_key.rs +17 -4
  52. data/ext/makiri/rust/src/xml/chars/expand.rs +126 -25
  53. data/ext/makiri/rust/src/xml/chars/mod.rs +1 -1
  54. data/ext/makiri/rust/src/xml/dom_name.rs +4 -3
  55. data/ext/makiri/rust/src/xml/mod.rs +1 -0
  56. data/ext/makiri/rust/src/xml/model.rs +37 -30
  57. data/ext/makiri/rust/src/xml/mutate/attr.rs +58 -31
  58. data/ext/makiri/rust/src/xml/mutate/copy.rs +219 -58
  59. data/ext/makiri/rust/src/xml/mutate/edit.rs +2 -0
  60. data/ext/makiri/rust/src/xml/mutate/factory.rs +70 -16
  61. data/ext/makiri/rust/src/xml/mutate/insert.rs +15 -14
  62. data/ext/makiri/rust/src/xml/mutate/mod.rs +8 -5
  63. data/ext/makiri/rust/src/xml/mutate/ns.rs +87 -138
  64. data/ext/makiri/rust/src/xml/ns_scope.rs +104 -0
  65. data/ext/makiri/rust/src/xml/qname.rs +66 -1
  66. data/ext/makiri/rust/src/xml/selftest.rs +20 -6
  67. data/ext/makiri/rust/src/xml/serialize/c14n.rs +22 -12
  68. data/ext/makiri/rust/src/xml/serialize/mod.rs +20 -15
  69. data/ext/makiri/rust/src/xml/serialize/xml.rs +23 -13
  70. data/ext/makiri/rust/src/xml/tree/dtd.rs +15 -33
  71. data/ext/makiri/rust/src/xml/tree/mod.rs +30 -37
  72. data/ext/makiri/rust/src/xml/xpath.rs +9 -8
  73. data/ext/makiri/rust/src/xpath/order.rs +73 -4
  74. data/ext/makiri/rust/src/xpath/verify.rs +8 -53
  75. data/lib/makiri/html/document.rb +45 -21
  76. data/lib/makiri/version.rb +1 -1
  77. data/lib/makiri/xml/document.rb +10 -16
  78. data/script/check_unsafe_boundaries.rb +69 -41
  79. data/suppressions/ruby.supp +14 -0
  80. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6432135ca3963c17f432c152ede49162c3f1be48e96179b914c971f0b2da1b76
4
- data.tar.gz: 2b0478171ee224ea5c29ae19cd0a16a08c3d786e017444241d4bb8f620ecf975
3
+ metadata.gz: baaa765e2bd6208137962299994628a55a36ab68e29f9de9921e686679c69bf2
4
+ data.tar.gz: cd74ab123b1f8384d7aa14c448f1f12ee78371335061b60728c06424ccc5973b
5
5
  SHA512:
6
- metadata.gz: 8447a816ba507fb6a74ed8e6a5a92deaff6da2453ef29ba6cfe24652ec160b440409ec94b58b4aa89d3d28ab05643086f4dd1bba150339eeff27819d2e3d86d7
7
- data.tar.gz: 2a9505ac1b1762cbdbc738645542acbda4bb029e06e5b4befe76f63e39e6e55a9f36a34106dd0fe24d3103d17f020939498373d9c872cb2042d71b23bb0d6513
6
+ metadata.gz: 9ce8aa721e0e6848e2f7015f924f510599064f309c590181abef990e9b81121a1675aade8917515fdc35577bb65bb5edcf0d72ce4582df686eb0156d461d5e7b
7
+ data.tar.gz: 692a2a400e1be9ecc12fcd67cf317f5d7e51eef38c2846f25ab3bc23dd6626acdf02d860309e4444bf1b2b646461e8a32934804ebc8d1cc3f9a8593d729a79d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
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
+
77
+ ## [0.13.0] - 2026-10-03
78
+
79
+ ### Added
80
+
81
+ * `Document#tree_version` (HTML and XML): an Integer that increases whenever
82
+ a child list in the document changes - nodes added, removed or replaced,
83
+ `inner_html=`, `content=`, and both documents when a node moves between
84
+ them. Attribute changes leave it as it is, so it can key a cache of child
85
+ lists.
86
+ * `Element#attribute_value_ns(ns, local)` and `#attribute_node_ns(ns, local)`
87
+ (HTML and XML): the attribute's value or Attr node by namespace and local
88
+ name, like the DOM's `getAttributeNS` / `getAttributeNodeNS`. `nil` or `""`
89
+ means no namespace.
90
+ * `Element#set_loose_dom_attribute(name, value)` now works on HTML nodes too.
91
+ Like the DOM's `setAttribute`, it sets an attribute in no namespace whose
92
+ name may contain a colon (`v-on:click`).
93
+
94
+ ### Fixed
95
+
96
+ * HTML documents keep namespace URIs as written. `create_element_ns`,
97
+ `set_attribute_ns` and `import_node` lower-cased them (`fooNamespace` read
98
+ back as `foonamespace`), and a differently cased XHTML namespace URI made
99
+ an HTML element.
100
+
3
101
  ## [0.12.1] - 2026-10-02
4
102
 
5
103
  ### 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,33 @@ 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
+
193
+ ### Node identity
194
+
195
+ Navigating to the same node always gives the same Ruby object, for as long as
196
+ its document is alive. The document keeps every wrapper it has handed out, so
197
+ instance variables, singleton methods and `freeze` set on a node survive
198
+ garbage collection and are there the next time the node is reached.
199
+
200
+ That makes a node a safe place to keep per-node state, such as a wrapper
201
+ object of your own:
202
+
203
+ ```ruby
204
+ el = doc.at_css("p")
205
+ el.instance_variable_set(:@wrapper, MyElement.new(el))
206
+ doc.at_css("p").instance_variable_get(:@wrapper) # => the same MyElement
207
+ ```
208
+
209
+ A node created by `clone_node` or `import_node` is a new node with no state.
210
+ `pointer_id` is unique only among live nodes: once a document is freed, a node
211
+ of another document may reuse its value. So key a pointer-based cache per
212
+ document, not across documents.
213
+
187
214
  ## Non-goals (v1.0)
188
215
 
189
216
  * XSLT, DTD / Schema / RelaxNG validation, XPointer, XInclude.
@@ -224,6 +251,7 @@ See also [`spec/conformance/README.md`](spec/conformance/README.md).
224
251
  | XPath 1.0 | XML | `Nokogiri::XML` — differential | `conformance:xpath_xml` |
225
252
  | Parsed tree (property-based) | XML | `Nokogiri::XML` — differential | `conformance:xml_pbt` |
226
253
  | CSS selectors | XML | `Nokogiri::XML` — differential | `conformance:css_xml` |
254
+ | XML Builder | Ruby DSL | `Nokogiri::XML::Builder` — differential | `conformance:builder` |
227
255
 
228
256
  ## Requirements
229
257
 
@@ -186,8 +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")
190
- .allowlist_function("lxb_dom_document_root")
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")
191
196
  .allowlist_type("lxb_html_token_t")
192
197
  .allowlist_type("lxb_html_token_type_t")
193
198
  // The token-type FLAGS are in `enum lxb_html_token_type` - no trailing
@@ -277,6 +282,7 @@ fn main() {
277
282
  .allowlist_function("lxb_dom_document_type_create")
278
283
  .allowlist_function("lxb_dom_document_import_node")
279
284
  .allowlist_function("lexbor_str_init")
285
+ .allowlist_function("lxb_html_document_create")
280
286
  .allowlist_function("lxb_html_document_destroy")
281
287
  // The document title reader, generated rather than hand-declared like
282
288
  // the rest of this list.
@@ -410,12 +416,6 @@ const UNDECLARED_EXPORTS: &[(&str, &str, &str, &str)] = &[
410
416
  "lxb_dom_element_t *element, const lxb_char_t *prefix, size_t prefix_len, \
411
417
  const lxb_char_t *lname, size_t lname_len",
412
418
  ),
413
- (
414
- "lexbor/ns/ns.c",
415
- "LXB_API const lxb_ns_data_t *",
416
- "lxb_ns_append",
417
- "lexbor_hash_t *hash, const lxb_char_t *link, size_t length",
418
- ),
419
419
  ];
420
420
 
421
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).
@@ -324,10 +338,35 @@ impl<'a> HtmlEdit<'a> {
324
338
  /// not the token's - so a caller keeps the span from here to the change
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).
341
+ ///
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`].
327
345
  pub fn node(self) -> Result<HtmlNodeMut<'a>, Error> {
346
+ self.mutable(EditKind::ChildList)
347
+ }
348
+
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.
353
+ pub fn node_for_attributes(self) -> Result<HtmlNodeMut<'a>, Error> {
354
+ self.mutable(EditKind::Attributes)
355
+ }
356
+
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> {
328
365
  crate::bridge::ruby::check_frozen(self.this.value)?;
366
+ check_attr_owner_frozen(self.this)?;
329
367
  ensure_document_mutable(self.this.document)?;
330
368
  invalidate_indexes(self.this.document);
369
+ record_edit(self.this.document, kind);
331
370
  // SAFETY: the receiver is not frozen and no XPath evaluation is
332
371
  // reading its document - both checked just now.
333
372
  Ok(unsafe { HtmlNodeMut::assume_mutable(self.this.raw().as_node()) })
@@ -363,14 +402,16 @@ fn adopt_copy<'d>(doc: RawDoc, node: HtmlNode<'_>) -> Result<HtmlNode<'d>, Error
363
402
  /// document's indexes, which still list it. A structural change to a document
364
403
  /// invalidates ITS indexes; this is one, made from another document's method.
365
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);
366
410
  with_arg_node(src, |node| {
367
411
  /* SAFETY: the source document was cleared for editing by
368
412
  * `take_incoming` before anything was copied out of it. */
369
413
  release_from_tree(unsafe { HtmlNodeMut::assume_mutable(node) });
370
- })?;
371
- /* After the borrow `with_arg_node` held: dropping them borrows again. */
372
- invalidate_indexes(keepalive_document(src)?);
373
- Ok(())
414
+ })
374
415
  }
375
416
 
376
417
  fn release_from_tree(node: HtmlNodeMut<'_>) {
@@ -394,7 +435,7 @@ fn release_from_tree(node: HtmlNodeMut<'_>) {
394
435
  /// [`Insertion::check`], the shared `crate::dom_rules`); after that only the adoption copy can fail, and it
395
436
  /// too runs before a link is touched.
396
437
  pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value, Error> {
397
- let target = edit(this)?.node()?;
438
+ let edit = edit(this)?;
398
439
  let (key, incoming_doc) = html_node_key(rb_incoming)?;
399
440
  /* The argument is relinked too - `place` changes its parent and siblings, and
400
441
  * an adoption removes it from its own document - so a frozen argument is a
@@ -404,6 +445,12 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
404
445
  * checked, because frozenness lives on the Ruby object and there is no map
405
446
  * from a node back to its wrapper. */
406
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()?;
407
454
  /* The argument, resolved against its own Document, for the checks, the
408
455
  * copy or move, and the placing - none of which runs Ruby or wraps a
409
456
  * node. Releasing an adopted original and wrapping its copy borrow a
@@ -411,7 +458,7 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
411
458
  let (placed, adopted) = with_html_node(incoming_doc, key, |incoming| {
412
459
  Insertion::new(target.node(), place, incoming)
413
460
  .and_then(|i| i.check())
414
- .map_err(|e| refused(e, place))?;
461
+ .map_err(crate::bridge::dom_error::pre_insert_error)?;
415
462
  let (node, adopted) = take_incoming(target, incoming_doc, incoming)?;
416
463
  target.place(node, place);
417
464
  Ok::<_, Error>((RawNode::from(node.node()), adopted))
@@ -423,38 +470,6 @@ pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value
423
470
  wrap_html_node(placed, this.document)
424
471
  }
425
472
 
426
- /// A refused insertion, worded. The one place these messages live.
427
- fn refused(e: PreInsertError, place: Place) -> Error {
428
- use crate::dom_rules::{Hierarchy as H, Violation};
429
- makiri_error(match e {
430
- PreInsertError::NoParent if place == Place::Replace => {
431
- "cannot replace a node with no parent"
432
- }
433
- PreInsertError::NoParent => "cannot add a sibling to a node with no parent",
434
- /* Unreachable through `Insertion::new`, which takes the reference child
435
- * from the parent it names; worded all the same. */
436
- PreInsertError::Rule(Violation::NotFound) => {
437
- "the reference node is not a child of the parent"
438
- }
439
- PreInsertError::Rule(Violation::HierarchyRequest(h)) => match h {
440
- H::ParentNotContainer => {
441
- "only a document, a document fragment or an element can have children"
442
- }
443
- H::Ancestor => "cannot insert a node into its own subtree",
444
- H::AttributeNode => "an attribute node cannot be inserted into the tree",
445
- H::DocumentNode => "a document node cannot be inserted into the tree",
446
- H::UnsupportedNode => "this kind of node cannot be inserted into the tree",
447
- H::DoctypeParent => "a doctype node can only be a child of the document",
448
- H::DuplicateDoctype => "the document already has a doctype",
449
- H::DoctypeAfterElement | H::ElementBeforeDoctype => {
450
- "a doctype must precede the document element"
451
- }
452
- H::SecondDocumentElement => "the document already has a root element",
453
- H::TextUnderDocument => "text cannot be a child of the document",
454
- },
455
- })
456
- }
457
-
458
473
  /// The node to put in the tree for `incoming`: itself, taken out of where it
459
474
  /// was, or - from another document - a copy made in `target`'s, with `true`
460
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