diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
new file mode 100644
index 0000000..e4350ca
--- /dev/null
+++ b/.github/workflows/rust.yml
@@ -0,0 +1,119 @@
+name: Rust checks
+
+on:
+ pull_request:
+ paths:
+ - 'src/**'
+ - 'tests/**'
+ - 'benches/**'
+ - 'examples/**'
+ - 'build.rs'
+ - 'Cargo.toml'
+ - 'Cargo.lock'
+ - 'README.md'
+ - '.cargo/**'
+ - 'rust-toolchain*'
+ - '.github/workflows/rust.yml'
+ push:
+ branches: [master]
+ paths:
+ - 'src/**'
+ - 'tests/**'
+ - 'benches/**'
+ - 'examples/**'
+ - 'build.rs'
+ - 'Cargo.toml'
+ - 'Cargo.lock'
+ - 'README.md'
+ - '.cargo/**'
+ - 'rust-toolchain*'
+ - '.github/workflows/rust.yml'
+ workflow_dispatch:
+ inputs:
+ miri:
+ description: Run the focused Miri safety tests
+ type: boolean
+ default: false
+
+permissions:
+ contents: read
+
+concurrency:
+ group: rust-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ CARGO_TERM_COLOR: always
+ CARGO_INCREMENTAL: '0'
+
+jobs:
+ stable:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: '1.99.0'
+ components: rustfmt, clippy
+ - run: cargo fmt --check
+ - name: Tests and strict Clippy for each stable feature configuration
+ shell: bash
+ run: |
+ set -euo pipefail
+ cargo test --no-default-features
+ cargo test --features std
+ cargo test --features entities
+ cargo clippy --all-targets --no-default-features -- -D warnings
+ cargo clippy --all-targets --features entities -- -D warnings
+ - name: Benchmark smoke checks (no performance claims)
+ run: |
+ cargo bench --bench tl -- --test
+ cargo bench --features std --bench tl -- --test
+ cargo bench --features entities --bench audit -- --test
+
+ compatibility:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: '1.92.0'
+ - run: cargo check --all-targets --no-default-features
+ - run: cargo check --all-targets --features entities
+
+ portable-simd:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@nightly
+ with:
+ toolchain: nightly-2026-09-25
+ - run: cargo test --no-default-features --features portable-simd
+ - run: cargo test --features entities,portable-simd
+
+ miri:
+ if: github.event_name == 'workflow_dispatch' && inputs.miri
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ env:
+ MIRI_AUTOSETUP: '1'
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+ - uses: dtolnay/rust-toolchain@nightly
+ with:
+ toolchain: nightly-2026-09-25
+ components: miri
+ - run: cargo miri test --lib --features std tests::audit
+ - run: cargo miri test --lib --features std owned
+ - run: cargo miri test --lib --features std inline
diff --git a/Cargo.toml b/Cargo.toml
index 8fb2154..642afb3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -53,3 +53,12 @@ codegen-units = 1
incremental = false
debug = false
overflow-checks = false
+
+[[bench]]
+name = "audit"
+harness = false
+required-features = ["entities"]
+
+[[example]]
+name = "audit_allocations"
+required-features = ["entities"]
diff --git a/README.md b/README.md
index 09a9054..0a36662 100644
--- a/README.md
+++ b/README.md
@@ -5,10 +5,11 @@
tl is a fast HTML parser written in pure Rust.
By default this crate builds without `std` or `alloc`. Enable `std` for the
-allocating convenience API:
+allocating convenience API. The examples below target the current Git revision
+(next breaking 0.3.x release), including APIs not present in published 0.2.0:
```toml
-rustedbytes-tl = { version = "0.1", features = ["std"] }
+rustedbytes-tl = { git = "https://github.com/RustedBytes/rustedbytes-tl", features = ["std"] }
```
For the nightly portable SIMD path, enable `portable-simd` and build with
@@ -37,6 +38,9 @@ Enable `entities` to use `decoded_inner_text` for HTML character references.
See [compatibility notes](docs/silkworm-compatibility.md) for supported selectors,
remaining HTML5 differences, query limits and release compatibility.
+For checks, benchmarks, safety changes and migration guidance, see the
+[Rust audit notes](docs/rust-audit.md).
+
## Provenance
This crate is a fork of [`astral-tl`](https://github.com/astral-sh/astral-tl), modified to
diff --git a/benches/audit.rs b/benches/audit.rs
new file mode 100644
index 0000000..d816641
--- /dev/null
+++ b/benches/audit.rs
@@ -0,0 +1,36 @@
+use criterion::{Criterion, criterion_group, criterion_main};
+use std::hint::black_box;
+
+fn fixture() -> String {
+ let mut input = String::from("
");
+ for _ in 0..200 {
+ input.push_str("- plain text
");
+ }
+ input.push_str("
");
+ input
+}
+
+fn benchmarks(c: &mut Criterion) {
+ let input = fixture();
+ let dom = tl::parse(&input, tl::ParserOptions::default()).unwrap();
+ let node = dom
+ .query_selector("ul")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(dom.parser())
+ .unwrap();
+ assert_eq!(dom.query_selector("li:has(a)").unwrap().count(), 200);
+ assert_eq!(
+ node.decoded_inner_text(dom.parser()),
+ "plain text".repeat(200)
+ );
+ c.bench_function("audit/has_200", |b| {
+ b.iter(|| black_box(dom.query_selector(black_box("li:has(a)")).unwrap().count()))
+ });
+ c.bench_function("audit/decoded_text_200", |b| {
+ b.iter(|| black_box(node.decoded_inner_text(black_box(dom.parser()))))
+ });
+}
+criterion_group!(benches, benchmarks);
+criterion_main!(benches);
diff --git a/docs/rust-audit.md b/docs/rust-audit.md
new file mode 100644
index 0000000..781b2a2
--- /dev/null
+++ b/docs/rust-audit.md
@@ -0,0 +1,139 @@
+# Rust audit and measured improvements
+
+Baseline: merged PR #1, commit `46a4f9f`. The crate is edition 2024 with no declared
+MSRV; compatibility is checked on Rust 1.92 without raising a compiler floor.
+Current stable was verified from the [official release notes](https://doc.rust-lang.org/stable/releases.html):
+Rust 1.99.0, released 2026-10-01. No dependency versions or allocator were changed.
+
+The review applied correctness testing, unsafe auditing, idiom/API review,
+standard-library and performance optimization, Criterion measurement, Rust
+modernization, CI and documentation guidance. The library has no async runtime,
+FFI or PyO3 boundary, so those workflows are not applicable. This is a focused
+source and test review, not proof that every input/platform is correct.
+
+## Confirmed defects and fixes
+
+| Area | Trigger and consequence | Fix and evidence |
+| --- | --- | --- |
+| Inline raw storage, high severity | Safe `inline_parts_mut()` allowed replacing a live slot with uninitialized storage; later get/drop would access invalid values. | The public method now requires `unsafe` and documents initialization and unique ownership obligations. Two compile-fail examples enforce the safe-call boundary. |
+| Map spill, high severity | A key's `Hash` or `Eq` panicked after an entry was moved into a temporary heap map, while the source still counted that slot as initialized. Unwinding could drop moved/uninitialized storage. | Reduce the initialized prefix before moving each entry. A `Hash` panic test counts all three destructors and then accesses/drops the remaining map; it passes Miri. |
+| Container cloning, medium severity | A later `Clone` panicked, leaking earlier clones held in an unguarded MaybeUninit array. | Build into an owner with an incrementally updated initialized prefix. Both baseline regressions fail (0 destructor calls versus 2 expected); candidate regressions and Miri pass. |
+| Bytes ordering, medium severity | Equal content in borrowed and owned storage compared non-equal under derived pointer-based Ord; BTreeSet could retain duplicate equal values. | Implement lexicographic byte-content Ord/PartialOrd, consistent with Eq/Hash. The baseline regression fails; candidate and Miri pass. |
+| Duplicate map keys, medium severity | Re-inserting an existing inline key grew storage or spilled rather than replacing it, unlike the heap-backed map. | Replace existing values before checking capacity. Works with/without std. The HTML parser retains the first ordinary attribute using a dedicated insertion path. |
+
+The raw-storage signature change is intentional and requires the planned breaking
+0.3.x release. Existing safe code should use `get_mut`, `as_mut_slice` (vector),
+or map insertion instead. Code that genuinely manipulates raw storage must use
+an unsafe block and establish the documented obligations; simply wrapping the
+old code in unsafe does not establish them. No release or version bump occurs
+in this change. Ordinary parser/selector public signatures remain unchanged.
+After a panic during map spill, entries may have been consumed; remaining
+storage stays valid and each live owner is dropped once. Transactional rollback
+is not promised.
+
+The std-only allocator used for probes is an example, not a production allocator.
+New production unsafe reads have local initialized-prefix proofs. The empty
+MaybeUninit array now uses safe inline-const initialization.
+
+## Performance boundaries and results
+
+Fixture: 200 identical `plain text` rows inside `` (5,209
+input bytes). Setup builds/parses the fixture outside each measured operation.
+`has_200` parses the query string, builds its context, counts all matches and drops
+the iterator. `decoded_text_200` extracts the entire ul's decoded text and drops
+the returned string. Both benchmarks verify the expected outputs before timing.
+
+The matcher now passes a borrowed boundary to descendant matching inside `:has`
+instead of cloning three index vectors per anchor. Text extraction shares one
+output buffer across nested tags; a single unchanged text leaf remains borrowed.
+The aggregate output still allocates and grows. No general zero-allocation or
+crawler throughput claim follows from these results.
+
+Measurements: x86_64 Linux, AMD EPYC 9V74 shared virtual host, rustc 1.99.0
+`b940084d7`, LLVM 23.1.1, default system allocator. Criterion 0.3.6 was retained
+from the existing 0.3 requirement. Bench profile: opt-level 3, fat LTO, one codegen
+unit; RUSTFLAGS unset. The baseline and candidate lockfiles are identical
+(SHA-256 `fefdea9c09e825ee74b9d274cde8d27270ce38e8adb534d405d93a3020ba7ead`).
+
+| Operation | Baseline estimates (two runs) | Candidate estimates (two runs) | Alloc calls before → after | Realloc calls before → after | Requested bytes before → after |
+| --- | --- | --- | --- | --- | --- |
+| has_200 | 276.58 / 271.05 µs | 118.05 / 122.14 µs | 606 → 6 | 0 → 0 | 4,832,160 → 24,160 |
+| decoded_text_200 | 11.465 / 11.498 µs | 5.9473 / 5.9770 µs | 401 → 1 | 8 → 8 | 9,110 → 5,110 |
+
+Each timing run uses 100 samples, 3 s warmup, approximately 5 s measurement,
+95% confidence intervals, and retains outliers. Matched comparisons report
+has time changes of [-58.176%, -57.327%] and [-56.364%, -54.840%]; text changes
+of [-49.256%, -47.051%] and [-49.082%, -46.363%]. These are operation-specific
+microbenchmarks on a shared host; compilation activity overlapped part of the
+first candidate run. Run order was base/base/candidate/candidate rather than a
+fully alternated experiment. Treat the repeated large changes as exploratory
+support for the allocation mechanism, not portable speed guarantees.
+
+Allocation probes run separately, single-threaded, with fixture/DOM creation
+excluded and output destruction included. Alloc and alloc_zeroed are counted
+together; realloc is separate. Requested bytes sum allocation sizes plus new
+reallocation sizes, not live/peak memory or bytes copied. Peak memory and tail
+latency were not measured. The probe also measures an unchanged single leaf
+separately; no DOM/query setup is included in that operation.
+
+Existing parsing benchmarks were also repeated. Final Example Domain estimates
+were 2.4609/2.4591 µs against 2.7641/2.4055 µs baseline runs; PyPI Simple estimates
+were 6.7912/6.4526 µs against 6.9033/6.7192 µs. Example Domain comparisons changed
+sign (-5.5% then +4.0%); PyPI gave no significant change then -2.7%. The small,
+mixed raw-parse differences are inconclusive on this shared host. An earlier
+prototype showed a PyPI regression; the final first-attribute insertion path
+is inlined and did not reproduce that regression. These safety fixes do not
+imply all parsing workloads become faster.
+
+Reproduce with the same bench/example files and dependency lockfile in both
+checkouts; never compare different fixtures. Baseline names must be unique:
+
+```sh
+cargo bench --features entities --bench audit -- --save-baseline base-SHA --noplot
+# Rebuild candidate in the same target directory, then:
+cargo bench --features entities --bench audit -- --baseline base-SHA --noplot
+cargo run --release --features entities --example audit_allocations
+cargo bench --features std --bench tl -- --test
+```
+
+Full measurements belong on controlled hardware. CI runs smoke checks only.
+Generated target reports and logs are not committed.
+
+## Validation and limits
+
+Run stable configurations separately; all-features includes nightly-only SIMD:
+
+```sh
+cargo test --no-default-features
+cargo test --features std
+cargo test --features entities
+cargo clippy --all-targets --no-default-features -- -D warnings
+cargo clippy --all-targets --features entities -- -D warnings
+cargo +1.92.0 check --all-targets --no-default-features
+cargo +1.92.0 check --all-targets --features entities
+cargo +nightly-2026-09-25 test --features entities,portable-simd
+cargo +nightly-2026-09-25 miri test --lib --features std tests::audit
+cargo +nightly-2026-09-25 miri test --lib --features std owned
+cargo +nightly-2026-09-25 miri test --lib --features std inline
+cargo fmt --check
+```
+
+The Miri groups passed (7, 7 and 22 test executions, with overlapping clone
+regressions). Miri covers those executions on one toolchain/target; it is not a
+soundness proof. The raw-mutation misuse is checked at compile time rather than
+executing undefined behavior. The unsafe map spill reproducer was only executed
+after the fix; its old failure follows from the ownership/initialized-prefix
+trace. No claim of baseline Miri failure is made.
+
+Strict standard Clippy checks pass. Pedantic was also inspected; numerous
+warnings about existing underscore fields, missing convenience annotations and
+fixture formatting remain, and pedantic is not made a blanket release gate.
+No cargo-semver-checks or formal-verification result is claimed. Cross-platform
+execution and full HTML5 recovery remain outside this review.
+
+CI runs on relevant source/manifest/bench/example/README/workflow changes, PRs
+and master pushes, with cancellation of superseded runs. It does not run full
+performance measurements or publish releases. Miri is an optional manual input.
+Do not mark a path-filtered workflow as an unconditional required check without
+adding an always-reporting dispatcher: documentation-only changes may skip it.
+The 1.92 job is compatibility evidence, not a declaration of an undeclared MSRV.
diff --git a/docs/silkworm-compatibility.md b/docs/silkworm-compatibility.md
index be99950..35136fa 100644
--- a/docs/silkworm-compatibility.md
+++ b/docs/silkworm-compatibility.md
@@ -20,9 +20,8 @@ suffix attribute values do not match. Invalid syntax returns `None`.
Queries using structural selectors build an O(nodes) parent/sibling/position
index. Simple selectors keep the existing path without this index. `:has()`
-scans candidates and builds a scoped index per anchor; this is correctness work,
-not a claim of optimized relational-selector performance. No before/after
-performance measurement has been made. Parsing is bounded to fewer than 64
+scans candidates with a borrowed scope boundary, without copying the index per
+anchor. See [Rust audit notes](rust-audit.md) for measured timing and allocations. Parsing is bounded to fewer than 64
recursive levels and fewer than 64 simple selectors per compound; larger inputs
return `None` instead of risking recursive stack exhaustion.
@@ -85,4 +84,5 @@ The final clippy allowance is for the pre-existing byte-character array in
combinators, mixed/list precedence, sibling numbering across text/comments,
negative and positive nth-child formulae, has scoping, malformed/overlarge
queries, quoted attributes, owner movement across a thread, and decoded-text
-edge cases. Miri was not run: the installed pinned nightly has no Miri component.
+edge cases. The initial investigation did not run Miri. The subsequent
+[Rust audit](rust-audit.md) covers panic cleanup, containers and owned DOMs under Miri.
diff --git a/examples/audit_allocations.rs b/examples/audit_allocations.rs
new file mode 100644
index 0000000..cd0a614
--- /dev/null
+++ b/examples/audit_allocations.rs
@@ -0,0 +1,79 @@
+//! Isolated, single-threaded allocation probe. DOM/fixture setup is excluded.
+use std::alloc::{GlobalAlloc, Layout, System};
+use std::hint::black_box;
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed};
+
+struct Counting;
+static ACTIVE: AtomicBool = AtomicBool::new(false);
+static CALLS: AtomicUsize = AtomicUsize::new(0);
+static REALLOCS: AtomicUsize = AtomicUsize::new(0);
+static BYTES: AtomicUsize = AtomicUsize::new(0);
+// SAFETY: all allocation/deallocation operations delegate unchanged to System.
+// The counters allocate nothing, never panic, and do not access allocated memory.
+unsafe impl GlobalAlloc for Counting {
+ unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
+ if ACTIVE.load(Relaxed) {
+ CALLS.fetch_add(1, Relaxed);
+ BYTES.fetch_add(layout.size(), Relaxed);
+ }
+ unsafe { System.alloc(layout) }
+ }
+ unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
+ if ACTIVE.load(Relaxed) {
+ CALLS.fetch_add(1, Relaxed);
+ BYTES.fetch_add(layout.size(), Relaxed);
+ }
+ unsafe { System.alloc_zeroed(layout) }
+ }
+ unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
+ if ACTIVE.load(Relaxed) {
+ REALLOCS.fetch_add(1, Relaxed);
+ BYTES.fetch_add(size, Relaxed);
+ }
+ unsafe { System.realloc(ptr, layout, size) }
+ }
+ unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
+ unsafe { System.dealloc(ptr, layout) }
+ }
+}
+#[global_allocator]
+static ALLOCATOR: Counting = Counting;
+fn measure(name: &str, operation: impl FnOnce() -> T) {
+ CALLS.store(0, Relaxed);
+ REALLOCS.store(0, Relaxed);
+ BYTES.store(0, Relaxed);
+ ACTIVE.store(true, Relaxed);
+ let result = black_box(operation());
+ drop(result);
+ ACTIVE.store(false, Relaxed);
+ println!(
+ "{name}: alloc={}, realloc={}, requested_bytes={}",
+ CALLS.load(Relaxed),
+ REALLOCS.load(Relaxed),
+ BYTES.load(Relaxed)
+ );
+}
+fn main() {
+ let input = format!("", "- plain text
".repeat(200));
+ let dom = tl::parse(&input, tl::ParserOptions::default()).unwrap();
+ let node = dom
+ .query_selector("ul")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(dom.parser())
+ .unwrap();
+ let leaf = dom
+ .query_selector("a")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(dom.parser())
+ .unwrap();
+ assert_eq!(dom.query_selector("li:has(a)").unwrap().count(), 200);
+ measure("has_200", || {
+ dom.query_selector(black_box("li:has(a)")).unwrap().count()
+ });
+ measure("decoded_text_200", || node.decoded_inner_text(dom.parser()));
+ measure("decoded_leaf", || leaf.decoded_inner_text(dom.parser()));
+}
diff --git a/src/bytes.rs b/src/bytes.rs
index 4fb4c9b..ee3a35c 100644
--- a/src/bytes.rs
+++ b/src/bytes.rs
@@ -13,7 +13,7 @@ use std::borrow::Cow;
use crate::errors::SetBytesError;
/// A storage type for raw bytes, used by the parser
-#[derive(Eq, PartialOrd, Ord)]
+#[derive(Eq)]
pub struct Bytes<'a> {
/// The inner data
data: BytesInner,
@@ -61,6 +61,18 @@ impl<'a> PartialEq for Bytes<'a> {
}
}
+impl PartialOrd for Bytes<'_> {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for Bytes<'_> {
+ fn cmp(&self, other: &Self) -> core::cmp::Ordering {
+ self.as_bytes().cmp(other.as_bytes())
+ }
+}
+
impl<'a> Hash for Bytes<'a> {
#[inline]
fn hash(&self, state: &mut H) {
diff --git a/src/inline/bounded_tests.rs b/src/inline/bounded_tests.rs
new file mode 100644
index 0000000..b5b67dd
--- /dev/null
+++ b/src/inline/bounded_tests.rs
@@ -0,0 +1,45 @@
+use super::{hashmap::InlineHashMap, vec::InlineVec};
+
+#[test]
+fn vector_clone_and_remove_preserve_prefix() {
+ let mut values = InlineVec::<_, 4>::new();
+ values.push(10).unwrap();
+ values.push(20).unwrap();
+ values.push(30).unwrap();
+ let cloned = values.clone();
+ assert_eq!(values.remove(1), 20);
+ assert_eq!(values.as_slice(), &[10, 30]);
+ assert_eq!(cloned.as_slice(), &[10, 20, 30]);
+}
+
+#[test]
+fn map_replacement_clone_and_remove() {
+ let mut values = InlineHashMap::<_, _, 2>::new();
+ values.insert(1, 10).unwrap();
+ values.insert(1, 20).unwrap();
+ assert_eq!(values.len(), 1);
+ let cloned = values.clone();
+ assert_eq!(values.remove(&1), Some(20));
+ assert_eq!(cloned.get(&1), Some(&20));
+}
+
+#[cfg(not(feature = "std"))]
+#[test]
+fn capacity_errors_leave_existing_values_intact() {
+ let mut vector = InlineVec::<_, 1>::new();
+ vector.push(10).unwrap();
+ assert_eq!(
+ vector.push(20),
+ Err(crate::ParseError::ChildCapacityExceeded)
+ );
+ assert_eq!(vector.as_slice(), &[10]);
+ let mut map = InlineHashMap::<_, _, 1>::new();
+ map.insert(1, 10).unwrap();
+ assert_eq!(
+ map.insert(2, 20),
+ Err(crate::ParseError::AttributeCapacityExceeded)
+ );
+ map.insert(1, 30).unwrap();
+ assert_eq!(map.get(&1), Some(&30));
+ assert_eq!(map.len(), 1);
+}
diff --git a/src/inline/hashmap.rs b/src/inline/hashmap.rs
index 8ae4d2f..5788e27 100644
--- a/src/inline/hashmap.rs
+++ b/src/inline/hashmap.rs
@@ -47,9 +47,21 @@ where
/// If `self` is inlined, this returns the underlying raw parts that make up this `InlineHashMap`.
///
/// Only the first `.1` elements are initialized.
+ ///
+ /// ```compile_fail
+ /// fn mutate(values: &mut tl::inline::hashmap::InlineHashMap) {
+ /// let _ = values.inline_parts_mut(); // an unsafe call is required
+ /// }
+ /// ```
+ ///
+ /// # Safety
+ /// Before the borrow ends, the initialized prefix must contain valid, uniquely
+ /// owned values of the element type. Replacing a slot with uninitialized data,
+ /// duplicating an owner or dropping it without replacing it violates this contract.
+ /// Prefer `get_mut` or typed mutable access instead.
#[inline]
#[allow(clippy::type_complexity)]
- pub fn inline_parts_mut(&mut self) -> Option<(&mut [MaybeUninit<(K, V)>; N], usize)> {
+ pub unsafe fn inline_parts_mut(&mut self) -> Option<(&mut [MaybeUninit<(K, V)>; N], usize)> {
self.0.inline_parts_mut()
}
@@ -76,6 +88,15 @@ where
self.0.insert(key, value)
}
+ // HTML preserves the first ordinary attribute with a given name.
+ #[inline]
+ pub(crate) fn insert_first(&mut self, key: K, value: V) -> Result<(), ParseError> {
+ if self.contains_key(&key) {
+ return Ok(());
+ }
+ self.0.insert_new(key, value)
+ }
+
/// Removes an element from the map, and returns ownership over the value
#[inline]
pub fn remove(&mut self, key: &K) -> Option {
@@ -130,20 +151,21 @@ where
#[cfg(feature = "std")]
Self::Heap(m) => Self::Heap(m.clone()),
Self::Inline { len, data } => {
- let mut new_data = super::uninit_array();
-
- let iter = data.iter().take(*len).enumerate();
-
- for (idx, element) in iter {
- let element = unsafe { &*element.as_ptr() };
- let (key, value) = element.clone();
- new_data[idx] = MaybeUninit::new((key, value));
- }
-
- Self::Inline {
- len: *len,
- data: new_data,
+ let mut cloned = Self::new();
+ let (initialized, target) = match &mut cloned {
+ Self::Inline { len, data } => (len, data),
+ #[cfg(feature = "std")]
+ Self::Heap(_) => unreachable!("new storage is inline"),
+ };
+ for element in data.iter().take(*len) {
+ // SAFETY: only the initialized prefix is read. The new owner
+ // updates its prefix after each successful clone, so a panic
+ // drops all previously cloned elements exactly once.
+ let value = unsafe { element.assume_init_ref() }.clone();
+ target[*initialized].write(value);
+ *initialized += 1;
}
+ cloned
}
}
}
@@ -287,6 +309,15 @@ impl InlineHashMapInner {
}
pub fn insert(&mut self, k: K, v: V) -> Result<(), ParseError> {
+ // Match std::HashMap replacement semantics before capacity checks.
+ if let Some(value) = self.get_mut(&k) {
+ *value = v;
+ return Ok(());
+ }
+ self.insert_new(k, v)
+ }
+
+ fn insert_new(&mut self, k: K, v: V) -> Result<(), ParseError> {
let (array, len) = match self {
Self::Inline { data, len } => (data, len),
#[cfg(feature = "std")]
@@ -306,11 +337,14 @@ impl InlineHashMapInner {
{
let mut map = std::collections::HashMap::with_capacity(*len);
- // move old elements to heap
- for element in array.iter_mut().take(*len) {
- let element = core::mem::replace(element, MaybeUninit::uninit());
- let (key, value) = unsafe { element.assume_init() };
-
+ // Remove from the end of the initialized prefix before invoking
+ // user Hash/Eq code. On unwind, self owns only untouched entries,
+ // and the temporary map owns entries already moved into it.
+ while *len > 0 {
+ *len -= 1;
+ // SAFETY: this slot belonged to the initialized prefix; its
+ // length has now been reduced so Drop will not visit it again.
+ let (key, value) = unsafe { array[*len].assume_init_read() };
map.insert(key, value);
}
diff --git a/src/inline/mod.rs b/src/inline/mod.rs
index fd8e79e..d3ba52b 100644
--- a/src/inline/mod.rs
+++ b/src/inline/mod.rs
@@ -6,6 +6,8 @@ pub mod hashmap;
pub mod vec;
fn uninit_array() -> [MaybeUninit; N] {
- // SAFETY: an array of MaybeUninits is allowed to be entirely uninit
- unsafe { MaybeUninit::uninit().assume_init() }
+ [const { MaybeUninit::uninit() }; N]
}
+
+#[cfg(test)]
+mod bounded_tests;
diff --git a/src/inline/vec.rs b/src/inline/vec.rs
index 2aa9679..9a0d7a9 100644
--- a/src/inline/vec.rs
+++ b/src/inline/vec.rs
@@ -37,8 +37,20 @@ impl InlineVec {
/// If `self` is inlined, this returns the underlying raw parts that make up this `InlineVec`.
///
/// Only the first `.1` elements are initialized.
+ ///
+ /// ```compile_fail
+ /// fn mutate(values: &mut tl::inline::vec::InlineVec) {
+ /// let _ = values.inline_parts_mut(); // an unsafe call is required
+ /// }
+ /// ```
+ ///
+ /// # Safety
+ /// Before the borrow ends, the initialized prefix must contain valid, uniquely
+ /// owned values of the element type. Replacing a slot with uninitialized data,
+ /// duplicating an owner or dropping it without replacing it violates this contract.
+ /// Prefer `get_mut` or typed mutable access instead.
#[inline]
- pub fn inline_parts_mut(&mut self) -> Option<(&mut [MaybeUninit; N], usize)> {
+ pub unsafe fn inline_parts_mut(&mut self) -> Option<(&mut [MaybeUninit; N], usize)> {
self.0.inline_parts_mut()
}
@@ -141,19 +153,21 @@ where
#[cfg(feature = "std")]
Self::Heap(m) => Self::Heap(m.clone()),
Self::Inline { len, data } => {
- let mut new_data = super::uninit_array();
-
- let iter = data.iter().take(*len).enumerate();
-
- for (idx, element) in iter {
- let element = unsafe { &*element.as_ptr() };
- new_data[idx] = MaybeUninit::new(T::clone(element));
- }
-
- Self::Inline {
- len: *len,
- data: new_data,
+ let mut cloned = Self::new();
+ let (initialized, target) = match &mut cloned {
+ Self::Inline { len, data } => (len, data),
+ #[cfg(feature = "std")]
+ Self::Heap(_) => unreachable!("new storage is inline"),
+ };
+ for element in data.iter().take(*len) {
+ // SAFETY: only the initialized prefix is read. The new owner
+ // updates its prefix after each successful clone, so a panic
+ // drops all previously cloned elements exactly once.
+ let value = unsafe { element.assume_init_ref() }.clone();
+ target[*initialized].write(value);
+ *initialized += 1;
}
+ cloned
}
}
}
diff --git a/src/parser/base.rs b/src/parser/base.rs
index de5294e..378a0fe 100644
--- a/src/parser/base.rs
+++ b/src/parser/base.rs
@@ -277,7 +277,7 @@ impl<
let value = if let Some(quote) = self.stream.expect_oneof_and_skip(b"\"'") {
self.read_to(quote)
} else {
- self.read_to3([b' ', b'\n', b'>'])
+ self.read_to3(*b" \n>")
};
Some((name, Some(value)))
@@ -307,7 +307,7 @@ impl<
b"class" => attributes.class = value,
_ => attributes
.raw
- .insert(key.into(), value)
+ .insert_first(key.into(), value)
.map_err(|_| ParseError::AttributeCapacityExceeded)?,
};
diff --git a/src/parser/tag.rs b/src/parser/tag.rs
index b28934b..762a7bc 100644
--- a/src/parser/tag.rs
+++ b/src/parser/tag.rs
@@ -513,8 +513,10 @@ impl<'a> HTMLTag<'a> {
(offset, end)
}
- /// Returns the contained text of this element, excluding any markup.
- /// Equivalent to [Element#innerText](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText) in browsers.
+ /// Concatenates descendant text, excluding markup and comments.
+ /// Character references remain encoded. This does not perform browser layout,
+ /// visibility filtering or rendered-whitespace normalization.
+ /// Enable `entities` and use `decoded_inner_text` for character references.
/// This function may not allocate memory for a new string as it can just return the part of the tag that doesn't have markup.
/// For tags that *do* have more than one subnode, this will allocate memory
#[cfg(feature = "std")]
@@ -602,10 +604,19 @@ impl<'a> HTMLTag<'a> {
{
return self.inner_text(parser);
}
+ let children = &self._children;
+ if children.is_empty() {
+ return Cow::Borrowed("");
+ }
+ if children.len() == 1 {
+ return children[0]
+ .get(parser)
+ .map_or(Cow::Borrowed(""), |node| node.decoded_inner_text(parser));
+ }
let mut text = String::new();
- for handle in self.children().top().iter() {
+ for handle in children.iter() {
if let Some(node) = handle.get(parser) {
- text.push_str(&node.decoded_inner_text(parser));
+ node.append_decoded_text(parser, &mut text);
}
}
Cow::Owned(text)
@@ -969,6 +980,48 @@ impl<'a> Node<'a> {
}
}
+ #[cfg(feature = "entities")]
+ fn append_decoded_text<
+ 's,
+ 'p: 's,
+ const MAX_NODES: usize,
+ const MAX_STACK: usize,
+ const MAX_ROOTS: usize,
+ const MAX_IDS: usize,
+ const MAX_CLASSES: usize,
+ const MAX_SELECTOR_NODES: usize,
+ >(
+ &'s self,
+ parser: &'p Parser<
+ 'a,
+ MAX_NODES,
+ MAX_STACK,
+ MAX_ROOTS,
+ MAX_IDS,
+ MAX_CLASSES,
+ MAX_SELECTOR_NODES,
+ >,
+ output: &mut String,
+ ) {
+ match self {
+ Node::Comment(_) => {}
+ Node::Raw(raw) => output.push_str(&crate::entities::decode(&raw.as_utf8_str())),
+ Node::Tag(tag) => {
+ if tag.name().as_bytes().eq_ignore_ascii_case(b"script")
+ || tag.name().as_bytes().eq_ignore_ascii_case(b"style")
+ {
+ output.push_str(&tag.inner_text(parser));
+ } else {
+ for child in tag.children().top().iter() {
+ if let Some(child) = child.get(parser) {
+ child.append_decoded_text(parser, output);
+ }
+ }
+ }
+ }
+ }
+ }
+
/// Returns text with HTML character references decoded (the `entities` feature).
/// Unlike `inner_text`, named and numeric references in ordinary text are decoded.
/// Raw nodes use text-context decoding. Query a script/style tag to preserve its raw text.
diff --git a/src/queryselector/selector.rs b/src/queryselector/selector.rs
index abc4372..5562cb2 100644
--- a/src/queryselector/selector.rs
+++ b/src/queryselector/selector.rs
@@ -198,6 +198,16 @@ impl Selector<'_, N> {
}
pub(crate) fn matches_in(&self, id: usize, nodes: &[Node<'_>], ctx: &Context) -> bool {
+ self.matches_scoped(id, nodes, ctx, None)
+ }
+
+ fn matches_scoped(
+ &self,
+ id: usize,
+ nodes: &[Node<'_>],
+ ctx: &Context,
+ scope: Option,
+ ) -> bool {
let Some(node) = nodes.get(id) else {
return false;
};
@@ -205,19 +215,25 @@ impl Selector<'_, N> {
return false;
}
match self {
- Self::And(a, b) => a.matches_in(id, nodes, ctx) && b.matches_in(id, nodes, ctx),
- Self::Or(a, b) => a.matches_in(id, nodes, ctx) || b.matches_in(id, nodes, ctx),
- Self::Not(a) => !a.matches_in(id, nodes, ctx),
+ Self::And(a, b) => {
+ a.matches_scoped(id, nodes, ctx, scope) && b.matches_scoped(id, nodes, ctx, scope)
+ }
+ Self::Or(a, b) => {
+ a.matches_scoped(id, nodes, ctx, scope) || b.matches_scoped(id, nodes, ctx, scope)
+ }
+ Self::Not(a) => !a.matches_scoped(id, nodes, ctx, scope),
Self::Parent(a, b) => {
- b.matches_in(id, nodes, ctx)
- && ctx.parent[id].is_some_and(|p| a.matches_in(p, nodes, ctx))
+ b.matches_scoped(id, nodes, ctx, scope)
+ && ctx.parent[id]
+ .filter(|p| Some(*p) != scope)
+ .is_some_and(|p| a.matches_scoped(p, nodes, ctx, scope))
}
Self::Adjacent(a, b) => {
- b.matches_in(id, nodes, ctx)
- && ctx.previous[id].is_some_and(|p| a.matches_in(p, nodes, ctx))
+ b.matches_scoped(id, nodes, ctx, scope)
+ && ctx.previous[id].is_some_and(|p| a.matches_scoped(p, nodes, ctx, scope))
}
Self::Descendant(a, b) | Self::Sibling(a, b) => {
- if !b.matches_in(id, nodes, ctx) {
+ if !b.matches_scoped(id, nodes, ctx, scope) {
return false;
}
let links = if matches!(self, Self::Descendant(..)) {
@@ -225,16 +241,19 @@ impl Selector<'_, N> {
} else {
&ctx.previous
};
- let mut cursor = links[id];
+ let mut cursor = links[id]
+ .filter(|next| !matches!(self, Self::Descendant(..)) || Some(*next) != scope);
// Bounded traversal also tolerates a cyclic graph introduced by mutation.
for _ in 0..nodes.len() {
let Some(p) = cursor else {
break;
};
- if a.matches_in(p, nodes, ctx) {
+ if a.matches_scoped(p, nodes, ctx, scope) {
return true;
}
- cursor = links[p];
+ cursor = links[p].filter(|next| {
+ !matches!(self, Self::Descendant(..)) || Some(*next) != scope
+ });
}
false
}
@@ -248,14 +267,8 @@ impl Selector<'_, N> {
}
}
Self::Has(a) => {
- // Implicit descendant-relative arguments must not use the anchor
- // itself or its ancestors to satisfy their left-hand selector.
- let mut scoped = ctx.clone();
- for parent in &mut scoped.parent {
- if *parent == Some(id) {
- *parent = None;
- }
- }
+ // A borrowed boundary replaces the per-anchor cloned index.
+ // Descendant arguments cannot use the anchor or its ancestors.
nodes.iter().enumerate().any(|(candidate, _)| {
let mut cursor = ctx.parent[candidate];
for _ in 0..nodes.len() {
@@ -263,7 +276,7 @@ impl Selector<'_, N> {
break;
};
if p == id {
- return a.matches_in(candidate, nodes, &scoped);
+ return a.matches_scoped(candidate, nodes, ctx, Some(id));
}
cursor = ctx.parent[p];
}
diff --git a/src/tests.rs b/src/tests.rs
index e9ec058..4c8f0d8 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -321,7 +321,7 @@ mod simd {
#[test]
fn string_search_3() {
- const NEEDLE: [u8; 3] = [b'a', b'b', b'c'];
+ const NEEDLE: [u8; 3] = *b"abc";
assert_eq!(crate::simd::find3(b"e", NEEDLE), None);
assert_eq!(crate::simd::find3(b"a", NEEDLE), Some(0));
@@ -1125,3 +1125,5 @@ fn quoted_attributes_and_bounded_selectors() {
.is_none()
);
}
+
+mod audit;
diff --git a/src/tests/audit.rs b/src/tests/audit.rs
new file mode 100644
index 0000000..e7f888c
--- /dev/null
+++ b/src/tests/audit.rs
@@ -0,0 +1,235 @@
+use super::*;
+use crate::inline::{hashmap::InlineHashMap, vec::InlineVec};
+use std::{
+ cell::Cell,
+ panic::{AssertUnwindSafe, catch_unwind},
+ rc::Rc,
+};
+
+#[test]
+fn bytes_order_matches_equality_and_contents() {
+ let source = String::from("same");
+ let borrowed = Bytes::from(source.as_str());
+ let owned = Bytes::try_from(String::from("same")).unwrap();
+ assert_eq!(borrowed, owned);
+ assert_eq!(borrowed.cmp(&owned), std::cmp::Ordering::Equal);
+ let mut set = std::collections::BTreeSet::new();
+ set.insert(borrowed);
+ set.insert(owned);
+ assert_eq!(set.len(), 1);
+ let lower = Bytes::from("aaa");
+ let upper = Bytes::from("zzz");
+ assert!(lower < upper);
+}
+
+struct PanicClone {
+ clones: Rc>,
+ drops: Rc>,
+}
+impl Clone for PanicClone {
+ fn clone(&self) -> Self {
+ let count = self.clones.get();
+ self.clones.set(count + 1);
+ assert!(count < 2, "third clone panics");
+ Self {
+ clones: self.clones.clone(),
+ drops: self.drops.clone(),
+ }
+ }
+}
+impl Drop for PanicClone {
+ fn drop(&mut self) {
+ self.drops.set(self.drops.get() + 1);
+ }
+}
+
+#[test]
+fn inline_vector_clone_cleans_up_on_panic() {
+ let clones = Rc::new(Cell::new(0));
+ let drops = Rc::new(Cell::new(0));
+ let mut values = InlineVec::<_, 4>::new();
+ for _ in 0..3 {
+ values
+ .push(PanicClone {
+ clones: clones.clone(),
+ drops: drops.clone(),
+ })
+ .unwrap();
+ }
+ assert!(catch_unwind(AssertUnwindSafe(|| values.clone())).is_err());
+ assert_eq!(drops.get(), 2);
+ assert_eq!(values.len(), 3);
+ drop(values);
+ assert_eq!(drops.get(), 5);
+}
+
+#[test]
+fn inline_map_clone_cleans_up_on_panic() {
+ let clones = Rc::new(Cell::new(0));
+ let drops = Rc::new(Cell::new(0));
+ let mut values = InlineHashMap::<_, _, 4>::new();
+ for key in 0..3 {
+ values
+ .insert(
+ key,
+ PanicClone {
+ clones: clones.clone(),
+ drops: drops.clone(),
+ },
+ )
+ .unwrap();
+ }
+ assert!(catch_unwind(AssertUnwindSafe(|| values.clone())).is_err());
+ assert_eq!(drops.get(), 2);
+ assert_eq!(values.len(), 3);
+ drop(values);
+ assert_eq!(drops.get(), 5);
+}
+
+#[derive(Clone)]
+struct Key {
+ id: usize,
+ panic: Rc>,
+}
+impl PartialEq for Key {
+ fn eq(&self, other: &Self) -> bool {
+ self.id == other.id
+ }
+}
+impl Eq for Key {}
+impl std::hash::Hash for Key {
+ fn hash(&self, state: &mut H) {
+ assert!(!self.panic.get(), "Hash panics");
+ self.id.hash(state);
+ }
+}
+struct DropCount(Rc| >);
+impl Drop for DropCount {
+ fn drop(&mut self) {
+ self.0.set(self.0.get() + 1);
+ }
+}
+
+#[test]
+fn map_spill_remains_valid_after_hash_panic() {
+ let panic = Rc::new(Cell::new(false));
+ let drops = Rc::new(Cell::new(0));
+ let mut map = InlineHashMap::<_, _, 2>::new();
+ for id in 0..2 {
+ map.insert(
+ Key {
+ id,
+ panic: panic.clone(),
+ },
+ DropCount(drops.clone()),
+ )
+ .unwrap();
+ }
+ panic.set(true);
+ assert!(
+ catch_unwind(AssertUnwindSafe(|| map.insert(
+ Key {
+ id: 2,
+ panic: panic.clone()
+ },
+ DropCount(drops.clone())
+ )))
+ .is_err()
+ );
+ panic.set(false);
+ // A panic may consume entries, but the remaining prefix must be valid.
+ for (_, _) in map.iter() {}
+ drop(map);
+ assert_eq!(drops.get(), 3);
+}
+
+#[test]
+fn map_replaces_duplicate_keys_before_spilling() {
+ let mut map = InlineHashMap::<_, _, 1>::new();
+ map.insert("same", 1).unwrap();
+ map.insert("same", 2).unwrap();
+ assert_eq!(map.len(), 1);
+ assert_eq!(map.get(&"same"), Some(&2));
+ assert!(!map.is_heap_allocated());
+ map.insert("other", 3).unwrap();
+ map.insert("same", 4).unwrap();
+ assert!(map.is_heap_allocated());
+ assert_eq!(map.len(), 2);
+ assert_eq!(map.get(&"same"), Some(&4));
+}
+
+#[cfg(feature = "entities")]
+#[test]
+fn decoded_text_reuses_borrowed_leaf_and_preserves_mixed_text() {
+ use std::borrow::Cow;
+ let dom = parse(" plain &Ω ", ParserOptions::default()).unwrap();
+ let parser = dom.parser();
+ let div = dom
+ .query_selector("div")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(parser)
+ .unwrap();
+ assert!(matches!(
+ div.decoded_inner_text(parser),
+ Cow::Borrowed("plain")
+ ));
+ let p = dom
+ .query_selector("p")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(parser)
+ .unwrap();
+ assert_eq!(p.decoded_inner_text(parser), "&Ω&");
+ let em = dom
+ .query_selector("em")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(parser)
+ .unwrap();
+ assert!(matches!(em.decoded_inner_text(parser), Cow::Borrowed("")));
+}
+
+#[test]
+fn nested_has_retains_independent_boundaries() {
+ let dom = parse(
+ "",
+ ParserOptions::default(),
+ )
+ .unwrap();
+ for (query, count) in [
+ ("li:has(span:has(a))", 1),
+ ("li:has(ul a)", 0),
+ ("li:has(li a)", 0),
+ ("ul:has(li > a)", 1),
+ ("li:not(:has(span a))", 1),
+ ] {
+ assert_eq!(dom.query_selector(query).unwrap().count(), count, "{query}");
+ }
+}
+
+#[test]
+fn duplicate_html_attributes_keep_the_first_value() {
+ let dom = parse(
+ "",
+ ParserOptions::default(),
+ )
+ .unwrap();
+ let parser = dom.parser();
+ let tag = dom
+ .query_selector("p")
+ .unwrap()
+ .next()
+ .unwrap()
+ .get(parser)
+ .unwrap()
+ .as_tag()
+ .unwrap();
+ assert_eq!(
+ tag.attributes().get("data-x").flatten().unwrap().as_bytes(),
+ b"first"
+ );
+}
diff --git a/src/vdom.rs b/src/vdom.rs
index 553f0aa..91f24bc 100644
--- a/src/vdom.rs
+++ b/src/vdom.rs
@@ -377,7 +377,8 @@ impl VDomGuard {
&self.dom
}
- /// Returns a mutable reference to the inner DOM.
+ /// Mutably borrows the guard and returns a shared reference to its inner DOM.
+ /// The returned DOM cannot be mutated through this accessor.
///
/// The lifetime of the returned `VDom` is bound to self so that elements cannot outlive this `VDomGuard` struct.
pub fn get_mut_ref<'a, 'b: 'a>(
| | | |