makiri 0.14.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 (37) 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 +31 -11
  9. data/NOKOGIRI_DIFFERENCES.md +6 -12
  10. data/Rakefile +19 -5
  11. data/ext/makiri/rust/build.rs +0 -1
  12. data/ext/makiri/rust/fuzz/fuzz_targets/html_css_diff.rs +21 -3
  13. data/ext/makiri/rust/src/css/lower.rs +5 -65
  14. data/ext/makiri/rust/src/glue/html_node/css.rs +1 -1
  15. data/ext/makiri/rust/src/glue/html_node/mod.rs +4 -0
  16. data/ext/makiri/rust/src/glue/html_node/mutate.rs +44 -0
  17. data/ext/makiri/rust/src/lexbor/adapter/html/mod.rs +7 -0
  18. data/ext/makiri/rust/src/lexbor/contains_guard.rs +41 -115
  19. data/ext/makiri/rust/src/lexbor/css_match/compile.rs +11 -10
  20. data/ext/makiri/rust/src/lexbor/css_match/mod.rs +20 -7
  21. data/ext/makiri/rust/src/lexbor/css_match/query.rs +46 -5
  22. data/ext/makiri/rust/src/lexbor/css_match/simple.rs +9 -6
  23. data/ext/makiri/rust/src/lexbor/css_parser.rs +39 -24
  24. data/ext/makiri/rust/src/lexbor/css_tokens.rs +10 -22
  25. data/ext/makiri/rust/src/lexbor/mod.rs +4 -2
  26. data/ext/makiri/rust/src/lexbor/selector_text.rs +340 -0
  27. data/ext/makiri/rust/src/lexbor/stylesheet.rs +85 -59
  28. data/ext/makiri/rust/src/lexbor/tests.rs +335 -69
  29. data/ext/makiri/rust/src/xml/attr_key.rs +3 -0
  30. data/ext/makiri/rust/src/xpath/funcs/ext.rs +0 -18
  31. data/ext/makiri/rust/src/xpath/funcs/mod.rs +0 -8
  32. data/ext/makiri/rust/src/xpath/number.rs +1 -1
  33. data/lib/makiri/version.rb +1 -1
  34. data/lib/makiri/xml/document.rb +15 -0
  35. data/script/check_alloc_failures.rb +5 -4
  36. data/script/check_unsafe_boundaries.rb +23 -23
  37. metadata +2 -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: 383a2feea9ae25aca8e66985862eb3dfbbb64682771fc4578d9f0f6c11100d43
4
+ data.tar.gz: 774f2089e9b581b757cf0e142e937f892a87ccd71bb7c60f74666d9007bcac5c
5
5
  SHA512:
6
- metadata.gz: 9ce8aa721e0e6848e2f7015f924f510599064f309c590181abef990e9b81121a1675aade8917515fdc35577bb65bb5edcf0d72ce4582df686eb0156d461d5e7b
7
- data.tar.gz: 692a2a400e1be9ecc12fcd67cf317f5d7e51eef38c2846f25ab3bc23dd6626acdf02d860309e4444bf1b2b646461e8a32934804ebc8d1cc3f9a8593d729a79d8
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,36 @@
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
+
3
34
  ## [0.14.0] - 2026-10-04
4
35
 
5
36
  ### Added
@@ -964,14 +995,3 @@ Tooling
964
995
  benchmark harness (`rake bench`); conformance harnesses (html5lib-tests, WPT
965
996
  domxpath, CSS differential vs `Nokogiri::HTML5`). GitHub Actions CI across
966
997
  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
 
@@ -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
@@ -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
  _ => {}
@@ -31,9 +31,7 @@ use crate::xpath::ast::{Axis, Expr, NodeTest, Op, Step};
31
31
 
32
32
  /// The internal of-type position functions, whose names carry a leading \x01 so
33
33
  /// no user expression can name them.
34
- use crate::xpath::funcs::{
35
- FN_CHILD_POS, FN_CHILD_POS_LAST, FN_IS_TEXT, FN_OF_TYPE_POS, FN_OF_TYPE_POS_LAST,
36
- };
34
+ use crate::xpath::funcs::{FN_CHILD_POS, FN_CHILD_POS_LAST, FN_OF_TYPE_POS, FN_OF_TYPE_POS_LAST};
37
35
 
