makiri 0.13.0 → 0.15.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -0
  3. data/.github/workflows/conformance.yml +7 -0
  4. data/.github/workflows/css-match.yml +38 -1
  5. data/.github/workflows/security.yml +7 -0
  6. data/.github/workflows/valgrind.yml +22 -4
  7. data/.github/workflows/verify.yml +26 -0
  8. data/CHANGELOG.md +105 -11
  9. data/NOKOGIRI_DIFFERENCES.md +6 -12
  10. data/README.md +6 -0
  11. data/Rakefile +19 -5
  12. data/ext/makiri/rust/build.rs +1 -8
  13. data/ext/makiri/rust/fuzz/fuzz_targets/html_css_diff.rs +21 -3
  14. data/ext/makiri/rust/src/bridge/doc.rs +30 -8
  15. data/ext/makiri/rust/src/bridge/dom_error.rs +47 -0
  16. data/ext/makiri/rust/src/bridge/fragment.rs +1 -2
  17. data/ext/makiri/rust/src/bridge/gvl.rs +1 -2
  18. data/ext/makiri/rust/src/bridge/html.rs +50 -53
  19. data/ext/makiri/rust/src/bridge/mod.rs +3 -0
  20. data/ext/makiri/rust/src/bridge/ruby.rs +8 -2
  21. data/ext/makiri/rust/src/bridge/string.rs +36 -10
  22. data/ext/makiri/rust/src/bridge/wrapper.rs +171 -49
  23. data/ext/makiri/rust/src/bridge/xml.rs +125 -55
  24. data/ext/makiri/rust/src/bridge/xml_decode.rs +49 -69
  25. data/ext/makiri/rust/src/bridge/xpath/context_object.rs +24 -39
  26. data/ext/makiri/rust/src/bridge/xpath/handler.rs +11 -15
  27. data/ext/makiri/rust/src/bridge/xpath/mod.rs +74 -16
  28. data/ext/makiri/rust/src/css/lower.rs +5 -65
  29. data/ext/makiri/rust/src/dom_rules.rs +18 -0
  30. data/ext/makiri/rust/src/glue/html_doc.rs +20 -0
  31. data/ext/makiri/rust/src/glue/html_node/css.rs +4 -1
  32. data/ext/makiri/rust/src/glue/html_node/mod.rs +4 -0
  33. data/ext/makiri/rust/src/glue/html_node/mutate.rs +87 -40
  34. data/ext/makiri/rust/src/glue/node.rs +43 -5
  35. data/ext/makiri/rust/src/glue/query.rs +2 -21
  36. data/ext/makiri/rust/src/glue/xml_doc.rs +11 -0
  37. data/ext/makiri/rust/src/glue/xml_node/css.rs +3 -3
  38. data/ext/makiri/rust/src/glue/xml_node/mod.rs +1 -0
  39. data/ext/makiri/rust/src/glue/xml_node/mutate.rs +88 -21
  40. data/ext/makiri/rust/src/glue/xml_node/serialize.rs +6 -0
  41. data/ext/makiri/rust/src/lexbor/abi.rs +11 -18
  42. data/ext/makiri/rust/src/lexbor/adapter/cross_import.rs +56 -14
  43. data/ext/makiri/rust/src/lexbor/adapter/html/attrs.rs +15 -7
  44. data/ext/makiri/rust/src/lexbor/adapter/html/build.rs +12 -4
  45. data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +61 -10
  46. data/ext/makiri/rust/src/lexbor/adapter/html/mutate.rs +16 -13
  47. data/ext/makiri/rust/src/lexbor/adapter/post_parse.rs +31 -17
  48. data/ext/makiri/rust/src/lexbor/adapter/text_index.rs +9 -9
  49. data/ext/makiri/rust/src/lexbor/adapter/tree_guard.rs +31 -24
  50. data/ext/makiri/rust/src/lexbor/contains_guard.rs +41 -115
  51. data/ext/makiri/rust/src/lexbor/css_match/compile.rs +80 -36
  52. data/ext/makiri/rust/src/lexbor/css_match/mod.rs +26 -7
  53. data/ext/makiri/rust/src/lexbor/css_match/query.rs +73 -48
  54. data/ext/makiri/rust/src/lexbor/css_match/scratch.rs +1 -1
  55. data/ext/makiri/rust/src/lexbor/css_match/simple.rs +13 -10
  56. data/ext/makiri/rust/src/lexbor/css_parser.rs +39 -24
  57. data/ext/makiri/rust/src/lexbor/css_tokens.rs +10 -22
  58. data/ext/makiri/rust/src/lexbor/fragment.rs +22 -24
  59. data/ext/makiri/rust/src/lexbor/mod.rs +4 -2
  60. data/ext/makiri/rust/src/lexbor/selector_text.rs +340 -0
  61. data/ext/makiri/rust/src/lexbor/stylesheet.rs +136 -83
  62. data/ext/makiri/rust/src/lexbor/tests.rs +356 -78
  63. data/ext/makiri/rust/src/xml/arena.rs +107 -13
  64. data/ext/makiri/rust/src/xml/attr_key.rs +20 -4
  65. data/ext/makiri/rust/src/xml/chars/expand.rs +126 -25
  66. data/ext/makiri/rust/src/xml/chars/mod.rs +1 -1
  67. data/ext/makiri/rust/src/xml/dom_name.rs +4 -3
  68. data/ext/makiri/rust/src/xml/mod.rs +1 -0
  69. data/ext/makiri/rust/src/xml/model.rs +37 -30
  70. data/ext/makiri/rust/src/xml/mutate/attr.rs +58 -31
  71. data/ext/makiri/rust/src/xml/mutate/copy.rs +219 -58
  72. data/ext/makiri/rust/src/xml/mutate/edit.rs +2 -0
  73. data/ext/makiri/rust/src/xml/mutate/factory.rs +70 -16
  74. data/ext/makiri/rust/src/xml/mutate/insert.rs +15 -14
  75. data/ext/makiri/rust/src/xml/mutate/mod.rs +8 -5
  76. data/ext/makiri/rust/src/xml/mutate/ns.rs +87 -138
  77. data/ext/makiri/rust/src/xml/ns_scope.rs +104 -0
  78. data/ext/makiri/rust/src/xml/qname.rs +66 -1
  79. data/ext/makiri/rust/src/xml/selftest.rs +20 -6
  80. data/ext/makiri/rust/src/xml/serialize/c14n.rs +22 -12
  81. data/ext/makiri/rust/src/xml/serialize/mod.rs +20 -15
  82. data/ext/makiri/rust/src/xml/serialize/xml.rs +23 -13
  83. data/ext/makiri/rust/src/xml/tree/dtd.rs +15 -33
  84. data/ext/makiri/rust/src/xml/tree/mod.rs +30 -37
  85. data/ext/makiri/rust/src/xml/xpath.rs +9 -8
  86. data/ext/makiri/rust/src/xpath/funcs/ext.rs +0 -18
  87. data/ext/makiri/rust/src/xpath/funcs/mod.rs +0 -8
  88. data/ext/makiri/rust/src/xpath/number.rs +1 -1
  89. data/ext/makiri/rust/src/xpath/order.rs +73 -4
  90. data/ext/makiri/rust/src/xpath/verify.rs +8 -53
  91. data/lib/makiri/html/document.rb +45 -21
  92. data/lib/makiri/version.rb +1 -1
  93. data/lib/makiri/xml/document.rb +24 -15
  94. data/script/check_alloc_failures.rb +5 -4
  95. data/script/check_unsafe_boundaries.rb +58 -30
  96. data/suppressions/ruby.supp +14 -0
  97. metadata +4 -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: 383a2feea9ae25aca8e66985862eb3dfbbb64682771fc4578d9f0f6c11100d43
