diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dee48d..acd39cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: version: 0.16.0 - name: Check formatting - run: zig fmt --check src/ test/ + run: zig fmt --check src/ test/ bench/ build-and-test: needs: lint @@ -74,3 +74,26 @@ jobs: # tests without emitting a binary to keep source compatibility covered. - name: Semantic compatibility check run: zig test src/root.zig -fno-emit-bin + + bench: + name: Benchmarks + runs-on: ubuntu-latest + needs: build-and-test + steps: + - name: Checkout + uses: actions/checkout@v7.0.0 + + - name: Setup Zig + uses: mlugg/setup-zig@v2.2.1 + with: + version: 0.16.0 + + # Wall-clock numbers are too noisy on shared runners to gate on, so this + # checks the two things that are not: no benchmark may retain memory in + # the document arena, and no workload may scale superlinearly. + # + # Run at full corpus size deliberately. The --quick corpora are small + # enough that fixed costs dominate and every workload measures as linear, + # which would make this gate useless. + - name: Complexity and retention gates + run: zig build bench -- --strict --scaling --filter find/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42738db..d8512a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid release tag: $RELEASE_TAG" >&2; exit 1; } ZON_VERSION=$(sed -n 's/.*\.version = "\([^"]*\)".*/\1/p' build.zig.zon) test "${RELEASE_TAG#v}" = "$ZON_VERSION" - zig fmt --check src/ test/ + zig fmt --check src/ test/ bench/ zig build zig build test diff --git a/README.md b/README.md index 509d730..78f9cc6 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,20 @@ pub fn main() !void { ); defer doc.deinit(); + // A Query owns everything the queries below allocate. + var q = doc.query(allocator); + defer q.deinit(); + // Find all paragraphs. - const paragraphs = try doc.find("p"); + const paragraphs = try q.find("p"); std.debug.print("Found {} paragraphs\n", .{paragraphs.len()}); // Get text content. - const title = try (try doc.find("h1")).text(); + const title = try (try q.find("h1")).text(); std.debug.print("Title: {s}\n", .{title}); // Read attributes. - const link = try doc.find("a.active"); + const link = try q.find("a.active"); const href = link.attr("href") orelse ""; std.debug.print("Link: {s}\n", .{href}); } @@ -96,25 +100,56 @@ Supported selector syntax: ## API overview -### Document +### Document and Query + +A `Document` owns the parsed DOM. A `Query` owns everything a query produces: +node slices, chained `Selection` states, parsed selectors, and the strings +returned by `html()`, `text()` and `outerHtml()`. ```zig -// Parse HTML into a document. All allocations use an internal arena. +// Parse HTML into a document. The DOM lives in the document's arena. var doc = try zq.Document.initFromSlice(allocator, html); defer doc.deinit(); -// Query from the document root. -const sel = try doc.find("div.content"); +// Open a query scope. Results live here, not in the document. +var q = doc.query(allocator); +defer q.deinit(); + +const sel = try q.find("div.content"); // Deep-clone the entire document. var copy = try doc.clone(allocator); defer copy.deinit(); ``` +Keeping the two apart is what makes a long-lived document practical. When you +run many queries against one document -- a scraper looping over pages, or a +server handling requests -- reclaim between rounds: + +```zig +var q = doc.query(allocator); +defer q.deinit(); + +for (jobs) |job| { + const rows = try q.find(job.selector); + try handle(rows); + q.reset(); // releases this round's results, keeps the capacity +} +``` + +`q.reset()` and `q.deinit()` invalidate every `Selection` and every string +obtained through that query. Anything you need to keep, copy out first. + +Changes you make to the DOM are not query data and are unaffected: attributes, +inserted nodes and parsed fragments belong to the document and outlive the +query that created them. + +All the examples below assume a `q` in scope, as above. + ### Selection — Traversal ```zig -const sel = try doc.find("div"); +const sel = try q.find("div"); // Descendants matching a selector. const links = try sel.find("a"); @@ -139,7 +174,7 @@ const prev_all = try sel.prevAll(); ### Selection — Filtering ```zig -const items = try doc.find("li"); +const items = try q.find("li"); const active = try items.filter(".active"); const inactive = try items.not(".active"); @@ -158,7 +193,7 @@ const is_active = try items.is(".active"); ### Selection — Properties ```zig -const el = try doc.find("a.nav"); +const el = try q.find("a.nav"); // Attributes. const href = el.attr("href"); @@ -182,14 +217,14 @@ const name = zq.nodeName(el); ### Selection — Manipulation ```zig -const div = try doc.find("div"); +const div = try q.find("div"); // Insert content. try div.appendHtml("

appended

"); try div.prependHtml("

prepended