38
36
  /* ------------------------------------------------------------------ *
39
37
  * simple selectors *
@@ -353,8 +351,7 @@ fn lower_pseudo_simple(b: &Build, pc: PseudoClass) -> Built {
353
351
  *
354
352
  * `NodeTest::Text` matches a CDATA section as well, and that is wanted
355
353
  * HERE: Lexbor's `:empty` ignores comments alone, so any other child -
356
- * a CDATA section included - makes the element non-empty. The
357
- * CDATA-excluding rule is `:lexbor-contains`'s, not this one. */
354
+ * a CDATA section included - makes the element non-empty. */
358
355
  PseudoClass::Empty => build::fold(
359
356
  b,
360
357
  Op::And,
@@ -398,29 +395,8 @@ pub(crate) fn selector_list_selftest(b: &Build, lists: Lists<'_>) -> Built {
398
395
  )
399
396
  }
400
397
 
401
- /// `child::text()[is-text()][pred]` - the element's direct child TEXT nodes
402
- /// satisfying `pred`, which is consumed.
403
- ///
404
- /// In predicate position a non-empty node-set is truthy, so this reads "some
405
- /// direct child text node matches" - exactly how Lexbor's `:lexbor-contains`
406
- /// matcher scans, which looks at immediate child TEXT nodes only and not at the
407
- /// deep string value. Matching that is what keeps the XML path's answer equal to
408
- /// the HTML one.
409
- ///
410
- /// The `is-text()` filter drops CDATA sections, which XPath's `text()` matches
411
- /// but Lexbor's matcher does not: it scans `LXB_DOM_NODE_TYPE_TEXT` alone, so an
412
- /// XML `:lexbor-contains` must not see a CDATA section either.
413
- fn child_text_pred(b: &Build, pred: Built) -> Built {
414
- let pred = pred?;
415
- let mut step = Step::new(Axis::Child, NodeTest::Text);
416
- let is_text = build::call0(b, FN_IS_TEXT);
417
- build::push(b, &mut step.predicates, is_text?)?;
418
- build::push(b, &mut step.predicates, pred)?;
419
- build::single_step_path(b, step)
420
- }
421
-
422
398
  /// The functional pseudo-classes: `:nth-*(an+b)`, `:not()`, `:is()`/`:where()`,