4
+ data.tar.gz: 774f2089e9b581b757cf0e142e937f892a87ccd71bb7c60f74666d9007bcac5c
5
5
  SHA512:
6
- metadata.gz: f847d9d772a2865bcd6e3f9b1626f0b67e4215d14d844b529bd194e9d287aa288cc2a555156799f842ac68fcc13405e1cfff317a506c2476b87eb24646f17c46
7
- data.tar.gz: ee1fe65b9055dc2a9c11cdbbd789544ef72ef1bd2c8fa731ab0eab2f97173125ab3f96637da903d33df76dfa80e221dd0e11abeaa446f5683e1428cc7f6552af
6
+ metadata.gz: 575cdf673cf1c0f2f76bc641c978a5c946e99a19ebc9bd92d761aa23ac9e7d9a72d980120c22b7bc344a95d0236bcfff6887fc09e28836f64739ca87a18c7e8d
7
+ data.tar.gz: 8b5f74af6c317ce0928c2b700d4061828621c4115685b80c3b49c47bf65f521f46431e4cf22ce064eadf0c30dc2f68611f84b3e4e4ead2b162ef5369c5544b7a
@@ -5,6 +5,13 @@ on:
5
5
  branches: [main, master]
6
6
  pull_request:
7
7
 
8
+ # A new push to a pull request supersedes the run on its previous head. The
9
+ # event is in the group so a push to main never queues behind (or replaces) a
10
+ # nightly run of the same workflow.
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
13
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
14
+
8
15
  jobs:
9
16
  test:
10
17
  name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }}
@@ -15,6 +15,13 @@ on:
15
15
  - cron: "37 5 * * *" # nightly, offset from the other workflows
16
16
  workflow_dispatch:
17
17
 
18
+ # A new push to a pull request supersedes the run on its previous head. The
19
+ # event is in the group so a push to main never queues behind (or replaces) a
20
+ # nightly run of the same workflow.
21
+ concurrency:
22
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
23
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
24
+
18
25
  jobs:
19
26
  # PR + push: HTML5 parsing conformance, plus the XPath and CSS differentials
20
27
  # over the curated corpus and a moderate batch of generated XPath expressions.
@@ -37,6 +37,13 @@ on:
37
37
  - cron: "41 4 * * *"
38
38
  workflow_dispatch:
39
39
 
40
+ # A new push to a pull request supersedes the run on its previous head. The
41
+ # event is in the group so a push to main never queues behind (or replaces) a
42
+ # nightly run of the same workflow.
43
+ concurrency:
44
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
45
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
46
+
40
47
  env:
41
48
  # Short on a push or PR, long on the nightly schedule.
42
49
  LONG: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
@@ -139,8 +146,12 @@ jobs:
139
146
  # also starts from the other's corpus (read-only: new units go to the
140
147
  # first directory). The cache prefix is libfuzzer.yml's, so the corpus
141
148
  # accumulated there carries over.
149
+ # Restored here, saved after the minimization below - and not on a pull
150
+ # request: a cache a PR saves is scoped to that PR's ref, so main and the
151
+ # nightly never read it, and minimizing it (~5 min) only bought a cache
152
+ # nobody restores.
142
153
  - name: Restore the corpus
143
- uses: actions/cache@v6
154
+ uses: actions/cache/restore@v6
144
155
  with:
145
156
  path: ext/makiri/rust/fuzz/corpus/${{ matrix.target }}
146
157
  key: cargo-fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
@@ -171,6 +182,32 @@ jobs:
171
182
  -dict=css.dict \
172
183
  -print_final_stats=1
173
184
 
185
+ # The corpus is saved after every main/nightly run, so without this it
186
+ # only grows: every nightly hour adds units and nothing ever drops one.
187
+ # It reached ~58k files (with the sibling, ~107k units replayed before
188
+ # the first mutation) of which libFuzzer kept ~2.8k, and a short run
189
+ # spent its whole 180 s replaying - 0 new units, no fuzzing at all.
190
+ # Minimizing before the save keeps only what adds coverage. The first
191
+ # run after a long gap pays one replay of the backlog; later ones a
192
+ # few thousand units.
193
+ - name: Minimize the corpus before it is cached
194
+ if: github.event_name != 'pull_request'
195
+ working-directory: ext/makiri/rust/fuzz
196
+ run: |
197
+ before=$(ls corpus/${{ matrix.target }} | wc -l)
198
+ cargo fuzz cmin ${{ matrix.target }} corpus/${{ matrix.target }} -- \
199
+ -max_len=4096 \
200
+ -timeout=30
201
+ after=$(ls corpus/${{ matrix.target }} | wc -l)
202
+ echo "corpus/${{ matrix.target }}: $before -> $after files"
203
+
204
+ - name: Save the corpus
205
+ if: github.event_name != 'pull_request'
206
+ uses: actions/cache/save@v6
207
+ with:
208
+ path: ext/makiri/rust/fuzz/corpus/${{ matrix.target }}
209
+ key: cargo-fuzz-corpus-${{ matrix.target }}-${{ github.run_id }}
210
+
174
211
  - name: Upload reproducers
175
212
  if: failure()
176
213
  uses: actions/upload-artifact@v4
@@ -8,6 +8,13 @@ on:
8
8
  - cron: "17 19 * * *"
9
9
  workflow_dispatch:
10
10
 
11
+ # A new push to a pull request supersedes the run on its previous head. The
12
+ # event is in the group so a push to main never queues behind (or replaces) a
13
+ # nightly run of the same workflow.
14
+ concurrency:
15
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
16
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
17
+
11
18
  jobs:
12
19
  # A "C safety lint" job stood here (`rake security:clint`, over
13
20
  # script/check_c_safety.rb). It enforced rules about C that no longer exists -
@@ -23,6 +23,13 @@ on:
23
23
  - cron: "0 2 * * *"
24
24
  workflow_dispatch:
25
25
 
26
+ # A new push to a pull request supersedes the run on its previous head. The
27
+ # event is in the group so a push to main never queues behind (or replaces) a
28
+ # nightly run of the same workflow.
29
+ concurrency:
30
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
31
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
32
+
26
33
  jobs:
27
34
  # Valgrind memcheck on the full spec suite. Complements ASan by catching
28
35
  # layout-independent errors ASan misses: use of uninitialised values, and
@@ -111,8 +118,8 @@ jobs:
111
118
  # (parse/drop cycles) scale their iteration count down under stress
112
119
  # (`gc_churn_iters` / `GC_COMPACT_ITERS`) because each stressed iteration is
113
120
  # orders of magnitude heavier; `GC_COMPACT_ITERS` below tunes the total runtime
114
- # (~6-9 min on CI at 200). An earlier version forced GC.stress onto the
115
- # *entire* suite (~800 examples): it ran 1h40m+ and never finished, while
121
+ # (~14.5 min on CI at 200 in one job, hence the three shards below). An
122
+ # earlier version forced GC.stress onto the *entire* suite (~800 examples): it ran 1h40m+ and never finished, while
116
123
  # testing borrowed-pointer discipline on hundreds of examples that have none.
117
124
  # The rest of the suite still runs in ci.yml.
118
125
  #
@@ -123,7 +130,7 @@ jobs:
123
130
  # Pull requests, nightly and on demand - not on push (the valgrind job is the
124
131
  # post-merge gate). See the note under `on:` for why pull requests.
125
132
  if: github.event_name != 'push'
126
- name: GC.auto_compact + GC.stress (Ruby ${{ matrix.ruby }})
133
+ name: GC.auto_compact + GC.stress (Ruby ${{ matrix.ruby }}, ${{ matrix.shard.name }})
127
134
  runs-on: ubuntu-latest
128
135
  timeout-minutes: 30
129
136
  env:
@@ -137,6 +144,17 @@ jobs:
137
144
  fail-fast: false
138
145
  matrix:
139
146
  ruby: ["3.4"]
147
+ # Split by spec file into three ~equal parts (measured per file: the
148
+ # handler spec alone is a third, attribute + source_location another).
149
+ # The last shard is everything ELSE, so a newly tagged file lands
150
+ # there rather than in no shard; rebalance if it grows past the others.
151
+ shard:
152
+ - name: xpath_handler
153
+ args: spec/xpath_handler_spec.rb
154
+ - name: attribute
155
+ args: spec/attribute_spec.rb spec/source_location_spec.rb
156
+ - name: rest
157
+ args: --exclude-pattern "spec/{xpath_handler,attribute,source_location}_spec.rb" spec
140
158
 
141
159
  steps:
142
160
  - name: Checkout (with vendored Lexbor submodule)
@@ -166,4 +184,4 @@ jobs:
166
184
  # auto_compact globally and wrap each example in GC.stress; --tag gc_compact
167
185
  # limits the run to the borrowed-pointer examples.
168
186
  - name: Run GC-sensitive examples under GC.auto_compact + GC.stress
169
- run: bundle exec rspec --tag gc_compact spec
187
+ run: bundle exec rspec --tag gc_compact ${{ matrix.shard.args }}
@@ -1,11 +1,37 @@
1
1
  name: Verify
2
2
 
3
3
  on:
4
+ # Ten minutes of proofs, over the Ruby-free engine only - so they run when
5
+ # that engine (or how it is built) changes, not on every push.
4
6
  push:
5
7
  branches: [main, master]
8
+ paths: &kani-paths
9
+ # The proofs build `--no-default-features`, so the Ruby/Lexbor layers
10
+ # below are compiled out of them; a change confined to those cannot
11
+ # change a proof's outcome. Everything else under src/ stays in, since
12
+ # the harnesses reach the engine's shared modules (limits, text, ...).
13
+ - "ext/makiri/rust/src/**"
14
+ - "!ext/makiri/rust/src/bridge/**"
15
+ - "!ext/makiri/rust/src/glue/**"
16
+ - "!ext/makiri/rust/src/lexbor/**"
17
+ - "!ext/makiri/rust/src/css/**"
18
+ - "!ext/makiri/rust/src/init.rs"
19
+ - "ext/makiri/rust/build.rs"
20
+ - "ext/makiri/rust/Cargo.toml"
21
+ - "ext/makiri/rust/Cargo.lock"
22
+ - "Rakefile"
23
+ - ".github/workflows/verify.yml"
6
24
  pull_request:
25
+ paths: *kani-paths
7
26
  workflow_dispatch:
8
27
 
28
+ # A new push to a pull request supersedes the run on its previous head. The
29
+ # event is in the group so a push to main never queues behind (or replaces) a
30
+ # nightly run of the same workflow.
31
+ concurrency:
32
+ group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
33
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
34
+
9
35
  jobs:
10
36
  # A CBMC job stood here and proved ext/makiri/{core,xml,xpath}/*.c. Those
11
37
  # files are gone, and `rake verify` with them: every one of its fifteen
data/CHANGELOG.md CHANGED
@@ -1,5 +1,110 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.0] - 2026-10-09
4
+
5
+ ### Added
6
+
7
+ * `Makiri::HTML::Document#create_loose_dom_element(qualified_name, prefix,
8
+ local_name, namespace_uri)`, like the XML Document's: the local name is
9
+ taken as given (`"Foo:Bar"` keeps its colon and case). Names the DOM
10
+ refuses raise `ArgumentError`.
11
+ * `Makiri::XML::Document#quirks_mode?` (always `false`) and `#compat_mode`
12
+ (always `"CSS1Compat"`), as on the HTML Document.
13
+
14
+ ### Changed
15
+
16
+ * `:lexbor-contains()` is no longer supported. `#css` / `#at_css` /
17
+ `#matches?` raise `Makiri::CSS::SyntaxError` for it (HTML and XML), and
18
+ `parse_stylesheet` reports its rule as `:bad_style`. Use XPath `contains()`
19
+ instead.
20
+ * HTML `#css` / `#at_css` / `#matches?` raise `Makiri::Error` ("could not be
21
+ run") for `:current(S)` instead of matching nothing.
22
+
23
+ ### Fixed
24
+
25
+ * `parse_stylesheet` keeps escapes in a selector's `:text`: `.md\:block` came
26
+ back as `.md:block`. `:current(S)` keeps its argument, and a rule
27
+ with `[|a]` is no longer reported as `:bad_style`.
28
+ * `parse_stylesheet` trims only CSS whitespace and never an escaped one:
29
+ `screen\ ` came back as `screen\`. A `:bad_style`'s `selector_text` no
30
+ longer keeps the whitespace before its `{`.
31
+ * HTML `:has()` with a descendant combinator is no longer slow on deep
32
+ documents: `:has(* * x)` exceeded the work budget at 200 levels.
33
+
34
+ ## [0.14.0] - 2026-10-04
35
+
36
+ ### Added
37
+
38
+ * `Document#attribute_version` (HTML and XML): an Integer that increases
39
+ whenever an attribute is added, removed or set (to the same value too),
40
+ including through an Attr node's `content=` or `remove`. Child-list and
41
+ text edits leave it as it is, so it can key a cache of attributes.
42
+ * `Makiri::XML::Document#create_element_ns(namespace_uri, qualified_name)`,
43
+ like the HTML Document's and the DOM's `createElementNS`. The element keeps
44
+ its namespace wherever it is inserted; a name that is not an XML QName
45
+ (`f}oo`) makes an element that `to_xml` refuses.
46
+ * `Makiri::HTML::Document.new`: an empty HTML document in no-quirks mode.
47
+ * `Makiri::HTML::Document#quirks_mode?` and `#compat_mode` (`"BackCompat"` /
48
+ `"CSS1Compat"`, like the DOM's `compatMode`).
49
+ * HTML `Attr#remove` / `#unlink` remove the attribute from its element, as on
50
+ XML. They raised "use delete(name) to remove an attribute".
51
+
52
+ ### Changed
53
+
54
+ * `tree_version` counts child-list changes only. A Text, Comment, CDATA or
55
+ PI node's `content=` no longer moves it, and an HTML Attr node's
56
+ `content=` moves `attribute_version` instead.
57
+ * `tree_version` and `attribute_version` are documented as cache keys: an
58
+ unchanged version means nothing it covers changed, while a changed one may
59
+ be conservative (an edit refused part-way can still move it).
60
+ * `namespace_uri` and `prefix` are documented as `nil`, never `""`, when
61
+ there is no namespace or prefix, in HTML and XML.
62
+ * `Document#dup` copies the tree node by node instead of re-parsing its
63
+ serialization. An HTML copy keeps a tree with no `<html>` root as it is and
64
+ keeps the quirks mode, and `Node#line` on it is `nil`. An XML copy keeps
65
+ every name and namespace as written (a re-parse invented `ns1:` prefixes),
66
+ copies data XML cannot write, and keeps the original's `max_bytes`.
67
+ * XML `create_processing_instruction` accepts the target `xml` (in any case),
68
+ as the DOM does; `to_xml` and `canonicalize` refuse a tree holding one.
69
+ * HTML `create_processing_instruction` raises `ArgumentError`, as XML does,
70
+ for data containing `?>`. It raised `Makiri::Error`.
71
+ * A refused XML insertion names the DOM rule it breaks, in the same words as
72
+ HTML. It said "invalid placement".
73
+
74
+ ### Fixed
75
+
76
+ * An Attr's `content=` and `remove` raise `FrozenError` when the element
77
+ that owns it is frozen, in HTML and XML.
78
+ * XML `Attr#content=` sets the attribute's value. It raised "operation
79
+ unsupported".
80
+ * HTML `title=` and `meta_encoding=` do nothing on a document with no
81
+ `<head>`, as the DOM's title setter does. They inserted the element directly
82
+ under the root element.
83
+ * HTML `Document#root` and CSS `:root` return the document element, or `nil`.
84
+ Without an `<html>` element they returned the first child of any kind, such
85
+ as a comment, and `title=` raised on such a document.
86
+ * An edit refused for its arguments - a frozen or non-node argument to an
87
+ HTML insertion, an invalid name given to XML `[]=`, `set_attribute_ns` or
88
+ `set_loose_dom_attribute`, a refused HTML `Attr#remove` - no longer moves a
89
+ version.
90
+ * An XML copy that runs out of the document's byte budget (`import_node`,
91
+ `clone_node`, an insertion from another document) gives back what it used.
92
+ The document could not be edited any more after one.
93
+ * Importing HTML into an XML document stores each namespace URI once instead
94
+ of once per element, which used up the byte budget faster.
95
+ * An HTML Attr imported on its own from another HTML document keeps its
96
+ namespace URI as written. It was lower-cased.
97
+ * `to_xml` and `canonicalize` write an element in the XML namespace as
98
+ `xml:local`. They wrote a declaration that does not parse. An element in
99
+ the XMLNS namespace is refused with `Makiri::Error`.
100
+ * XML XPath: a prefixed attribute on a detached element, whose namespace is
101
+ not decided yet, no longer matches an unprefixed name test.
102
+ * XML: references are read by one grammar everywhere. The internal subset
103
+ accepted a character reference to a character XML does not allow
104
+ (`<!ENTITY x "&#0;">`), and a malformed reference next to an entity
105
+ reference was reported as an unsupported DTD construct instead of as
106
+ malformed XML.
107
+
3
108
  ## [0.13.0] - 2026-10-03
4
109
 
5
110
  ### Added
@@ -890,14 +995,3 @@ Tooling
890
995
  benchmark harness (`rake bench`); conformance harnesses (html5lib-tests, WPT
891
996
  domxpath, CSS differential vs `Nokogiri::HTML5`). GitHub Actions CI across
892
997
  Ruby 3.2–4.0 × Ubuntu/macOS plus a sanitizer job.
893
-
894
- [0.9.0]: https://github.com/takahashim/makiri/compare/v0.8.0...v0.9.0
895
- [0.8.0]: https://github.com/takahashim/makiri/compare/v0.7.0...v0.8.0
896
- [0.7.0]: https://github.com/takahashim/makiri/compare/v0.6.0...v0.7.0
897
- [0.6.0]: https://github.com/takahashim/makiri/compare/v0.5.1...v0.6.0
898
- [0.5.1]: https://github.com/takahashim/makiri/compare/v0.5.0...v0.5.1
899
- [0.5.0]: https://github.com/takahashim/makiri/compare/v0.4.0...v0.5.0
900
- [0.4.0]: https://github.com/takahashim/makiri/compare/v0.3.0...v0.4.0
901
- [0.3.0]: https://github.com/takahashim/makiri/compare/v0.2.0...v0.3.0
902
- [0.2.0]: https://github.com/takahashim/makiri/compare/v0.1.0...v0.2.0
903
- [0.1.0]: https://github.com/takahashim/makiri/releases/tag/v0.1.0
@@ -245,18 +245,12 @@ what browsers do - rather than libxml2. Detailed, test-backed notes live in
245
245
  text-containment extension. Use XPath (`xpath("//p[contains(., 'x')]")`) or
246
246
  Enumerable (`css('li')[1]`) for the rest.
247
247
  Standard Level-4 selectors (`:is` / `:where` / `:has`) are supported; some of which Nokogiri rejects.
248
- * `:lexbor-contains("text")` - Lexbor's spelling of the jQuery `:contains()`
249
- substring filter, matching an element whose text contains the string;
250
- append ` i` (`:lexbor-contains("text" i)`) for an ASCII case-insensitive
251
- match. (Nokogiri's name `:contains` is not an alias.) **XML only**: it
252
- lowers to XPath `child::text()[contains(., "text")]`, testing the
253
- element's immediate child text nodes, not the deep string-value. HTML no
254
- longer supports it - `lexbor::css_match`, the safe-Rust port that
255
- replaced Lexbor's own matcher for HTML, deliberately does not reimplement
256
- it, so a well-formed `:lexbor-contains()` now raises `Makiri::Error`
257
- ("could not be run") on HTML rather than ever matching (it still PARSES,
258
- and an actually malformed one still raises `Makiri::CSS::SyntaxError`, as
259
- before).
248
+ * There is no text-containment pseudo-class. Nokogiri's `:contains()` is
249
+ not supported, and neither is Lexbor's `:lexbor-contains()`: it raises
250
+ `Makiri::CSS::SyntaxError` on HTML and XML alike, like any unknown
251
+ pseudo-class (Lexbor's parser for it had memory-safety bugs, so Makiri
252
+ never lets it run). Use XPath instead:
253
+ `xpath('//p[contains(., "text")]')`.
260
254
  * Untyped `:*-of-type` (`:first-of-type`, `:nth-of-type(an+b)`, ... with no type
261
255
  selector) is supported and correct on both HTML and XML - the "type" is the
262
256
  element's own expanded name.
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
data/Rakefile CHANGED
@@ -94,10 +94,12 @@ end
94
94
  # the report down to errors whose stack touches our extension, so we no longer
95
95
  # have to fetch ruby.supp from ruby/ruby (that path was removed upstream).
96
96
  #
97
- # We keep this job's historical contract: catch *use of uninitialised values*
98
- # and *invalid reads/writes* (incl. intra-arena overflows) - NOT leaks (leak
99
- # detection stays with `rake leaks`). So we override ruby_memcheck's defaults,
100
- # which disable undef-value errors and turn on full leak-check.
97
+ # The job catches *use of uninitialised values* and *invalid reads/writes*
98
+ # (incl. intra-arena overflows), and also any "definitely lost" block whose
99
+ # stack touches our extension - the one leak gate with no frequency threshold
100
+ # (`rake leaks`, macOS, flags only stacks repeated >= 30x). It once meant to
101
+ # leave leaks out and could not: see `--leak-check` below. ruby_memcheck's
102
+ # defaults disable undef-value errors, so we override them.
101
103
  #
102
104
  # `filter_all_errors: true` is essential: by default ruby_memcheck only applies
103
105
  # its "stack must touch the makiri binary" filter to *leak*-kind errors
@@ -146,7 +148,19 @@ begin
146
148
  # post-merge push gate turns it off (VALGRIND_TRACK_ORIGINS=no) to run in
147
149
  # ~half the time, while the nightly / manual runs keep it on for the backtrace.
148
150
  "--track-origins=#{ENV.fetch('VALGRIND_TRACK_ORIGINS', 'yes')}",
149
- "--leak-check=no", # leaks are `rake leaks`' job, not this one
151
+ # Leaks ARE checked here, whatever this says: ruby_memcheck always runs
152
+ # valgrind with --xml=yes, and in XML mode memcheck writes its leak
153
+ # records even under --leak-check=no (a "definitely lost" in a spec's
154
+ # child process failed run 37864917248 that way). So say what happens,
155
+ # and keep ruby_memcheck's own leak kinds - these options REPLACE its
156
+ # defaults rather than adding to them, and without the line below
157
+ # valgrind's default (definite,possible) fails the job on a "possibly
158
+ # lost" too. A spec that runs Ruby in a child must leave
159
+ # RUBY_FREE_AT_EXIT set there (it is inherited): without it the child's
160
+ # live strings at exit are reported as definitely lost through our
161
+ # frames (PR #55).
162
+ "--leak-check=full",
163
+ "--show-leak-kinds=definite",
150
164
  ],
151
165
  )
152
166
 
@@ -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.
@@ -292,7 +292,6 @@ fn main() {
292
292
  .allowlist_function("lxb_css_stylesheet_destroy")
293
293
  .allowlist_function("lxb_css_property_serialize")
294
294
  .allowlist_function("lxb_css_property_serialize_name")
295
- .allowlist_function("lxb_css_selector_serialize_chain")
296
295
  // One node's own markup, plain and pretty: Makiri drives the walk
297
296
  // itself (`adapter::html::serialize`), so a <template> writes its
298
297
  // contents and not its own children, as the HTML Standard says, and
@@ -416,12 +415,6 @@ const UNDECLARED_EXPORTS: &[(&str, &str, &str, &str)] = &[
416
415
  "lxb_dom_element_t *element, const lxb_char_t *prefix, size_t prefix_len, \
417
416
  const lxb_char_t *lname, size_t lname_len",
418
417
  ),
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
418
  ];
426
419
 
427
420
  fn check_undeclared_exports(include: &std::path::Path) {
@@ -22,7 +22,9 @@
22
22
  //! (`:checked` / `:disabled` / `:enabled`, and `:read-only` / `:read-write`,
23
23
  //! which ask the same `is_disabled`), `of S`, `An+B` past 2^53, a
24
24
  //! compound that starts with a list pseudo-class where it has several
25
- //! candidates (Lexbor tries only the first - found by this target), and,
25
+ //! candidates (Lexbor tries only the first - found by this target), a
26
+ //! compound holding one after `+` in a `:has()` argument (Lexbor stops after
27
+ //! the first pair it fails), and,
26
28
  //! on a document with foreign elements, the HTML Standard's case-sensitivity
27
29
  //! and qualified-name rules for type and attribute selectors. A selector either engine refuses is not compared either.
28
30
  //!
@@ -173,6 +175,8 @@ impl Shape {
173
175
  // Whether the compound being walked starts with a list
174
176
  // pseudo-class (`:is()`, `:where()`, `:not()`, `:has()`).
175
177
  let mut leads_with_list = false;
178
+ // How the compound being walked attaches to the one before.
179
+ let mut attached = Combinator::Descendant;
176
180
  let mut first = true;
177
181
  let mut sel: Option<Selector<'_>> = list.first();
178
182
  while let Some(s) = sel {
@@ -182,6 +186,7 @@ impl Shape {
182
186
  }
183
187
  let combinator = s.combinator();
184
188
  if first || combinator != Combinator::Close {
189
+ attached = combinator;
185
190
  // The compound before this one is searched over every
186
191
  // ancestor / preceding sibling when this one attaches
187
192
  // by a descendant or `~` combinator.
@@ -199,6 +204,18 @@ impl Shape {
199
204
  shape.departs = true;
200
205
  }
201
206
  }
207
+ // In a `:has()` argument, a compound attached by `+` that
208
+ // holds `:is()` / `:not()` / `:where()` / `:has()`: Lexbor
209
+ // stops after the first pair that fails it.
210
+ if in_has
211
+ && attached == Combinator::NextSibling
212
+ && matches!(
213
+ s.simple(),
214
+ Simple::PseudoClassFunction(FunctionArg::Selectors { .. })
215
+ )
216
+ {
217
+ shape.departs = true;
218
+ }
202
219
  first = false;
203
220
  shape.simple(s, in_has, &mut work);
204
221
  sel = s.next();
@@ -211,7 +228,7 @@ impl Shape {
211
228
  fn simple<'p>(&mut self, s: Selector<'p>, in_has: bool, work: &mut Vec<(Lists<'p>, bool)>) {
212
229
  match s.combinator() {
213
230
  Combinator::Descendant | Combinator::SubsequentSibling => self.backtracks += 1,
214
- Combinator::Other => self.departs = true,
231
+ Combinator::Column | Combinator::Other => self.departs = true,
215
232
  _ => {}
216
233
  }
217
234
  let upper = s.name().iter().any(u8::is_ascii_uppercase);
@@ -245,7 +262,8 @@ impl Shape {
245
262
  }
246
263
  work.push((lists, in_has || has));
247
264
  }
248
- FunctionArg::Contains(_) => self.departs = true,
265
+ // Both raised by the port and matched by Lexbor.
266
+ FunctionArg::Contains | FunctionArg::Current(_) => self.departs = true,
249
267
  FunctionArg::Other => {}
250
268
  },
251
269
  _ => {}
@@ -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
+ }