makiri 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) 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 +58 -11
  9. data/NOKOGIRI_DIFFERENCES.md +6 -12
  10. data/Rakefile +19 -5
  11. data/ext/makiri/rust/build.rs +37 -1
  12. data/ext/makiri/rust/fuzz/fuzz_targets/html_css_diff.rs +21 -3
  13. data/ext/makiri/rust/src/bridge/html.rs +47 -1
  14. data/ext/makiri/rust/src/bridge/ruby.rs +41 -0
  15. data/ext/makiri/rust/src/bridge/string.rs +24 -0
  16. data/ext/makiri/rust/src/bridge/wrapper.rs +53 -0
  17. data/ext/makiri/rust/src/bridge/xml.rs +30 -0
  18. data/ext/makiri/rust/src/child_index/tests.rs +407 -0
  19. data/ext/makiri/rust/src/child_index.rs +357 -0
  20. data/ext/makiri/rust/src/css/lower.rs +5 -65
  21. data/ext/makiri/rust/src/dom_rules/tests.rs +32 -0
  22. data/ext/makiri/rust/src/dom_rules.rs +15 -1
  23. data/ext/makiri/rust/src/glue/html_node/css.rs +1 -1
  24. data/ext/makiri/rust/src/glue/html_node/mod.rs +21 -2
  25. data/ext/makiri/rust/src/glue/html_node/mutate.rs +44 -0
  26. data/ext/makiri/rust/src/glue/html_node/read.rs +27 -28
  27. data/ext/makiri/rust/src/glue/node.rs +44 -3
  28. data/ext/makiri/rust/src/glue/xml_node/mod.rs +17 -2
  29. data/ext/makiri/rust/src/glue/xml_node/read.rs +30 -31
  30. data/ext/makiri/rust/src/init.rs +14 -0
  31. data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +7 -0
  32. data/ext/makiri/rust/src/lexbor/adapter/html/mutate.rs +21 -3
  33. data/ext/makiri/rust/src/lexbor/contains_guard.rs +41 -115
  34. data/ext/makiri/rust/src/lexbor/css_match/compile.rs +11 -10
  35. data/ext/makiri/rust/src/lexbor/css_match/mod.rs +20 -7
  36. data/ext/makiri/rust/src/lexbor/css_match/query.rs +46 -5
  37. data/ext/makiri/rust/src/lexbor/css_match/simple.rs +9 -6
  38. data/ext/makiri/rust/src/lexbor/css_parser.rs +39 -24
  39. data/ext/makiri/rust/src/lexbor/css_tokens.rs +10 -22
  40. data/ext/makiri/rust/src/lexbor/mod.rs +4 -2
  41. data/ext/makiri/rust/src/lexbor/selector_text.rs +340 -0
  42. data/ext/makiri/rust/src/lexbor/stylesheet.rs +85 -59
  43. data/ext/makiri/rust/src/lexbor/tests.rs +335 -69
  44. data/ext/makiri/rust/src/lib.rs +4 -0
  45. data/ext/makiri/rust/src/ptr_table.rs +14 -0
  46. data/ext/makiri/rust/src/xml/attr_key.rs +3 -0
  47. data/ext/makiri/rust/src/xml/mutate/insert.rs +8 -0
  48. data/ext/makiri/rust/src/xpath/funcs/ext.rs +0 -18
  49. data/ext/makiri/rust/src/xpath/funcs/mod.rs +0 -8
  50. data/ext/makiri/rust/src/xpath/number.rs +1 -1
  51. data/lib/makiri/version.rb +1 -1
  52. data/lib/makiri/xml/document.rb +15 -0
  53. data/lib/makiri.rb +27 -8
  54. data/script/check_alloc_failures.rb +5 -4
  55. data/script/check_unsafe_boundaries.rb +45 -37
  56. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baaa765e2bd6208137962299994628a55a36ab68e29f9de9921e686679c69bf2
4
- data.tar.gz: cd74ab123b1f8384d7aa14c448f1f12ee78371335061b60728c06424ccc5973b
3
+ metadata.gz: 80c74b7d335f5f5f93f8476a8f99a8918560df8cf75f43a5a71d9b132e892873
4
+ data.tar.gz: d6593459917f22ceb301093d17b981a597d8be7d975d1fab1e4142f6cf17fcbc
5
5
  SHA512:
6
- metadata.gz: 9ce8aa721e0e6848e2f7015f924f510599064f309c590181abef990e9b81121a1675aade8917515fdc35577bb65bb5edcf0d72ce4582df686eb0156d461d5e7b
7
- data.tar.gz: 692a2a400e1be9ecc12fcd67cf317f5d7e51eef38c2846f25ab3bc23dd6626acdf02d860309e4444bf1b2b646461e8a32934804ebc8d1cc3f9a8593d729a79d8
6
+ metadata.gz: efa98c3ca322d6a3ee141428c1f5e9c3e5b46fba9dde4d7ac75d7dcfdf27d4dd083ef46eed5fb8db21685396d86caeb58e1755f9dd5014d396674cc149378df9
7
+ data.tar.gz: 836d97359577dfcd4bd60a8de0dfcf355c596b95fa7fffd5e56a0a355bb4267f3141bf8786f4f905596d79bef44721eeb3bfe4f25fa97e489c5aa3319c9f7655
@@ -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,63 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.16.0] - 2026-10-10
4
+
5
+ ### Added
6
+
7
+ * `Makiri::NATIVE_VERSION` and `Makiri::NATIVE_RUBY_API_VERSION`: the gem
8
+ version and Ruby `MAJOR.MINOR` the native extension was built as.
9
+ `require "makiri"` raises `LoadError` for an extension built for another
10
+ Ruby (refused by the extension itself, before it defines anything) or as
11
+ another makiri version (e.g. a stale `lib/makiri/makiri.bundle` in a
12
+ checkout).
13
+ * `Node#child_count`, `#child_at(i)`, `#element_child_count` and
14
+ `#element_child_at(i)` (HTML and XML): count and index a child list without
15
+ building `#children`. A loop over the indexes in order costs O(1) a step,
16
+ also while other lists are read in between. Any child-list edit in the
17
+ document resets that, so a loop that edits as it indexes walks from the
18
+ nearer end after each edit. `child_at` returns nil for a negative index or
19
+ one past the end.
20
+ * `Node#first_child` (an alias of `#child`) and `#last_child`.
21
+ * `Node#root_node`, DOM `getRootNode()`: the Document, a DocumentFragment, or
22
+ the top of a detached subtree. An Attr is its own root.
23
+
24
+ ### Changed
25
+
26
+ * `#local_name`, `#prefix`, `#namespace_uri` and `#tag_name` return frozen,
27
+ interned Strings: every read of one name returns the same object. `#name`
28
+ still returns a new, mutable String.
29
+
30
+ ## [0.15.0] - 2026-10-09
31
+
32
+ ### Added
33
+
34
+ * `Makiri::HTML::Document#create_loose_dom_element(qualified_name, prefix,
35
+ local_name, namespace_uri)`, like the XML Document's: the local name is
36
+ taken as given (`"Foo:Bar"` keeps its colon and case). Names the DOM
37
+ refuses raise `ArgumentError`.
38
+ * `Makiri::XML::Document#quirks_mode?` (always `false`) and `#compat_mode`
39
+ (always `"CSS1Compat"`), as on the HTML Document.
40
+
41
+ ### Changed
42
+
43
+ * `:lexbor-contains()` is no longer supported. `#css` / `#at_css` /
44
+ `#matches?` raise `Makiri::CSS::SyntaxError` for it (HTML and XML), and
45
+ `parse_stylesheet` reports its rule as `:bad_style`. Use XPath `contains()`
46
+ instead.
47
+ * HTML `#css` / `#at_css` / `#matches?` raise `Makiri::Error` ("could not be
48
+ run") for `:current(S)` instead of matching nothing.
49
+
50
+ ### Fixed
51
+
52
+ * `parse_stylesheet` keeps escapes in a selector's `:text`: `.md\:block` came
53
+ back as `.md:block`. `:current(S)` keeps its argument, and a rule
54
+ with `[|a]` is no longer reported as `:bad_style`.
55
+ * `parse_stylesheet` trims only CSS whitespace and never an escaped one:
56
+ `screen\ ` came back as `screen\`. A `:bad_style`'s `selector_text` no
57
+ longer keeps the whitespace before its `{`.
58
+ * HTML `:has()` with a descendant combinator is no longer slow on deep
59
+ documents: `:has(* * x)` exceeded the work budget at 200 levels.
60
+
3
61
  ## [0.14.0] - 2026-10-04
4
62
 
5
63
  ### Added
@@ -964,14 +1022,3 @@ Tooling
964
1022
  benchmark harness (`rake bench`); conformance harnesses (html5lib-tests, WPT
965
1023
  domxpath, CSS differential vs `Nokogiri::HTML5`). GitHub Actions CI across
966
1024
  Ruby 3.2–4.0 × Ubuntu/macOS plus a sanitizer job.
967
-
968
- [0.9.0]: https://github.com/takahashim/makiri/compare/v0.8.0...v0.9.0
969
- [0.8.0]: https://github.com/takahashim/makiri/compare/v0.7.0...v0.8.0
970
- [0.7.0]: https://github.com/takahashim/makiri/compare/v0.6.0...v0.7.0
971
- [0.6.0]: https://github.com/takahashim/makiri/compare/v0.5.1...v0.6.0
972
- [0.5.1]: https://github.com/takahashim/makiri/compare/v0.5.0...v0.5.1
973
- [0.5.0]: https://github.com/takahashim/makiri/compare/v0.4.0...v0.5.0
974
- [0.4.0]: https://github.com/takahashim/makiri/compare/v0.3.0...v0.4.0
975
- [0.3.0]: https://github.com/takahashim/makiri/compare/v0.2.0...v0.3.0
976
- [0.2.0]: https://github.com/takahashim/makiri/compare/v0.1.0...v0.2.0
977
- [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/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
 
@@ -41,6 +41,10 @@ fn main() {
41
41
  println!("cargo:rerun-if-changed=build.rs");
42
42
  println!("cargo:rerun-if-env-changed=MAKIRI_LEXBOR_INCLUDE");
43
43
 
44
+ if std::env::var_os("CARGO_FEATURE_RUBY").is_some() {
45
+ gem_version();
46
+ }
47
+
44
48
  // Only a build that reads Lexbor's layout pays for this. Kani builds with
45
49
  // `--no-default-features` and has no Lexbor headers to point at, which is
46
50
  // the whole reason `rake kani` needs no `rake compile` first.
@@ -292,7 +296,6 @@ fn main() {
292
296
  .allowlist_function("lxb_css_stylesheet_destroy")
293
297
  .allowlist_function("lxb_css_property_serialize")
294
298
  .allowlist_function("lxb_css_property_serialize_name")
295
- .allowlist_function("lxb_css_selector_serialize_chain")
296
299
  // One node's own markup, plain and pretty: Makiri drives the walk
297
300
  // itself (`adapter::html::serialize`), so a <template> writes its
298
301
  // contents and not its own children, as the HTML Standard says, and
@@ -473,3 +476,36 @@ fn definition(text: &str, name: &str) -> Option<(String, String)> {
473
476
  }
474
477
  None
475
478
  }
479
+
480
+ /// Embed the gem's version (`lib/makiri/version.rb`, the one place it is
481
+ /// written) as `MAKIRI_GEM_VERSION`, which `init` publishes as
482
+ /// `Makiri::NATIVE_VERSION` for `lib/makiri.rb` to check the loaded binary
483
+ /// against. Read from the source rather than passed in by extconf, so every
484
+ /// cargo build of the extension carries it and none can carry a stale one.
485
+ fn gem_version() {
486
+ let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
487
+ let path = manifest.join("../../../lib/makiri/version.rb");
488
+ println!("cargo:rerun-if-changed={}", path.display());
489
+ let source = std::fs::read_to_string(&path)
490
+ .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
491
+ /* `VERSION = "x"` or `VERSION = 'x'`, a trailing `.freeze` or comment
492
+ * allowed: the literal is what is between the first quote and its match. */
493
+ let version = source
494
+ .lines()
495
+ .find_map(|line| {
496
+ let rest = line.trim().strip_prefix("VERSION")?.trim_start();
497
+ let rest = rest.strip_prefix('=')?.trim_start();
498
+ let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?;
499
+ let body = &rest[1..];
500
+ body.find(quote).map(|end| &body[..end])
501
+ })
502
+ .filter(|v| !v.is_empty())
503
+ .unwrap_or_else(|| {
504
+ panic!(
505
+ "build.rs reads the gem version from a `VERSION = \"x.y.z\"` line in {}, \
506
+ and found none",
507
+ path.display()
508
+ )
509
+ });
510
+ println!("cargo:rustc-env=MAKIRI_GEM_VERSION={version}");
511
+ }
@@ -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
  _ => {}
