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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +7 -0
- data/.github/workflows/conformance.yml +7 -0
- data/.github/workflows/css-match.yml +38 -1
- data/.github/workflows/security.yml +7 -0
- data/.github/workflows/valgrind.yml +22 -4
- data/.github/workflows/verify.yml +26 -0
- data/CHANGELOG.md +58 -11
- data/NOKOGIRI_DIFFERENCES.md +6 -12
- data/Rakefile +19 -5
- data/ext/makiri/rust/build.rs +37 -1
- data/ext/makiri/rust/fuzz/fuzz_targets/html_css_diff.rs +21 -3
- data/ext/makiri/rust/src/bridge/html.rs +47 -1
- data/ext/makiri/rust/src/bridge/ruby.rs +41 -0
- data/ext/makiri/rust/src/bridge/string.rs +24 -0
- data/ext/makiri/rust/src/bridge/wrapper.rs +53 -0
- data/ext/makiri/rust/src/bridge/xml.rs +30 -0
- data/ext/makiri/rust/src/child_index/tests.rs +407 -0
- data/ext/makiri/rust/src/child_index.rs +357 -0
- data/ext/makiri/rust/src/css/lower.rs +5 -65
- data/ext/makiri/rust/src/dom_rules/tests.rs +32 -0
- data/ext/makiri/rust/src/dom_rules.rs +15 -1
- data/ext/makiri/rust/src/glue/html_node/css.rs +1 -1
- data/ext/makiri/rust/src/glue/html_node/mod.rs +21 -2
- data/ext/makiri/rust/src/glue/html_node/mutate.rs +44 -0
- data/ext/makiri/rust/src/glue/html_node/read.rs +27 -28
- data/ext/makiri/rust/src/glue/node.rs +44 -3
- data/ext/makiri/rust/src/glue/xml_node/mod.rs +17 -2
- data/ext/makiri/rust/src/glue/xml_node/read.rs +30 -31
- data/ext/makiri/rust/src/init.rs +14 -0
- data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +7 -0
- data/ext/makiri/rust/src/lexbor/adapter/html/mutate.rs +21 -3
- data/ext/makiri/rust/src/lexbor/contains_guard.rs +41 -115
- data/ext/makiri/rust/src/lexbor/css_match/compile.rs +11 -10
- data/ext/makiri/rust/src/lexbor/css_match/mod.rs +20 -7
- data/ext/makiri/rust/src/lexbor/css_match/query.rs +46 -5
- data/ext/makiri/rust/src/lexbor/css_match/simple.rs +9 -6
- data/ext/makiri/rust/src/lexbor/css_parser.rs +39 -24
- data/ext/makiri/rust/src/lexbor/css_tokens.rs +10 -22
- data/ext/makiri/rust/src/lexbor/mod.rs +4 -2
- data/ext/makiri/rust/src/lexbor/selector_text.rs +340 -0
- data/ext/makiri/rust/src/lexbor/stylesheet.rs +85 -59
- data/ext/makiri/rust/src/lexbor/tests.rs +335 -69
- data/ext/makiri/rust/src/lib.rs +4 -0
- data/ext/makiri/rust/src/ptr_table.rs +14 -0
- data/ext/makiri/rust/src/xml/attr_key.rs +3 -0
- data/ext/makiri/rust/src/xml/mutate/insert.rs +8 -0
- data/ext/makiri/rust/src/xpath/funcs/ext.rs +0 -18
- data/ext/makiri/rust/src/xpath/funcs/mod.rs +0 -8
- data/ext/makiri/rust/src/xpath/number.rs +1 -1
- data/lib/makiri/version.rb +1 -1
- data/lib/makiri/xml/document.rb +15 -0
- data/lib/makiri.rb +27 -8
- data/script/check_alloc_failures.rb +5 -4
- data/script/check_unsafe_boundaries.rb +45 -37
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80c74b7d335f5f5f93f8476a8f99a8918560df8cf75f43a5a71d9b132e892873
|
|
4
|
+
data.tar.gz: d6593459917f22ceb301093d17b981a597d8be7d975d1fab1e4142f6cf17fcbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: efa98c3ca322d6a3ee141428c1f5e9c3e5b46fba9dde4d7ac75d7dcfdf27d4dd083ef46eed5fb8db21685396d86caeb58e1755f9dd5014d396674cc149378df9
|
|
7
|
+
data.tar.gz: 836d97359577dfcd4bd60a8de0dfcf355c596b95fa7fffd5e56a0a355bb4267f3141bf8786f4f905596d79bef44721eeb3bfe4f25fa97e489c5aa3319c9f7655
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
# (~
|
|
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
|
|
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
|
data/NOKOGIRI_DIFFERENCES.md
CHANGED
|
@@ -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
|
-
*
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
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
|
-
|
|
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
|
|
data/ext/makiri/rust/build.rs
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
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,
|
|
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
|