"); // Insert around selection. -const p = try doc.find("p"); +const p = try q.find("p"); try p.afterHtml("
"); try p.beforeHtml(""); @@ -210,7 +245,7 @@ try div.unwrap(); ### Selection — Iteration ```zig -const rows = try doc.find("tr"); +const rows = try q.find("tr"); // Iterator (idiomatic Zig). var it = rows.iterator(); @@ -231,8 +266,8 @@ rows.each(struct { ### Selection — Set operations ```zig -const a = try doc.find(".foo"); -const b = try doc.find(".bar"); +const a = try q.find(".foo"); +const b = try q.find(".bar"); const combined = try a.add(".bar"); const merged = try a.addSelection(b); @@ -248,22 +283,56 @@ Compile a selector once when it is reused across queries or documents: var active_links = try zq.CompiledSelector.init(allocator, "a.active"); defer active_links.deinit(); -const links = try doc.findCompiled(&active_links); +const links = try q.findCompiled(&active_links); const matches = links.isCompiled(&active_links); ``` ## Ownership and errors -`Document.initFromSlice`, `Document.initFromNode`, `Document.clone`, parsed fragments, -attributes, and inserted nodes own their data through the document arena. Input buffers -and source documents may be released after these operations complete. Use -`Document.initBorrowedNode` only when the source tree is guaranteed to outlive the -document. +Two arenas, with a clear division: + +| Lives in the **document** arena | Lives in the **query** arena | +|---|---| +| Parsed nodes and their data | `Selection.nodes` slices | +| Attribute keys and values you set | Chained selection state (`end()`, `addBack()`) | +| Nodes from `appendHtml`, `setHtml`, `wrapHtml`, … | Parsed selector ASTs | +| Clones from `cloneSel` | Strings from `html()`, `text()`, `outerHtml()` | +| Released by `doc.deinit()` | Released by `q.deinit()` or `q.reset()` | + +Input buffers and source documents may be released once parsing completes. Use +`Document.initBorrowedNode` only when the source tree is guaranteed to outlive +the document. A `Query` borrows its `Document`, so the document must outlive it. -Operations that allocate return an error union. In v0.2 this includes traversal methods -such as `children`, positional methods such as `first`, attribute/class updates, and DOM -mutations. Mutations parse or clone all required data before changing the tree, so an -allocation failure does not leave a partially updated selection. +Operations that allocate return an error union: traversal methods such as +`children`, positional methods such as `first`, attribute and class updates, and +DOM mutations. Mutations parse or clone all required data before changing the +tree, so an allocation failure does not leave a partially updated selection. + +## v0.3 migration + +Queries now run through a `Query` rather than the `Document`: + +```zig +// v0.2 +const links = try doc.find("a.active"); + +// v0.3 +var q = doc.query(allocator); +defer q.deinit(); +const links = try q.find("a.active"); +``` + +- `Document.find`, `findMatcher`, `findCompiled` and `select` move to `Query` + (`select` is now `Query.root`). +- `Document.allocator` is renamed `Document.domAllocator` and is reserved for + content that joins the DOM. +- `Selection.document` is now the method `Selection.document()`; the struct + field is `Selection.q`. +- Strings from `html()`, `text()` and `outerHtml()` belong to the query and do + not survive `q.deinit()` or `q.reset()`. Duplicate anything you need to keep. + +Previously every query allocated into the document's arena and nothing was ever +released, so repeated queries against one document grew without bound. ## v0.2 migration @@ -285,6 +354,19 @@ full named entity table remain roadmap items. See [ROADMAP.md](ROADMAP.md). zig build test ``` +## Benchmarks + +```sh +zig build bench # the standard suite, always ReleaseFast +zig build bench -- --scaling # empirical complexity at 1x / 2x / 4x input +zig build bench -- --filter find/ # a subset +zig build bench -- --corpus # also run against local .html files +``` + +Corpora are generated deterministically, so results are comparable across +machines. `--scaling` reports an exponent per workload: about 1.0 for linear, +2.0 for quadratic. `bench/baseline.json` records a reference run. + Inspired by Go's [goquery](https://github.com/PuerkitoBio/goquery) and, by extension, jQuery ## License diff --git a/ROADMAP.md b/ROADMAP.md index e202549..8f848c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,24 @@ dependencies remain at zero, and supported Zig versions are tested explicitly. - Relative `:has()`, selector lists in `:not()`/`:is()`/`:where()`, and strict parsing. - Reusable `CompiledSelector`, tree validation, and Zig 0.15.2/0.16.x CI. -## v0.3 - parser conformance +## v0.3 - ownership and complexity (current) + +Done: + +- `Query` scratch arena: query results, chained selections, parsed selectors and + returned strings live and die with the query, not the document. Repeated + queries against a long-lived document no longer grow it. +- Removed the quadratic paths found by the benchmark harness: + `wouldCreateCycle` under an `assert` (evaluated in every build mode) made tree + building O(nodes x depth); `autoClose` scanned the whole open-element stack per + start tag; `:has()` re-scanned the entire document per anchor; `:nth-child()` + recounted a sibling list per candidate. +- Iterative traversal for render, clone, validate, text extraction and matching, + so deeply nested documents no longer overflow the stack. +- Benchmark harness (`zig build bench`) with deterministic corpora, per-operation + memory accounting, and an empirical complexity check (`--scaling`). + +Remaining for parser conformance, deferred to v0.4: - Replace ad hoc tree construction with explicit WHATWG insertion modes. - Add foster parenting, template modes, foreign SVG/MathML content, and adoption agency. @@ -27,12 +44,17 @@ dependencies remain at zero, and supported Zig versions are tested explicitly. - Selector specificity metadata and bounded matching for untrusted selectors. - Conformance fixtures derived from Web Platform Tests. -## v0.5 - performance and tooling +## v0.5 - memory and tooling -- Benchmarks for parsing, matching, rendering, cloning, and bulk mutation. +- Reduce per-node footprint: pooled node allocation, exact-size attribute + arrays, an interned namespace field, and coalesced text nodes. On a + node-dense document the arena currently holds about 108 bytes per node + against a 96-byte `Node`, so node size is essentially the whole cost there. - Fuzz targets for HTML/CSS parsers and mutation sequences with tree validation. -- Selector planning indexes for IDs/classes/tags and reduced temporary allocations. - Streaming writer APIs and configurable serialization modes. +- Selector planning indexes for IDs/classes/tags, if profiling justifies them. + Measured selector times are currently in the low hundreds of microseconds on + a 2 MB document, so this is not yet the bottleneck it was assumed to be. ## v1.0 - stable contract diff --git a/bench/baseline-v0.2.json b/bench/baseline-v0.2.json new file mode 100644 index 0000000..bfa5408 --- /dev/null +++ b/bench/baseline-v0.2.json @@ -0,0 +1,36 @@ +{ + "benchmarks": [ + {"name": "parse/small", "ns_p50": 99703, "ns_min": 91528, "ns_p99": 126536, "bytes_in": 65623, "peak_bytes": 564888, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 1968}, + {"name": "parse/medium", "ns_p50": 4697167, "ns_min": 4497666, "ns_p99": 4784291, "bytes_in": 2200074, "peak_bytes": 15720190, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 43}, + {"name": "parse/large", "ns_p50": 23316417, "ns_min": 23153625, "ns_p99": 23455666, "bytes_in": 10485790, "peak_bytes": 74809384, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 9}, + {"name": "parse/attr_heavy", "ns_p50": 3773000, "ns_min": 3563250, "ns_p99": 3829375, "bytes_in": 2200308, "peak_bytes": 8273720, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 2, "iters": 54}, + {"name": "parse/entity_heavy", "ns_p50": 4690667, "ns_min": 4490417, "ns_p99": 4727833, "bytes_in": 2200099, "peak_bytes": 15720434, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 43}, + {"name": "parse/script_heavy", "ns_p50": 4696875, "ns_min": 4620625, "ns_p99": 5025750, "bytes_in": 2200063, "peak_bytes": 15720342, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 43}, + {"name": "parse/dense", "ns_p50": 12112042, "ns_min": 11718042, "ns_p99": 12238750, "bytes_in": 2200052, "peak_bytes": 50126640, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 4, "iters": 17}, + {"name": "parse/shallow_wide", "ns_p50": 3970041, "ns_min": 3764417, "ns_p99": 4043041, "bytes_in": 2200091, "peak_bytes": 15720314, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 3, "iters": 51}, + {"name": "find/tag", "ns_p50": 357770, "ns_min": 321531, "ns_p99": 378271, "bytes_in": 0, "peak_bytes": 199760678, "growth_per_op": 324014, "growth_capped": true, "allocs_per_op": 0, "iters": 568}, + {"name": "find/class", "ns_p50": 623958, "ns_min": 566333, "ns_p99": 717021, "bytes_in": 0, "peak_bytes": 55076258, "growth_per_op": 122223, "growth_capped": true, "allocs_per_op": 0, "iters": 322}, + {"name": "find/compound", "ns_p50": 348521, "ns_min": 328760, "ns_p99": 360135, "bytes_in": 0, "peak_bytes": 55080428, "growth_per_op": 68811, "growth_capped": true, "allocs_per_op": 0, "iters": 572}, + {"name": "find/descendant", "ns_p50": 396031, "ns_min": 374750, "ns_p99": 428718, "bytes_in": 0, "peak_bytes": 26914344, "growth_per_op": 22210, "growth_capped": true, "allocs_per_op": 0, "iters": 504}, + {"name": "find/child", "ns_p50": 372896, "ns_min": 344718, "ns_p99": 404489, "bytes_in": 0, "peak_bytes": 15720190, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 0, "iters": 544}, + {"name": "find/attr_exists", "ns_p50": 521416, "ns_min": 497041, "ns_p99": 599437, "bytes_in": 0, "peak_bytes": 307982506, "growth_per_op": 849599, "growth_capped": true, "allocs_per_op": 0, "iters": 344}, + {"name": "find/attr_prefix", "ns_p50": 316271, "ns_min": 301218, "ns_p99": 375968, "bytes_in": 0, "peak_bytes": 15720190, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 0, "iters": 616}, + {"name": "find/nth_child", "ns_p50": 1213958, "ns_min": 1178583, "ns_p99": 1422541, "bytes_in": 0, "peak_bytes": 26917838, "growth_per_op": 69121, "growth_capped": true, "allocs_per_op": 0, "iters": 162}, + {"name": "find/has_child", "ns_p50": 1063179792, "ns_min": 999960458, "ns_p99": 1077509666, "bytes_in": 0, "peak_bytes": 15720190, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 0, "iters": 4}, + {"name": "find/has_descendant", "ns_p50": 1479490542, "ns_min": 1474244167, "ns_p99": 1527702459, "bytes_in": 0, "peak_bytes": 15720190, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 0, "iters": 3}, + {"name": "find/contains", "ns_p50": 358333, "ns_min": 306156, "ns_p99": 368593, "bytes_in": 0, "peak_bytes": 26910620, "growth_per_op": 19982, "growth_capped": true, "allocs_per_op": 0, "iters": 560}, + {"name": "find/group", "ns_p50": 770562, "ns_min": 720354, "ns_p99": 808250, "bytes_in": 0, "peak_bytes": 27469646, "growth_per_op": 45190, "growth_capped": true, "allocs_per_op": 0, "iters": 260}, + {"name": "find/not", "ns_p50": 309271, "ns_min": 287958, "ns_p99": 366291, "bytes_in": 0, "peak_bytes": 31058508, "growth_per_op": 23966, "growth_capped": true, "allocs_per_op": 0, "iters": 640}, + {"name": "find/universal", "ns_p50": 1198083, "ns_min": 1118541, "ns_p99": 1561833, "bytes_in": 0, "peak_bytes": 442710746, "growth_per_op": 3234775, "growth_capped": true, "allocs_per_op": 0, "iters": 132}, + {"name": "leak/find_repeat", "ns_p50": 3329819, "ns_min": 3329819, "ns_p99": 3329819, "bytes_in": 0, "peak_bytes": 285026700, "growth_per_op": 117449, "growth_capped": true, "allocs_per_op": 0, "iters": 2000}, + {"name": "render/document", "ns_p50": 3863292, "ns_min": 3736708, "ns_p99": 3903083, "bytes_in": 2200074, "peak_bytes": 18731094, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 1, "iters": 52}, + {"name": "clone/document", "ns_p50": 2377542, "ns_min": 2314000, "ns_p99": 2795042, "bytes_in": 2200074, "peak_bytes": 27564896, "growth_per_op": 0, "growth_capped": false, "allocs_per_op": 20, "iters": 84}, + {"name": "text/document", "ns_p50": 525250, "ns_min": 460000, "ns_p99": 816875, "bytes_in": 0, "peak_bytes": 266572964, "growth_per_op": 670729, "growth_capped": true, "allocs_per_op": 0, "iters": 374}, + {"name": "traverse/children", "ns_p50": 224812, "ns_min": 184854, "ns_p99": 317736, "bytes_in": 0, "peak_bytes": 307219546, "growth_per_op": 883331, "growth_capped": true, "allocs_per_op": 0, "iters": 330}, + {"name": "traverse/parent", "ns_p50": 83505, "ns_min": 78205, "ns_p99": 131913, "bytes_in": 0, "peak_bytes": 307501030, "growth_per_op": 393235, "growth_capped": true, "allocs_per_op": 0, "iters": 742}, + {"name": "traverse/siblings", "ns_p50": 11742166, "ns_min": 10991333, "ns_p99": 12354250, "bytes_in": 0, "peak_bytes": 470076422, "growth_per_op": 25242004, "growth_capped": true, "allocs_per_op": 0, "iters": 18}, + {"name": "traverse/next_all", "ns_p50": 4297708, "ns_min": 4141625, "ns_p99": 4776959, "bytes_in": 0, "peak_bytes": 470123848, "growth_per_op": 11651372, "growth_capped": true, "allocs_per_op": 0, "iters": 39}, + {"name": "traverse/filter", "ns_p50": 115133, "ns_min": 103642, "ns_p99": 140514, "bytes_in": 0, "peak_bytes": 55049894, "growth_per_op": 23026, "growth_capped": true, "allocs_per_op": 0, "iters": 1708}, + {"name": "traverse/first", "ns_p50": 5, "ns_min": 5, "ns_p99": 10, "bytes_in": 0, "peak_bytes": 153196428, "growth_per_op": 32, "growth_capped": true, "allocs_per_op": 0, "iters": 4194304} + ] +} diff --git a/bench/baseline.json b/bench/baseline.json new file mode 100644 index 0000000..4f77f17 --- /dev/null +++ b/bench/baseline.json @@ -0,0 +1,36 @@ +{ + "benchmarks": [ + {"name": "parse/small", "ns_p50": 107750, "ns_min": 88197, "ns_p99": 400187, "bytes_in": 65623, "peak_bytes": 564888, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 800}, + {"name": "parse/medium", "ns_p50": 3936500, "ns_min": 3776959, "ns_p99": 4245209, "bytes_in": 2200074, "peak_bytes": 15720190, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 51}, + {"name": "parse/large", "ns_p50": 19200542, "ns_min": 18762666, "ns_p99": 19435833, "bytes_in": 10485790, "peak_bytes": 74809384, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 11}, + {"name": "parse/attr_heavy", "ns_p50": 3530834, "ns_min": 3370542, "ns_p99": 9244708, "bytes_in": 2200308, "peak_bytes": 8273720, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 2, "iters": 50}, + {"name": "parse/entity_heavy", "ns_p50": 4035167, "ns_min": 3795375, "ns_p99": 4166125, "bytes_in": 2200099, "peak_bytes": 15720434, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 50}, + {"name": "parse/script_heavy", "ns_p50": 3941750, "ns_min": 3834750, "ns_p99": 3977875, "bytes_in": 2200063, "peak_bytes": 15720342, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 51}, + {"name": "parse/dense", "ns_p50": 11055958, "ns_min": 10904375, "ns_p99": 11172542, "bytes_in": 2200052, "peak_bytes": 50126640, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 4, "iters": 19}, + {"name": "parse/shallow_wide", "ns_p50": 3985250, "ns_min": 3755292, "ns_p99": 4247500, "bytes_in": 2200091, "peak_bytes": 15720314, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 3, "iters": 51}, + {"name": "find/tag", "ns_p50": 220270, "ns_min": 181416, "ns_p99": 233916, "bytes_in": 0, "peak_bytes": 44281206, "growth_per_op": 29750, "doc_grew": false, "allocs_per_op": 0, "iters": 960}, + {"name": "find/class", "ns_p50": 523646, "ns_min": 493833, "ns_p99": 576875, "bytes_in": 0, "peak_bytes": 22491952, "growth_per_op": 17913, "doc_grew": false, "allocs_per_op": 0, "iters": 378}, + {"name": "find/compound", "ns_p50": 281593, "ns_min": 271583, "ns_p99": 328708, "bytes_in": 0, "peak_bytes": 20396502, "growth_per_op": 6757, "doc_grew": false, "allocs_per_op": 0, "iters": 692}, + {"name": "find/descendant", "ns_p50": 389406, "ns_min": 338312, "ns_p99": 434000, "bytes_in": 0, "peak_bytes": 17657422, "growth_per_op": 3696, "doc_grew": false, "allocs_per_op": 0, "iters": 524}, + {"name": "find/child", "ns_p50": 358239, "ns_min": 317854, "ns_p99": 403739, "bytes_in": 0, "peak_bytes": 16255026, "growth_per_op": 941, "doc_grew": false, "allocs_per_op": 0, "iters": 568}, + {"name": "find/attr_exists", "ns_p50": 308562, "ns_min": 268979, "ns_p99": 372041, "bytes_in": 0, "peak_bytes": 68599304, "growth_per_op": 84201, "doc_grew": false, "allocs_per_op": 0, "iters": 628}, + {"name": "find/attr_prefix", "ns_p50": 288364, "ns_min": 222468, "ns_p99": 339625, "bytes_in": 0, "peak_bytes": 17008138, "growth_per_op": 1768, "doc_grew": false, "allocs_per_op": 0, "iters": 728}, + {"name": "find/nth_child", "ns_p50": 353458, "ns_min": 333302, "ns_p99": 474166, "bytes_in": 0, "peak_bytes": 22917512, "growth_per_op": 13229, "doc_grew": false, "allocs_per_op": 0, "iters": 544}, + {"name": "find/has_child", "ns_p50": 635125, "ns_min": 577395, "ns_p99": 721500, "bytes_in": 0, "peak_bytes": 16985578, "growth_per_op": 4003, "doc_grew": false, "allocs_per_op": 0, "iters": 316}, + {"name": "find/has_descendant", "ns_p50": 580708, "ns_min": 546416, "ns_p99": 627625, "bytes_in": 0, "peak_bytes": 19570118, "growth_per_op": 11125, "doc_grew": false, "allocs_per_op": 0, "iters": 346}, + {"name": "find/contains", "ns_p50": 298177, "ns_min": 292479, "ns_p99": 352895, "bytes_in": 0, "peak_bytes": 16998200, "growth_per_op": 1983, "doc_grew": false, "allocs_per_op": 0, "iters": 644}, + {"name": "find/group", "ns_p50": 717229, "ns_min": 663604, "ns_p99": 796937, "bytes_in": 0, "peak_bytes": 18860442, "growth_per_op": 11213, "doc_grew": false, "allocs_per_op": 0, "iters": 280}, + {"name": "find/not", "ns_p50": 286135, "ns_min": 257635, "ns_p99": 355604, "bytes_in": 0, "peak_bytes": 18783870, "growth_per_op": 4376, "doc_grew": false, "allocs_per_op": 0, "iters": 700}, + {"name": "find/universal", "ns_p50": 203323, "ns_min": 191166, "ns_p99": 260567, "bytes_in": 0, "peak_bytes": 336512486, "growth_per_op": 400989, "doc_grew": false, "allocs_per_op": 0, "iters": 800}, + {"name": "leak/find_repeat", "ns_p50": 3160792, "ns_min": 3160792, "ns_p99": 3160792, "bytes_in": 0, "peak_bytes": 75573962, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 0, "iters": 2000}, + {"name": "render/document", "ns_p50": 2743125, "ns_min": 2704458, "ns_p99": 2812291, "bytes_in": 2200074, "peak_bytes": 18731094, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 1, "iters": 73}, + {"name": "clone/document", "ns_p50": 2413292, "ns_min": 2290833, "ns_p99": 2505458, "bytes_in": 2200074, "peak_bytes": 27154736, "growth_per_op": 0, "doc_grew": false, "allocs_per_op": 20, "iters": 83}, + {"name": "text/document", "ns_p50": 486187, "ns_min": 465271, "ns_p99": 644562, "bytes_in": 0, "peak_bytes": 280807662, "growth_per_op": 662717, "doc_grew": false, "allocs_per_op": 0, "iters": 400}, + {"name": "traverse/children", "ns_p50": 40441, "ns_min": 40225, "ns_p99": 50980, "bytes_in": 0, "peak_bytes": 337292564, "growth_per_op": 824208, "doc_grew": false, "allocs_per_op": 0, "iters": 390}, + {"name": "traverse/parent", "ns_p50": 26532, "ns_min": 25309, "ns_p99": 31563, "bytes_in": 0, "peak_bytes": 362369004, "growth_per_op": 328764, "doc_grew": false, "allocs_per_op": 0, "iters": 1054}, + {"name": "traverse/siblings", "ns_p50": 7192500, "ns_min": 7008208, "ns_p99": 8275792, "bytes_in": 0, "peak_bytes": 362282788, "growth_per_op": 21651969, "doc_grew": false, "allocs_per_op": 0, "iters": 16}, + {"name": "traverse/next_all", "ns_p50": 2673167, "ns_min": 2567416, "ns_p99": 2757625, "bytes_in": 0, "peak_bytes": 361906190, "growth_per_op": 10178085, "doc_grew": false, "allocs_per_op": 0, "iters": 34}, + {"name": "traverse/filter", "ns_p50": 124199, "ns_min": 111431, "ns_p99": 129714, "bytes_in": 0, "peak_bytes": 43430736, "growth_per_op": 16982, "doc_grew": false, "allocs_per_op": 0, "iters": 1624}, + {"name": "traverse/first", "ns_p50": 5, "ns_min": 5, "ns_p99": 10, "bytes_in": 0, "peak_bytes": 161427644, "growth_per_op": 34, "doc_grew": false, "allocs_per_op": 0, "iters": 4194304} + ] +} diff --git a/bench/corpus.zig b/bench/corpus.zig new file mode 100644 index 0000000..520b260 --- /dev/null +++ b/bench/corpus.zig @@ -0,0 +1,382 @@ +//! Deterministic synthetic HTML generation for benchmarking. +//! +//! Nothing here touches the filesystem or the clock: the same `Shape` always +//! produces the same bytes, so a benchmark number from one machine is +//! comparable with one from another. The random source is written out by hand +//! rather than taken from `std.Random`, whose algorithms are free to change +//! between Zig releases. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// xorshift64. Stable by construction. +const Rng = struct { + state: u64, + + fn init(seed: u64) Rng { + return .{ .state = if (seed == 0) 0x9E3779B97F4A7C15 else seed }; + } + + fn next(self: *Rng) u64 { + var x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + return x; + } + + fn below(self: *Rng, n: u32) u32 { + std.debug.assert(n > 0); + return @intCast(self.next() % n); + } + + fn chance(self: *Rng, percent: u32) bool { + return self.below(100) < percent; + } +}; + +/// Container elements the generator nests. `div` is weighted heavily so that +/// `div.row` selectors have a realistic amount of chaff to walk past. +const containers = [_][]const u8{ + "div", "div", "div", "section", "ul", "li", "p", "article", "span", "td", +}; + +/// Leaf elements. `a` is present so `div.row a.link` and `:has(> a)` have +/// something to match. +const leaves = [_][]const u8{ "a", "span", "em", "strong", "code", "b" }; + +/// Short tags for the `minimal_markup` shape. +const terse_containers = [_][]const u8{ "div", "p", "li", "td", "b", "i", "u", "s" }; +const terse_leaves = [_][]const u8{ "a", "i", "b", "u" }; + +const short_words = [_][]const u8{ "ab", "cd", "ef", "gh", "ij", "kl", "mn", "op" }; + +const words = [_][]const u8{ + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", + "golf", "hotel", "india", "juliet", "kilo", "lima", + "mike", "november", "oscar", "papa", "quebec", "romeo", + "sierra", "tango", "uniform", "victor", "whiskey", "xray", +}; + +const attr_keys = [_][]const u8{ + "id", "data-id", "data-role", "title", "rel", "lang", "data-index", +}; + +pub const Shape = struct { + /// Generation stops once the buffer passes this size; the result is + /// approximately, not exactly, this many bytes. + target_bytes: usize, + max_depth: u32 = 12, + /// Percent chance of descending rather than closing the current element. + descend_percent: u32 = 55, + attrs_per_el: u32 = 2, + /// Number of distinct class names in circulation. A small pool makes class + /// selectors match often; a large one makes them selective. + class_pool: u32 = 24, + /// Percent of elements that get a text child. + text_percent: u32 = 45, + /// Percent of text runs that carry a character reference. + entity_percent: u32 = 0, + /// Emit a ` + ); + } + + const tag = if (shape.minimal_markup) + terse_containers[rng.below(terse_containers.len)] + else + containers[rng.below(containers.len)]; + try openTag(gpa, &buf, &rng, shape, tag, depth); + try stack.append(gpa, tag); + elements += 1; + + if (rng.chance(shape.text_percent)) { + try emitText(gpa, &buf, &rng, shape); + } + + // Leaves are emitted inline and closed immediately, which is what + // gives `div.row > a.link` and `:has(> a)` their match sets. + if (rng.chance(35)) { + const leaf = if (shape.minimal_markup) + terse_leaves[rng.below(terse_leaves.len)] + else + leaves[rng.below(leaves.len)]; + try openTag(gpa, &buf, &rng, shape, leaf, depth + 1); + try emitText(gpa, &buf, &rng, shape); + try buf.appendSlice(gpa, "'); + elements += 1; + } + } + + while (stack.items.len > 0) try closeTop(gpa, &buf, &stack); + try buf.appendSlice(gpa, "\n"); + + return buf.toOwnedSlice(gpa); +} + +fn closeTop(gpa: Allocator, buf: *std.ArrayList(u8), stack: *std.ArrayList([]const u8)) !void { + const tag = stack.pop().?; + try buf.appendSlice(gpa, "'); +} + +fn openTag( + gpa: Allocator, + buf: *std.ArrayList(u8), + rng: *Rng, + shape: Shape, + tag: []const u8, + depth: u32, +) !void { + try buf.append(gpa, '<'); + try buf.appendSlice(gpa, tag); + + if (shape.minimal_markup) { + // Keep just enough classes for `.row` / `.link` selectors to have a + // match set, but spend no bytes on anything else. + if (std.mem.eql(u8, tag, "div") and rng.chance(30)) { + try buf.appendSlice(gpa, " class=row"); + } else if (std.mem.eql(u8, tag, "a") and rng.chance(60)) { + try buf.appendSlice(gpa, " class=link"); + } + try buf.append(gpa, '>'); + return; + } + + try buf.appendSlice(gpa, " class=\""); + // `row` on divs and `link` on anchors are the selectors the benchmarks + // target; the pooled classes surround them with realistic noise. + if (std.mem.eql(u8, tag, "div") and rng.chance(30)) { + try buf.appendSlice(gpa, "row "); + } else if (std.mem.eql(u8, tag, "a") and rng.chance(60)) { + try buf.appendSlice(gpa, "link "); + } + try buf.appendSlice(gpa, "c"); + try printInt(gpa, buf, rng.below(shape.class_pool)); + if (rng.chance(40)) { + try buf.appendSlice(gpa, " d"); + try printInt(gpa, buf, rng.below(shape.class_pool)); + } + try buf.append(gpa, '"'); + + if (std.mem.eql(u8, tag, "a")) { + try buf.appendSlice(gpa, " href=\"/p/"); + try printInt(gpa, buf, rng.below(100000)); + try buf.append(gpa, '"'); + } + + var i: u32 = 0; + while (i < shape.attrs_per_el) : (i += 1) { + const key = attr_keys[rng.below(attr_keys.len)]; + try buf.append(gpa, ' '); + try buf.appendSlice(gpa, key); + try buf.appendSlice(gpa, "=\""); + try buf.appendSlice(gpa, words[rng.below(words.len)]); + try buf.append(gpa, '-'); + try printInt(gpa, buf, depth * 1000 + rng.below(1000)); + try buf.append(gpa, '"'); + } + + try buf.append(gpa, '>'); +} + +fn emitText(gpa: Allocator, buf: *std.ArrayList(u8), rng: *Rng, shape: Shape) !void { + if (shape.minimal_markup) { + try buf.appendSlice(gpa, short_words[rng.below(short_words.len)]); + return; + } + const count = 1 + rng.below(5); + var i: u32 = 0; + while (i < count) : (i += 1) { + if (i > 0) try buf.append(gpa, ' '); + try buf.appendSlice(gpa, words[rng.below(words.len)]); + } + if (shape.entity_percent > 0 and rng.chance(shape.entity_percent)) { + try buf.appendSlice(gpa, " & <tag> —  "); + } +} + +fn printInt(gpa: Allocator, buf: *std.ArrayList(u8), value: u32) !void { + var tmp: [16]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch unreachable; + try buf.appendSlice(gpa, s); +} + +/// `count` nested `
` elements. This is the shape that overflows the stack +/// in every recursive traversal in the library. +pub fn generateNested(gpa: Allocator, count: u32) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(gpa); + try buf.ensureTotalCapacity(gpa, @as(usize, count) * 12 + 128); + + try buf.appendSlice(gpa, ""); + var i: u32 = 0; + while (i < count) : (i += 1) try buf.appendSlice(gpa, "
"); + try buf.appendSlice(gpa, "bottom"); + i = 0; + while (i < count) : (i += 1) try buf.appendSlice(gpa, "
"); + try buf.appendSlice(gpa, ""); + + return buf.toOwnedSlice(gpa); +} + +/// `count` siblings under a single parent. Exercises the sibling scans in +/// `:nth-child` and friends, which are quadratic today. +pub fn generateWide(gpa: Allocator, count: u32) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(gpa); + try buf.ensureTotalCapacity(gpa, @as(usize, count) * 40 + 128); + + try buf.appendSlice(gpa, "
"); + var i: u32 = 0; + while (i < count) : (i += 1) { + try buf.appendSlice(gpa, "x"); + } + try buf.appendSlice(gpa, "
"); + + return buf.toOwnedSlice(gpa); +} + +test "generate is deterministic and hits its target size" { + const gpa = std.testing.allocator; + const shape: Shape = .{ .target_bytes = 200_000 }; + + const a = try generate(gpa, shape); + defer gpa.free(a); + const b = try generate(gpa, shape); + defer gpa.free(b); + + try std.testing.expectEqualSlices(u8, a, b); + try std.testing.expect(a.len >= shape.target_bytes); + // The overshoot is only the closing tags for the open element stack. + try std.testing.expect(a.len < shape.target_bytes + 4096); +} + +test "generate emits the selector targets the benchmarks use" { + const gpa = std.testing.allocator; + const html = try generate(gpa, .{ .target_bytes = 200_000 }); + defer gpa.free(html); + + try std.testing.expect(std.mem.indexOf(u8, html, "class=\"row ") != null); + try std.testing.expect(std.mem.indexOf(u8, html, "class=\"link ") != null); + try std.testing.expect(std.mem.indexOf(u8, html, "href=\"/p/") != null); +} + +test "every preset generates" { + const gpa = std.testing.allocator; + for (presets) |p| { + // Keep the test fast: shrink the big presets but keep their character. + var shape = p.shape; + shape.target_bytes = @min(shape.target_bytes, 120_000); + const html = try generate(gpa, shape); + defer gpa.free(html); + try std.testing.expect(html.len >= shape.target_bytes); + } +} + +test "nested and wide generators" { + const gpa = std.testing.allocator; + + const nested = try generateNested(gpa, 1000); + defer gpa.free(nested); + try std.testing.expectEqual(@as(usize, 1000), std.mem.count(u8, nested, "")); + + const wide = try generateWide(gpa, 1000); + defer gpa.free(wide); + try std.testing.expectEqual(@as(usize, 1000), std.mem.count(u8, wide, " self.peak) self.peak = self.live; + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + const result = self.backing.rawAlloc(len, alignment, ret_addr) orelse return null; + self.alloc_count += 1; + self.note(len); + return result; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + if (!self.backing.rawResize(memory, alignment, new_len, ret_addr)) return false; + self.resize_count += 1; + self.applyDelta(memory.len, new_len); + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + const result = self.backing.rawRemap(memory, alignment, new_len, ret_addr) orelse return null; + self.resize_count += 1; + self.applyDelta(memory.len, new_len); + return result; + } + + fn free(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { + const self: *CountingAllocator = @ptrCast(@alignCast(ctx)); + self.backing.rawFree(memory, alignment, ret_addr); + self.free_count += 1; + self.live -= memory.len; + } + + fn applyDelta(self: *CountingAllocator, old_len: usize, new_len: usize) void { + if (new_len >= old_len) { + self.note(new_len - old_len); + } else { + self.live -= old_len - new_len; + } + } +}; + +test "counts live bytes and returns to zero" { + var counting = CountingAllocator.init(std.testing.allocator); + const a = counting.allocator(); + + const first = try a.alloc(u8, 1000); + try std.testing.expectEqual(@as(usize, 1000), counting.live); + + const second = try a.alloc(u8, 500); + try std.testing.expectEqual(@as(usize, 1500), counting.live); + try std.testing.expectEqual(@as(usize, 1500), counting.peak); + + a.free(second); + try std.testing.expectEqual(@as(usize, 1000), counting.live); + // The peak remembers the high-water mark even after the free. + try std.testing.expectEqual(@as(usize, 1500), counting.peak); + + a.free(first); + try std.testing.expectEqual(@as(usize, 0), counting.live); + try std.testing.expectEqual(@as(usize, 1500), counting.total); +} + +test "reset keeps live so later frees do not underflow" { + var counting = CountingAllocator.init(std.testing.allocator); + const a = counting.allocator(); + + const held = try a.alloc(u8, 4096); + try std.testing.expectEqual(@as(usize, 4096), counting.live); + + counting.reset(); + // Still outstanding, so still counted. + try std.testing.expectEqual(@as(usize, 4096), counting.live); + try std.testing.expectEqual(@as(usize, 4096), counting.peak); + try std.testing.expectEqual(@as(usize, 0), counting.total); + + a.free(held); + try std.testing.expectEqual(@as(usize, 0), counting.live); +} + +test "tracks growth through an ArrayList" { + var counting = CountingAllocator.init(std.testing.allocator); + const a = counting.allocator(); + + var list: std.ArrayList(u32) = .empty; + for (0..1000) |i| try list.append(a, @intCast(i)); + try std.testing.expect(counting.live > 0); + + list.deinit(a); + try std.testing.expectEqual(@as(usize, 0), counting.live); +} diff --git a/bench/harness.zig b/bench/harness.zig new file mode 100644 index 0000000..1d29e46 --- /dev/null +++ b/bench/harness.zig @@ -0,0 +1,313 @@ +//! Measurement core: batched timing, percentile reporting, and per-operation +//! memory accounting. +//! +//! Operations here range from ~100 ns (`Selection.first`) to ~100 ms (parsing +//! 10 MB). A single timing strategy cannot cover that, so the runner first +//! calibrates a batch size that makes one batch last at least +//! `Options.min_batch_ns`, then times whole batches. That keeps clock overhead +//! off the fast benchmarks without inflating iteration counts on the slow ones. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const CountingAllocator = @import("counting_allocator.zig").CountingAllocator; + +pub const Options = struct { + /// Keep growing the batch until one batch takes at least this long, so + /// clock resolution and vtable overhead stay in the noise. + min_batch_ns: u64 = 1 * std.time.ns_per_ms, + /// Stop once the measured phase has run this long... + min_total_ns: u64 = 200 * std.time.ns_per_ms, + /// ...and stop unconditionally at this point, even if `min_batches` has + /// not been reached. Some operations under test take seconds per call; + /// without this the suite would take hours. + max_total_ns: u64 = 4 * std.time.ns_per_s, + /// ...or this many batches, whichever comes first. + max_batches: u32 = 200, + min_batches: u32 = 5, + warmup_iters: u32 = 2, + /// Upper bound on batch size, so a benchmark that turns out to be + /// unexpectedly cheap cannot run for minutes. + max_batch: u64 = 1 << 16, + /// Hard cap on total operations, including calibration. + max_iters: u64 = 1 << 22, + /// Stop sampling once the workload has retained this many bytes. + /// + /// Several operations under test leak into the document arena by design of + /// the current code -- that is what `Stats.growth_per_op` reports. Without + /// a budget those benchmarks would run the machine out of memory before + /// the time budget expired. + max_growth_bytes: usize = 512 << 20, +}; + +pub const Stats = struct { + name: []const u8, + iters: u64, + batches: u32, + /// Per-operation nanoseconds. + ns_min: u64, + ns_p50: u64, + ns_p99: u64, + /// Input size for throughput reporting. Zero means "not a throughput + /// benchmark" and suppresses the MB/s column. + bytes_in: u64 = 0, + /// High-water mark of live bytes during the measured phase. + peak_bytes: usize = 0, + /// Live bytes still checked out per operation once the operation claims to + /// be finished. Anything above zero is a leak. + growth_per_op: usize = 0, + alloc_count_per_op: u64 = 0, + /// Set when `Options.max_growth_bytes` ended sampling early, i.e. the + /// operation retains memory fast enough to matter. + growth_capped: bool = false, + /// Set when a read-only query grew the *document* arena, which it never + /// should: query results belong to the query. + doc_grew: bool = false, + + pub fn mbPerSec(self: Stats) f64 { + if (self.bytes_in == 0 or self.ns_p50 == 0) return 0; + const bytes_per_ns = @as(f64, @floatFromInt(self.bytes_in)) / + @as(f64, @floatFromInt(self.ns_p50)); + return bytes_per_ns * 1000.0; // bytes/ns -> MB/s (1e9 ns/s / 1e6 B/MB) + } +}; + +/// Run `step` against `ctx` until the sampling budget is exhausted. +/// +/// `ctx` must be a pointer; `step` receives it unchanged. Whatever `step` +/// produces must be fed to `std.mem.doNotOptimizeAway` by the caller, since the +/// harness cannot see the value. +pub fn measure( + name: []const u8, + io: Io, + counting: *CountingAllocator, + opts: Options, + ctx: anytype, + comptime step: fn (@TypeOf(ctx)) anyerror!void, +) !Stats { + var warm: u32 = 0; + while (warm < opts.warmup_iters) : (warm += 1) try step(ctx); + + const batch = try calibrate(io, counting, opts, ctx, step); + + var samples: [256]u64 = undefined; + var sample_count: u32 = 0; + + counting.reset(); + const live_before = counting.live; + + var total_ns: u64 = 0; + var total_iters: u64 = 0; + var batches: u32 = 0; + var capped = false; + + while (batches < opts.max_batches) : (batches += 1) { + if (batches >= opts.min_batches and total_ns >= opts.min_total_ns) break; + if (total_ns >= opts.max_total_ns) break; + if (total_iters >= opts.max_iters) break; + if (counting.live -| live_before >= opts.max_growth_bytes) { + capped = true; + break; + } + + const started = Io.Clock.now(.awake, io); + var i: u64 = 0; + while (i < batch) : (i += 1) try step(ctx); + const elapsed: u64 = @intCast(started.untilNow(io, .awake).toNanoseconds()); + + total_ns += elapsed; + total_iters += batch; + if (sample_count < samples.len) { + samples[sample_count] = elapsed / batch; + sample_count += 1; + } + } + + const live_after = counting.live; + + std.mem.sort(u64, samples[0..sample_count], {}, std.sort.asc(u64)); + + return .{ + .name = name, + .iters = total_iters, + .batches = batches, + .ns_min = samples[0], + .ns_p50 = percentile(samples[0..sample_count], 50), + .ns_p99 = percentile(samples[0..sample_count], 99), + .peak_bytes = counting.peak, + .growth_per_op = if (live_after > live_before) + (live_after - live_before) / total_iters + else + 0, + .alloc_count_per_op = counting.alloc_count / total_iters, + .growth_capped = capped, + }; +} + +/// Pick the smallest batch size whose runtime clears `min_batch_ns`. +fn calibrate( + io: Io, + counting: *CountingAllocator, + opts: Options, + ctx: anytype, + comptime step: fn (@TypeOf(ctx)) anyerror!void, +) !u64 { + const live_at_start = counting.live; + var batch: u64 = 1; + while (batch < opts.max_batch) { + // Calibration runs real operations, so it is subject to the same + // retention budget as the measured phase. + if (counting.live -| live_at_start >= opts.max_growth_bytes) return batch; + const started = Io.Clock.now(.awake, io); + var i: u64 = 0; + while (i < batch) : (i += 1) try step(ctx); + const elapsed: u64 = @intCast(started.untilNow(io, .awake).toNanoseconds()); + + if (elapsed >= opts.min_batch_ns) return batch; + // Scale toward the target rather than blindly doubling, so a benchmark + // three orders of magnitude too fast converges in a couple of rounds. + const scaled = if (elapsed == 0) + batch * 16 + else + batch * @max(2, opts.min_batch_ns / @max(elapsed, 1)); + batch = @min(scaled, opts.max_batch); + } + return batch; +} + +fn percentile(sorted: []const u64, p: u32) u64 { + if (sorted.len == 0) return 0; + const idx = (sorted.len * p) / 100; + return sorted[@min(idx, sorted.len - 1)]; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +pub fn writeHeader(gpa: Allocator, out: *std.ArrayList(u8)) !void { + try out.appendSlice(gpa, + \\ benchmark p50 min MB/s mem/in B/op allocs + \\ ----------------------------------------- ---------- ---------- ------- ------ -------- ------ + \\ + ); +} + +pub fn writeRow(gpa: Allocator, out: *std.ArrayList(u8), s: Stats) !void { + var line: [320]u8 = undefined; + + var mbps_buf: [16]u8 = undefined; + const mbps_str = if (s.bytes_in == 0) + " -" + else + try std.fmt.bufPrint(&mbps_buf, "{d:7.1}", .{s.mbPerSec()}); + + // Peak resident bytes per input byte. This is the arena-amplification + // number: 12.5 today on a parse benchmark. + var ratio_buf: [16]u8 = undefined; + const ratio_str = if (s.bytes_in == 0) + " -" + else + try std.fmt.bufPrint(&ratio_buf, "{d:6.1}", .{ + @as(f64, @floatFromInt(s.peak_bytes)) / @as(f64, @floatFromInt(s.bytes_in)), + }); + + // A trailing '!' marks a row where the operation grew the document arena, + // which a read-only query must never do. + var growth_buf: [24]u8 = undefined; + const growth_str = try std.fmt.bufPrint(&growth_buf, "{d}{s}", .{ + s.growth_per_op, + if (s.doc_grew) "!" else "", + }); + + var p50_buf: [24]u8 = undefined; + var min_buf: [24]u8 = undefined; + + const text = try std.fmt.bufPrint(&line, " {s: <41} {s: >10} {s: >10} {s} {s} {s: >8} {d: >6}\n", .{ + s.name, + try formatNs(&p50_buf, s.ns_p50), + try formatNs(&min_buf, s.ns_min), + mbps_str, + ratio_str, + growth_str, + s.alloc_count_per_op, + }); + try out.appendSlice(gpa, text); +} + +/// Render a nanosecond count with a unit, so a table can mix 120 ns and 84 ms +/// without the reader counting digits. The caller owns `buf`; two calls in one +/// argument list need two buffers. +fn formatNs(buf: []u8, ns: u64) ![]const u8 { + if (ns < 1_000) return std.fmt.bufPrint(buf, "{d}ns", .{ns}); + if (ns < 1_000_000) return std.fmt.bufPrint(buf, "{d:.2}us", .{ + @as(f64, @floatFromInt(ns)) / 1_000.0, + }); + if (ns < 1_000_000_000) return std.fmt.bufPrint(buf, "{d:.2}ms", .{ + @as(f64, @floatFromInt(ns)) / 1_000_000.0, + }); + return std.fmt.bufPrint(buf, "{d:.2}s", .{ + @as(f64, @floatFromInt(ns)) / 1_000_000_000.0, + }); +} + +/// Hand-rolled rather than `std.json`, whose API has moved between the two Zig +/// versions this project supports. +pub fn writeJson(gpa: Allocator, out: *std.ArrayList(u8), all: []const Stats) !void { + try out.appendSlice(gpa, "{\n \"benchmarks\": [\n"); + for (all, 0..) |s, i| { + var line: [512]u8 = undefined; + const text = try std.fmt.bufPrint(&line, + \\ {{"name": "{s}", "ns_p50": {d}, "ns_min": {d}, "ns_p99": {d}, "bytes_in": {d}, "peak_bytes": {d}, "growth_per_op": {d}, "doc_grew": {}, "allocs_per_op": {d}, "iters": {d}}} + , .{ + s.name, s.ns_p50, s.ns_min, s.ns_p99, + s.bytes_in, s.peak_bytes, s.growth_per_op, s.doc_grew, + s.alloc_count_per_op, s.iters, + }); + try out.appendSlice(gpa, text); + if (i + 1 < all.len) try out.appendSlice(gpa, ","); + try out.appendSlice(gpa, "\n"); + } + try out.appendSlice(gpa, " ]\n}\n"); +} + +test "percentile picks the expected element" { + const xs = [_]u64{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + try std.testing.expectEqual(@as(u64, 6), percentile(&xs, 50)); + try std.testing.expectEqual(@as(u64, 10), percentile(&xs, 99)); + try std.testing.expectEqual(@as(u64, 1), percentile(&xs, 0)); + try std.testing.expectEqual(@as(u64, 0), percentile(&.{}, 50)); +} + +test "formatNs switches units" { + var buf: [24]u8 = undefined; + try std.testing.expectEqualStrings("999ns", try formatNs(&buf, 999)); + try std.testing.expectEqualStrings("1.50us", try formatNs(&buf, 1500)); + try std.testing.expectEqualStrings("2.00ms", try formatNs(&buf, 2_000_000)); + try std.testing.expectEqualStrings("1.50s", try formatNs(&buf, 1_500_000_000)); +} + +test "two formatNs calls in one argument list do not alias" { + var a: [24]u8 = undefined; + var b: [24]u8 = undefined; + var line: [64]u8 = undefined; + const out = try std.fmt.bufPrint(&line, "{s} {s}", .{ + try formatNs(&a, 1500), + try formatNs(&b, 2_000_000), + }); + try std.testing.expectEqualStrings("1.50us 2.00ms", out); +} + +test "mbPerSec" { + // 1 MB in 1 ms is 1000 MB/s. + const s: Stats = .{ + .name = "x", + .iters = 1, + .batches = 1, + .ns_min = 1_000_000, + .ns_p50 = 1_000_000, + .ns_p99 = 1_000_000, + .bytes_in = 1_000_000, + }; + try std.testing.expectApproxEqAbs(@as(f64, 1000.0), s.mbPerSec(), 0.001); +} diff --git a/bench/main.zig b/bench/main.zig new file mode 100644 index 0000000..f7173ea --- /dev/null +++ b/bench/main.zig @@ -0,0 +1,706 @@ +//! zigquery benchmark driver. +//! +//! zig build bench -- the standard suite +//! zig build bench -- --filter find -- only benchmarks whose name matches +//! zig build bench -- --json out.json -- machine-readable results +//! zig build bench -- --scaling -- report empirical complexity +//! zig build bench -- --corpus -- also run against local .html files +//! zig build bench -- --deep -- include the deep-nesting suite +//! +//! The deep suite is opt-in because it builds documents up to 50 000 levels +//! deep and 100 000 siblings wide, which takes a moment even now that the +//! quadratic paths are gone. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Io = std.Io; + +const zq = @import("zigquery"); +const corpus = @import("corpus.zig"); +const harness = @import("harness.zig"); +const CountingAllocator = @import("counting_allocator.zig").CountingAllocator; + +const Stats = harness.Stats; + +const Config = struct { + filter: ?[]const u8 = null, + json_path: ?[]const u8 = null, + corpus_dir: ?[]const u8 = null, + scaling: bool = false, + deep: bool = false, + /// Shrink the corpora so a full run takes seconds. For smoke-testing the + /// harness itself, not for producing a baseline. + quick: bool = false, + /// Fail the process when a gate is violated. This is what CI runs. + strict: bool = false, +}; + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + + const argv = try init.minimal.args.toSlice(init.arena.allocator()); + const config = parseArgs(argv) catch { + std.debug.print("{s}\n", .{usage}); + return; + }; + + var counting = CountingAllocator.init(gpa); + + var runner: Runner = .{ + .gpa = gpa, + .measured = counting.allocator(), + .counting = &counting, + .io = io, + .config = config, + }; + defer runner.deinit(); + + try runner.runAll(); + try runner.report(); + + if (config.strict and runner.violations > 0) { + std.debug.print("\n{d} gate violation(s)\n", .{runner.violations}); + return error.BenchmarkGateFailed; + } +} + +const usage = + \\usage: bench [options] + \\ --filter only run benchmarks whose name contains + \\ --json write results as JSON + \\ --corpus also benchmark every .html file in + \\ --scaling report the empirical complexity exponent + \\ --deep include the deep-nesting suite (may abort) + \\ --quick shrink corpora for a fast smoke run + \\ --strict exit non-zero if a gate is violated (for CI) + \\ + \\Gates checked by --strict: + \\ * no benchmark may grow the document arena + \\ * no workload may exceed the superlinear scaling threshold +; + +fn parseArgs(argv: []const [:0]const u8) !Config { + var config: Config = .{}; + var i: usize = 1; + while (i < argv.len) : (i += 1) { + const arg = argv[i]; + if (std.mem.eql(u8, arg, "--filter")) { + i += 1; + if (i >= argv.len) return error.Usage; + config.filter = argv[i]; + } else if (std.mem.eql(u8, arg, "--json")) { + i += 1; + if (i >= argv.len) return error.Usage; + config.json_path = argv[i]; + } else if (std.mem.eql(u8, arg, "--corpus")) { + i += 1; + if (i >= argv.len) return error.Usage; + config.corpus_dir = argv[i]; + } else if (std.mem.eql(u8, arg, "--scaling")) { + config.scaling = true; + } else if (std.mem.eql(u8, arg, "--deep")) { + config.deep = true; + } else if (std.mem.eql(u8, arg, "--quick")) { + config.quick = true; + } else if (std.mem.eql(u8, arg, "--strict")) { + config.strict = true; + } else { + return error.Usage; + } + } + return config; +} + +// --------------------------------------------------------------------------- +// Benchmark contexts +// --------------------------------------------------------------------------- + +/// Parse and immediately release, so `peak_bytes` measures the cost of holding +/// one parsed document. +const ParseCtx = struct { + alloc: Allocator, + html: []const u8, + + fn step(self: *ParseCtx) !void { + var doc = try zq.Document.initFromSlice(self.alloc, self.html); + defer doc.deinit(); + std.mem.doNotOptimizeAway(doc.root_node); + } +}; + +const FindCtx = struct { + q: *zq.Query, + selector: []const u8, + sink: usize = 0, + + fn step(self: *FindCtx) !void { + const sel = try self.q.find(self.selector); + self.sink = sel.len(); + std.mem.doNotOptimizeAway(&self.sink); + } +}; + +const RenderCtx = struct { + alloc: Allocator, + doc: *zq.Document, + + fn step(self: *RenderCtx) !void { + const html = try zq.html_render.renderToString(self.alloc, self.doc.root_node); + defer self.alloc.free(html); + std.mem.doNotOptimizeAway(html.ptr); + } +}; + +const CloneCtx = struct { + alloc: Allocator, + doc: *zq.Document, + + fn step(self: *CloneCtx) !void { + var copy = try self.doc.clone(self.alloc); + defer copy.deinit(); + std.mem.doNotOptimizeAway(copy.root_node); + } +}; + +const TextCtx = struct { + q: *zq.Query, + sink: usize = 0, + + fn step(self: *TextCtx) !void { + const sel = try self.q.find("body"); + const text = try sel.text(); + self.sink = text.len; + std.mem.doNotOptimizeAway(&self.sink); + } +}; + +/// Traversal benchmarks share this shape: take a starting selection and walk +/// somewhere from it. +const TraverseCtx = struct { + q: *zq.Query, + base: zq.Selection, + kind: Kind, + sink: usize = 0, + + const Kind = enum { children, parent, siblings, next_all, filter, first }; + + fn step(self: *TraverseCtx) !void { + const out = switch (self.kind) { + .children => try self.base.children(), + .parent => try self.base.parent(), + .siblings => try self.base.siblings(), + .next_all => try self.base.nextAll(), + .filter => try self.base.filter(".row"), + .first => try self.base.first(), + }; + self.sink = out.len(); + std.mem.doNotOptimizeAway(&self.sink); + } +}; + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +const Runner = struct { + gpa: Allocator, + /// The allocator handed to the library, wrapped so every request is counted. + measured: Allocator, + counting: *CountingAllocator, + io: Io, + config: Config, + results: std.ArrayList(Stats) = .empty, + /// Benchmark names outlive the loop that creates them. + names: std.ArrayList([]u8) = .empty, + scaling_rows: std.ArrayList(ScalingRow) = .empty, + /// Gate failures seen so far; `--strict` turns these into a non-zero exit. + violations: u32 = 0, + + const ScalingRow = struct { + name: []const u8, + exponent: f64, + ns: [3]u64, + }; + + fn deinit(self: *Runner) void { + for (self.names.items) |n| self.gpa.free(n); + self.names.deinit(self.gpa); + self.results.deinit(self.gpa); + self.scaling_rows.deinit(self.gpa); + } + + fn wanted(self: *Runner, name: []const u8) bool { + const f = self.config.filter orelse return true; + return std.mem.indexOf(u8, name, f) != null; + } + + fn own(self: *Runner, comptime fmt: []const u8, args: anytype) ![]const u8 { + const s = try std.fmt.allocPrint(self.gpa, fmt, args); + try self.names.append(self.gpa, s); + return s; + } + + fn add(self: *Runner, s: Stats) !void { + try self.results.append(self.gpa, s); + // Print as we go: a full run takes a while and the reader should not + // have to wait for the last benchmark to see the first result. + var line: std.ArrayList(u8) = .empty; + defer line.deinit(self.gpa); + try harness.writeRow(self.gpa, &line, s); + std.debug.print("{s}", .{line.items}); + } + + /// Measure a benchmark that queries a live document, reporting how much + /// that document's own arena grew per operation. + /// + /// The counting allocator sits *below* the arena and only observes chunk + /// requests, so an operation that fits in a chunk's spare capacity looks + /// free to it. The arena's own capacity is the honest number, and it is + /// the one that matches "the arena went from 27 MB to 261 MB". + fn measureOnDocument( + self: *Runner, + name: []const u8, + doc: *zq.Document, + q: *zq.Query, + opts: harness.Options, + ctx: anytype, + comptime step: fn (@TypeOf(ctx)) anyerror!void, + ) !Stats { + const doc_before = doc.arena.queryCapacity(); + const q_before = q.bytesUsed(); + var stats = try harness.measure(name, self.io, self.counting, opts, ctx, step); + + // Query-arena growth is expected: that arena is the scratch space, and + // it is reclaimed wholesale by `deinit`/`reset`. It is reported so the + // per-operation cost is visible, not as a failure. + stats.growth_per_op = (q.bytesUsed() -| q_before) / @max(stats.iters, 1); + // Document growth from a read-only query is the actual defect. + stats.doc_grew = doc.arena.queryCapacity() > doc_before; + return stats; + } + + fn scale(self: *Runner, bytes: usize) usize { + return if (self.config.quick) @min(bytes, 150_000) else bytes; + } + + fn runAll(self: *Runner) !void { + var header: std.ArrayList(u8) = .empty; + defer header.deinit(self.gpa); + try harness.writeHeader(self.gpa, &header); + std.debug.print("{s}", .{header.items}); + + try self.runParseSuite(); + try self.runQuerySuite(); + try self.runLeakGate(); + try self.runDocumentSuite(); + if (self.config.deep) try self.runDeepSuite(); + if (self.config.corpus_dir) |dir| try self.runCorpusDir(dir); + if (self.config.scaling) try self.runScaling(); + } + + fn runParseSuite(self: *Runner) !void { + for (corpus.presets) |preset| { + const name = try self.own("parse/{s}", .{preset.name}); + if (!self.wanted(name)) continue; + + var shape = preset.shape; + shape.target_bytes = self.scale(shape.target_bytes); + const html = try corpus.generate(self.gpa, shape); + defer self.gpa.free(html); + + var ctx: ParseCtx = .{ .alloc = self.measured, .html = html }; + var stats = try harness.measure(name, self.io, self.counting, .{}, &ctx, ParseCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + } + + const selectors = [_]struct { name: []const u8, sel: []const u8 }{ + .{ .name = "find/tag", .sel = "span" }, + .{ .name = "find/class", .sel = ".row" }, + .{ .name = "find/compound", .sel = "a.link" }, + .{ .name = "find/descendant", .sel = "div.row a.link" }, + .{ .name = "find/child", .sel = "div.row > a.link" }, + .{ .name = "find/attr_exists", .sel = "[data-role]" }, + .{ .name = "find/attr_prefix", .sel = "[href^=\"/p/1\"]" }, + .{ .name = "find/nth_child", .sel = "li:nth-child(2n+1)" }, + .{ .name = "find/has_child", .sel = "div:has(> a)" }, + .{ .name = "find/has_descendant", .sel = "div:has(a.link)" }, + .{ .name = "find/contains", .sel = "p:contains(\"alpha\")" }, + .{ .name = "find/group", .sel = "span.c1, p.c2, a.link" }, + .{ .name = "find/not", .sel = "a:not(.link)" }, + .{ .name = "find/universal", .sel = "*" }, + }; + + fn runQuerySuite(self: *Runner) !void { + var shape = corpus.presetByName("medium").?; + shape.target_bytes = self.scale(shape.target_bytes); + const html = try corpus.generate(self.gpa, shape); + defer self.gpa.free(html); + + for (selectors) |entry| { + if (!self.wanted(entry.name)) continue; + + // A fresh document per selector: every query permanently retains + // memory in the document arena today, so sharing one document + // across the suite would make later benchmarks measure a + // progressively more fragmented arena. + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + + var ctx: FindCtx = .{ .q = &q, .selector = entry.sel }; + const stats = try self.measureOnDocument(entry.name, &doc, &q, .{ + .max_growth_bytes = 256 << 20, + // `:has()` currently takes about a second per call on this + // corpus, so a single sample has to be acceptable. + .warmup_iters = 1, + }, &ctx, FindCtx.step); + try self.add(stats); + } + } + + /// The headline regression: repeated queries against one document must not + /// grow it. Measured directly rather than through the sampling loop, so + /// the number is exactly "bytes the document retained". + /// + /// The count is deliberately large. Arena capacity moves in chunks, and a + /// freshly parsed document has enough spare capacity in its last chunk to + /// absorb a few hundred queries without asking the backing allocator for + /// anything -- which would read as "no leak" when there very much is one. + fn runLeakGate(self: *Runner) !void { + const name = "leak/find_repeat"; + if (!self.wanted(name)) return; + + const query_count = 2000; + var shape = corpus.presetByName("dense").?; + shape.target_bytes = self.scale(shape.target_bytes); + const html = try corpus.generate(self.gpa, shape); + defer self.gpa.free(html); + + self.counting.reset(); + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + + var q = doc.query(self.measured); + defer q.deinit(); + + const after_parse = doc.arena.queryCapacity(); + + const started = Io.Clock.now(.awake, self.io); + var i: u32 = 0; + var sink: usize = 0; + while (i < query_count) : (i += 1) { + const sel = try q.find("div.row a.link"); + sink += sel.len(); + } + std.mem.doNotOptimizeAway(&sink); + const elapsed: u64 = @intCast(started.untilNow(self.io, .awake).toNanoseconds()); + + const after_finds = doc.arena.queryCapacity(); + const retained = after_finds -| after_parse; + try self.add(.{ + .name = name, + .iters = query_count, + .batches = 1, + .ns_min = elapsed / query_count, + .ns_p50 = elapsed / query_count, + .ns_p99 = elapsed / query_count, + .peak_bytes = self.counting.peak, + .growth_per_op = retained / query_count, + .alloc_count_per_op = self.counting.alloc_count / query_count, + .doc_grew = retained > 0, + }); + + std.debug.print( + " ^ document arena: {d} KB after parse -> {d} KB after {d} finds ({d}x)\n", + .{ + after_parse / 1024, + after_finds / 1024, + query_count, + if (after_parse == 0) 0 else after_finds / after_parse, + }, + ); + } + + fn runDocumentSuite(self: *Runner) !void { + var shape = corpus.presetByName("medium").?; + shape.target_bytes = self.scale(shape.target_bytes); + const html = try corpus.generate(self.gpa, shape); + defer self.gpa.free(html); + + if (self.wanted("render/document")) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var ctx: RenderCtx = .{ .alloc = self.measured, .doc = &doc }; + var stats = try harness.measure("render/document", self.io, self.counting, .{}, &ctx, RenderCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + + if (self.wanted("clone/document")) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var ctx: CloneCtx = .{ .alloc = self.measured, .doc = &doc }; + var stats = try harness.measure("clone/document", self.io, self.counting, .{}, &ctx, CloneCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + + if (self.wanted("text/document")) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + var ctx: TextCtx = .{ .q = &q }; + const stats = try self.measureOnDocument("text/document", &doc, &q, .{ + .max_growth_bytes = 256 << 20, + }, &ctx, TextCtx.step); + try self.add(stats); + } + + const traversals = [_]struct { name: []const u8, kind: TraverseCtx.Kind }{ + .{ .name = "traverse/children", .kind = .children }, + .{ .name = "traverse/parent", .kind = .parent }, + .{ .name = "traverse/siblings", .kind = .siblings }, + .{ .name = "traverse/next_all", .kind = .next_all }, + .{ .name = "traverse/filter", .kind = .filter }, + .{ .name = "traverse/first", .kind = .first }, + }; + + for (traversals) |t| { + if (!self.wanted(t.name)) continue; + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + const base = try q.find("div"); + var ctx: TraverseCtx = .{ .q = &q, .base = base, .kind = t.kind }; + const stats = try self.measureOnDocument(t.name, &doc, &q, .{ + .max_growth_bytes = 256 << 20, + }, &ctx, TraverseCtx.step); + try self.add(stats); + } + } + + /// Deeply nested input. Every one of these exercises a traversal that + /// currently recurses once per DOM level. + fn runDeepSuite(self: *Runner) !void { + const depths = [_]u32{ 1_000, 10_000, 50_000 }; + for (depths) |depth| { + const html = try corpus.generateNested(self.gpa, depth); + defer self.gpa.free(html); + + const parse_name = try self.own("deep/parse_{d}", .{depth}); + if (self.wanted(parse_name)) { + var ctx: ParseCtx = .{ .alloc = self.measured, .html = html }; + var stats = try harness.measure(parse_name, self.io, self.counting, .{ + .max_batches = 5, + .min_batches = 1, + .warmup_iters = 0, + .min_total_ns = 50 * std.time.ns_per_ms, + }, &ctx, ParseCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + + const render_name = try self.own("deep/render_{d}", .{depth}); + if (self.wanted(render_name)) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var ctx: RenderCtx = .{ .alloc = self.measured, .doc = &doc }; + var stats = try harness.measure(render_name, self.io, self.counting, .{ + .max_batches = 5, + .min_batches = 1, + .warmup_iters = 0, + .min_total_ns = 50 * std.time.ns_per_ms, + }, &ctx, RenderCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + + const find_name = try self.own("deep/find_{d}", .{depth}); + if (self.wanted(find_name)) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + var ctx: FindCtx = .{ .q = &q, .selector = "a.link" }; + const stats = try harness.measure(find_name, self.io, self.counting, .{ + .max_batches = 5, + .min_batches = 1, + .warmup_iters = 0, + .min_total_ns = 50 * std.time.ns_per_ms, + .max_growth_bytes = 128 << 20, + }, &ctx, FindCtx.step); + try self.add(stats); + } + } + + const wide_html = try corpus.generateWide(self.gpa, 100_000); + defer self.gpa.free(wide_html); + + if (self.wanted("deep/wide_nth_child")) { + var doc = try zq.Document.initFromSlice(self.measured, wide_html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + var ctx: FindCtx = .{ .q = &q, .selector = "span:nth-child(2n+1)" }; + const stats = try harness.measure("deep/wide_nth_child", self.io, self.counting, .{ + .max_batches = 5, + .min_batches = 1, + .warmup_iters = 0, + .min_total_ns = 50 * std.time.ns_per_ms, + .max_growth_bytes = 128 << 20, + }, &ctx, FindCtx.step); + try self.add(stats); + } + } + + fn runCorpusDir(self: *Runner, dir_path: []const u8) !void { + var dir = std.Io.Dir.cwd().openDir(self.io, dir_path, .{ .iterate = true }) catch |err| { + std.debug.print("corpus: cannot open {s}: {s}\n", .{ dir_path, @errorName(err) }); + return; + }; + defer dir.close(self.io); + + var it = dir.iterate(); + while (try it.next(self.io)) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".html")) continue; + + const html = try dir.readFileAlloc(self.io, entry.name, self.gpa, .limited(64 << 20)); + defer self.gpa.free(html); + + const parse_name = try self.own("corpus/parse:{s}", .{entry.name}); + if (self.wanted(parse_name)) { + var ctx: ParseCtx = .{ .alloc = self.measured, .html = html }; + var stats = try harness.measure(parse_name, self.io, self.counting, .{}, &ctx, ParseCtx.step); + stats.bytes_in = html.len; + try self.add(stats); + } + + const find_name = try self.own("corpus/find:{s}", .{entry.name}); + if (self.wanted(find_name)) { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + var ctx: FindCtx = .{ .q = &q, .selector = "div a" }; + const stats = try harness.measure(find_name, self.io, self.counting, .{ + .max_growth_bytes = 128 << 20, + }, &ctx, FindCtx.step); + try self.add(stats); + } + } + } + + /// Run the same workload at 1x, 2x and 4x input and fit an exponent. + /// + /// A linear algorithm scores ~1.0 and a quadratic one ~2.0. This is the + /// check that survives a noisy machine: absolute timings drift, but the + /// ratio between three runs on the same machine does not. + fn runScaling(self: *Runner) !void { + std.debug.print("\n scaling (exponent: 1.0 = linear, 2.0 = quadratic)\n", .{}); + std.debug.print(" ----------------------------------------- -------- ----------------------------\n", .{}); + + const base: usize = if (self.config.quick) 60_000 else 400_000; + const sizes = [3]usize{ base, base * 2, base * 4 }; + + var htmls: [3][]u8 = undefined; + for (sizes, 0..) |size, i| { + var shape = corpus.presetByName("medium").?; + shape.target_bytes = size; + htmls[i] = try corpus.generate(self.gpa, shape); + } + defer for (htmls) |h| self.gpa.free(h); + + // Parsing first, then each selector. + { + var ns: [3]u64 = undefined; + for (htmls, 0..) |html, i| { + var ctx: ParseCtx = .{ .alloc = self.measured, .html = html }; + const s = try harness.measure("scale", self.io, self.counting, .{ + .min_total_ns = 150 * std.time.ns_per_ms, + .max_batches = 60, + }, &ctx, ParseCtx.step); + ns[i] = s.ns_p50; + } + try self.addScaling("parse", ns); + } + + for (selectors) |entry| { + if (!self.wanted(entry.name)) continue; + var ns: [3]u64 = undefined; + for (htmls, 0..) |html, i| { + var doc = try zq.Document.initFromSlice(self.measured, html); + defer doc.deinit(); + var q = doc.query(self.measured); + defer q.deinit(); + var ctx: FindCtx = .{ .q = &q, .selector = entry.sel }; + const s = try harness.measure("scale", self.io, self.counting, .{ + .min_total_ns = 150 * std.time.ns_per_ms, + .max_batches = 60, + .max_growth_bytes = 128 << 20, + }, &ctx, FindCtx.step); + ns[i] = s.ns_p50; + } + try self.addScaling(entry.name, ns); + } + } + + /// Above this, a workload is doing more than linear work. + /// + /// A genuinely linear selector does not measure 1.00 here: quadrupling the + /// document takes it out of one cache level and into the next, and the + /// per-call bookkeeping grows with the result set. On the machine this was + /// developed on, linear selectors land between 1.20 and 1.50 and vary by + /// about 0.1 between runs, while the quadratic ones scored 1.78 (`:nth-child` + /// rescanning siblings) through 2.16 (`:has()` rescanning the document). + /// 1.60 sits in the gap, with room for run-to-run noise on either side. + const superlinear_threshold = 1.60; + + fn addScaling(self: *Runner, name: []const u8, ns: [3]u64) !void { + // Average the two doublings: log2(t2/t1) and log2(t4/t2). + const exponent = if (ns[0] == 0 or ns[1] == 0) + 0.0 + else + (std.math.log2(@as(f64, @floatFromInt(ns[2])) / @as(f64, @floatFromInt(ns[0])))) / 2.0; + + const owned = try self.own("{s}", .{name}); + try self.scaling_rows.append(self.gpa, .{ .name = owned, .exponent = exponent, .ns = ns }); + + if (exponent > superlinear_threshold) self.violations += 1; + const flag: []const u8 = if (exponent > superlinear_threshold) " <== superlinear" else ""; + std.debug.print(" {s: <41} {d:8.2} {d}/{d}/{d} ns{s}\n", .{ + name, exponent, ns[0], ns[1], ns[2], flag, + }); + } + + fn report(self: *Runner) !void { + var out: std.ArrayList(u8) = .empty; + defer out.deinit(self.gpa); + + if (self.config.json_path) |path| { + try harness.writeJson(self.gpa, &out, self.results.items); + const file = try std.Io.Dir.cwd().createFile(self.io, path, .{}); + defer file.close(self.io); + try file.writeStreamingAll(self.io, out.items); + std.debug.print("\nwrote {s} ({d} benchmarks)\n", .{ path, self.results.items.len }); + } + + var doc_growers: u32 = 0; + for (self.results.items) |s| { + if (s.doc_grew) { + doc_growers += 1; + self.violations += 1; + } + } + std.debug.print("\n{d} benchmarks; {d} grew the document arena (must be 0)\n", .{ + self.results.items.len, doc_growers, + }); + std.debug.print("B/op is query-arena bytes per operation, reclaimed by Query.deinit/reset\n", .{}); + } +}; diff --git a/build.zig b/build.zig index 1c8901f..5e4aba3 100644 --- a/build.zig +++ b/build.zig @@ -16,6 +16,31 @@ pub fn build(b: *std.Build) void { }); b.installArtifact(lib); + // Benchmarks are always built in ReleaseFast: a Debug-mode timing is not + // a measurement, and `tree.appendChild`'s cycle assertion alone makes + // parsing O(N*depth) in safe builds. + const bench_step = b.step("bench", "Run benchmarks (ReleaseFast)"); + const bench_lib_mod = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = .ReleaseFast, + }); + const bench_exe = b.addExecutable(.{ + .name = "zigquery-bench", + .root_module = b.createModule(.{ + .root_source_file = b.path("bench/main.zig"), + .target = target, + .optimize = .ReleaseFast, + .imports = &.{ + .{ .name = "zigquery", .module = bench_lib_mod }, + }, + }), + }); + const run_bench = b.addRunArtifact(bench_exe); + run_bench.stdio = .inherit; + if (b.args) |args| run_bench.addArgs(args); + bench_step.dependOn(&run_bench.step); + const test_step = b.step("test", "Run unit tests"); const test_files = [_][]const u8{ @@ -29,6 +54,15 @@ pub fn build(b: *std.Build) void { "test/manipulation_test.zig", "test/compiled_selector_test.zig", "test/api_test.zig", + "test/deep_nesting_test.zig", + "test/pseudo_class_test.zig", + "test/query_test.zig", + }; + + const bench_test_files = [_][]const u8{ + "bench/counting_allocator.zig", + "bench/corpus.zig", + "bench/harness.zig", }; // Run inline tests from the library module itself. @@ -38,6 +72,17 @@ pub fn build(b: *std.Build) void { const run_lib_tests = b.addRunArtifact(lib_tests); test_step.dependOn(&run_lib_tests.step); + for (bench_test_files) |path| { + const t = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path(path), + .target = target, + .optimize = optimize, + }), + }); + test_step.dependOn(&b.addRunArtifact(t).step); + } + for (test_files) |path| { const t = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/build.zig.zon b/build.zig.zon index d272c99..aa419a3 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .zigquery, - .version = "0.2.0", + .version = "0.3.0", .fingerprint = 0xb1f22613cdce64cf, .minimum_zig_version = "0.15.2", .paths = .{ diff --git a/src/css/matcher.zig b/src/css/matcher.zig index f101d5d..af3861b 100644 --- a/src/css/matcher.zig +++ b/src/css/matcher.zig @@ -5,6 +5,7 @@ const Selector = sel_mod.Selector; const AttrOp = sel_mod.AttrOp; const CombinatorKind = sel_mod.CombinatorKind; const PseudoClassKind = sel_mod.PseudoClassKind; +const tree = @import("../dom/tree.zig"); const node_mod = @import("../dom/node.zig"); const Node = node_mod.Node; const NodeType = node_mod.NodeType; @@ -44,15 +45,11 @@ pub const Matcher = struct { }; fn collectMatches(selector: *const Selector, node: *const Node, result: *std.ArrayList(*Node), allocator: Allocator) !void { - var child = node.first_child; - while (child) |c| { - if (c.node_type == .element) { - if (matchNode(selector, c)) { - try result.append(allocator, @constCast(c)); - } - try collectMatches(selector, c, result, allocator); + var cur = tree.nextInPreorderConst(node, node); + while (cur) |current| : (cur = tree.nextInPreorderConst(current, node)) { + if (current.node_type == .element and matchNode(selector, current)) { + try result.append(allocator, @constCast(current)); } - child = c.next_sibling; } } @@ -159,12 +156,17 @@ fn eqlMaybeCI(a: []const u8, b: []const u8, case_insensitive: bool) bool { fn matchPseudoClass(node: *const Node, pc: sel_mod.PseudoClassSelector) bool { return switch (pc.kind) { - .first_child => isNthChild(node, 0, 1, false), - .last_child => isNthChild(node, 0, 1, true), - .only_child => isNthChild(node, 0, 1, false) and isNthChild(node, 0, 1, true), - .first_of_type => isNthOfType(node, 0, 1, false), - .last_of_type => isNthOfType(node, 0, 1, true), - .only_of_type => isNthOfType(node, 0, 1, false) and isNthOfType(node, 0, 1, true), + // The positional pseudo-classes below are answered by looking at the + // immediate neighbours rather than by counting the whole sibling list. + // Counting made matching a wide parent quadratic in its child count. + .first_child => siblingElement(node, .previous, null) == null, + .last_child => siblingElement(node, .next, null) == null, + .only_child => siblingElement(node, .previous, null) == null and + siblingElement(node, .next, null) == null, + .first_of_type => siblingElement(node, .previous, node.data) == null, + .last_of_type => siblingElement(node, .next, node.data) == null, + .only_of_type => siblingElement(node, .previous, node.data) == null and + siblingElement(node, .next, node.data) == null, .empty => nodeIsEmpty(node), .root => node.parent != null and (node.parent.?.node_type == .document), .nth_child => isNthChild(node, pc.a, pc.b, false), @@ -178,52 +180,189 @@ fn matchPseudoClass(node: *const Node, pc: sel_mod.PseudoClassSelector) bool { }; } -fn isNthChild(node: *const Node, a: i32, b: i32, from_end: bool) bool { - const parent = node.parent orelse return false; - var index: i32 = 0; +const Direction = enum { previous, next }; - if (from_end) { - var c = parent.last_child; - while (c) |child| : (c = child.prev_sibling) { - if (child.node_type == .element) { - index += 1; - if (child == @as(*const Node, node)) return matchesNth(a, b, index); - } - } - } else { - var c = parent.first_child; - while (c) |child| : (c = child.next_sibling) { - if (child.node_type == .element) { - index += 1; - if (child == @as(*const Node, node)) return matchesNth(a, b, index); +/// Nearest element sibling in `dir`, optionally restricted to a tag name. +/// Returns null when there is none. +fn siblingElement(node: *const Node, comptime dir: Direction, of_type: ?[]const u8) ?*const Node { + var cur = switch (dir) { + .previous => node.prev_sibling, + .next => node.next_sibling, + }; + while (cur) |current| { + if (current.node_type == .element) { + if (of_type) |tag| { + if (std.mem.eql(u8, current.data, tag)) return current; + } else { + return current; } } + cur = switch (dir) { + .previous => current.prev_sibling, + .next => current.next_sibling, + }; } - return false; + return null; } -fn isNthOfType(node: *const Node, a: i32, b: i32, from_end: bool) bool { - const parent = node.parent orelse return false; - var index: i32 = 0; +/// Remembers where the last position query landed, so walking a sibling list in +/// document order costs one step per element instead of a rescan. +/// +/// Both memos are validated against `tree.structure_generation`, so any +/// insertion or removal anywhere invalidates them. That is deliberately +/// conservative: a stale position would silently produce wrong matches. +const PositionMemo = struct { + generation: u64 = std.math.maxInt(u64), + parent: ?*const Node = null, + node: ?*const Node = null, + index: i32 = 0, + of_type: ?[]const u8 = null, +}; - if (from_end) { - var c = parent.last_child; - while (c) |child| : (c = child.prev_sibling) { - if (child.node_type == .element and std.mem.eql(u8, child.data, node.data)) { - index += 1; - if (child == @as(*const Node, node)) return matchesNth(a, b, index); - } +const CountMemo = struct { + generation: u64 = std.math.maxInt(u64), + parent: ?*const Node = null, + of_type: ?[]const u8 = null, + count: i32 = 0, +}; + +/// A pre-order walk descends into each child's subtree before reaching the +/// next sibling, so a single memo slot is evicted by the nested subtree and +/// never hits. Enough slots to cover the parents along the current path -- plus +/// the ones just finished -- keeps the sibling walk O(1) amortized. +const memo_slots = 128; + +threadlocal var position_memo: [memo_slots]PositionMemo = @splat(.{}); +threadlocal var count_memo: [memo_slots]CountMemo = @splat(.{}); + +fn memoSlot(parent: *const Node) usize { + // Nodes come from an arena, so consecutive ones sit a fixed stride apart + // and the low bits are alignment padding. Mix before masking, or whole + // runs of siblings land in the same slot. + const mixed = @as(u64, @intFromPtr(parent) >> 3) *% 0x9E3779B97F4A7C15; + return @intCast((mixed >> 32) % memo_slots); +} + +fn sameOfType(a: ?[]const u8, b: ?[]const u8) bool { + if (a == null) return b == null; + if (b == null) return false; + return std.mem.eql(u8, a.?, b.?); +} + +/// 1-based position of `node` among its element siblings, counting from the +/// start, optionally restricted to siblings sharing `of_type`. +/// +/// Counting from scratch on every call made matching a parent with S children +/// cost O(S^2): `:nth-child(2n+1)` over 100k siblings took 31 seconds. +fn elementPosition(node: *const Node, of_type: ?[]const u8) ?i32 { + const parent = node.parent orelse return null; + const slot = &position_memo[memoSlot(parent)]; + const memo_valid = slot.generation == tree.structure_generation and + slot.parent == parent and + sameOfType(slot.of_type, of_type); + + // Walk back toward the start, but stop as soon as we reach the sibling + // whose position we worked out last time under this parent. Matching moves + // forward through a sibling list, so that is usually only a step or two -- + // whereas restarting from the first child every time is what made this + // O(S^2) for a parent with S children. + var index: i32 = 0; + var steps: i32 = 0; + var cur = siblingElement(node, .previous, of_type); + while (cur) |current| : (cur = siblingElement(current, .previous, of_type)) { + steps += 1; + if (memo_valid and slot.node == current) { + index = slot.index + steps; + break; } - } else { - var c = parent.first_child; - while (c) |child| : (c = child.next_sibling) { - if (child.node_type == .element and std.mem.eql(u8, child.data, node.data)) { - index += 1; - if (child == @as(*const Node, node)) return matchesNth(a, b, index); - } + } + if (index == 0) index = steps + 1; + + slot.* = .{ + .generation = tree.structure_generation, + .parent = parent, + .node = node, + .index = index, + .of_type = of_type, + }; + return index; +} + +/// How many element children `parent` has, optionally restricted by tag. +fn elementCount(parent: *const Node, of_type: ?[]const u8) i32 { + const slot = &count_memo[memoSlot(parent)]; + if (slot.generation == tree.structure_generation and + slot.parent == parent and + sameOfType(slot.of_type, of_type)) + { + return slot.count; + } + + var count: i32 = 0; + var child = parent.first_child; + while (child) |current| : (child = current.next_sibling) { + if (current.node_type != .element) continue; + if (of_type) |tag| { + if (!std.mem.eql(u8, current.data, tag)) continue; } + count += 1; } - return false; + + slot.* = .{ + .generation = tree.structure_generation, + .parent = parent, + .of_type = of_type, + .count = count, + }; + return count; +} + +/// Position counted from whichever end the selector asks for. +fn positionFor(node: *const Node, from_end: bool, of_type: ?[]const u8) ?i32 { + const forward = elementPosition(node, of_type) orelse return null; + if (!from_end) return forward; + // Counting backward from a forward position avoids a second memo that + // would never hit: matching walks siblings forward, not backward. + return elementCount(node.parent.?, of_type) - forward + 1; +} + +fn isNthChild(node: *const Node, a: i32, b: i32, from_end: bool) bool { + // With no step, only position `b` can match, so walking more than `b` + // siblings is wasted work. Anything at or before position `b` is found + // within `b` steps; anything after it is rejected just as quickly. + if (a == 0) return isAtPosition(node, b, from_end, null); + const index = positionFor(node, from_end, null) orelse return false; + return matchesNth(a, b, index); +} + +fn isNthOfType(node: *const Node, a: i32, b: i32, from_end: bool) bool { + if (a == 0) return isAtPosition(node, b, from_end, node.data); + const index = positionFor(node, from_end, node.data) orelse return false; + return matchesNth(a, b, index); +} + +/// Is `node` exactly at 1-based position `b` among its element siblings +/// (counting from the end when `from_end`), optionally among siblings sharing +/// its tag name? Costs at most `b` steps rather than a full sibling scan. +fn isAtPosition(node: *const Node, b: i32, from_end: bool, of_type: ?[]const u8) bool { + if (b <= 0) return false; + if (node.parent == null) return false; + + var remaining = b - 1; + var cur: ?*const Node = node; + while (remaining > 0) : (remaining -= 1) { + cur = if (from_end) + siblingElement(cur.?, .next, of_type) + else + siblingElement(cur.?, .previous, of_type); + if (cur == null) return false; + } + // `cur` is now `b - 1` steps toward the relevant end; the node is at + // position `b` exactly when nothing lies beyond it. + return if (from_end) + siblingElement(cur.?, .next, of_type) == null + else + siblingElement(cur.?, .previous, of_type) == null; } fn matchesNth(a: i32, b: i32, index: i32) bool { @@ -304,21 +443,62 @@ fn matchHas(node: *const Node, inner: *const Selector) bool { }; } +/// Evaluate a `:has()` relative selector against `node`. +/// +/// The candidate set is scoped by the leading combinator rather than searched +/// for across the whole document: +/// +/// * `:has(a)` / `:has(> a)` -- every match lies inside the anchor's own +/// subtree. Sibling combinators inside the relative selector move between +/// children of the anchor, so they cannot escape it either. +/// * `:has(+ a)` / `:has(~ a)` -- the match lies in a following sibling or +/// one of their subtrees, so those are scanned instead. +/// +/// Scanning the entire document per anchor, as this used to, makes `:has()` +/// quadratic in document size: about a second per call on a 2 MB page, against +/// well under a millisecond for every other selector. fn matchRelative(node: *const Node, relative: sel_mod.RelativeSelector) bool { - var root = node; - while (root.parent) |parent| root = parent; - return anyRelativeCandidate(node, relative.kind, relative.selector, root); + return switch (relative.kind) { + .descendant, .child => anyCandidateInSubtree(node, relative, node), + .next_sibling, .subsequent_sibling => blk: { + // Both sibling combinators scan every following sibling, not just + // the immediate one: `:has(+ p ~ span)` anchors `p` to the next + // sibling but matches `span`, a later sibling still. The + // "immediate" versus "any" distinction is enforced where it + // belongs, by `matchesLeadingRelation` on the leftmost compound. + var sibling = nextElementSibling(node); + while (sibling) |current| : (sibling = nextElementSibling(current)) { + if (matchRelativeCandidate(node, relative.kind, relative.selector, current)) break :blk true; + if (anyCandidateInSubtree(node, relative, current)) break :blk true; + } + break :blk false; + }, + }; } -fn anyRelativeCandidate(anchor: *const Node, leading: CombinatorKind, selector: *const Selector, root: *const Node) bool { - if (root.node_type == .element and matchRelativeCandidate(anchor, leading, selector, root)) return true; - var child = root.first_child; - while (child) |current| : (child = current.next_sibling) { - if (anyRelativeCandidate(anchor, leading, selector, current)) return true; +/// Test every strict descendant of `root` as a candidate. Iterative, so a +/// deeply nested document cannot overflow the stack. +fn anyCandidateInSubtree( + anchor: *const Node, + relative: sel_mod.RelativeSelector, + root: *const Node, +) bool { + var cur: ?*const Node = root.first_child; + while (cur) |current| : (cur = tree.nextInPreorderConst(current, root)) { + if (current.node_type != .element) continue; + if (matchRelativeCandidate(anchor, relative.kind, relative.selector, current)) return true; } return false; } +fn nextElementSibling(node: *const Node) ?*const Node { + var sibling = node.next_sibling; + while (sibling) |current| : (sibling = current.next_sibling) { + if (current.node_type == .element) return current; + } + return null; +} + fn matchRelativeCandidate(anchor: *const Node, leading: CombinatorKind, selector: *const Selector, candidate: *const Node) bool { if (candidate.node_type != .element) return false; return switch (selector.*) { @@ -355,7 +535,9 @@ fn matchRelativeCandidate(anchor: *const Node, leading: CombinatorKind, selector }, }; }, - else => matchesLeadingRelation(anchor, candidate, leading) and matchNode(selector, candidate), + // `matchNode` is a tag/class/attribute test on this one node, while + // `matchesLeadingRelation` may walk every ancestor. Cheap test first. + else => matchNode(selector, candidate) and matchesLeadingRelation(anchor, candidate, leading), }; } @@ -394,10 +576,9 @@ fn nodeContainsText(node: *const Node, text: []const u8) bool { if (node.node_type == .text) { return std.mem.indexOf(u8, node.data, text) != null; } - var c = node.first_child; - while (c) |child| { - if (nodeContainsText(child, text)) return true; - c = child.next_sibling; + var cur = tree.nextInPreorderConst(node, node); + while (cur) |current| : (cur = tree.nextInPreorderConst(current, node)) { + if (current.node_type == .text and std.mem.indexOf(u8, current.data, text) != null) return true; } return false; } @@ -495,3 +676,165 @@ test "matchAll collects descendants" { const results = try m.matchAll(&root); try std.testing.expect(results.len == 2); } + +// --------------------------------------------------------------------------- +// Differential tests for the memoized sibling positions. +// +// `elementPosition` and `elementCount` cache across calls, so a bug in them +// shows up only for particular visit orders. These tests compare every +// positional pseudo-class against a straightforward rescanning implementation +// over trees with mixed node types, checking every node in several orders. +// --------------------------------------------------------------------------- + +/// Deliberately naive: rescans the sibling list on every call. +fn referencePosition(node: *const Node, from_end: bool, of_type: ?[]const u8) ?i32 { + const parent = node.parent orelse return null; + var index: i32 = 0; + var child = if (from_end) parent.last_child else parent.first_child; + while (child) |current| : (child = if (from_end) current.prev_sibling else current.next_sibling) { + if (current.node_type != .element) continue; + if (of_type) |tag| { + if (!std.mem.eql(u8, current.data, tag)) continue; + } + index += 1; + if (current == node) return index; + } + return null; +} + +const PositionCase = struct { from_end: bool, of_type: bool }; + +fn checkPositionsAgainstReference(root: *const Node) !void { + const cases = [_]PositionCase{ + .{ .from_end = false, .of_type = false }, + .{ .from_end = true, .of_type = false }, + .{ .from_end = false, .of_type = true }, + .{ .from_end = true, .of_type = true }, + }; + + for (cases) |case| { + var cur: ?*const Node = root; + while (cur) |node| : (cur = tree.nextInPreorderConst(node, root)) { + if (node.node_type != .element) continue; + const of_type: ?[]const u8 = if (case.of_type) node.data else null; + const want = referencePosition(node, case.from_end, of_type); + const got = positionFor(node, case.from_end, of_type); + try std.testing.expectEqual(want, got); + } + } +} + +fn buildMixedTree(gpa: std.mem.Allocator, seed: u64) !*Node { + // Element tags repeat so `of_type` counting has something to skip over, + // and text/comment nodes are interleaved so they must not be counted. + const tags = [_][]const u8{ "div", "p", "span", "div", "li" }; + var state = seed | 1; + const rand = struct { + fn next(x: *u64) u64 { + x.* ^= x.* << 13; + x.* ^= x.* >> 7; + x.* ^= x.* << 17; + return x.*; + } + }; + + const root = try gpa.create(Node); + root.* = .{ .node_type = .element, .data = "root" }; + + var parents: std.ArrayList(*Node) = .empty; + defer parents.deinit(gpa); + try parents.append(gpa, root); + + var made: usize = 0; + while (made < 120) : (made += 1) { + const parent = parents.items[rand.next(&state) % parents.items.len]; + const roll = rand.next(&state) % 10; + const node = try gpa.create(Node); + if (roll < 6) { + node.* = .{ .node_type = .element, .data = tags[rand.next(&state) % tags.len] }; + tree.appendChild(parent, node); + if (parents.items.len < 12) try parents.append(gpa, node); + } else if (roll < 8) { + node.* = .{ .node_type = .text, .data = "t" }; + tree.appendChild(parent, node); + } else { + node.* = .{ .node_type = .comment, .data = "c" }; + tree.appendChild(parent, node); + } + } + return root; +} + +test "memoized sibling positions match a rescanning reference" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + for (0..16) |seed| { + const root = try buildMixedTree(arena.allocator(), seed + 1); + try checkPositionsAgainstReference(root); + } +} + +test "position memo is invalidated by structural changes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const gpa = arena.allocator(); + + const parent = try gpa.create(Node); + parent.* = .{ .node_type = .element, .data = "ul" }; + + var kids: [5]*Node = undefined; + for (&kids) |*slot| { + slot.* = try gpa.create(Node); + slot.*.* = .{ .node_type = .element, .data = "li" }; + tree.appendChild(parent, slot.*); + } + + // Warm the memo on the last child. + try std.testing.expectEqual(@as(?i32, 5), positionFor(kids[4], false, null)); + + // Insert at the front: every cached position is now wrong by one. + const inserted = try gpa.create(Node); + inserted.* = .{ .node_type = .element, .data = "li" }; + tree.insertBefore(parent, inserted, kids[0]); + + try std.testing.expectEqual(@as(?i32, 6), positionFor(kids[4], false, null)); + try std.testing.expectEqual(@as(?i32, 1), positionFor(inserted, false, null)); + try checkPositionsAgainstReference(parent); + + // Removal invalidates too. + tree.removeChild(parent, inserted); + try std.testing.expectEqual(@as(?i32, 5), positionFor(kids[4], false, null)); + try checkPositionsAgainstReference(parent); +} + +test "positions are correct when siblings are visited out of order" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const gpa = arena.allocator(); + + const parent = try gpa.create(Node); + parent.* = .{ .node_type = .element, .data = "ul" }; + + var kids: [8]*Node = undefined; + for (&kids, 0..) |*slot, i| { + slot.* = try gpa.create(Node); + slot.*.* = .{ .node_type = .element, .data = if (i % 2 == 0) "li" else "p" }; + tree.appendChild(parent, slot.*); + } + + // Backward, then a stride, then forward: none of these are the order the + // memo is optimized for, so each must fall back to a full count. + var i: usize = kids.len; + while (i > 0) { + i -= 1; + try std.testing.expectEqual(referencePosition(kids[i], false, null), positionFor(kids[i], false, null)); + } + for ([_]usize{ 0, 3, 6, 1, 7, 2 }) |idx| { + try std.testing.expectEqual(referencePosition(kids[idx], true, null), positionFor(kids[idx], true, null)); + try std.testing.expectEqual( + referencePosition(kids[idx], false, kids[idx].data), + positionFor(kids[idx], false, kids[idx].data), + ); + } +} diff --git a/src/document.zig b/src/document.zig index b34aeee..8e0011d 100644 --- a/src/document.zig +++ b/src/document.zig @@ -4,8 +4,7 @@ const Node = @import("dom/node.zig").Node; const NodeType = @import("dom/node.zig").NodeType; const html_parser = @import("dom/parser.zig"); const tree = @import("dom/tree.zig"); -const Selection = @import("selection.zig").Selection; -const CompiledSelector = @import("css/compiled.zig").CompiledSelector; +const Query = @import("query.zig").Query; pub const Document = struct { arena: std.heap.ArenaAllocator, @@ -55,7 +54,7 @@ pub const Document = struct { /// Store a document URL in the document arena. pub fn setUrl(self: *Document, url: []const u8) !void { - self.url = try self.allocator().dupe(u8, url); + self.url = try self.domAllocator().dupe(u8, url); } /// Release all memory owned by this document. @@ -63,28 +62,25 @@ pub const Document = struct { self.arena.deinit(); } - /// Root selection spanning the document's root node. - pub fn select(self: *Document) !Selection { - return Selection.initSingle(self.root_node, self); + /// Open a query scope over this document. + /// + /// Query results live in the returned `Query`, not in the document, so + /// releasing the query releases them. See `Query` for the rationale. + /// + /// var q = doc.query(gpa); + /// defer q.deinit(); + /// const links = try q.find("a.link"); + /// + /// The document must outlive the query. + pub fn query(self: *Document, backing: Allocator) Query { + return Query.init(self, backing); } - /// Convenience: parse a CSS selector and return matching elements. - pub fn find(self: *Document, selector: []const u8) !Selection { - return (try self.select()).find(selector); - } - - /// Convenience: find using a pre-compiled matcher. - pub fn findMatcher(self: *Document, m: @import("css/matcher.zig").Matcher) !Selection { - return (try self.select()).findMatcher(m); - } - - /// Convenience: find using a reusable compiled selector. - pub fn findCompiled(self: *Document, compiled: *const CompiledSelector) !Selection { - return (try self.select()).findCompiled(compiled); - } - - /// Arena allocator for this document. Selections allocate node slices here. - pub fn allocator(self: *Document) Allocator { + /// Allocator for the DOM itself. + /// + /// Reserved for content that becomes part of the document and must live as + /// long as it does. Query results belong in `Query.scratch` instead. + pub fn domAllocator(self: *Document) Allocator { return self.arena.allocator(); } }; @@ -95,9 +91,49 @@ test "Document initFromSlice" { try std.testing.expect(doc.root_node.node_type == .document); } -test "Document find" { +test "Document query" { var doc = try Document.initFromSlice(std.testing.allocator, "

Hello

World

"); defer doc.deinit(); - const sel = try doc.find("p"); + + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const sel = try q.find("p"); try std.testing.expect(sel.len() == 2); } + +test "query allocations do not touch the document arena" { + var doc = try Document.initFromSlice(std.testing.allocator, "

a

b

"); + defer doc.deinit(); + + const before = doc.arena.queryCapacity(); + + var q = doc.query(std.testing.allocator); + defer q.deinit(); + for (0..500) |_| { + const sel = try q.find("p"); + std.mem.doNotOptimizeAway(sel.len()); + } + + try std.testing.expectEqual(before, doc.arena.queryCapacity()); +} + +test "query reset reclaims without freeing the document" { + var doc = try Document.initFromSlice(std.testing.allocator, "

a

b

"); + defer doc.deinit(); + + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + for (0..200) |_| { + const sel = try q.find("p"); + std.mem.doNotOptimizeAway(sel.len()); + } + const grown = q.bytesUsed(); + try std.testing.expect(grown > 0); + + q.reset(); + // Capacity is retained for reuse, and the document is untouched. + try std.testing.expectEqual(@as(usize, 2), (try q.find("p")).len()); + try std.testing.expect(q.bytesUsed() <= grown); +} diff --git a/src/dom/node.zig b/src/dom/node.zig index 31ae3f5..53f0951 100644 --- a/src/dom/node.zig +++ b/src/dom/node.zig @@ -25,6 +25,13 @@ pub const Node = struct { prev_sibling: ?*Node = null, next_sibling: ?*Node = null, + /// Scratch mark used to suppress duplicates while collecting nodes. + /// + /// Lives in what was tail padding, so it costs nothing: `@sizeOf(Node)` is + /// 96 either way. Always zero outside a single collection pass -- see + /// `VisitMarks` in selection.zig, which clears every mark it sets. + visit_mark: u32 = 0, + /// Attribute lookup by key. Returns the value if found. pub fn getAttr(self: *const Node, key: []const u8) ?[]const u8 { for (self.attr) |a| { @@ -75,6 +82,12 @@ pub const Node = struct { } }; +test "visit_mark fits in existing padding" { + // If this ever fails, adding the field started costing real memory and the + // trade-off needs revisiting. + try std.testing.expectEqual(@as(usize, 96), @sizeOf(Node)); +} + test "node attribute lookup" { const attrs = [_]Attribute{ .{ .key = "id", .val = "main" }, diff --git a/src/dom/parser.zig b/src/dom/parser.zig index bf93311..0c69874 100644 --- a/src/dom/parser.zig +++ b/src/dom/parser.zig @@ -69,6 +69,22 @@ const auto_close_map = std.StaticStringMap([]const []const u8).initComptime(.{ .{ "rp", &.{ "rt", "rp" } }, }); +/// The tags that appear as *targets* in `auto_close_map`, i.e. the only tags a +/// later start tag can implicitly close. The parser keeps a live count of how +/// many of each are open so `autoClose` can answer "none" without walking the +/// open-element stack. +const Closer = enum(u8) { p, dd, dt, li, option, optgroup, tbody, tfoot, tr, td, th, rt, rp }; + +const closer_ids = std.StaticStringMap(Closer).initComptime(.{ + .{ "p", .p }, .{ "dd", .dd }, + .{ "dt", .dt }, .{ "li", .li }, + .{ "option", .option }, .{ "optgroup", .optgroup }, + .{ "tbody", .tbody }, .{ "tfoot", .tfoot }, + .{ "tr", .tr }, .{ "td", .td }, + .{ "th", .th }, .{ "rt", .rt }, + .{ "rp", .rp }, +}); + // Formatting elements for adoption agency. const formatting_elements = std.StaticStringMap(void).initComptime(.{ .{ "a", {} }, .{ "b", {} }, .{ "big", {} }, @@ -142,6 +158,8 @@ const Parser = struct { pos: usize, doc: *Node, open_elements: std.ArrayList(*Node), + /// How many elements of each closable tag are currently open. + open_closer_counts: [@typeInfo(Closer).@"enum".fields.len]u32 = @splat(0), fragment_context: ?*const Node, head_inserted: bool, body_inserted: bool, @@ -300,7 +318,7 @@ const Parser = struct { const is_void = void_elements.has(tag_name) or self_closing; if (!is_void) { - try self.open_elements.append(self.allocator, node); + try self.pushOpen(node); // Raw text elements: slurp content until closing tag. if (raw_text_elements.has(tag_name) or rcdata_elements.has(tag_name)) { @@ -329,7 +347,7 @@ const Parser = struct { while (i > 0) { i -= 1; if (std.mem.eql(u8, self.open_elements.items[i].data, tag_name)) { - self.open_elements.items.len = i; + self.popOpenTo(i); return; } } @@ -519,6 +537,20 @@ const Parser = struct { } fn autoClose(self: *Parser, closers: []const []const u8) void { + // Nothing to close unless one of these tags is actually open. Without + // this check the scan below runs to the nearest scope boundary on every + // start tag -- and in a plain nested document the nearest boundary is + // , which makes parsing quadratic in nesting depth. + var any_open = false; + for (closers) |closer| { + const id = closer_ids.get(closer) orelse continue; + if (self.open_closer_counts[@intFromEnum(id)] > 0) { + any_open = true; + break; + } + } + if (!any_open) return; + // Search backward through the stack for the nearest element that // should be auto-closed. Don't cross scope boundaries (html, table, etc.). var i = self.open_elements.items.len; @@ -528,7 +560,7 @@ const Parser = struct { for (closers) |closer| { if (std.mem.eql(u8, el.data, closer)) { // Pop everything from this element up. - self.open_elements.items.len = i; + self.popOpenTo(i); return; } } @@ -548,7 +580,7 @@ const Parser = struct { if (is_html) return; // about to be added by caller const html_node = try self.createNode(.element, "html"); tree.appendChild(self.doc, html_node); - try self.open_elements.append(self.allocator, html_node); + try self.pushOpen(html_node); } const in_head = is_head or std.mem.eql(u8, tag_name, "meta") or @@ -563,28 +595,46 @@ const Parser = struct { if (self.head_node == null) { const head = try self.createNode(.element, "head"); tree.appendChild(self.open_elements.items[0], head); - try self.open_elements.append(self.allocator, head); + try self.pushOpen(head); self.head_node = head; self.head_inserted = true; } else if (self.open_elements.items.len == 1) { - try self.open_elements.append(self.allocator, self.head_node.?); + try self.pushOpen(self.head_node.?); } return; } if (self.body_node == null) { if (self.open_elements.items.len > 1 and self.open_elements.items[1] == self.head_node) { - self.open_elements.items.len = 1; + self.popOpenTo(1); } if (is_body) return; const body = try self.createNode(.element, "body"); tree.appendChild(self.open_elements.items[0], body); - try self.open_elements.append(self.allocator, body); + try self.pushOpen(body); self.body_node = body; self.body_inserted = true; } else if (!is_html and !is_head and self.open_elements.items.len == 1) { - try self.open_elements.append(self.allocator, self.body_node.?); + try self.pushOpen(self.body_node.?); + } + } + + /// Push an element onto the open-element stack, keeping the closable-tag + /// counts in step. + fn pushOpen(self: *Parser, node: *Node) HtmlParseError!void { + self.open_elements.append(self.allocator, node) catch return HtmlParseError.OutOfMemory; + if (closer_ids.get(node.data)) |id| self.open_closer_counts[@intFromEnum(id)] += 1; + } + + /// Truncate the open-element stack to `len`, keeping the counts in step. + fn popOpenTo(self: *Parser, len: usize) void { + var i = self.open_elements.items.len; + while (i > len) { + i -= 1; + const node = self.open_elements.items[i]; + if (closer_ids.get(node.data)) |id| self.open_closer_counts[@intFromEnum(id)] -= 1; } + self.open_elements.items.len = len; } fn currentNode(self: *Parser) *Node { diff --git a/src/dom/render.zig b/src/dom/render.zig index e7a251f..fb1ae37 100644 --- a/src/dom/render.zig +++ b/src/dom/render.zig @@ -18,10 +18,60 @@ const raw_text_elements = std.StaticStringMap(void).initComptime(.{ /// Render a node and all descendants as HTML. The writer must provide /// `writeAll([]const u8)` and `writeByte(u8)` methods returning error unions. pub fn render(writer: anytype, node: *const Node) anyerror!void { + return renderSubtree(writer, node, true); +} + +/// Render only the children of a node (inner HTML). +pub fn renderChildren(writer: anytype, node: *const Node) anyerror!void { + return renderSubtree(writer, node, false); +} + +/// Walk `root` in document order, emitting each node's opening markup on the +/// way down and its closing tag on the way back up. +/// +/// This is iterative on purpose. The obvious mutually recursive version costs +/// two stack frames per level of nesting and overflows the stack on deeply +/// nested documents -- 50 000 levels is well within what a hostile or merely +/// sloppy page can contain. Parent pointers make the climb possible without an +/// auxiliary stack, so rendering needs no allocation and no depth limit. +fn renderSubtree(writer: anytype, root: *const Node, comptime include_root: bool) anyerror!void { + var cur: *const Node = if (include_root) root else (root.first_child orelse return); + + while (true) { + try renderOpen(writer, cur); + + // A void element serializes as its start tag alone; if a malformed + // tree gave it children, they are not part of its serialization. + if (!isVoidElement(cur)) { + if (cur.first_child) |child| { + cur = child; + continue; + } + } + try renderClose(writer, cur); + + // Climb toward the root, closing each ancestor as we leave it, until + // there is a sibling to move on to. + while (true) { + if (include_root and cur == root) return; + + if (cur.next_sibling) |sibling| { + cur = sibling; + break; + } + + const parent = cur.parent orelse return; + if (!include_root and parent == root) return; + try renderClose(writer, parent); + cur = parent; + } + } +} + +/// Everything a node emits before its children: the whole node for leaves. +fn renderOpen(writer: anytype, node: *const Node) anyerror!void { switch (node.node_type) { - .document => { - try renderChildren(writer, node); - }, + .document => {}, .element => { try writer.writeAll("<"); try writer.writeAll(node.data); @@ -38,15 +88,6 @@ pub fn render(writer: anytype, node: *const Node) anyerror!void { try writer.writeAll("\""); } - if (void_elements.has(node.data)) { - try writer.writeAll(">"); - return; - } - - try writer.writeAll(">"); - try renderChildren(writer, node); - try writer.writeAll(""); }, .text => { @@ -71,13 +112,17 @@ pub fn render(writer: anytype, node: *const Node) anyerror!void { } } -/// Render only the children of a node (inner HTML). -pub fn renderChildren(writer: anytype, node: *const Node) anyerror!void { - var child = node.first_child; - while (child) |c| { - try render(writer, c); - child = c.next_sibling; - } +fn isVoidElement(node: *const Node) bool { + return node.node_type == .element and void_elements.has(node.data); +} + +/// The closing tag, for the nodes that have one. +fn renderClose(writer: anytype, node: *const Node) anyerror!void { + if (node.node_type != .element) return; + if (void_elements.has(node.data)) return; + try writer.writeAll(""); } /// Render a node to an allocated string. @@ -166,6 +211,71 @@ test "render void element" { try std.testing.expectEqualStrings("
", buf.items); } +test "render nested structure with mixed node types" { + const tree_m = @import("tree.zig"); + + var root = Node{ .node_type = .element, .data = "div" }; + var a = Node{ .node_type = .element, .data = "p" }; + var t1 = Node{ .node_type = .text, .data = "one" }; + var br = Node{ .node_type = .element, .data = "br" }; + var b = Node{ .node_type = .element, .data = "span" }; + var t2 = Node{ .node_type = .text, .data = "two" }; + var c = Node{ .node_type = .comment, .data = "note" }; + + tree_m.appendChild(&root, &a); + tree_m.appendChild(&a, &t1); + tree_m.appendChild(&a, &br); + tree_m.appendChild(&root, &b); + tree_m.appendChild(&b, &t2); + tree_m.appendChild(&root, &c); + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(std.testing.allocator); + var writer = ArrayListWriter{ .list = &buf, .allocator = std.testing.allocator }; + try render(&writer, &root); + try std.testing.expectEqualStrings( + "

one

two
", + buf.items, + ); + + // Inner HTML skips the root's own tags but is otherwise identical. + buf.clearRetainingCapacity(); + try renderChildren(&writer, &root); + try std.testing.expectEqualStrings( + "

one

two", + buf.items, + ); +} + +test "render deeply nested tree without recursing" { + // The recursive implementation overflowed the stack here in Debug builds. + const gpa = std.testing.allocator; + const tree_m = @import("tree.zig"); + const depth = 50_000; + + const nodes = try gpa.alloc(Node, depth); + defer gpa.free(nodes); + for (nodes) |*n| n.* = .{ .node_type = .element, .data = "div" }; + for (nodes[1..], 0..) |*n, i| tree_m.appendChild(&nodes[i], n); + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + var writer = ArrayListWriter{ .list = &buf, .allocator = gpa }; + try render(&writer, &nodes[0]); + + try std.testing.expectEqual(@as(usize, depth), std.mem.count(u8, buf.items, "
")); + try std.testing.expectEqual(@as(usize, depth), std.mem.count(u8, buf.items, "
")); +} + +test "renderChildren of a leaf emits nothing" { + var leaf = Node{ .node_type = .element, .data = "div" }; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(std.testing.allocator); + var writer = ArrayListWriter{ .list = &buf, .allocator = std.testing.allocator }; + try renderChildren(&writer, &leaf); + try std.testing.expectEqualStrings("", buf.items); +} + test "render escaping" { var node = Node{ .node_type = .text, .data = "a < b & c > d" }; var buf: std.ArrayList(u8) = .empty; diff --git a/src/dom/tree.zig b/src/dom/tree.zig index dd42ad9..792aef3 100644 --- a/src/dom/tree.zig +++ b/src/dom/tree.zig @@ -3,6 +3,14 @@ const Node = @import("node.zig").Node; const NodeType = @import("node.zig").NodeType; const Attribute = @import("node.zig").Attribute; +/// Bumped by every structural change to any tree. +/// +/// Caches keyed on tree shape -- such as the sibling-position memo in the +/// selector matcher -- compare against this to notice they are stale without +/// having to track individual nodes. Reads never bump it, so a pure matching +/// pass keeps its caches. +pub var structure_generation: u64 = 0; + pub const MutationError = error{ Cycle, InvalidReference, @@ -18,6 +26,7 @@ pub const ValidationError = error{ /// Remove a node from its parent, updating sibling links. pub fn removeChild(parent: *Node, child: *Node) void { std.debug.assert(child.parent == parent); + structure_generation +%= 1; if (child.prev_sibling) |prev| { prev.next_sibling = child.next_sibling; @@ -47,6 +56,7 @@ pub fn detach(node: *Node) void { /// If `ref_child` is null, appends to end. pub fn insertBefore(parent: *Node, new_child: *Node, ref_child: ?*Node) void { std.debug.assert(!wouldCreateCycle(parent, new_child)); + structure_generation +%= 1; if (ref_child) |ref| { std.debug.assert(ref.parent == parent); if (ref == new_child) return; @@ -68,6 +78,7 @@ pub fn insertBefore(parent: *Node, new_child: *Node, ref_child: ?*Node) void { /// Append a child node to the end of parent's children. pub fn appendChild(parent: *Node, child: *Node) void { std.debug.assert(!wouldCreateCycle(parent, child)); + structure_generation +%= 1; detach(child); child.parent = parent; child.prev_sibling = parent.last_child; @@ -98,6 +109,15 @@ pub fn appendChildChecked(parent: *Node, child: *Node) MutationError!void { fn wouldCreateCycle(parent: *const Node, child: *const Node) bool { if (parent == child) return true; + + // A childless node has no descendants, so it cannot be an ancestor of + // `parent` -- only the identity case above could apply. Every node the + // parser appends is freshly created, and `appendChild` asserts on this + // function, whose argument is evaluated in every build mode including + // ReleaseFast. Without this short-circuit the ancestor walk below runs + // once per node and makes tree building O(nodes * depth). + if (child.first_child == null) return false; + var current = parent.parent; while (current) |ancestor| : (current = ancestor.parent) { if (ancestor == child) return true; @@ -105,31 +125,70 @@ fn wouldCreateCycle(parent: *const Node, child: *const Node) bool { return false; } +/// Next node in document order within `root`'s subtree, or null once the walk +/// is finished. `root` is the boundary: it is never returned, and the walk +/// never escapes above it. +/// +/// This is the iterative replacement for the "recurse over every child" +/// pattern. Parent pointers make the climb possible without an auxiliary +/// stack, so a full-subtree walk costs no allocation and cannot overflow the +/// stack on deeply nested documents. +/// +/// var cur: ?*Node = root.first_child; +/// while (cur) |n| : (cur = nextInPreorder(n, root)) { ... } +pub fn nextInPreorder(node: *Node, root: *const Node) ?*Node { + if (node.first_child) |child| return child; + var cur = node; + while (cur != root) { + if (cur.next_sibling) |sibling| return sibling; + cur = cur.parent orelse return null; + } + return null; +} + +/// `nextInPreorder` for read-only walks. +pub fn nextInPreorderConst(node: *const Node, root: *const Node) ?*const Node { + if (node.first_child) |child| return child; + var cur = node; + while (cur != root) { + if (cur.next_sibling) |sibling| return sibling; + cur = cur.parent orelse return null; + } + return null; +} + /// Validate parent, sibling, and last-child links for a subtree. +/// +/// Traversal follows only `first_child` and `next_sibling` and uses an explicit +/// stack: this function exists to find broken links, so it must not rely on the +/// parent pointers it is checking, and it must not recurse per level. pub fn validate(allocator: std.mem.Allocator, root: *const Node) (ValidationError || std.mem.Allocator.Error)!void { var seen = std.AutoHashMap(*const Node, void).init(allocator); defer seen.deinit(); - try validateNode(root, root.parent, &seen); -} -fn validateNode(node: *const Node, expected_parent: ?*Node, seen: *std.AutoHashMap(*const Node, void)) (ValidationError || std.mem.Allocator.Error)!void { - if (seen.contains(node)) return error.Cycle; - try seen.put(node, {}); - if (node.parent != expected_parent) return error.InvalidParent; - - var previous: ?*Node = null; - var child = node.first_child; - while (child) |current| { - if (current.prev_sibling != previous) return error.InvalidPreviousSibling; - try validateNode(current, @constCast(node), seen); - previous = current; - child = current.next_sibling; + var stack: std.ArrayList(*const Node) = .empty; + defer stack.deinit(allocator); + try stack.append(allocator, root); + + while (stack.pop()) |node| { + if (seen.contains(node)) return error.Cycle; + try seen.put(node, {}); + + var previous: ?*Node = null; + var child = node.first_child; + while (child) |current| { + if (current.parent != @constCast(node)) return error.InvalidParent; + if (current.prev_sibling != previous) return error.InvalidPreviousSibling; + try stack.append(allocator, current); + previous = current; + child = current.next_sibling; + } + if (node.last_child != previous) return error.InvalidLastChild; } - if (node.last_child != previous) return error.InvalidLastChild; } -/// Deep-clone a node and all its descendants using the given arena. -pub fn cloneNode(allocator: std.mem.Allocator, original: *const Node) !*Node { +/// Copy one node's own data, without its children or tree links. +fn cloneShallow(allocator: std.mem.Allocator, original: *const Node) !*Node { const new_attrs = try allocator.alloc(Attribute, original.attr.len); for (original.attr, 0..) |attr, i| { new_attrs[i] = .{ @@ -151,14 +210,45 @@ pub fn cloneNode(allocator: std.mem.Allocator, original: *const Node) !*Node { .prev_sibling = null, .next_sibling = null, }; + return node; +} - var it = original.childIterator(); - while (it.next()) |child| { - const cloned_child = try cloneNode(allocator, child); - appendChild(node, cloned_child); - } +/// Deep-clone a node and all its descendants using the given arena. +/// +/// Walks the source in document order while keeping a cursor into the copy, so +/// no stack frame is spent per level. The recursive version overflowed the +/// stack on deeply nested documents. +pub fn cloneNode(allocator: std.mem.Allocator, original: *const Node) !*Node { + const root_clone = try cloneShallow(allocator, original); + + var src: *const Node = original; + var dst: *Node = root_clone; + + while (true) { + if (src.first_child) |child| { + const child_clone = try cloneShallow(allocator, child); + appendChild(dst, child_clone); + src = child; + dst = child_clone; + continue; + } - return node; + // Climb in lockstep until there is a sibling to copy next. + while (true) { + if (src == original) return root_clone; + + if (src.next_sibling) |sibling| { + const sibling_clone = try cloneShallow(allocator, sibling); + appendChild(dst.parent.?, sibling_clone); + src = sibling; + dst = sibling_clone; + break; + } + + src = src.parent orelse return root_clone; + dst = dst.parent orelse return root_clone; + } + } } /// Clone a slice of nodes. @@ -257,3 +347,71 @@ test "validate detects broken sibling links" { try std.testing.expectError(error.InvalidPreviousSibling, validate(std.testing.allocator, &parent)); } + +test "nextInPreorder visits a subtree in document order" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // root + // +- a + // | +- b + // | +- c + // +- d + var root = Node{ .node_type = .element, .data = "root" }; + var a = Node{ .node_type = .element, .data = "a" }; + var b = Node{ .node_type = .element, .data = "b" }; + var c = Node{ .node_type = .element, .data = "c" }; + var d = Node{ .node_type = .element, .data = "d" }; + appendChild(&root, &a); + appendChild(&a, &b); + appendChild(&a, &c); + appendChild(&root, &d); + + var seen: std.ArrayList(u8) = .empty; + defer seen.deinit(std.testing.allocator); + + var cur: ?*Node = root.first_child; + while (cur) |n| : (cur = nextInPreorder(n, &root)) { + try seen.appendSlice(std.testing.allocator, n.data); + } + try std.testing.expectEqualStrings("abcd", seen.items); +} + +test "nextInPreorder does not escape above the boundary" { + var root = Node{ .node_type = .element, .data = "root" }; + var a = Node{ .node_type = .element, .data = "a" }; + var b = Node{ .node_type = .element, .data = "b" }; + var sibling = Node{ .node_type = .element, .data = "sibling" }; + appendChild(&root, &a); + appendChild(&a, &b); + appendChild(&root, &sibling); + + // Walking `a`'s subtree must stop at `b` and not continue into `sibling`. + var count: usize = 0; + var cur: ?*Node = a.first_child; + while (cur) |n| : (cur = nextInPreorder(n, &a)) { + try std.testing.expectEqualStrings("b", n.data); + count += 1; + } + try std.testing.expectEqual(@as(usize, 1), count); +} + +test "nextInPreorder on a leaf yields nothing" { + var leaf = Node{ .node_type = .element, .data = "leaf" }; + try std.testing.expect(leaf.first_child == null); + try std.testing.expect(nextInPreorder(&leaf, &leaf) == null); +} + +test "nextInPreorder walks a deep chain without recursing" { + const gpa = std.testing.allocator; + const depth = 50_000; + const nodes = try gpa.alloc(Node, depth); + defer gpa.free(nodes); + for (nodes) |*n| n.* = .{ .node_type = .element, .data = "div" }; + for (nodes[1..], 0..) |*n, i| appendChild(&nodes[i], n); + + var count: usize = 0; + var cur: ?*Node = nodes[0].first_child; + while (cur) |n| : (cur = nextInPreorder(n, &nodes[0])) count += 1; + try std.testing.expectEqual(@as(usize, depth - 1), count); +} diff --git a/src/main.zig b/src/main.zig deleted file mode 100644 index 7326b9a..0000000 --- a/src/main.zig +++ /dev/null @@ -1,27 +0,0 @@ -const std = @import("std"); -const zigquery = @import("zigquery"); - -pub fn main() !void { - // Prints to stderr, ignoring potential errors. - std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); - try zigquery.bufferedPrint(); -} - -test "simple test" { - const gpa = std.testing.allocator; - var list: std.ArrayList(i32) = .empty; - defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak! - try list.append(gpa, 42); - try std.testing.expectEqual(@as(i32, 42), list.pop()); -} - -test "fuzz example" { - const Context = struct { - fn testOne(context: @This(), input: []const u8) anyerror!void { - _ = context; - // Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case! - try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); - } - }; - try std.testing.fuzz(Context{}, Context.testOne, .{}); -} diff --git a/src/query.zig b/src/query.zig new file mode 100644 index 0000000..3e8ef5d --- /dev/null +++ b/src/query.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Document = @import("document.zig").Document; +const Selection = @import("selection.zig").Selection; +const Selector = @import("css/selector.zig").Selector; +const CompiledSelector = @import("css/compiled.zig").CompiledSelector; +const Matcher = @import("css/matcher.zig").Matcher; +const css_parser = @import("css/parser.zig"); + +/// A scratch arena for querying a document. +/// +/// A `Document` owns its DOM and nothing else. Everything a query produces -- +/// node slices, chained `Selection` states, parsed selector ASTs, and the +/// strings returned by `html()`, `text()` and `outerHtml()` -- belongs to a +/// `Query` instead, and is released together when the `Query` is deinitialized +/// or reset. +/// +/// This is what makes a long-lived document viable. Previously every query +/// allocated into the document's own arena and nothing was ever released, so a +/// scraper holding one document and running queries against it grew without +/// bound: 2000 queries took a 43 MB document to 243 MB. +/// +/// var doc = try Document.initFromSlice(gpa, html); +/// defer doc.deinit(); +/// +/// var q = doc.query(gpa); +/// defer q.deinit(); +/// +/// const links = try q.find("a.link"); +/// +/// A `Query` borrows its `Document`, so the document must outlive it. Because +/// the arena is stored inline, a `Query` must not be moved once a `Selection` +/// refers to it -- keep it in a local or on the heap, not in a container that +/// reallocates. +pub const Query = struct { + doc: *Document, + arena: std.heap.ArenaAllocator, + /// Parsed selectors, keyed by source text. Repeating a selector in a loop + /// is the common case and used to re-parse and re-allocate every time. + selector_cache: std.StringHashMapUnmanaged(*const Selector) = .empty, + + pub fn init(doc: *Document, backing: Allocator) Query { + return .{ .doc = doc, .arena = std.heap.ArenaAllocator.init(backing) }; + } + + /// Release every allocation made by this query. All `Selection` values and + /// all strings obtained through it become invalid. + pub fn deinit(self: *Query) void { + self.arena.deinit(); + self.* = undefined; + } + + /// Release query allocations but keep the arena's capacity, so the next + /// round of queries reuses the same memory. Use this when looping over + /// many queries against one document. + /// + /// As with `deinit`, every `Selection` and every string obtained from this + /// query before the reset becomes invalid. + pub fn reset(self: *Query) void { + _ = self.arena.reset(.retain_capacity); + // The map's own storage came from the arena, so it is already gone. + self.selector_cache = .empty; + } + + /// Allocator for query results and temporaries. Freed by `deinit`/`reset`. + pub fn scratch(self: *Query) Allocator { + return self.arena.allocator(); + } + + /// Allocator for content that joins the DOM and must outlive this query -- + /// nodes, attribute keys and values, parsed fragments. Freed only when the + /// document is. + pub fn docAlloc(self: *Query) Allocator { + return self.doc.domAllocator(); + } + + /// A selection containing just the document root. + pub fn root(self: *Query) !Selection { + return Selection.initSingle(self.doc.root_node, self); + } + + /// Find elements matching a CSS selector, starting from the document root. + pub fn find(self: *Query, selector: []const u8) !Selection { + return (try self.root()).find(selector); + } + + /// Find elements using a pre-built matcher. + pub fn findMatcher(self: *Query, m: Matcher) !Selection { + return (try self.root()).findMatcher(m); + } + + /// Find elements using a reusable compiled selector. + pub fn findCompiled(self: *Query, compiled: *const CompiledSelector) !Selection { + return (try self.root()).findCompiled(compiled); + } + + /// Parse a selector, reusing the result if this query has seen it before. + pub fn compile(self: *Query, selector: []const u8) !*const Selector { + if (self.selector_cache.get(selector)) |cached| return cached; + + const alloc = self.scratch(); + // The cache outlives the caller's slice, so both key and parsed AST + // have to reference arena-owned text. + const owned = try alloc.dupe(u8, selector); + const parsed = try css_parser.parseSelector(alloc, owned); + try self.selector_cache.put(alloc, owned, parsed); + return parsed; + } + + /// Bytes currently held by this query's arena. + pub fn bytesUsed(self: *const Query) usize { + return self.arena.queryCapacity(); + } +}; diff --git a/src/root.zig b/src/root.zig index afe7198..08fe71b 100644 --- a/src/root.zig +++ b/src/root.zig @@ -4,15 +4,22 @@ //! //! ```zig //! const zq = @import("zigquery"); +//! //! var doc = try zq.Document.initFromSlice(allocator, html); //! defer doc.deinit(); -//! const links = try doc.find("a.active"); +//! +//! // A Query owns everything the queries below allocate. +//! var q = doc.query(allocator); +//! defer q.deinit(); +//! +//! const links = try q.find("a.active"); //! for (links.nodes) |node| { //! std.debug.print("href={s}\n", .{node.getAttr("href") orelse ""}); //! } //! ``` pub const Document = @import("document.zig").Document; +pub const Query = @import("query.zig").Query; pub const Selection = @import("selection.zig").Selection; pub const outerHtml = @import("selection.zig").outerHtml; pub const nodeName = @import("selection.zig").nodeName; diff --git a/src/selection.zig b/src/selection.zig index 585ead8..5985643 100644 --- a/src/selection.zig +++ b/src/selection.zig @@ -12,48 +12,60 @@ const css_matcher = @import("css/matcher.zig"); const Selector = @import("css/selector.zig").Selector; const Matcher = css_matcher.Matcher; const CompiledSelector = @import("css/compiled.zig").CompiledSelector; -const Document = @import("document.zig").Document; +const Query = @import("query.zig").Query; const max_int = std.math.maxInt(usize); +/// A set of matched nodes, plus the query scope that owns the memory backing +/// them. +/// +/// `Selection` is a value type and is passed by value. It borrows both its +/// node slice and its `Query`, so it must not outlive the `Query` it came +/// from -- `q.deinit()` or `q.reset()` invalidates every `Selection` obtained +/// through it. pub const Selection = struct { nodes: []*Node, - document: *Document, + q: *Query, prev_sel: ?*const Selection = null, - pub fn initSingle(node: *Node, doc: *Document) !Selection { - const nodes_buf = try doc.allocator().alloc(*Node, 1); + pub fn initSingle(node: *Node, q: *Query) !Selection { + const nodes_buf = try q.scratch().alloc(*Node, 1); nodes_buf[0] = node; - return .{ .nodes = nodes_buf, .document = doc }; + return .{ .nodes = nodes_buf, .q = q }; } - pub fn initEmpty(doc: *Document) Selection { - return .{ .nodes = &.{}, .document = doc }; + pub fn initEmpty(q: *Query) Selection { + return .{ .nodes = &.{}, .q = q }; } - pub fn initFromSlice(nodes: []*Node, doc: *Document) Selection { - return .{ .nodes = nodes, .document = doc }; + pub fn initFromSlice(nodes: []*Node, q: *Query) Selection { + return .{ .nodes = nodes, .q = q }; + } + + /// The document these nodes belong to. + pub fn document(self: Selection) *@import("document.zig").Document { + return self.q.doc; } // Traversal. /// Find descendants matching a CSS selector string. pub fn find(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.findMatcher(m); } /// Find descendants matching a compiled Matcher. pub fn findMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.pushStack(try findWithMatcher(alloc, self.nodes, m)); } /// Find descendants using a reusable compiled selector. pub fn findCompiled(self: Selection, compiled: *const CompiledSelector) !Selection { - return self.findMatcher(compiled.matcher(self.document.allocator())); + return self.findMatcher(compiled.matcher(self.q.scratch())); } /// Find descendants matching nodes from another Selection. @@ -63,7 +75,7 @@ pub const Selection = struct { /// Find descendants matching specific nodes. pub fn findNodes(self: Selection, target_nodes: []*Node) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (target_nodes) |target| { if (sliceContains(self.nodes, target)) { @@ -75,27 +87,27 @@ pub const Selection = struct { /// Get child elements. pub fn children(self: Selection) !Selection { - return self.pushStack(try getChildrenNodes(self.document.allocator(), self.nodes, .all)); + return self.pushStack(try getChildrenNodes(self.q.scratch(), self.nodes, .all)); } /// Get child elements matching a CSS selector. pub fn childrenFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.childrenMatcher(m); } /// Get child elements matching a Matcher. pub fn childrenMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const raw = try getChildrenNodes(alloc, self.nodes, .all); return self.filterAndPush(raw, m); } /// Get all children including text and comment nodes. pub fn contents(self: Selection) !Selection { - return self.pushStack(try getChildrenNodes(self.document.allocator(), self.nodes, .all_including_non_elements)); + return self.pushStack(try getChildrenNodes(self.q.scratch(), self.nodes, .all_including_non_elements)); } /// Get contents filtered by selector. Since selectors only act on elements, @@ -107,39 +119,39 @@ pub const Selection = struct { /// Get parent of each element. pub fn parent(self: Selection) !Selection { - return self.pushStack(try getParentNodes(self.document.allocator(), self.nodes)); + return self.pushStack(try getParentNodes(self.q.scratch(), self.nodes)); } /// Get parent of each element, filtered by selector. pub fn parentFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.parentMatcher(m); } /// Get parent filtered by Matcher. pub fn parentMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getParentNodes(alloc, self.nodes), m); } /// Get all ancestors. pub fn parents(self: Selection) !Selection { - return self.pushStack(try getParentsNodes(self.document.allocator(), self.nodes, null, null)); + return self.pushStack(try getParentsNodes(self.q.scratch(), self.nodes, null, null)); } /// Get all ancestors filtered by selector. pub fn parentsFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.parentsMatcher(m); } /// Get all ancestors filtered by Matcher. pub fn parentsMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getParentsNodes(alloc, self.nodes, null, null), m); } @@ -151,7 +163,7 @@ pub const Selection = struct { /// Get ancestors up to (not including) the element matching options. pub fn parentsUntil(self: Selection, opts: UntilOpts) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const raw = try getParentsNodes(alloc, self.nodes, opts.until, opts.until_nodes); if (opts.filter) |f| { return self.filterAndPush(raw, f); @@ -161,25 +173,23 @@ pub const Selection = struct { /// Get first ancestor matching the selector, testing the element itself first. pub fn closest(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.closestMatcher(m); } /// Get first ancestor matching the Matcher. pub fn closestMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (self.nodes) |n| { var cur: ?*Node = n; while (cur) |c| { if (c.node_type == .element and m.match(c)) { - if (!seen.contains(c)) { - try seen.put(c, {}); - try result.append(alloc, c); - } + if (!try marks.seen(alloc, c)) try result.append(alloc, c); break; } cur = c.parent; @@ -190,22 +200,20 @@ pub const Selection = struct { /// Get first ancestor matching a reusable compiled selector. pub fn closestCompiled(self: Selection, compiled: *const CompiledSelector) !Selection { - return self.closestMatcher(compiled.matcher(self.document.allocator())); + return self.closestMatcher(compiled.matcher(self.q.scratch())); } /// Get first ancestor matching one of the given nodes. pub fn closestNodes(self: Selection, target_nodes: []*Node) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (self.nodes) |n| { var cur: ?*Node = n; while (cur) |c| { if (isInSlice(target_nodes, c)) { - if (!seen.contains(c)) { - try seen.put(c, {}); - try result.append(alloc, c); - } + if (!try marks.seen(alloc, c)) try result.append(alloc, c); break; } cur = c.parent; @@ -221,64 +229,64 @@ pub const Selection = struct { /// Get siblings of each element (excluding the element itself). pub fn siblings(self: Selection) !Selection { - return self.pushStack(try getSiblingNodes(self.document.allocator(), self.nodes, .all, null, null)); + return self.pushStack(try getSiblingNodes(self.q.scratch(), self.nodes, .all, null, null)); } /// Get siblings filtered by CSS selector. pub fn siblingsFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.siblingsMatcher(m); } /// Get siblings filtered by Matcher. pub fn siblingsMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getSiblingNodes(alloc, self.nodes, .all, null, null), m); } /// Get immediately following sibling element. pub fn next(self: Selection) !Selection { - return self.pushStack(try getSiblingNodes(self.document.allocator(), self.nodes, .next, null, null)); + return self.pushStack(try getSiblingNodes(self.q.scratch(), self.nodes, .next, null, null)); } /// Get next sibling filtered by selector. pub fn nextFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.nextMatcher(m); } /// Get next sibling filtered by Matcher. pub fn nextMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getSiblingNodes(alloc, self.nodes, .next, null, null), m); } /// Get all following siblings. pub fn nextAll(self: Selection) !Selection { - return self.pushStack(try getSiblingNodes(self.document.allocator(), self.nodes, .next_all, null, null)); + return self.pushStack(try getSiblingNodes(self.q.scratch(), self.nodes, .next_all, null, null)); } /// Get all following siblings filtered by selector. pub fn nextAllFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.nextAllMatcher(m); } /// Get all following siblings filtered by Matcher. pub fn nextAllMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getSiblingNodes(alloc, self.nodes, .next_all, null, null), m); } /// Get all following siblings until a matcher/nodes boundary. pub fn nextUntil(self: Selection, opts: UntilOpts) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const raw = try getSiblingNodes(alloc, self.nodes, .next_until, opts.until, opts.until_nodes); if (opts.filter) |f| return self.filterAndPush(raw, f); return self.pushStack(raw); @@ -286,45 +294,45 @@ pub const Selection = struct { /// Get immediately preceding sibling element. pub fn prev(self: Selection) !Selection { - return self.pushStack(try getSiblingNodes(self.document.allocator(), self.nodes, .prev, null, null)); + return self.pushStack(try getSiblingNodes(self.q.scratch(), self.nodes, .prev, null, null)); } /// Get previous sibling filtered by selector. pub fn prevFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.prevMatcher(m); } /// Get previous sibling filtered by Matcher. pub fn prevMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getSiblingNodes(alloc, self.nodes, .prev, null, null), m); } /// Get all preceding siblings. pub fn prevAll(self: Selection) !Selection { - return self.pushStack(try getSiblingNodes(self.document.allocator(), self.nodes, .prev_all, null, null)); + return self.pushStack(try getSiblingNodes(self.q.scratch(), self.nodes, .prev_all, null, null)); } /// Get all preceding siblings filtered by selector. pub fn prevAllFiltered(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.prevAllMatcher(m); } /// Get all preceding siblings filtered by Matcher. pub fn prevAllMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return self.filterAndPush(try getSiblingNodes(alloc, self.nodes, .prev_all, null, null), m); } /// Get all preceding siblings until a matcher/nodes boundary. pub fn prevUntil(self: Selection, opts: UntilOpts) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const raw = try getSiblingNodes(alloc, self.nodes, .prev_until, opts.until, opts.until_nodes); if (opts.filter) |f| return self.filterAndPush(raw, f); return self.pushStack(raw); @@ -334,8 +342,8 @@ pub const Selection = struct { /// Filter to elements matching the CSS selector. pub fn filter(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.filterMatcher(m); } @@ -347,7 +355,7 @@ pub const Selection = struct { /// Filter using a reusable compiled selector. pub fn filterCompiled(self: Selection, compiled: *const CompiledSelector) !Selection { - return self.filterMatcher(compiled.matcher(self.document.allocator())); + return self.filterMatcher(compiled.matcher(self.q.scratch())); } /// Filter using a callback function. @@ -367,8 +375,8 @@ pub const Selection = struct { /// Remove elements matching the CSS selector. pub fn not(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.notMatcher(m); } @@ -395,15 +403,15 @@ pub const Selection = struct { /// Reduce to elements that have a descendant matching the selector. pub fn has(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.hasMatcher(m); } /// Reduce to elements that have a descendant matching a Matcher. pub fn hasMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (self.nodes) |n| { if (hasDescendantMatch(n, m)) { @@ -415,12 +423,12 @@ pub const Selection = struct { /// Reduce to elements having a descendant matched by a compiled selector. pub fn hasCompiled(self: Selection, compiled: *const CompiledSelector) !Selection { - return self.hasMatcher(compiled.matcher(self.document.allocator())); + return self.hasMatcher(compiled.matcher(self.q.scratch())); } /// Reduce to elements that have a descendant matching given nodes. pub fn hasNodes(self: Selection, target_nodes: []*Node) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (self.nodes) |n| { for (target_nodes) |target| { @@ -446,15 +454,15 @@ pub const Selection = struct { /// Return to the previous selection in the chain. pub fn end(self: Selection) Selection { if (self.prev_sel) |p| return p.*; - return Selection.initEmpty(self.document); + return Selection.initEmpty(self.q); } // Query. /// Check if any element matches the CSS selector. pub fn is(self: Selection, selector: []const u8) !bool { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.isMatcher(m); } @@ -469,13 +477,13 @@ pub const Selection = struct { /// Check if any element matches a reusable compiled selector. pub fn isCompiled(self: Selection, compiled: *const CompiledSelector) bool { - return self.isMatcher(compiled.matcher(self.document.allocator())); + return self.isMatcher(compiled.matcher(self.q.scratch())); } /// Check if any element matches using a callback. pub fn isFn(self: Selection, f: *const fn (usize, Selection) bool) bool { for (self.nodes, 0..) |_, i| { - if (f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.document))) return true; + if (f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.q))) return true; } return false; } @@ -555,14 +563,14 @@ pub const Selection = struct { /// Position of first element relative to elements matching selector. pub fn indexOfSelector(self: Selection, selector: []const u8) !?usize { if (self.nodes.len == 0) return null; - const doc_sel = try self.document.find(selector); + const doc_sel = try self.q.find(selector); return indexInSlice(doc_sel.nodes, self.nodes[0]); } /// Position of first element relative to elements matched by Matcher. pub fn indexOfMatcher(self: Selection, m: Matcher) !?usize { if (self.nodes.len == 0) return null; - const doc_sel = try self.document.findMatcher(m); + const doc_sel = try self.q.findMatcher(m); return indexInSlice(doc_sel.nodes, self.nodes[0]); } @@ -593,14 +601,14 @@ pub const Selection = struct { /// single-element Selection. pub fn each(self: Selection, f: *const fn (usize, Selection) void) void { for (self.nodes, 0..) |_, i| { - f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.document)); + f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.q)); } } /// Like each but the callback can return false to break. pub fn eachWithBreak(self: Selection, f: *const fn (usize, Selection) bool) void { for (self.nodes, 0..) |_, i| { - if (!f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.document))) return; + if (!f(i, Selection.initFromSlice(self.nodes[i .. i + 1], self.q))) return; } } @@ -617,7 +625,7 @@ pub const Selection = struct { if (self.pos >= self.sel.nodes.len) return null; const pos = self.pos; self.pos += 1; - return Selection.initFromSlice(self.sel.nodes[pos .. pos + 1], self.sel.document); + return Selection.initFromSlice(self.sel.nodes[pos .. pos + 1], self.sel.q); } }; @@ -636,7 +644,7 @@ pub const Selection = struct { /// Set an attribute on all elements. pub fn setAttr(self: Selection, name: []const u8, val: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); var pending: std.ArrayList(PendingAttribute) = .empty; for (self.nodes) |n| { if (n.node_type == .element) { @@ -678,7 +686,7 @@ pub const Selection = struct { } fn updateClasses(self: Selection, classes: []const u8, mode: ClassUpdateMode) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); var pending: std.ArrayList(PendingAttribute) = .empty; for (self.nodes) |n| { if (n.node_type != .element) continue; @@ -696,13 +704,13 @@ pub const Selection = struct { /// Get inner HTML of the first element. pub fn html(self: Selection) ![]const u8 { if (self.nodes.len == 0) return ""; - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); return html_render.renderChildrenToString(alloc, self.nodes[0]); } /// Get combined text content of all elements. pub fn text(self: Selection) ![]const u8 { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var buf: std.ArrayList(u8) = .empty; for (self.nodes) |n| { try collectText(alloc, n, &buf); @@ -714,7 +722,7 @@ pub const Selection = struct { /// Insert nodes after each element in the selection. pub fn afterNodes(self: Selection, ns: []*Node) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try cloneNodesForTargets(alloc, self.nodes.len, ns); for (self.nodes, prepared) |sn, clones| { if (sn.parent == null) continue; @@ -727,7 +735,7 @@ pub const Selection = struct { /// Insert HTML after each element. pub fn afterHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .parent); for (self.nodes, prepared) |n, nodes| { const parent_node = n.parent orelse continue; @@ -740,7 +748,7 @@ pub const Selection = struct { /// Insert nodes before each element. pub fn beforeNodes(self: Selection, ns: []*Node) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try cloneNodesForTargets(alloc, self.nodes.len, ns); for (self.nodes, prepared) |sn, clones| { if (sn.parent == null) continue; @@ -752,7 +760,7 @@ pub const Selection = struct { /// Insert HTML before each element. pub fn beforeHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .parent); for (self.nodes, prepared) |n, nodes| { const parent_node = n.parent orelse continue; @@ -764,7 +772,7 @@ pub const Selection = struct { /// Append nodes as children of each element. pub fn appendNodes(self: Selection, ns: []*Node) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try cloneNodesForTargets(alloc, self.nodes.len, ns); for (self.nodes, prepared) |sn, clones| { if (sn.node_type != .element and sn.node_type != .document) continue; @@ -776,7 +784,7 @@ pub const Selection = struct { /// Append parsed HTML as children of each element. pub fn appendHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .element); for (self.nodes, prepared) |n, nodes| { if (n.node_type != .element and n.node_type != .document) continue; @@ -788,7 +796,7 @@ pub const Selection = struct { /// Prepend nodes as first children of each element. pub fn prependNodes(self: Selection, ns: []*Node) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try cloneNodesForTargets(alloc, self.nodes.len, ns); for (self.nodes, prepared) |sn, clones| { if (sn.node_type != .element and sn.node_type != .document) continue; @@ -801,7 +809,7 @@ pub const Selection = struct { /// Prepend parsed HTML. pub fn prependHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .element); for (self.nodes, prepared) |n, nodes| { if (n.node_type != .element and n.node_type != .document) continue; @@ -844,14 +852,14 @@ pub const Selection = struct { /// Deep-clone the matched elements. pub fn cloneSel(self: Selection) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const cloned = try tree.cloneNodes(alloc, self.nodes); - return Selection.initFromSlice(cloned, self.document); + return Selection.initFromSlice(cloned, self.q); } /// Remove all children from each element, returning removed children. pub fn empty(self: Selection) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); var removed: std.ArrayList(*Node) = .empty; for (self.nodes) |n| { var child = n.first_child; @@ -871,7 +879,7 @@ pub const Selection = struct { /// Set inner HTML of each element. pub fn setHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .element); for (self.nodes) |n| { if (n.node_type != .element and n.node_type != .document) continue; @@ -887,7 +895,7 @@ pub const Selection = struct { /// Set literal text content. pub fn setText(self: Selection, text_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try alloc.alloc(?*Node, self.nodes.len); for (self.nodes, prepared) |n, *text_node| { if (n.node_type != .element and n.node_type != .document) { @@ -923,7 +931,7 @@ pub const Selection = struct { /// Wrap each element inside a clone of the first matched wrapper node. pub fn wrapNode(self: Selection, wrapper: *Node) !void { if (wrapper.node_type != .element) return error.InvalidWrapper; - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const wraps = try cloneNodesForTargets(alloc, self.nodes.len, &.{wrapper}); for (self.nodes, wraps) |n, clones| { const wrap = clones[0]; @@ -942,7 +950,7 @@ pub const Selection = struct { /// Wrap each element inside the first element matched by HTML string. pub fn wrapHtml(self: Selection, html_str: []const u8) !void { - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const prepared = try prepareFragments(alloc, self.nodes, html_str, .parent_or_synthetic); for (prepared) |parsed| { if (parsed.len > 0 and firstElement(parsed) == null) return error.InvalidWrapper; @@ -966,7 +974,7 @@ pub const Selection = struct { pub fn wrapAllNode(self: Selection, wrapper: *Node) !void { if (self.nodes.len == 0) return; if (wrapper.node_type != .element) return error.InvalidWrapper; - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const wrap = try tree.cloneNode(alloc, wrapper); const first_node = self.nodes[0]; if (first_node.parent) |p| { @@ -985,7 +993,7 @@ pub const Selection = struct { /// Wrap content of each element. pub fn wrapInnerNode(self: Selection, wrapper: *Node) !void { if (wrapper.node_type != .element) return error.InvalidWrapper; - const alloc = self.document.allocator(); + const alloc = self.q.docAlloc(); const wraps = try cloneNodesForTargets(alloc, self.nodes.len, &.{wrapper}); for (self.nodes, wraps) |n, clones| { if (n.node_type != .element and n.node_type != .document) continue; @@ -1005,16 +1013,16 @@ pub const Selection = struct { /// Add nodes matching CSS selector to this selection. pub fn add(self: Selection, selector: []const u8) !Selection { - const alloc = self.document.allocator(); - const parsed = try css_parser.parseSelector(alloc, selector); + const alloc = self.q.scratch(); + const parsed = try self.q.compile(selector); const m = Matcher.init(alloc, parsed); return self.addMatcher(m); } /// Add nodes matching Matcher to this selection. pub fn addMatcher(self: Selection, m: Matcher) !Selection { - const alloc = self.document.allocator(); - const root_slice = [_]*Node{self.document.root_node}; + const alloc = self.q.scratch(); + const root_slice = [_]*Node{self.q.doc.root_node}; const found = try findWithMatcher(alloc, @constCast(root_slice[0..]), m); return self.addNodes(found); } @@ -1026,7 +1034,7 @@ pub const Selection = struct { /// Add specific nodes to this selection. pub fn addNodes(self: Selection, extra_nodes: []*Node) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const merged = try appendWithoutDuplicates(alloc, self.nodes, extra_nodes); return self.pushStack(merged); } @@ -1051,18 +1059,18 @@ pub const Selection = struct { // Internal helpers. fn pushStack(self: Selection, new_nodes: []*Node) !Selection { - const alloc = self.document.allocator(); + const alloc = self.q.scratch(); const saved = try alloc.create(Selection); saved.* = self; return Selection{ .nodes = new_nodes, - .document = self.document, + .q = self.q, .prev_sel = saved, }; } fn filterAndPush(self: Selection, raw_nodes: []*Node, m: Matcher) !Selection { - const filtered = try filterWithMatcher(self.document.allocator(), raw_nodes, m); + const filtered = try filterWithMatcher(self.q.scratch(), raw_nodes, m); return self.pushStack(filtered); } }; @@ -1070,7 +1078,7 @@ pub const Selection = struct { /// Render the outer HTML of the first element. pub fn outerHtml(sel: Selection) ![]const u8 { if (sel.nodes.len == 0) return ""; - return html_render.renderToString(sel.document.allocator(), sel.nodes[0]); + return html_render.renderToString(sel.q.scratch(), sel.nodes[0]); } /// Get the node name of the first element. @@ -1087,41 +1095,74 @@ pub fn nodeName(sel: Selection) []const u8 { fn findWithMatcher(alloc: Allocator, nodes: []*Node, m: Matcher) ![]*Node { var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); - for (nodes) |n| { - var child = n.first_child; - while (child) |c| { - if (c.node_type == .element) { - try collectMatchesDedup(alloc, m, c, &result, &seen); + + // A single root cannot yield duplicates: one pre-order walk visits every + // descendant exactly once. Only overlapping roots need the dedup set, and + // that is the uncommon case. + if (nodes.len == 1) { + var cur = tree.nextInPreorder(nodes[0], nodes[0]); + while (cur) |node| : (cur = tree.nextInPreorder(node, nodes[0])) { + if (node.node_type == .element and m.match(node)) { + try result.append(alloc, node); } - child = c.next_sibling; + } + return result.toOwnedSlice(alloc); + } + + // Overlapping roots can reach the same node twice. + var marks: VisitMarks = .{}; + defer marks.clear(alloc); + for (nodes) |root| { + var cur = tree.nextInPreorder(root, root); + while (cur) |node| : (cur = tree.nextInPreorder(node, root)) { + if (node.node_type != .element) continue; + if (!m.match(node)) continue; + if (try marks.seen(alloc, node)) continue; + try result.append(alloc, node); } } return result.toOwnedSlice(alloc); } -fn collectMatchesDedup(alloc: Allocator, m: Matcher, node: *Node, result: *std.ArrayList(*Node), seen: *std.AutoHashMap(*Node, void)) !void { - if (m.match(node)) { - if (!seen.contains(node)) { - try seen.put(node, {}); - try result.append(alloc, node); - } +/// Clone `source` once per destination. The clones become part of the +/// document, so they are allocated from the document arena rather than the +/// query's scratch space. +/// Duplicate suppression that does not allocate a set. +/// +/// Membership is a compare against a mark stored on the node itself, rather +/// than a hash lookup into a map that -- in an arena -- was never released. +/// Every mark set is cleared again before the pass returns, so `visit_mark` is +/// zero everywhere outside one of these, and there is no stamp counter that +/// could wrap. +const VisitMarks = struct { + const mark: u32 = 1; + + marked: std.ArrayList(*Node) = .empty, + + /// Returns true if `node` was already seen in this pass. + fn seen(self: *VisitMarks, alloc: Allocator, node: *Node) !bool { + if (node.visit_mark == mark) return true; + // Record the node before marking it. If this allocation fails the node + // must be left unmarked, or `clear` will not know to reset it and the + // stale mark would make the next pass drop it as a duplicate. + try self.marked.append(alloc, node); + node.visit_mark = mark; + return false; } - var child = node.first_child; - while (child) |c| { - if (c.node_type == .element) { - try collectMatchesDedup(alloc, m, c, result, seen); - } - child = c.next_sibling; + + /// Must run on every exit path, including errors. + fn clear(self: *VisitMarks, alloc: Allocator) void { + for (self.marked.items) |node| node.visit_mark = 0; + self.marked.deinit(alloc); } -} +}; -fn cloneNodesForTargets(alloc: Allocator, target_count: usize, source: []const *Node) ![][]*Node { - const prepared = try alloc.alloc([]*Node, target_count); +fn cloneNodesForTargets(dom: Allocator, target_count: usize, source: []const *Node) ![][]*Node { + const prepared = try dom.alloc([]*Node, target_count); for (prepared) |*clones| { - clones.* = try alloc.alloc(*Node, source.len); + clones.* = try dom.alloc(*Node, source.len); for (source, clones.*) |node, *clone| { - clone.* = try tree.cloneNode(alloc, node); + clone.* = try tree.cloneNode(dom, node); } } return prepared; @@ -1133,8 +1174,10 @@ const FragmentContext = enum { parent_or_synthetic, }; -fn prepareFragments(alloc: Allocator, targets: []*Node, html: []const u8, mode: FragmentContext) ![][]*Node { - const prepared = try alloc.alloc([]*Node, targets.len); +/// Parse `html` once per destination. The parsed nodes join the document, +/// so they come from the document arena. +fn prepareFragments(dom: Allocator, targets: []*Node, html: []const u8, mode: FragmentContext) ![][]*Node { + const prepared = try dom.alloc([]*Node, targets.len); for (targets, prepared) |target, *fragment| { const context = switch (mode) { .parent => target.parent orelse { @@ -1148,12 +1191,12 @@ fn prepareFragments(alloc: Allocator, targets: []*Node, html: []const u8, mode: continue; }, .parent_or_synthetic => target.parent orelse blk: { - const synthetic = try alloc.create(Node); + const synthetic = try dom.create(Node); synthetic.* = .{ .node_type = .element, .data = "div" }; break :blk synthetic; }, }; - fragment.* = try html_parser.parseFragment(alloc, html, context); + fragment.* = try html_parser.parseFragment(dom, html, context); } return prepared; } @@ -1178,7 +1221,8 @@ const SiblingType = enum { fn getChildrenNodes(alloc: Allocator, nodes: []*Node, st: SiblingType) ![]*Node { var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (nodes) |n| { var child = n.first_child; while (child) |c| { @@ -1186,10 +1230,7 @@ fn getChildrenNodes(alloc: Allocator, nodes: []*Node, st: SiblingType) ![]*Node .all_including_non_elements => true, else => c.node_type == .element, }; - if (include and !seen.contains(c)) { - try seen.put(c, {}); - try result.append(alloc, c); - } + if (include and !try marks.seen(alloc, c)) try result.append(alloc, c); child = c.next_sibling; } } @@ -1198,11 +1239,11 @@ fn getChildrenNodes(alloc: Allocator, nodes: []*Node, st: SiblingType) ![]*Node fn getParentNodes(alloc: Allocator, nodes: []*Node) ![]*Node { var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (nodes) |n| { if (n.parent) |p| { - if (p.node_type == .element and !seen.contains(p)) { - try seen.put(p, {}); + if (p.node_type == .element and !try marks.seen(alloc, p)) { try result.append(alloc, p); } } @@ -1212,7 +1253,8 @@ fn getParentNodes(alloc: Allocator, nodes: []*Node) ![]*Node { fn getParentsNodes(alloc: Allocator, nodes: []*Node, until_matcher: ?Matcher, until_nodes: ?[]*Node) ![]*Node { var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (nodes) |n| { var p = n.parent; while (p) |parent| { @@ -1222,8 +1264,7 @@ fn getParentsNodes(alloc: Allocator, nodes: []*Node, until_matcher: ?Matcher, un if (until_nodes) |un| { if (isInSlice(un, parent)) break; } - if (parent.node_type == .element and !seen.contains(parent)) { - try seen.put(parent, {}); + if (parent.node_type == .element and !try marks.seen(alloc, parent)) { try result.append(alloc, parent); } p = parent.parent; @@ -1234,14 +1275,12 @@ fn getParentsNodes(alloc: Allocator, nodes: []*Node, until_matcher: ?Matcher, un fn getSiblingNodes(alloc: Allocator, nodes: []*Node, st: SiblingType, until_matcher: ?Matcher, until_nodes: ?[]*Node) ![]*Node { var result: std.ArrayList(*Node) = .empty; - var seen = std.AutoHashMap(*Node, void).init(alloc); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); for (nodes) |n| { const siblings = try getNodeSiblings(alloc, n, st, until_matcher, until_nodes); for (siblings) |s| { - if (!seen.contains(s)) { - try seen.put(s, {}); - try result.append(alloc, s); - } + if (!try marks.seen(alloc, s)) try result.append(alloc, s); } } return result.toOwnedSlice(alloc); @@ -1331,9 +1370,9 @@ fn matchesUntil(node: *Node, until_matcher: ?Matcher, until_nodes: ?[]*Node) boo fn winnow(sel: Selection, m: Matcher, keep: bool) ![]*Node { if (keep) { - return filterWithMatcher(sel.document.allocator(), sel.nodes, m); + return filterWithMatcher(sel.q.scratch(), sel.nodes, m); } - const alloc = sel.document.allocator(); + const alloc = sel.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (sel.nodes) |n| { if (!m.match(n)) try result.append(alloc, n); @@ -1350,17 +1389,17 @@ fn filterWithMatcher(alloc: Allocator, nodes: []*Node, m: Matcher) ![]*Node { } fn winnowFn(sel: Selection, f: *const fn (usize, Selection) bool, keep: bool) ![]*Node { - const alloc = sel.document.allocator(); + const alloc = sel.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (sel.nodes, 0..) |n, i| { - const matches = f(i, Selection.initFromSlice(sel.nodes[i .. i + 1], sel.document)); + const matches = f(i, Selection.initFromSlice(sel.nodes[i .. i + 1], sel.q)); if (matches == keep) try result.append(alloc, n); } return result.toOwnedSlice(alloc); } fn winnowNodes(sel: Selection, target_nodes: []*Node, keep: bool) ![]*Node { - const alloc = sel.document.allocator(); + const alloc = sel.q.scratch(); var result: std.ArrayList(*Node) = .empty; for (sel.nodes) |n| { const in_targets = isInSlice(target_nodes, n); @@ -1372,13 +1411,11 @@ fn winnowNodes(sel: Selection, target_nodes: []*Node, keep: bool) ![]*Node { fn appendWithoutDuplicates(alloc: Allocator, target: []*Node, new_nodes: []*Node) ![]*Node { var result: std.ArrayList(*Node) = .empty; try result.appendSlice(alloc, target); - var seen = std.AutoHashMap(*Node, void).init(alloc); - for (target) |n| try seen.put(n, {}); + var marks: VisitMarks = .{}; + defer marks.clear(alloc); + for (target) |n| _ = try marks.seen(alloc, n); for (new_nodes) |n| { - if (!seen.contains(n)) { - try seen.put(n, {}); - try result.append(alloc, n); - } + if (!try marks.seen(alloc, n)) try result.append(alloc, n); } return result.toOwnedSlice(alloc); } @@ -1414,13 +1451,9 @@ fn nodeContains(container: *const Node, contained: *const Node) bool { } fn hasDescendantMatch(node: *const Node, m: Matcher) bool { - var child = node.first_child; - while (child) |c| { - if (c.node_type == .element) { - if (m.match(c)) return true; - if (hasDescendantMatch(c, m)) return true; - } - child = c.next_sibling; + var cur = tree.nextInPreorderConst(node, node); + while (cur) |current| : (cur = tree.nextInPreorderConst(current, node)) { + if (current.node_type == .element and m.match(current)) return true; } return false; } @@ -1430,10 +1463,9 @@ fn collectText(alloc: Allocator, node: *const Node, buf: *std.ArrayList(u8)) !vo try buf.appendSlice(alloc, node.data); return; } - var child = node.first_child; - while (child) |c| { - try collectText(alloc, c, buf); - child = c.next_sibling; + var cur = tree.nextInPreorderConst(node, node); + while (cur) |current| : (cur = tree.nextInPreorderConst(current, node)) { + if (current.node_type == .text) try buf.appendSlice(alloc, current.data); } } @@ -1526,14 +1558,16 @@ const PendingAttribute = union(enum) { }, }; -fn prepareSetAttribute(alloc: Allocator, node: *Node, key: []const u8, val: []const u8) !PendingAttribute { - const owned_val = try alloc.dupe(u8, val); +/// Attribute keys and values are stored on the node, so they outlive the +/// query and come from the document arena. +fn prepareSetAttribute(dom: Allocator, node: *Node, key: []const u8, val: []const u8) !PendingAttribute { + const owned_val = try dom.dupe(u8, val); if (findAttributeIndex(node, key)) |index| { return .{ .set_existing = .{ .node = node, .index = index, .value = owned_val } }; } - const owned_key = try alloc.dupe(u8, key); - const new_attrs = try alloc.alloc(Attribute, node.attr.len + 1); + const owned_key = try dom.dupe(u8, key); + const new_attrs = try dom.alloc(Attribute, node.attr.len + 1); @memcpy(new_attrs[0..node.attr.len], node.attr); new_attrs[node.attr.len] = .{ .key = owned_key, .val = owned_val }; return .{ .set_new = .{ .node = node, .attributes = new_attrs } }; @@ -1566,75 +1600,93 @@ fn removeNodeAttrAt(node: *Node, index: usize) void { } test "Selection find" { - var doc = try Document.initFromSlice(std.testing.allocator, "

Hello

World

"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "

Hello

World

"); defer doc.deinit(); - const sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const sel = try q.find("p"); try std.testing.expect(sel.len() == 2); } test "Selection children" { - var doc = try Document.initFromSlice(std.testing.allocator, "
AB
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
AB
"); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const spans = try div.children(); try std.testing.expect(spans.len() == 2); } test "Selection parent" { - var doc = try Document.initFromSlice(std.testing.allocator, "

test

"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "

test

"); defer doc.deinit(); - const p = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p = try q.find("p"); const par = try p.parent(); try std.testing.expect(par.len() == 1); try std.testing.expectEqualStrings("div", par.nodes[0].data); } test "Selection attr" { - var doc = try Document.initFromSlice(std.testing.allocator, "link"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "link"); defer doc.deinit(); - const a = try doc.find("a"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const a = try q.find("a"); try std.testing.expectEqualStrings("/test", a.attr("href").?); } test "Selection text" { - var doc = try Document.initFromSlice(std.testing.allocator, "
Hello World
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
Hello World
"); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const t = try div.text(); try std.testing.expect(std.mem.indexOf(u8, t, "Hello") != null); try std.testing.expect(std.mem.indexOf(u8, t, "World") != null); } test "Selection hasClass" { - var doc = try Document.initFromSlice(std.testing.allocator, "
test
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
test
"); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(div.hasClass("foo")); try std.testing.expect(div.hasClass("bar")); try std.testing.expect(!div.hasClass("baz")); } test "Selection first and last" { - var doc = try Document.initFromSlice(std.testing.allocator, "
  • 1
  • 2
  • 3
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
  • 1
  • 2
  • 3
"); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); try std.testing.expect(lis.len() == 3); try std.testing.expect((try lis.first()).len() == 1); try std.testing.expect((try lis.last()).len() == 1); } test "Selection is" { - var doc = try Document.initFromSlice(std.testing.allocator, "
test
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
test
"); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(try div.is(".active")); try std.testing.expect(!try div.is(".inactive")); } test "Selection empty selection" { - var doc = try Document.initFromSlice(std.testing.allocator, "
"); + var doc = try @import("document.zig").Document.initFromSlice(std.testing.allocator, "
"); defer doc.deinit(); - const nonexistent = try doc.find("span"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const nonexistent = try q.find("span"); try std.testing.expect(nonexistent.len() == 0); try std.testing.expect(nonexistent.attr("id") == null); try std.testing.expect(!nonexistent.hasClass("foo")); diff --git a/test/api_test.zig b/test/api_test.zig index 87df829..4bbd85c 100644 --- a/test/api_test.zig +++ b/test/api_test.zig @@ -13,13 +13,15 @@ test "allocating selection APIs propagate errors" { "

A

B

C

", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); var compiled = try zq.CompiledSelector.init(std.testing.allocator, "p"); defer compiled.deinit(); - const matcher = compiled.matcher(doc.allocator()); + const matcher = compiled.matcher(doc.domAllocator()); - const divs = try doc.find("div"); - const paragraphs = try doc.find("p"); + const divs = try q.find("div"); + const paragraphs = try q.find("p"); _ = try divs.findMatcher(matcher); _ = try divs.findSelection(paragraphs); _ = try divs.findNodes(paragraphs.nodes); @@ -60,11 +62,15 @@ test "allocating selection APIs propagate errors" { test "node mutation APIs compile and preserve invariants" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

A

"); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); var source = try zq.Document.initFromSlice(std.testing.allocator, "x"); defer source.deinit(); + var q_source = source.query(std.testing.allocator); + defer q_source.deinit(); - const empty = try doc.find("missing"); - const source_nodes = try source.find("i"); + const empty = try q.find("missing"); + const source_nodes = try q_source.find("i"); try empty.afterNodes(source_nodes.nodes); try empty.beforeNodes(source_nodes.nodes); try empty.appendNodes(source_nodes.nodes); @@ -78,5 +84,5 @@ test "node mutation APIs compile and preserve invariants" { } fn compiledMatcher(doc: *zq.Document) zq.Matcher { - return zq.Matcher.init(doc.allocator(), &universal_selector); + return zq.Matcher.init(doc.domAllocator(), &universal_selector); } diff --git a/test/compiled_selector_test.zig b/test/compiled_selector_test.zig index 9198d14..98a8a04 100644 --- a/test/compiled_selector_test.zig +++ b/test/compiled_selector_test.zig @@ -9,13 +9,17 @@ test "compiled selector is owning and reusable across documents" { var first = try zq.Document.initFromSlice(std.testing.allocator, "
  • A
  • B
"); defer first.deinit(); + var q_first = first.query(std.testing.allocator); + defer q_first.deinit(); var second = try zq.Document.initFromSlice(std.testing.allocator, "
  • C
  • "); defer second.deinit(); + var q_second = second.query(std.testing.allocator); + defer q_second.deinit(); - try std.testing.expect((try first.findCompiled(&compiled)).len() == 1); - try std.testing.expect((try second.findCompiled(&compiled)).len() == 1); + try std.testing.expect((try q_first.findCompiled(&compiled)).len() == 1); + try std.testing.expect((try q_second.findCompiled(&compiled)).len() == 1); - const all = try first.find("li"); + const all = try q_first.find("li"); const filtered = try all.filterCompiled(&compiled); try std.testing.expect(filtered.len() == 1); try std.testing.expect(filtered.isCompiled(&compiled)); diff --git a/test/css_parser_test.zig b/test/css_parser_test.zig index ad0a544..4c58e7d 100644 --- a/test/css_parser_test.zig +++ b/test/css_parser_test.zig @@ -108,10 +108,12 @@ test "relative has selectors" { "

    child

    nested

    ", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expect((try doc.find("section:has(> p)")).len() == 1); - try std.testing.expect((try doc.find("section:has(p)")).len() == 2); - try std.testing.expect((try doc.find("dt:has(+ dt)")).len() == 1); + try std.testing.expect((try q.find("section:has(> p)")).len() == 1); + try std.testing.expect((try q.find("section:has(p)")).len() == 2); + try std.testing.expect((try q.find("dt:has(+ dt)")).len() == 1); } test "relative has anchors complex selector chains" { @@ -121,32 +123,91 @@ test "relative has anchors complex selector chains" { "

    ", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expect((try doc.find("div:has(> p > span)")).len() == 1); - try std.testing.expect((try doc.find("h1:has(+ section > a)")).len() == 1); - try std.testing.expect((try doc.find("#b:has(> p > span)")).len() == 0); + try std.testing.expect((try q.find("div:has(> p > span)")).len() == 1); + try std.testing.expect((try q.find("h1:has(+ section > a)")).len() == 1); + try std.testing.expect((try q.find("#b:has(> p > span)")).len() == 0); } test "selector lists in logical pseudo classes" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    A

    B

    C

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expect((try doc.find(":is(h1, h2)")).len() == 2); - try std.testing.expect((try doc.find("*:not(h1, h2)")).len() >= 1); + try std.testing.expect((try q.find(":is(h1, h2)")).len() == 2); + try std.testing.expect((try q.find("*:not(h1, h2)")).len() >= 1); } test "HTML selector names are ASCII case insensitive" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    ok
    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expect((try doc.find("DIV[DATA-X=value]")).len() == 1); + try std.testing.expect((try q.find("DIV[DATA-X=value]")).len() == 1); } test "universal selector only matches elements" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    text
    "); defer doc.deinit(); - const contents = try (try doc.find("div")).contents(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const contents = try (try q.find("div")).contents(); const elements = try contents.filter("*"); try std.testing.expect(elements.len() == 1); try std.testing.expectEqualStrings("span", elements.nodes[0].data); } + +test ":has() scopes candidates correctly" { + const html = + \\
    + \\

    title

    + \\

    intro

    + \\
    link
    + \\ end + \\
    + \\
    no anchors here
    + ; + var doc = try zq.Document.initFromSlice(std.testing.allocator, html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + // The match is a later sibling, reached through a sibling combinator from + // the anchor's immediate next sibling. + try std.testing.expectEqual(@as(usize, 1), (try q.find("h1:has(+ p ~ span)")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("h1:has(+ p ~ section)")).len()); + // `+ section` is wrong: section is not h1's *immediate* next sibling. + try std.testing.expectEqual(@as(usize, 0), (try q.find("h1:has(+ section)")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("h1:has(~ section)")).len()); + + // A descendant relation stays inside the anchor's own subtree: #empty must + // not match on the that lives inside #outer. + try std.testing.expectEqual(@as(usize, 1), (try q.find("div:has(a)")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("#empty:has(a)")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("#outer:has(a)")).len()); + + // A child relation does not reach grandchildren. + try std.testing.expectEqual(@as(usize, 0), (try q.find("#outer:has(> a)")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("section:has(> a)")).len()); +} + +test ":has() on a deeply nested document does not recurse" { + const gpa = std.testing.allocator; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + for (0..50_000) |_| try buf.appendSlice(gpa, "
    "); + try buf.appendSlice(gpa, "x"); + for (0..50_000) |_| try buf.appendSlice(gpa, "
    "); + + var doc = try zq.Document.initFromSlice(gpa, buf.items); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + // Only the innermost div has an as a direct child. + try std.testing.expectEqual(@as(usize, 1), (try q.find("div:has(> a)")).len()); +} diff --git a/test/debug_main.zig b/test/debug_main.zig deleted file mode 100644 index 3438c22..0000000 --- a/test/debug_main.zig +++ /dev/null @@ -1,47 +0,0 @@ -const std = @import("std"); -const zq = @import("zigquery"); - -pub fn main() !void { - const alloc = std.heap.page_allocator; - var doc = try zq.Document.initFromSlice(alloc, "

    Hello

    World

    "); - defer doc.deinit(); - - printTree(doc.root_node, 0); - - std.debug.print("\n--- find p ---\n", .{}); - const ps = try doc.find("p"); - std.debug.print("ps: {}\n", .{ps.len()}); - - std.debug.print("\n--- find div ---\n", .{}); - const divs = try doc.find("div"); - std.debug.print("divs: {}\n", .{divs.len()}); - - if (divs.len() > 0) { - const ch = divs.children(); - std.debug.print("div children: {}\n", .{ch.len()}); - } - - std.debug.print("\n--- find html ---\n", .{}); - var doc3 = try zq.Document.initFromSlice(alloc, "

    X

    "); - const htmls = try doc3.find("html"); - std.debug.print("html elements: {}\n", .{htmls.len()}); - if (htmls.len() > 0) { - std.debug.print("html lang: {s}\n", .{htmls.attr("lang") orelse "null"}); - } -} - -fn printTree(node: *const zq.Node, depth: usize) void { - for (0..depth) |_| std.debug.print(" ", .{}); - switch (node.node_type) { - .element => std.debug.print("<{s}>\n", .{node.data}), - .text => std.debug.print("TEXT: '{s}'\n", .{node.data[0..@min(node.data.len, 40)]}), - .document => std.debug.print("[document]\n", .{}), - .comment => std.debug.print("\n", .{}), - .doctype => std.debug.print("\n", .{node.data}), - } - var child = node.first_child; - while (child) |c| { - printTree(c, depth + 1); - child = c.next_sibling; - } -} diff --git a/test/debug_test.zig b/test/debug_test.zig deleted file mode 100644 index f74c3df..0000000 --- a/test/debug_test.zig +++ /dev/null @@ -1,61 +0,0 @@ -const std = @import("std"); -const zq = @import("zigquery"); - -test "debug tree structure" { - var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    Hello

    World

    "); - defer doc.deinit(); - - printTree(doc.root_node, 0); - - std.debug.print("\n--- find p ---\n", .{}); - const ps = try doc.find("p"); - std.debug.print("ps: {}\n", .{ps.len()}); - - std.debug.print("\n--- find div ---\n", .{}); - const divs = try doc.find("div"); - std.debug.print("divs: {}\n", .{divs.len()}); - - if (divs.len() > 0) { - const ch = divs.children(); - std.debug.print("div children: {}\n", .{ch.len()}); - for (ch.nodes) |n| { - std.debug.print(" child: {s} type={}\n", .{ n.data, @intFromEnum(n.node_type) }); - } - } - - std.debug.print("\n--- html() test ---\n", .{}); - var doc2 = try zq.Document.initFromSlice(std.testing.allocator, "

    test

    "); - defer doc2.deinit(); - const d2 = try doc2.find("div"); - std.debug.print("div count: {}\n", .{d2.len()}); - if (d2.len() > 0) { - const h = try d2.html(); - std.debug.print("html: '{s}'\n", .{h}); - } - - std.debug.print("\n--- find html element ---\n", .{}); - var doc3 = try zq.Document.initFromSlice(std.testing.allocator, "

    X

    "); - defer doc3.deinit(); - printTree(doc3.root_node, 0); - const htmls = try doc3.find("html"); - std.debug.print("html elements: {}\n", .{htmls.len()}); -} - -fn printTree(node: *const zq.Node, depth: usize) void { - for (0..depth) |_| std.debug.print(" ", .{}); - switch (node.node_type) { - .element => std.debug.print("<{s}>\n", .{node.data}), - .text => { - const data = node.data[0..@min(node.data.len, 40)]; - std.debug.print("TEXT: '{s}'\n", .{data}); - }, - .document => std.debug.print("[document]\n", .{}), - .comment => std.debug.print("\n", .{}), - .doctype => std.debug.print("\n", .{node.data}), - } - var child = node.first_child; - while (child) |c| { - printTree(c, depth + 1); - child = c.next_sibling; - } -} diff --git a/test/deep_nesting_test.zig b/test/deep_nesting_test.zig new file mode 100644 index 0000000..c354479 --- /dev/null +++ b/test/deep_nesting_test.zig @@ -0,0 +1,132 @@ +//! Deeply nested input. +//! +//! `appendChild` asserts on `wouldCreateCycle`, and an assert's argument is +//! evaluated in every build mode -- so an O(depth) ancestor walk there turns +//! tree building into O(nodes * depth). At 50 000 levels that was about ten +//! seconds; these tests fail by timing out in CI if it ever comes back. + +const std = @import("std"); +const zq = @import("zigquery"); + +const depth = 50_000; + +fn nestedHtml(gpa: std.mem.Allocator, levels: u32) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(gpa); + try buf.appendSlice(gpa, ""); + for (0..levels) |_| try buf.appendSlice(gpa, "
    "); + try buf.appendSlice(gpa, "bottom"); + for (0..levels) |_| try buf.appendSlice(gpa, "
    "); + try buf.appendSlice(gpa, ""); + return buf.toOwnedSlice(gpa); +} + +test "parses 50k nested elements" { + const gpa = std.testing.allocator; + const html = try nestedHtml(gpa, depth); + defer gpa.free(html); + + var doc = try zq.Document.initFromSlice(gpa, html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + // Walk to the deepest element and confirm the tree really is that deep, + // rather than having been silently flattened. + var node = doc.root_node; + var measured: usize = 0; + while (node.first_child) |child| : (node = child) measured += 1; + try std.testing.expect(measured >= depth); +} + +test "queries a 50k deep document" { + const gpa = std.testing.allocator; + const html = try nestedHtml(gpa, depth); + defer gpa.free(html); + + var doc = try zq.Document.initFromSlice(gpa, html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const links = try q.find("a.link"); + try std.testing.expectEqual(@as(usize, 1), links.len()); + + const divs = try q.find("div.n"); + try std.testing.expectEqual(@as(usize, depth), divs.len()); +} + +test "renders and clones a 50k deep document" { + const gpa = std.testing.allocator; + const html = try nestedHtml(gpa, depth); + defer gpa.free(html); + + var doc = try zq.Document.initFromSlice(gpa, html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const rendered = try zq.html_render.renderToString(gpa, doc.root_node); + defer gpa.free(rendered); + try std.testing.expectEqual(@as(usize, depth), std.mem.count(u8, rendered, "
    ")); + + var copy = try doc.clone(gpa); + defer copy.deinit(); + var q_copy = copy.query(std.testing.allocator); + defer q_copy.deinit(); + const copied = try q_copy.find("a.link"); + try std.testing.expectEqual(@as(usize, 1), copied.len()); +} + +test "extracts text from a 50k deep document" { + const gpa = std.testing.allocator; + const html = try nestedHtml(gpa, depth); + defer gpa.free(html); + + var doc = try zq.Document.initFromSlice(gpa, html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const body = try q.find("body"); + const text = try body.text(); + try std.testing.expectEqualStrings("bottom", text); +} + +test "cycle detection still rejects a real cycle" { + // The `wouldCreateCycle` short-circuit keys off `child.first_child`, so + // the case that matters is a child that *does* have descendants and is an + // ancestor of the proposed parent. + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, "

    x

    "); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const outer = (try q.find("#outer")).nodes[0]; + const inner = (try q.find("#inner")).nodes[0]; + + // Re-parenting an ancestor under its own descendant is a cycle. + try std.testing.expectError(error.Cycle, zq.tree.appendChildChecked(inner, outer)); + try std.testing.expectError(error.Cycle, zq.tree.insertBeforeChecked(inner, outer, null)); + + // A node cannot be appended to itself. + try std.testing.expectError(error.Cycle, zq.tree.appendChildChecked(outer, outer)); + + // The legitimate direction still works, and leaves a valid tree. + const para = (try q.find("p")).nodes[0]; + try zq.tree.appendChildChecked(outer, para); + try zq.tree.validate(gpa, doc.root_node); +} + +test "cycle detection rejects a childless node appended to itself" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, "
    "); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const a = (try q.find("#a")).nodes[0]; + try std.testing.expect(a.first_child == null); + try std.testing.expectError(error.Cycle, zq.tree.appendChildChecked(a, a)); +} diff --git a/test/document_test.zig b/test/document_test.zig index c512765..417d8bf 100644 --- a/test/document_test.zig +++ b/test/document_test.zig @@ -5,22 +5,30 @@ const helper = @import("test_helper.zig"); test "Document.initFromSlice" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); try std.testing.expect(doc.root_node.node_type == .document); } test "Document.find" { var doc = try helper.parseDoc("

    Hello

    World

    "); defer doc.deinit(); - const sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const sel = try q.find("p"); try std.testing.expect(sel.len() == 2); } test "Document.clone" { var doc = try helper.parseDoc("

    test

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); var cloned = try doc.clone(std.testing.allocator); defer cloned.deinit(); - const sel = try cloned.find("p"); + var q_cloned = cloned.query(std.testing.allocator); + defer q_cloned.deinit(); + const sel = try q_cloned.find("p"); try std.testing.expect(sel.len() == 1); } @@ -29,11 +37,15 @@ test "Document owns input and clone strings" { var doc = try zq.Document.initFromSlice(std.testing.allocator, input); std.testing.allocator.free(input); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); var cloned = try doc.clone(std.testing.allocator); defer cloned.deinit(); + var q_cloned = cloned.query(std.testing.allocator); + defer q_cloned.deinit(); - const div = try cloned.find("div"); + const div = try q_cloned.find("div"); try std.testing.expectEqualStrings("original", div.attr("data-value").?); try std.testing.expectEqualStrings("text", try div.text()); } @@ -42,6 +54,8 @@ test "Document.initFromNode creates an owning clone" { var source = zq.Node{ .node_type = .element, .data = "div" }; var doc = try zq.Document.initFromNode(std.testing.allocator, &source); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); try std.testing.expect(doc.root_node != &source); try std.testing.expectEqualStrings("div", doc.root_node.data); @@ -50,22 +64,26 @@ test "Document.initFromNode creates an owning clone" { test "Document from page.html" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); // page.html has multiple divs. - const divs = try doc.find("div"); + const divs = try q.find("div"); try std.testing.expect(divs.len() > 0); // Has links. - const links = try doc.find("a"); + const links = try q.find("a"); try std.testing.expect(links.len() > 0); } test "Document from page2.html" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); // page2.html has div#main with 6 child divs. - const main_div = try doc.find("#main"); + const main_div = try q.find("#main"); try std.testing.expect(main_div.len() == 1); const children = try main_div.children(); @@ -75,14 +93,18 @@ test "Document from page2.html" { test "Document from page3.html" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page3_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const main_div = try doc.find("#main"); + const main_div = try q.find("#main"); try std.testing.expect(main_div.len() == 1); } test "empty document" { var doc = try helper.parseDoc(""); defer doc.deinit(); - const sel = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const sel = try q.find("div"); try std.testing.expect(sel.len() == 0); } diff --git a/test/filter_test.zig b/test/filter_test.zig index 2dfd56f..bf1851f 100644 --- a/test/filter_test.zig +++ b/test/filter_test.zig @@ -5,7 +5,9 @@ const helper = @import("test_helper.zig"); test "filter by selector" { var doc = try helper.parseDoc("

    1

    2

    3

    "); defer doc.deinit(); - const ps = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const ps = try q.find("p"); try std.testing.expect(ps.len() == 3); const filtered = try ps.filter(".a"); try std.testing.expect(filtered.len() == 2); @@ -14,7 +16,9 @@ test "filter by selector" { test "not by selector" { var doc = try helper.parseDoc("

    1

    2

    3

    "); defer doc.deinit(); - const ps = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const ps = try q.find("p"); const not_a = try ps.not(".a"); try std.testing.expect(not_a.len() == 1); } @@ -22,7 +26,9 @@ test "not by selector" { test "has" { var doc = try helper.parseDoc("

    yes

    no span

    "); defer doc.deinit(); - const divs = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const divs = try q.find("div"); const with_span = try divs.has("span"); try std.testing.expect(with_span.len() == 1); } @@ -30,7 +36,9 @@ test "has" { test "is" { var doc = try helper.parseDoc("

    test

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(try div.is(".active")); try std.testing.expect(!try div.is(".inactive")); } @@ -38,8 +46,10 @@ test "is" { test "filter on page2.html" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const rows = try doc.find(".row"); + const rows = try q.find(".row"); try std.testing.expect(rows.len() > 0); const odd = try rows.filter(".odd"); @@ -52,8 +62,10 @@ test "filter on page2.html" { test "intersection" { var doc = try helper.parseDoc("

    1

    2

    "); defer doc.deinit(); - const ps = try doc.find("p"); - const a = try doc.find(".a"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const ps = try q.find("p"); + const a = try q.find(".a"); const inter = try ps.intersection(a); try std.testing.expect(inter.len() == 1); } @@ -61,7 +73,9 @@ test "intersection" { test "end returns previous" { var doc = try helper.parseDoc("

    A

    B
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const children = try div.children(); const filtered = try children.filter("p"); try std.testing.expect(filtered.len() == 1); diff --git a/test/html_parser_test.zig b/test/html_parser_test.zig index ef56ca2..bdd4b8f 100644 --- a/test/html_parser_test.zig +++ b/test/html_parser_test.zig @@ -5,25 +5,33 @@ const helper = @import("test_helper.zig"); test "parse page.html without crashing" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); try std.testing.expect(doc.root_node.node_type == .document); } test "parse page2.html without crashing" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); try std.testing.expect(doc.root_node.node_type == .document); } test "parse page3.html without crashing" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page3_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); try std.testing.expect(doc.root_node.node_type == .document); } test "parse basic structure" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "Test

    Hello

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 1); const t = try p_sel.text(); try std.testing.expectEqualStrings("Hello", t); @@ -32,11 +40,13 @@ test "parse basic structure" { test "parse void elements" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "


    "); defer doc.deinit(); - const br = try doc.find("br"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const br = try q.find("br"); try std.testing.expect(br.len() == 1); - const hr = try doc.find("hr"); + const hr = try q.find("hr"); try std.testing.expect(hr.len() == 1); - const img = try doc.find("img"); + const img = try q.find("img"); try std.testing.expect(img.len() == 1); try std.testing.expectEqualStrings("test.png", img.attr("src").?); } @@ -44,45 +54,57 @@ test "parse void elements" { test "parse with doctype" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    content
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(div.len() == 1); } test "parse nested elements" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    "); defer doc.deinit(); - const li = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const li = try q.find("li"); try std.testing.expect(li.len() == 3); } test "parse comments" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    text

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 1); } test "parse raw text elements" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    ok

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 1); } test "case-insensitive tags" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    Hello

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(div.len() == 1); - const p_sel = try doc.find("p"); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 1); } test "numeric entities decode and render without double escaping" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    A 😀

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const paragraph = try doc.find("p"); + const paragraph = try q.find("p"); try std.testing.expectEqualStrings("A \xf0\x9f\x98\x80", try paragraph.text()); try std.testing.expectEqualStrings("

    A \xf0\x9f\x98\x80

    ", try zq.outerHtml(paragraph)); } @@ -90,8 +112,10 @@ test "numeric entities decode and render without double escaping" { test "implicit head and leading whitespace produce valid structure" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "\n\n\nT & C

    x

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const html = try doc.find("html"); + const html = try q.find("html"); const head = try html.childrenFiltered("head"); const body = try html.childrenFiltered("body"); try std.testing.expect(head.len() == 1); @@ -102,8 +126,10 @@ test "implicit head and leading whitespace produce valid structure" { test "table cells auto close" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    AB
    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const cells = try doc.find("td"); + const cells = try q.find("td"); try std.testing.expect(cells.len() == 2); try std.testing.expect(cells.nodes[0].parent == cells.nodes[1].parent); } @@ -114,16 +140,20 @@ test "explicit head and body retain later content" { "

    first

    second

    ", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expect((try doc.find("head > meta")).len() == 1); - try std.testing.expect((try doc.find("body > p")).len() == 2); + try std.testing.expect((try q.find("head > meta")).len() == 1); + try std.testing.expect((try q.find("body > p")).len() == 2); } test "duplicate attributes keep the first value" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const div = try doc.find("div"); + const div = try q.find("div"); try std.testing.expectEqualStrings("first", div.attr("id").?); try std.testing.expectEqual(@as(usize, 1), div.nodes[0].attr.len); } @@ -134,8 +164,10 @@ test "unquoted attribute value keeps slashes" { "Chapter 11", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const a = try doc.find("a"); + const a = try q.find("a"); try std.testing.expectEqual(@as(usize, 1), a.len()); try std.testing.expectEqualStrings("books/learning-zig/chapter11/", a.attr("href").?); try std.testing.expectEqualStrings("link", a.attr("class").?); @@ -156,16 +188,18 @@ test "unquoted attributes across a minified document" { var doc = try zq.Document.initFromSlice(std.testing.allocator, html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expectEqualStrings("en", (try doc.find("html")).attr("lang").?); - try std.testing.expectEqualStrings("UTF-8", (try doc.find("meta[charset]")).attr("charset").?); + try std.testing.expectEqualStrings("en", (try q.find("html")).attr("lang").?); + try std.testing.expectEqualStrings("UTF-8", (try q.find("meta[charset]")).attr("charset").?); try std.testing.expectEqualStrings( "width=device-width, initial-scale=1.0", - (try doc.find("meta[name=viewport]")).attr("content").?, + (try q.find("meta[name=viewport]")).attr("content").?, ); - try std.testing.expectEqualStrings("Document", try (try doc.find("title")).text()); + try std.testing.expectEqualStrings("Document", try (try q.find("title")).text()); - const links = try doc.find("a.link"); + const links = try q.find("a.link"); try std.testing.expectEqual(@as(usize, 1), links.len()); try std.testing.expectEqualStrings("books/learning-zig/chapter11/", links.attr("href").?); } @@ -176,8 +210,10 @@ test "unquoted attribute followed by self closing slash" { "
    ", ); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const imgs = try doc.find("img"); + const imgs = try q.find("img"); try std.testing.expectEqual(@as(usize, 2), imgs.len()); try std.testing.expectEqualStrings("a/b.png", imgs.nodes[0].getAttr("src").?); try std.testing.expectEqualStrings("c.png", imgs.nodes[1].getAttr("src").?); @@ -186,8 +222,10 @@ test "unquoted attribute followed by self closing slash" { test "trailing slash without gap belongs to unquoted value" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "text"); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const a = try doc.find("a"); + const a = try q.find("a"); try std.testing.expectEqual(@as(usize, 1), a.len()); try std.testing.expectEqualStrings("/docs/", a.attr("href").?); try std.testing.expectEqualStrings("text", try a.text()); @@ -196,8 +234,10 @@ test "trailing slash without gap belongs to unquoted value" { test "stray slash between attributes does not drop them" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "
    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const div = try doc.find("div"); + const div = try q.find("div"); try std.testing.expectEqual(@as(usize, 1), div.len()); try std.testing.expectEqualStrings("a", div.attr("id").?); try std.testing.expectEqualStrings("b", div.attr("class").?); @@ -206,14 +246,119 @@ test "stray slash between attributes does not drop them" { test "void element self closes with unquoted attribute" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "

    a
    b

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expectEqual(@as(usize, 1), (try doc.find("br")).len()); - try std.testing.expectEqual(@as(usize, 1), (try doc.find("p")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("br")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("p")).len()); } test "unquoted value decodes entities" { var doc = try zq.Document.initFromSlice(std.testing.allocator, "x"); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - try std.testing.expectEqualStrings("/a&b/c", (try doc.find("a")).attr("href").?); + try std.testing.expectEqualStrings("/a&b/c", (try q.find("a")).attr("href").?); +} + +// The parser tracks how many auto-closable elements are open so it can skip +// the open-element scan when there is nothing to close. A miscount there is +// invisible in the output of simple documents but silently stops auto-closing, +// so these cases exercise the counter across pushes, pops and nesting. + +test "implicit paragraph closing" { + var doc = try helper.parseDoc("

    one

    two

    three

    "); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + const ps = try q.find("p"); + try std.testing.expectEqual(@as(usize, 3), ps.len()); + // Siblings, not nested. + try std.testing.expectEqual(@as(usize, 3), (try q.find("div > p")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("p p")).len()); +} + +test "a block element closes an open paragraph" { + var doc = try helper.parseDoc("

    text

    inner
    "); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 0), (try q.find("p div")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("p")).len()); +} + +test "list items close each other" { + var doc = try helper.parseDoc(""); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 3), (try q.find("ul > li")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("li li")).len()); +} + +test "auto closing still works after explicit end tags" { + // The first

    is closed explicitly, so the open count must return to + // zero; the later paragraphs must still close each other. + var doc = try helper.parseDoc("

    one

    two

    three

    "); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 3), (try q.find("p")).len()); + try std.testing.expectEqual(@as(usize, 2), (try q.find("div > p")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("p p")).len()); +} + +test "auto closing works at depth" { + // Deeply nested containers must not hide an open

    from the scan. + const gpa = std.testing.allocator; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + for (0..200) |_| try buf.appendSlice(gpa, "

    "); + try buf.appendSlice(gpa, "

    one

    two"); + for (0..200) |_| try buf.appendSlice(gpa, "

    "); + + var doc = try zq.Document.initFromSlice(gpa, buf.items); + defer doc.deinit(); + var q = doc.query(gpa); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 2), (try q.find("p")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("p p")).len()); +} + +test "deeply nested divs parse in linear time" { + //
    is in the auto-close table, so every start tag used to scan the + // whole open-element stack looking for a

    to close. + const gpa = std.testing.allocator; + const depth = 50_000; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + for (0..depth) |_| try buf.appendSlice(gpa, "

    "); + for (0..depth) |_| try buf.appendSlice(gpa, "
    "); + + var doc = try zq.Document.initFromSlice(gpa, buf.items); + defer doc.deinit(); + var q = doc.query(gpa); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, depth), (try q.find("div")).len()); +} + +test "table sections and rows auto close" { + var doc = try helper.parseDoc( + "
    ab
    c
    ", + ); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 2), (try q.find("tr")).len()); + try std.testing.expectEqual(@as(usize, 3), (try q.find("td")).len()); + try std.testing.expectEqual(@as(usize, 0), (try q.find("td td")).len()); } diff --git a/test/manipulation_test.zig b/test/manipulation_test.zig index 6de490a..c3ef9ae 100644 --- a/test/manipulation_test.zig +++ b/test/manipulation_test.zig @@ -5,7 +5,9 @@ const helper = @import("test_helper.zig"); fn setHtmlWithFailures(allocator: std.mem.Allocator) !void { var doc = try zq.Document.initFromSlice(allocator, "

    A

    B

    "); defer doc.deinit(); - const divs = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const divs = try q.find("div"); const first_children = [_]?*zq.Node{ divs.nodes[0].first_child, divs.nodes[1].first_child }; divs.setHtml("
    updated
    ") catch |err| { @@ -21,18 +23,22 @@ fn setHtmlWithFailures(allocator: std.mem.Allocator) !void { test "remove" { var doc = try helper.parseDoc("

    keep

    remove
    "); defer doc.deinit(); - const span = try doc.find("span"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const span = try q.find("span"); _ = span.remove(); - const remaining = try doc.find("span"); + const remaining = try q.find("span"); try std.testing.expect(remaining.len() == 0); - const p_sel = try doc.find("p"); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 1); } test "empty" { var doc = try helper.parseDoc("

    A

    B

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const removed = try div.empty(); try std.testing.expect(removed.len() == 2); // two p nodes removed // div should now be empty. @@ -42,9 +48,11 @@ test "empty" { test "appendHtml" { var doc = try helper.parseDoc("

    existing

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.appendHtml("new"); - const span = try doc.find("span"); + const span = try q.find("span"); try std.testing.expect(span.len() == 1); const t = try span.text(); try std.testing.expectEqualStrings("new", t); @@ -53,7 +61,9 @@ test "appendHtml" { test "prependHtml" { var doc = try helper.parseDoc("

    existing

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.prependHtml("first"); const children = try div.children(); try std.testing.expect(children.len() == 2); @@ -64,7 +74,9 @@ test "prependHtml" { test "prependHtml preserves source order" { var doc = try helper.parseDoc("
    existing
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.prependHtml("AB"); const children = try div.children(); try std.testing.expectEqualStrings("span", children.nodes[0].data); @@ -75,9 +87,11 @@ test "prependHtml preserves source order" { test "afterHtml" { var doc = try helper.parseDoc("

    A

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try p_sel.afterHtml("B"); - const div = try doc.find("div"); + const div = try q.find("div"); const children = try div.children(); try std.testing.expect(children.len() == 2); } @@ -85,9 +99,11 @@ test "afterHtml" { test "beforeHtml" { var doc = try helper.parseDoc("

    A

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try p_sel.beforeHtml("B"); - const div = try doc.find("div"); + const div = try q.find("div"); const children = try div.children(); try std.testing.expect(children.len() == 2); try std.testing.expectEqualStrings("span", children.nodes[0].data); @@ -96,22 +112,26 @@ test "beforeHtml" { test "replaceWithHtml" { var doc = try helper.parseDoc("

    old

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); _ = try p_sel.replaceWithHtml("new"); - const new_span = try doc.find("span"); + const new_span = try q.find("span"); try std.testing.expect(new_span.len() == 1); - const old_p = try doc.find("p"); + const old_p = try q.find("p"); try std.testing.expect(old_p.len() == 0); } test "setHtml" { var doc = try helper.parseDoc("

    old

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.setHtml("new content"); - const span = try doc.find("span"); + const span = try q.find("span"); try std.testing.expect(span.len() == 1); - const p_sel = try doc.find("p"); + const p_sel = try q.find("p"); try std.testing.expect(p_sel.len() == 0); } @@ -122,7 +142,9 @@ test "setHtml is atomic on allocation failure" { test "setText" { var doc = try helper.parseDoc("

    old

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.setText("plain text "); const t = try div.text(); try std.testing.expect(std.mem.indexOf(u8, t, "plain text") != null); @@ -132,7 +154,9 @@ test "setText" { test "cloneSel" { var doc = try helper.parseDoc("

    test

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); const cloned = try p_sel.cloneSel(); try std.testing.expect(cloned.len() == 1); // Cloned node is a different pointer. @@ -144,20 +168,24 @@ test "cloneSel" { test "unwrap" { var doc = try helper.parseDoc("

    content

    "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); try p_sel.unwrap(); // The span wrapper should be gone. - const span = try doc.find("span"); + const span = try q.find("span"); try std.testing.expect(span.len() == 0); // The p should still be there under div. - const p_after = try doc.find("p"); + const p_after = try q.find("p"); try std.testing.expect(p_after.len() == 1); } test "outerHtml" { var doc = try helper.parseDoc("

    test

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const oh = try zq.outerHtml(div); try std.testing.expect(std.mem.indexOf(u8, oh, "") != null); @@ -167,27 +195,33 @@ test "outerHtml" { test "nodeName" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expectEqualStrings("div", zq.nodeName(div)); } test "node insertion clones source for every destination" { var target = try helper.parseDoc("
    "); defer target.deinit(); + var q_target = target.query(std.testing.allocator); + defer q_target.deinit(); { var source = try helper.parseDoc(""); defer source.deinit(); + var q_source = source.query(std.testing.allocator); + defer q_source.deinit(); - const destinations = try target.find("div"); - const source_nodes = try source.find("em"); + const destinations = try q_target.find("div"); + const source_nodes = try q_source.find("em"); const source_parent = source_nodes.nodes[0].parent; try destinations.appendNodes(source_nodes.nodes); try std.testing.expect(source_nodes.nodes[0].parent == source_parent); } - const inserted = try target.find("em"); + const inserted = try q_target.find("em"); try std.testing.expectEqual(@as(usize, 2), inserted.len()); try std.testing.expect(inserted.nodes[0] != inserted.nodes[1]); try std.testing.expectEqualStrings("value", inserted.attr("data-x").?); @@ -198,17 +232,21 @@ test "node insertion clones source for every destination" { test "wrap operations preserve tree invariants" { var doc = try helper.parseDoc("

    A

    B

    "); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const paragraphs = try doc.find("p"); + const paragraphs = try q.find("p"); try paragraphs.wrapHtml("
    "); - try std.testing.expect((try doc.find("section > article > p")).len() == 2); + try std.testing.expect((try q.find("section > article > p")).len() == 2); try zq.tree.validate(std.testing.allocator, doc.root_node); } test "wrap rejects non-element containers" { var doc = try helper.parseDoc("

    A

    "); defer doc.deinit(); - const paragraph = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const paragraph = try q.find("p"); var text = zq.Node{ .node_type = .text, .data = "not a wrapper" }; try std.testing.expectError(error.InvalidWrapper, paragraph.wrapNode(&text)); diff --git a/test/property_test.zig b/test/property_test.zig index 119342d..7f8d2c1 100644 --- a/test/property_test.zig +++ b/test/property_test.zig @@ -5,7 +5,9 @@ const helper = @import("test_helper.zig"); fn setAttrWithFailures(allocator: std.mem.Allocator) !void { var doc = try zq.Document.initFromSlice(allocator, "
    "); defer doc.deinit(); - const divs = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const divs = try q.find("div"); divs.setAttr("id", "updated") catch |err| { try std.testing.expectEqualStrings("first", divs.nodes[0].getAttr("id").?); @@ -20,7 +22,9 @@ fn setAttrWithFailures(allocator: std.mem.Allocator) !void { test "attr" { var doc = try helper.parseDoc("click"); defer doc.deinit(); - const a = try doc.find("a"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const a = try q.find("a"); try std.testing.expectEqualStrings("/test", a.attr("href").?); try std.testing.expectEqualStrings("link1", a.attr("id").?); try std.testing.expect(a.attr("class") == null); @@ -29,14 +33,18 @@ test "attr" { test "attrOr" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expectEqualStrings("default", div.attrOr("class", "default")); } test "setAttr" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.setAttr("id", "modified"); try std.testing.expectEqualStrings("modified", div.attr("id").?); } @@ -44,7 +52,9 @@ test "setAttr" { test "setAttr new attribute" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.setAttr("data-x", "value"); try std.testing.expectEqualStrings("value", div.attr("data-x").?); } @@ -52,7 +62,9 @@ test "setAttr new attribute" { test "setAttr owns caller buffers" { var doc = try helper.parseDoc("
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const name = try std.testing.allocator.dupe(u8, "data-owned"); const value = try std.testing.allocator.dupe(u8, "yes"); @@ -70,7 +82,9 @@ test "setAttr is atomic on allocation failure" { test "removeClass removes duplicate class tokens" { var doc = try helper.parseDoc("
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.removeClass("foo"); try std.testing.expectEqualStrings("bar", div.attr("class").?); @@ -79,7 +93,9 @@ test "removeClass removes duplicate class tokens" { test "removeAttr" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); div.removeAttr("class"); try std.testing.expect(div.attr("class") == null); try std.testing.expect(div.attr("id") != null); @@ -88,7 +104,9 @@ test "removeAttr" { test "hasClass" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try std.testing.expect(div.hasClass("foo")); try std.testing.expect(div.hasClass("bar")); try std.testing.expect(div.hasClass("baz")); @@ -98,7 +116,9 @@ test "hasClass" { test "addClass" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.addClass("bar"); try std.testing.expect(div.hasClass("foo")); try std.testing.expect(div.hasClass("bar")); @@ -107,7 +127,9 @@ test "addClass" { test "addClass does not duplicate" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.addClass("foo"); const class_val = div.attr("class").?; // Should not have "foo" twice. @@ -122,7 +144,9 @@ test "addClass does not duplicate" { test "removeClass" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.removeClass("bar"); try std.testing.expect(div.hasClass("foo")); try std.testing.expect(!div.hasClass("bar")); @@ -132,7 +156,9 @@ test "removeClass" { test "removeClass all" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.removeClass(""); try std.testing.expect(div.attr("class") == null); } @@ -140,7 +166,9 @@ test "removeClass all" { test "toggleClass" { var doc = try helper.parseDoc("
    test
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); try div.toggleClass("foo"); try std.testing.expect(!div.hasClass("foo")); try div.toggleClass("foo"); @@ -150,7 +178,9 @@ test "toggleClass" { test "text" { var doc = try helper.parseDoc("
    Hello World
    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const t = try div.text(); try std.testing.expect(std.mem.indexOf(u8, t, "Hello") != null); try std.testing.expect(std.mem.indexOf(u8, t, "World") != null); @@ -159,7 +189,9 @@ test "text" { test "html" { var doc = try helper.parseDoc("

    test

    "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const h = try div.html(); try std.testing.expect(std.mem.indexOf(u8, h, "

    ") != null); try std.testing.expect(std.mem.indexOf(u8, h, "test") != null); @@ -168,7 +200,9 @@ test "html" { test "page.html attr access" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page_html); defer doc.deinit(); - const html_el = try doc.find("html"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const html_el = try q.find("html"); try std.testing.expect(html_el.len() == 1); try std.testing.expectEqualStrings("en", html_el.attr("lang").?); } diff --git a/test/pseudo_class_test.zig b/test/pseudo_class_test.zig new file mode 100644 index 0000000..f112f84 --- /dev/null +++ b/test/pseudo_class_test.zig @@ -0,0 +1,156 @@ +//! Positional pseudo-classes. +//! +//! These are answered by looking at neighbouring siblings rather than by +//! counting the whole sibling list, so the cases that matter are the ones +//! where non-element nodes sit between elements and where the position is +//! counted from the far end. + +const std = @import("std"); +const zq = @import("zigquery"); + +// Element positions inside

      : l1=1, l2=2, s1=3, l3=4. +// The newlines between tags become text nodes, which must not be counted. +const list_html = + \\
        + \\
      • one
      • + \\
      • two
      • + \\ x + \\
      • three
      • + \\
      +; + +fn idsOf(sel: zq.Selection, buf: [][]const u8) [][]const u8 { + for (sel.nodes, 0..) |n, i| buf[i] = n.getAttr("id") orelse ""; + return buf[0..sel.nodes.len]; +} + +fn expectIds(sel: zq.Selection, expected: []const []const u8) !void { + var buf: [16][]const u8 = undefined; + const got = idsOf(sel, &buf); + try std.testing.expectEqual(expected.len, got.len); + for (expected, got) |want, have| try std.testing.expectEqualStrings(want, have); +} + +test ":first-child, :last-child, :only-child" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try expectIds(try q.find("li:first-child"), &.{"l1"}); + try expectIds(try q.find("li:last-child"), &.{"l3"}); + try expectIds(try q.find("span:first-child"), &.{}); + try expectIds(try q.find("li:only-child"), &.{}); +} + +test ":only-child matches a lone element" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, + \\

      only

      a

      b

      + ); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + try expectIds(try q.find("p:only-child"), &.{"solo"}); +} + +test ":first-of-type, :last-of-type, :only-of-type" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try expectIds(try q.find("li:first-of-type"), &.{"l1"}); + try expectIds(try q.find("li:last-of-type"), &.{"l3"}); + try expectIds(try q.find("span:only-of-type"), &.{"s1"}); + try expectIds(try q.find("li:only-of-type"), &.{}); +} + +test ":nth-child with a literal position" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + // Scoped to the list: an unscoped `:nth-child(1)` also matches , + //
        and every other first child in the implied document structure. + try expectIds(try q.find("ul > :nth-child(1)"), &.{"l1"}); + try expectIds(try q.find("ul > :nth-child(2)"), &.{"l2"}); + // Position 3 is the span: the intervening text nodes are not counted. + try expectIds(try q.find("ul > :nth-child(3)"), &.{"s1"}); + try expectIds(try q.find("ul > :nth-child(4)"), &.{"l3"}); + try expectIds(try q.find("ul > :nth-child(5)"), &.{}); + try expectIds(try q.find("li:nth-child(3)"), &.{}); +} + +test ":nth-last-child counts from the end" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try expectIds(try q.find("ul > :nth-last-child(1)"), &.{"l3"}); + try expectIds(try q.find("ul > :nth-last-child(2)"), &.{"s1"}); + try expectIds(try q.find("ul > :nth-last-child(4)"), &.{"l1"}); + try expectIds(try q.find("li:nth-last-child(2)"), &.{}); +} + +test ":nth-child with a step still walks the whole run" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + // Odd positions are 1 and 3; only l1 is an
      • . + try expectIds(try q.find("li:nth-child(2n+1)"), &.{"l1"}); + try expectIds(try q.find("ul > :nth-child(2n+1)"), &.{ "l1", "s1" }); + try expectIds(try q.find("ul > :nth-child(2n)"), &.{ "l2", "l3" }); + try expectIds(try q.find("ul > :nth-child(odd)"), &.{ "l1", "s1" }); + try expectIds(try q.find("ul > :nth-child(even)"), &.{ "l2", "l3" }); +} + +test ":nth-of-type counts only same-tag siblings" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try expectIds(try q.find("li:nth-of-type(1)"), &.{"l1"}); + try expectIds(try q.find("li:nth-of-type(2)"), &.{"l2"}); + try expectIds(try q.find("li:nth-of-type(3)"), &.{"l3"}); + try expectIds(try q.find("li:nth-of-type(4)"), &.{}); + try expectIds(try q.find("li:nth-last-of-type(1)"), &.{"l3"}); + try expectIds(try q.find("li:nth-last-of-type(3)"), &.{"l1"}); +} + +test "positional selectors reject a zero or negative literal" { + var doc = try zq.Document.initFromSlice(std.testing.allocator, list_html); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + try expectIds(try q.find(":nth-child(0)"), &.{}); + try expectIds(try q.find(":nth-last-child(0)"), &.{}); +} + +test "positional selectors on a wide parent stay linear" { + // 20k siblings. Counting the sibling list per candidate made this + // quadratic; the neighbour checks make it linear. + const gpa = std.testing.allocator; + const count = 20_000; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + try buf.appendSlice(gpa, "
          "); + for (0..count) |_| try buf.appendSlice(gpa, "
        • x
        • "); + try buf.appendSlice(gpa, "
        "); + + var doc = try zq.Document.initFromSlice(gpa, buf.items); + defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + + try std.testing.expectEqual(@as(usize, 1), (try q.find("li:first-child")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("li:last-child")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("li:nth-child(2)")).len()); + try std.testing.expectEqual(@as(usize, 1), (try q.find("li:nth-last-child(2)")).len()); + try std.testing.expectEqual(@as(usize, count), (try q.find("li:first-of-type, li:not(:first-of-type)")).len()); +} diff --git a/test/query_test.zig b/test/query_test.zig new file mode 100644 index 0000000..b5e6ffc --- /dev/null +++ b/test/query_test.zig @@ -0,0 +1,261 @@ +//! Query scoping and lifetime. +//! +//! A `Document` owns the DOM; a `Query` owns everything a query produces. +//! The tests here pin down that split, because getting it wrong in the other +//! direction -- putting DOM content in the query arena -- is a use-after-free +//! that no other test would catch. + +const std = @import("std"); +const zq = @import("zigquery"); + +const sample = + \\ + \\ + \\ + \\
        plain
        + \\ +; + +test "repeated queries do not grow the document" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + const before = doc.arena.queryCapacity(); + + var q = doc.query(gpa); + defer q.deinit(); + for (0..2000) |_| { + const sel = try q.find("div.row a.link"); + try std.testing.expectEqual(@as(usize, 2), sel.len()); + } + + // This is the regression that motivated the split: the same loop used to + // take the document arena from 43 MB to 243 MB. + try std.testing.expectEqual(before, doc.arena.queryCapacity()); +} + +test "query reset returns memory and stays usable" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + var q = doc.query(gpa); + defer q.deinit(); + + for (0..500) |_| std.mem.doNotOptimizeAway((try q.find("a")).len()); + const peak = q.bytesUsed(); + try std.testing.expect(peak > 0); + + q.reset(); + try std.testing.expectEqual(@as(usize, 2), (try q.find("a")).len()); + try std.testing.expect(q.bytesUsed() <= peak); + + // Reset must not have invalidated the cached selector machinery. + for (0..500) |_| std.mem.doNotOptimizeAway((try q.find("a")).len()); + try std.testing.expect(q.bytesUsed() <= peak * 2); +} + +test "several queries over one document are independent" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + var first = doc.query(gpa); + var second = doc.query(gpa); + defer second.deinit(); + + try std.testing.expectEqual(@as(usize, 2), (try first.find("a.link")).len()); + const from_second = try second.find("div.row"); + first.deinit(); + + // Ending one query leaves the other, and the document, untouched. + try std.testing.expectEqual(@as(usize, 2), from_second.len()); + try std.testing.expectEqual(@as(usize, 2), (try second.find("a.link")).len()); +} + +test "mutations outlive the query that made them" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + { + var q = doc.query(gpa); + defer q.deinit(); + + const rows = try q.find("div.row"); + try rows.setAttr("data-seen", "yes"); + try rows.addClass("marked"); + try (try q.find(".other")).appendHtml("bold"); + try (try q.find("span")).setText("replaced"); + try (try q.find("a.link")).wrapHtml(""); + } + // The query is gone; everything it wrote into the DOM must still be valid. + + try zq.tree.validate(gpa, doc.root_node); + + var q2 = doc.query(gpa); + defer q2.deinit(); + + const rows = try q2.find("div.row"); + try std.testing.expectEqual(@as(usize, 2), rows.len()); + try std.testing.expectEqualStrings("yes", rows.attr("data-seen").?); + try std.testing.expect(rows.hasClass("marked")); + try std.testing.expectEqual(@as(usize, 1), (try q2.find("#added")).len()); + try std.testing.expectEqualStrings("replaced", try (try q2.find("span")).text()); + try std.testing.expectEqual(@as(usize, 2), (try q2.find("em > a.link")).len()); + + // Serializing the whole document re-reads every string the mutations wrote. + const rendered = try zq.html_render.renderToString(gpa, doc.root_node); + defer gpa.free(rendered); + try std.testing.expect(std.mem.indexOf(u8, rendered, "data-seen=\"yes\"") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "id=\"added\"") != null); + try std.testing.expect(std.mem.indexOf(u8, rendered, "replaced") != null); +} + +test "mutations survive a query reset" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + var q = doc.query(gpa); + defer q.deinit(); + + try (try q.find("div.row")).setAttr("data-k", "v"); + try (try q.find(".other")).setHtml("italic"); + q.reset(); + + try zq.tree.validate(gpa, doc.root_node); + try std.testing.expectEqualStrings("v", (try q.find("div.row")).attr("data-k").?); + try std.testing.expectEqual(@as(usize, 1), (try q.find(".other > i")).len()); +} + +test "selector cache returns equivalent results for repeated text" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + var q = doc.query(gpa); + defer q.deinit(); + + // Caching is keyed on the selector text; a caller's buffer may be reused + // or freed, so the cache must not hold on to it. + var buf: [32]u8 = undefined; + const first_text = try std.fmt.bufPrint(&buf, "{s}", .{"a.link"}); + const a = try q.find(first_text); + @memset(&buf, 0); + const second_text = try std.fmt.bufPrint(&buf, "{s}", .{"a.link"}); + const b = try q.find(second_text); + + try std.testing.expectEqual(a.len(), b.len()); + try std.testing.expectEqual(@as(usize, 2), b.len()); +} + +test "query root is the document root" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, sample); + defer doc.deinit(); + + var q = doc.query(gpa); + defer q.deinit(); + + const root = try q.root(); + try std.testing.expectEqual(@as(usize, 1), root.len()); + try std.testing.expect(root.nodes[0] == doc.root_node); + try std.testing.expect(root.document() == &doc); +} + +// Duplicate suppression marks the nodes themselves instead of building a hash +// set. That only works if every mark is cleared again, so these check both the +// dedup result and the invariant that no mark survives a pass. + +fn assertNoMarksLeft(root: *const zq.Node) !void { + var cur: ?*const zq.Node = root; + while (cur) |node| : (cur = zq.tree.nextInPreorderConst(node, root)) { + try std.testing.expectEqual(@as(u32, 0), node.visit_mark); + } +} + +const overlapping = + \\
        + \\
        + \\

        one

        + \\

        two

        + \\
        + \\
        +; + +test "overlapping roots do not yield duplicates" { + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, overlapping); + defer doc.deinit(); + var q = doc.query(gpa); + defer q.deinit(); + + // Both boxes are in this selection, and one contains the other. + const boxes = try q.find(".box"); + try std.testing.expectEqual(@as(usize, 2), boxes.len()); + + // Each paragraph is a descendant of both, but must appear once. + try std.testing.expectEqual(@as(usize, 2), (try boxes.find("p.t")).len()); + try assertNoMarksLeft(doc.root_node); + + // Same for the traversal helpers that dedup. + try std.testing.expectEqual(@as(usize, 3), (try boxes.children()).len()); + try assertNoMarksLeft(doc.root_node); + + const paragraphs = try q.find("p.t"); + try std.testing.expectEqual(@as(usize, 1), (try paragraphs.parent()).len()); + try assertNoMarksLeft(doc.root_node); + + // All ancestors of both paragraphs, deduplicated: #middle, #outer, body, html. + try std.testing.expectEqual(@as(usize, 4), (try paragraphs.parents()).len()); + try assertNoMarksLeft(doc.root_node); + + try std.testing.expectEqual(@as(usize, 2), (try paragraphs.siblings()).len()); + try assertNoMarksLeft(doc.root_node); + + try std.testing.expectEqual(@as(usize, 1), (try paragraphs.closest(".box")).len()); + try assertNoMarksLeft(doc.root_node); + + const combined = try boxes.addSelection(paragraphs); + try std.testing.expectEqual(@as(usize, 4), combined.len()); + try assertNoMarksLeft(doc.root_node); +} + +fn runDedupPasses(allocator: std.mem.Allocator, doc: *zq.Document) !void { + var q = doc.query(allocator); + defer q.deinit(); + + const boxes = try q.find(".box"); + _ = try boxes.find("p.t"); + _ = try boxes.children(); + const paragraphs = try q.find("p.t"); + _ = try paragraphs.parent(); + _ = try paragraphs.parents(); + _ = try paragraphs.siblings(); + _ = try paragraphs.closest(".box"); + _ = try boxes.addSelection(paragraphs); +} + +test "marks are cleared even when an allocation fails" { + // Each pass sets marks as it goes and clears them on the way out. An + // allocation failure partway through must not leave a node marked, or the + // next query would silently drop it as a duplicate. + const gpa = std.testing.allocator; + var doc = try zq.Document.initFromSlice(gpa, overlapping); + defer doc.deinit(); + + var failing_index: usize = 0; + while (failing_index < 400) : (failing_index += 1) { + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = failing_index }); + runDedupPasses(failing.allocator(), &doc) catch |err| switch (err) { + error.OutOfMemory => {}, + else => return err, + }; + try assertNoMarksLeft(doc.root_node); + if (failing.has_induced_failure == false) break; + } + // The loop must actually have induced failures, or it proved nothing. + try std.testing.expect(failing_index > 0); +} diff --git a/test/selection_test.zig b/test/selection_test.zig index 88ec373..9773765 100644 --- a/test/selection_test.zig +++ b/test/selection_test.zig @@ -5,14 +5,18 @@ const helper = @import("test_helper.zig"); test "Selection.len" { var doc = try helper.parseDoc("

        1

        2

        3

        "); defer doc.deinit(); - const sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const sel = try q.find("p"); try std.testing.expect(sel.len() == 3); } test "Selection.first" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const first = try lis.first(); try std.testing.expect(first.len() == 1); const t = try first.text(); @@ -22,7 +26,9 @@ test "Selection.first" { test "Selection.last" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const last = try lis.last(); try std.testing.expect(last.len() == 1); const t = try last.text(); @@ -32,7 +38,9 @@ test "Selection.last" { test "Selection.eq" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const second = try lis.eq(1); try std.testing.expect(second.len() == 1); const t = try second.text(); @@ -42,7 +50,9 @@ test "Selection.eq" { test "Selection.eq negative index" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const last = try lis.eq(-1); try std.testing.expect(last.len() == 1); const t = try last.text(); @@ -52,7 +62,9 @@ test "Selection.eq negative index" { test "Selection.get" { var doc = try helper.parseDoc("
        • A
        • B
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const node = lis.get(0).?; try std.testing.expectEqualStrings("li", node.data); try std.testing.expect(lis.get(5) == null); @@ -61,7 +73,9 @@ test "Selection.get" { test "Selection.slice" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        • D
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); const middle = try lis.sliceRange(1, 3); try std.testing.expect(middle.len() == 2); } @@ -69,7 +83,9 @@ test "Selection.slice" { test "Selection.each" { var doc = try helper.parseDoc("
        • A
        • B
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); var count: usize = 0; const counter_fn = struct { fn f(_: usize, _: zq.Selection) void { @@ -83,7 +99,9 @@ test "Selection.each" { test "Selection.iterator" { var doc = try helper.parseDoc("
        • A
        • B
        • C
        "); defer doc.deinit(); - const lis = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const lis = try q.find("li"); var it = lis.iterator(); var count: usize = 0; while (it.next()) |_| count += 1; @@ -93,7 +111,9 @@ test "Selection.iterator" { test "empty selection operations" { var doc = try helper.parseDoc("
        "); defer doc.deinit(); - const empty = try doc.find("nonexistent"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const empty = try q.find("nonexistent"); try std.testing.expect(empty.len() == 0); try std.testing.expect((try empty.first()).len() == 0); try std.testing.expect((try empty.last()).len() == 0); @@ -107,7 +127,9 @@ test "empty selection operations" { test "Selection.end returns previous" { var doc = try helper.parseDoc("

        A

        "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const p_sel = try div.children(); try std.testing.expect(p_sel.len() == 1); const back = p_sel.end(); diff --git a/test/traversal_test.zig b/test/traversal_test.zig index 209daa7..4394605 100644 --- a/test/traversal_test.zig +++ b/test/traversal_test.zig @@ -5,7 +5,9 @@ const helper = @import("test_helper.zig"); test "find descendants" { var doc = try helper.parseDoc("

        A

        B

        "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const spans = try div.find("span"); try std.testing.expect(spans.len() == 2); } @@ -13,7 +15,9 @@ test "find descendants" { test "children" { var doc = try helper.parseDoc("

        A

        B

        C
        "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const ch = try div.children(); try std.testing.expect(ch.len() == 3); } @@ -21,7 +25,9 @@ test "children" { test "childrenFiltered" { var doc = try helper.parseDoc("

        A

        B

        C

        "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const ps = try div.childrenFiltered("p"); try std.testing.expect(ps.len() == 2); } @@ -29,7 +35,9 @@ test "childrenFiltered" { test "parent" { var doc = try helper.parseDoc("

        A

        "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); const par = try p_sel.parent(); try std.testing.expect(par.len() == 1); try std.testing.expectEqualStrings("div", par.nodes[0].data); @@ -38,7 +46,9 @@ test "parent" { test "parents" { var doc = try helper.parseDoc("
        • A
        "); defer doc.deinit(); - const li = try doc.find("li"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const li = try q.find("li"); const anc = try li.parents(); // ancestors: ul, div, body, html try std.testing.expect(anc.len() >= 2); @@ -47,8 +57,10 @@ test "parents" { test "siblings" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); - const n3 = try doc.find("#n3"); + const n3 = try q.find("#n3"); try std.testing.expect(n3.len() == 1); const sibs = try n3.siblings(); @@ -59,7 +71,9 @@ test "siblings" { test "next sibling" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); - const n2 = try doc.find("#n2"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const n2 = try q.find("#n2"); const nxt = try n2.next(); try std.testing.expect(nxt.len() == 1); try std.testing.expectEqualStrings("n3", nxt.attr("id").?); @@ -68,7 +82,9 @@ test "next sibling" { test "prev sibling" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); - const n3 = try doc.find("#n3"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const n3 = try q.find("#n3"); const prv = try n3.prev(); try std.testing.expect(prv.len() == 1); try std.testing.expectEqualStrings("n2", prv.attr("id").?); @@ -77,7 +93,9 @@ test "prev sibling" { test "nextAll" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); - const n2 = try doc.find("#n2"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const n2 = try q.find("#n2"); const nxt = try n2.nextAll(); // n3, n4, n5, n6 try std.testing.expect(nxt.len() == 4); @@ -86,7 +104,9 @@ test "nextAll" { test "prevAll" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page2_html); defer doc.deinit(); - const n5 = try doc.find("#n5"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const n5 = try q.find("#n5"); const prv = try n5.prevAll(); // n1, n2, n3, n4 try std.testing.expect(prv.len() == 4); @@ -95,7 +115,9 @@ test "prevAll" { test "closest" { var doc = try helper.parseDoc("

        test

        "); defer doc.deinit(); - const p_sel = try doc.find("p"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const p_sel = try q.find("p"); const c = try p_sel.closest("div"); try std.testing.expect(c.len() == 1); try std.testing.expect(c.hasClass("inner")); @@ -104,7 +126,9 @@ test "closest" { test "contents includes text nodes" { var doc = try helper.parseDoc("
        textchild
        "); defer doc.deinit(); - const div = try doc.find("div"); + var q = doc.query(std.testing.allocator); + defer q.deinit(); + const div = try q.find("div"); const c = try div.contents(); // At least a text node and a span. try std.testing.expect(c.len() >= 2); @@ -113,12 +137,14 @@ test "contents includes text nodes" { test "find on page.html" { var doc = try zq.Document.initFromSlice(std.testing.allocator, helper.page_html); defer doc.deinit(); + var q = doc.query(std.testing.allocator); + defer q.deinit(); // page.html has container-fluid divs. - const cf = try doc.find(".container-fluid"); + const cf = try q.find(".container-fluid"); try std.testing.expect(cf.len() > 0); // Has links with class "link". - const links = try doc.find("a.link"); + const links = try q.find("a.link"); try std.testing.expect(links.len() > 0); }