@@ -17,12 +17,14 @@ use crate::init::{
17
17
  CLASS_HTML_PROCESSING_INSTRUCTION, CLASS_HTML_TEXT, CLASS_XML_DOCUMENT,
18
18
  };
19
19
  use crate::lexbor::adapter::html::{
20
- ForeignNode, HtmlNode, HtmlNodeKey, HtmlNodeMut, Insertion, NodeType, Place, RawDoc, RawNode,
20
+ ForeignNode, HtmlNode, HtmlNodeKey, HtmlNodeMut, HtmlTree, Insertion, NodeType, Place, RawDoc,
21
+ RawNode,
21
22
  };
22
23
  use crate::lexbor::fragment::import_with_fixup;
23
24
 
24
25
  use crate::bridge::string::{RubyData, RubyText};
25
26
  use crate::bridge::wrapper::*;
27
+ use crate::child_index::{ChildList, Children, TokenTree};
26
28
  use crate::lexbor::adapter::html::{HtmlDoc, HtmlElementMut};
27
29
  use crate::lexbor::adapter::AdapterOom;
28
30
 
@@ -241,6 +243,50 @@ pub fn wrap_node(node: Option<HtmlNode<'_>>, document: Value) -> Result<Option<V
241
243
  .transpose()
242
244
  }