423
- /// `:has()`, `:lexbor-contains()`.
399
+ /// `:has()`.
424
400
  fn lower_pseudo_func(b: &Build, arg: FunctionArg<'_>) -> Built {
425
401
  match arg {
426
402
  FunctionArg::Nth {
@@ -460,43 +436,7 @@ fn lower_pseudo_func(b: &Build, arg: FunctionArg<'_>) -> Built {
460
436
  ),
461
437
  },
462
438
 
463
- FunctionArg::Contains(c) => {
464
- let Some(c) = c else {
465
- return Err(b.fail(ErrorKind::Syntax, "malformed :lexbor-contains()"));
466
- };
467
- let needle = c.needle;
468
-
469
- if !c.insensitive {
470
- let dot = build::step_path(b, Axis::SelfAxis, NodeTest::Node); /* "." */
471
- return child_text_pred(
472
- b,
473
- build::call2(b, b"contains", || dot, || build::literal(b, needle)),
474
- );
475
- }
476
-
477
- /* ASCII case-insensitive: fold both sides with translate(). The
478
- * flag is ASCII-only, which is what Lexbor's matcher does. */
479
- const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
480
- const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
481
- let Some(mut low) = crate::falloc::try_vec_with_capacity::<u8>(needle.len()) else {
482
- return Err(b.oom());
483
- };
484
- low.extend(needle.iter().map(|&ch| ch.to_ascii_lowercase()));
485
-
486
- let folded = build::call3(
487
- b,
488
- b"translate",
489
- || build::step_path(b, Axis::SelfAxis, NodeTest::Node),
490
- || build::literal(b, UPPER),
491
- || build::literal(b, LOWER),
492
- );
493
- child_text_pred(
494
- b,
495
- build::call2(b, b"contains", || folded, || build::literal(b, &low)),
496
- )
497
- }
498
-
499
- FunctionArg::Other => {
439
+ FunctionArg::Contains | FunctionArg::Current(_) | FunctionArg::Other => {
500
440
  Err(b.fail(ErrorKind::Syntax, "unsupported functional CSS pseudo-class"))
501
441
  }
502
442
  }
@@ -563,7 +503,7 @@ fn combinator_axis(b: &Build, c: Combinator, reverse: bool) -> Result<Axis, Repo
563
503
  (Combinator::Child, true) => Axis::Parent,
564
504
  (Combinator::SubsequentSibling, false) => Axis::FollowingSibling,
565
505
  (Combinator::SubsequentSibling, true) => Axis::PrecedingSibling,
566
- (Combinator::NextSibling | Combinator::Other, _) => {
506
+ (Combinator::NextSibling | Combinator::Column | Combinator::Other, _) => {
567
507
  return Err(b.fail(ErrorKind::Syntax, "unsupported CSS combinator"));
568
508
  }
569
509
  })
@@ -53,7 +53,7 @@ fn query_error(err: QueryFailure) -> Error {
53
53
  /// one node each, never a `Vec`) and so only ever fail the other way.
54
54
  ///
55
55
  /// The message for `Unsupported` (the column combinator `||`, or
56
- /// `:lexbor-contains()`) matches the OLD engine's own wording for the same
56
+ /// `:current()`) matches the OLD engine's own wording for the same
57
57
  /// case (`SelectError::Traversal`'s "CSS selector could not be run") on
58
58
  /// purpose: it is the same fact - this engine could not run the selector
59
59
  /// either - not a new one.
@@ -178,6 +178,10 @@ fn init_mutate() -> Result<(), Error> {
178
178
 
179
179
  doc.define_method("create_element", method!(mutate::create_element, 1))?;
180
180
  doc.define_method("create_element_ns", method!(mutate::create_element_ns, 2))?;
181
+ doc.define_method(
182
+ "create_loose_dom_element",
183
+ method!(mutate::create_loose_dom_element, 4),
184
+ )?;
181
185
  doc.define_method(
182
186
  "create_document_type",
183
187
  method!(mutate::create_document_type, -1),
@@ -340,6 +340,50 @@ pub fn create_element_ns(
340
340
  })
341
341
  }
342
342
 
343
+ /// `create_loose_dom_element(qualified_name, prefix, local_name, namespace_uri)`
344
+ /// -> Element.
345
+ ///
346
+ /// The XML Document's factory of the same name: the name is not parsed, the
347
+ /// caller gives its split, so a local name keeps its colons (`"foo:"`) and its
348
+ /// case. Neither [`create_element`] (lower-cased, in the HTML namespace) nor
349
+ /// [`create_element_ns`] (split at the first colon) can make that. The names
350
+ /// are held to the DOM's rule (`ArgumentError`); the namespace is not checked
351
+ /// against the prefix, as on XML.
352
+ pub fn create_loose_dom_element(
353
+ ruby: &Ruby,
354
+ rb_self: Value,
355
+ rb_qname: Value,
356
+ rb_prefix: Value,
357
+ rb_local: Value,
358
+ rb_ns: Value,
359
+ ) -> Result<Value, Error> {
360
+ crate::bridge::ruby::entry(|| {
361
+ let doc = owning_doc(&rb_self)?;
362
+ let qv = ruby_verified_name(rb_qname, "qualified name")?;
363
+ let lv = ruby_verified_name(rb_local, "local name")?;
364
+ let pv = if rb_prefix.is_nil() {
365
+ None
366
+ } else {
367
+ Some(ruby_verified_name(rb_prefix, "prefix")?)
368
+ };
369
+ let nv = namespace_arg(rb_ns, "namespace URI")?;
370
+ let prefix = pv.as_ref().map(|p| p.as_bytes());
371
+ dom_name::split_loose_dom_name(qv.as_bytes(), prefix, lv.as_bytes())
372
+ .map_err(|e| Error::new(ruby.exception_arg_error(), e.message()))?;
373
+ let ns = nv.as_ref().map_or(&b""[..], |n| n.as_bytes());
374
+ created(
375
+ crate::bridge::html::create_element_ns(
376
+ doc,
377
+ lv.as_bytes(),
378
+ ns,
379
+ prefix.unwrap_or_default(),
380
+ ),
381
+ rb_self,
382
+ "element",
383
+ )
384
+ })
385
+ }
386
+
343
387
  /// `Document#create_text_node(content)` -> Text.
344
388
  pub fn create_text_node(_ruby: &Ruby, rb_self: Value, rb_text: Value) -> Result<Value, Error> {
345
389
  crate::bridge::ruby::entry(|| {
@@ -939,6 +939,13 @@ impl<'doc> HtmlNode<'doc> {
939
939
  if let Some(c) = self.first_child() {
940
940
  return Some(c);
941
941
  }
942
+ self.preorder_next_past(root)
943
+ }
944
+
945
+ /// The node after this one's whole subtree in a pre-order walk of
946
+ /// `root`'s subtree: [`preorder_next`](Self::preorder_next) without going
947
+ /// down into this node's children. None past the last.
948
+ pub fn preorder_next_past(self, root: Self) -> Option<Self> {
942
949
  let mut n = self;
943
950
  loop {
944
951
  if n == root {