243
245
 
246
+ /* ------------------------------------------------------------------ *
247
+ * indexing a child list *
248
+ * ------------------------------------------------------------------ */
249
+
250
+ /// The Lexbor tree for [`crate::child_index`]: a node's pointer is its token.
251
+ /// The links are [`HtmlTree`]'s, the raw ones `#children` follows, so the two
252
+ /// agree - a `<template>`'s own (empty) child list included.
253
+ impl<'d> TokenTree for HtmlTree<'d> {
254
+ fn token(&self, n: HtmlNode<'d>) -> usize {
255
+ RawNode::from(n).as_ptr() as usize
256
+ }
257
+ fn node_of(&self, token: usize) -> Option<HtmlNode<'d>> {
258
+ let raw = RawNode::from_ptr(token as *mut core::ffi::c_void)?;
259
+ // SAFETY: `child_index` asks only for a token it recorded for a list
260
+ // of this document, so by `ChildPositionMemo`'s invariant it names a
261
+ // node that is still a child there - in the document the receiver
262
+ // keeps alive for `'d`. Lexbor detaches rather than frees, so it was
263
+ // never released.
264
+ Some(unsafe { raw.as_node() })
265
+ }
266
+ }
267
+
268
+ /// The tree every HTML receiver lends its child lists, as `&'static`: a
269
+ /// `const` item is what may be borrowed for that long (the type holds
270
+ /// nothing).
271
+ const HTML_TREE: HtmlTree<'static> = HtmlTree::new();
272
+
273
+ impl ChildListHost for HtmlSelf {
274
+ type Tree<'a> = HtmlTree<'a>;
275
+ fn document(&self) -> Value {
276
+ self.document
277
+ }
278
+ fn children(&self, list: ChildList) -> Children<'_, HtmlTree<'_>> {
279
+ Children {
280
+ tree: &HTML_TREE,
281
+ parent: self.node(),
282
+ list,
283
+ }
284
+ }
285
+ fn wrap_child(&self, n: Option<HtmlNode<'_>>) -> Result<Option<Value>, Error> {
286
+ wrap_node(n, self.document)
287
+ }
288
+ }
289
+
244
290
  /* ------------------------------------------------------------------ *
245
291
  * resolving a long-lived key *
246
292
  * ------------------------------------------------------------------ */
@@ -20,6 +20,47 @@ pub use rb_sys::{ID, VALUE};
20
20
 
21
21
  /* The two conveniences every bridge and glue module shares. */
22
22
 
23
+ /// The `MAJOR.MINOR` of the Ruby whose headers this extension was compiled
24
+ /// against - its ABI, since a teeny release keeps it. Published as
25
+ /// `Makiri::NATIVE_RUBY_API_VERSION`.
26
+ pub fn ruby_api_version() -> String {
27
+ format!(
28
+ "{}.{}",
29
+ rb_sys::RUBY_API_VERSION_MAJOR,
30
+ rb_sys::RUBY_API_VERSION_MINOR
31
+ )
32
+ }
33
+
34
+ /// `Err(LoadError)` when the running Ruby's API `MAJOR.MINOR` is not the one
35
+ /// this extension was compiled for.
36
+ ///
37
+ /// The FIRST thing `Init_makiri` does. A release Ruby does not check
38
+ /// `ruby_abi_version` itself, and under macOS's `-undefined dynamic_lookup`
39
+ /// a binary built for another Ruby loads and then misreads the interpreter's
40
+ /// structs - defining one class could already corrupt it, so the check
41
+ /// cannot wait for `lib/makiri.rb`. It reads the running Ruby's exported
42
+ /// `ruby_api_version` (three ints, whose layout no Ruby changes) and nothing
43
+ /// else; only a mismatch goes on to build the exception.
44
+ pub fn check_running_ruby_api(ruby: &Ruby) -> Result<(), Error> {
45
+ // SAFETY: an immutable `const int[3]` libruby exports, read by value.
46
+ let running = unsafe { (rb_sys::ruby_api_version[0], rb_sys::ruby_api_version[1]) };
47
+ let built = (
48
+ rb_sys::RUBY_API_VERSION_MAJOR as core::ffi::c_int,
49
+ rb_sys::RUBY_API_VERSION_MINOR as core::ffi::c_int,
50
+ );
51
+ if running == built {
52
+ return Ok(());
53
+ }
54
+ Err(Error::new(
55
+ ruby.exception_load_error(),
56
+ format!(
57
+ "makiri's native extension was built for Ruby {}.{}, but this is Ruby {}.{}; \
58
+ rebuild it (`bundle exec rake clean compile` in a checkout, or reinstall the gem)",
59
+ built.0, built.1, running.0, running.1
60
+ ),
61
+ ))
62
+ }
63
+
23
64
  /// `Makiri::Error` - the one definition; the modules that each kept a private
24
65
  /// copy of this now import it.
25
66
  pub fn error_class() -> magnus::ExceptionClass {
@@ -411,6 +411,30 @@ pub unsafe fn ruby_str_from_utf8(bytes: &[u8]) -> VALUE {
411
411
  rb_sys::rb_utf8_str_new(bytes.as_ptr() as *const c_char, bytes.len() as c_long)
412
412
  }
413
413
 
414
+ /// A DOM name as a Ruby String: frozen and deduplicated, tagged UTF-8 -
415
+ /// Ruby's own `-"div"`, so every read of one name answers the same object and
416
+ /// allocates nothing once Ruby has it.
417
+ ///
418
+ /// THE contract for the name readers of both representations: `local_name`,
419
+ /// `prefix`, `namespace_uri` and `tag_name` answer through this, and only
420
+ /// `#name` (Nokogiri's, which callers may edit) answers a fresh String.
421
+ /// Character data - text, attribute values - is never interned.
422
+ ///
423
+ /// Safe to call with any bytes: `rb_enc_interned_str` copies them, and bytes
424
+ /// that were not UTF-8 would make a wrong String, never a memory error. Every
425
+ /// caller passes a document's name, which is UTF-8 by the text-input contract.
426
+ pub fn dom_name_str(bytes: &[u8]) -> Value {
427
+ // SAFETY: a slice's pointer and length, read for the call; the encoding
428
+ // is Ruby's static UTF-8 one. The result is a live String.
429
+ unsafe {
430
+ crate::bridge::ruby::value(rb_sys::rb_enc_interned_str(
431
+ bytes.as_ptr() as *const c_char,
432
+ bytes.len() as c_long,
433
+ rb_sys::rb_utf8_encoding(),
434
+ ))
435
+ }
436
+ }
437
+
414
438
  /* ---- the strict text contract ---- */
415
439
 
416
440
  /// Check `bytes` against the strict contract, returning the specific