diff --git a/CLAUDE.md b/CLAUDE.md index f77748885..ea1030ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,16 @@ High-performance Rust parser as a drop-in replacement for Svelte's modern parser ## Committing -`git add` and `git commit` are denied by `.claude/settings.local.json` in -this repo — make the edits and stop, the user commits. +**Branch exception (`svelte-compiler` branch / the `~/dev/tsv-svelte-compiler` +worktree only — remove this block before merging to main):** agent commits +ARE permitted here. Commit at sensible stopping points with short 1-liner +messages (`fix:` / `feat:` / `docs:` / `test:` / `refactor:` / `chore:` +prefixes; no body, no trailers, no `Co-Authored-By`). Merging to main, +version bumps, and publishing remain user-owned. + +On main and every other branch: `git add` and `git commit` are denied by +`.claude/settings.local.json` in this repo — make the edits and stop, the +user commits. **Do not edit `CHANGELOG.md`.** Like release version bumps, the changelog is the user's responsibility — agents make the source/doc/fixture edits and leave @@ -179,6 +187,14 @@ deno task fixtures:update:formatted # regenerate output_prettier.svelte only deno task fixtures:audit # audit _prettier_divergence fixtures (diagnostic; --all for every fixture) deno task fixtures:ts-audit # which input.ts fixtures genuinely need .ts vs could be .svelte (alias of `ts_fixture_audit`) deno task conformance:audit # doc/fixture integrity: divergences cataloged + every docs/*.md + README link resolves + divergence READMEs back-link their sanctioning doc + no stray READMEs (gated in `deno task check`; ./docs/audits.md) +deno task conformance:audit:compiler # compile-fixture divergence integrity + checklist ↔ `Refusal` drift (gated in `deno task check`; ./docs/audits.md) +deno task canonicalize:audit # canonicalize_js idempotence + output validity + comment preservation over tests/fixtures + tests/fixtures_compile (gated in `deno task check`; ./docs/audits.md) +deno task compile:fixtures:init # create/reinit a compile fixture (oracle-compiles + canonicalizes; tests/fixtures_compile) +deno task compile:fixtures:validate # validate compile fixtures: oracle freshness + expected idempotence + ours parity (all gating; the sidecar-free slice also gates in cargo test) +deno task compile:corpus:compare # compile-parity wide net over real repos + the Svelte suites (sidecar-dependent, on demand; ./docs/compile_tooling.md) +deno task compile:validation # validation-suite RATCHET over Svelte's own compiler-errors + validator suites (sidecar-dependent, on demand; ./docs/compile_validation_ratchet.md) +deno task compile:validation:update # regenerate that snapshot after fixing a finding (or newly refusing a shape); refuses a narrowed run, and never pins a MISMATCH +deno task compile:fuzz # differential compile fuzzer over feature cross-products — a discovery tool with an open work list, currently RED by design (sidecar-dependent, on demand; ./docs/compile_tooling.md) deno task pins:audit # canonical-oracle version sync (gated in `deno task check`): (1) pin agreement — sidecar.ts VERSIONS + npm: imports, benches/js/package.json, actor.rs acorn import-map must be identical; (2) checkout alignment — a PRESENT ../svelte or ../acorn-typescript checkout must match its pin (absent → skipped, so clean machines pass) deno task scan:audit # no new raw find/rfind/match_indices substring scans over source (gated in `deno task check`; ./docs/audits.md) deno task fanout:audit # no super-linear doc-node fanout — the per-layout-candidate rebuild blowup (gated in `deno task check`; ./docs/audits.md) @@ -412,6 +428,8 @@ cargo run --release -p tsv_debug -- profile file.ts --iterations 20 # more iter # Also: --json (machine-readable) cargo run --release -p tsv_debug -- json_profile ~/dev/zzz/src/lib # parse vs wire-JSON write timing + +cargo run --release -p tsv_debug -- compile_profile tests/fixtures_compile # Svelte compile vs the format wall ``` For function-level hotspots, use `perf` with the `profiling` cargo profile: @@ -489,6 +507,7 @@ tsv/ │ ├── tsv_ts/ # TypeScript: parse(), format(), convert_ast_json_bytes() │ ├── tsv_css/ # CSS: parse(), format(), convert_ast_json_bytes() │ ├── tsv_svelte/ # Svelte: parse(), format(), convert_ast_json_bytes() +│ ├── tsv_svelte_compile/ # Svelte→JS compiler (Svelte's compile() oracle) + JS canonicalizer (intent-erased reprint); consumed by tsv_debug — no shipped artifact links it │ ├── tsv_cli/ # Production CLI (binary: tsv) - pure Rust │ ├── tsv_debug/ # Dev utilities (binary: tsv_debug) - uses Deno │ ├── tsv_ffi/ # C FFI bindings (Deno's native path) @@ -496,7 +515,8 @@ tsv/ │ └── tsv_napi/ # N-API bindings (Node/Bun native path; measurement-only for the Node bench, publish is a fast-follow after 0.2) ├── scripts/ # Publish orchestrator, npm package patcher, Node artifact + N-API addon tests, AST type drift check ├── tests/ # Integration tests (parser, formatter, CLI) -│ └── fixtures/ # Test fixtures organized by language/feature +│ ├── fixtures/ # Test fixtures organized by language/feature +│ └── fixtures_compile/ # Compiler fixtures (input.svelte + canonicalized oracle expected_server.js + expected.css) — a separate tree so parser/formatter fixture counts stay unperturbed └── docs/ # Documentation (fixtures, cli, architecture, etc.) ``` @@ -548,6 +568,7 @@ when trailing is lossless and the position carries no signal — otherwise add a - ./docs/conformance_prettier.md - Where we differ from Prettier (and why) - ./docs/conformance_svelte.md - Where we differ from Svelte (and why) +- ./docs/conformance_svelte_compiler.md - Where we differ from Svelte's compiler (expected to stay empty — a safety valve, not a budget) ## Fixtures @@ -677,6 +698,81 @@ cargo run -p tsv_debug ast_diff --render input.svelte # render-awa # canonical_parse - parse using external parsers (Svelte, acorn+typescript, or our CSS) cargo run -p tsv_debug canonical_parse file.svelte +# canonical_compile - compile Svelte with the canonical compiler (runes-only, deterministic oracle) +cargo run -p tsv_debug canonical_compile file.svelte # normalized server JS to stdout +cargo run -p tsv_debug canonical_compile file.svelte --target client --css # client JS + delimited CSS +cargo run -p tsv_debug canonical_compile file.svelte --json # { js, css, warnings } as JSON +# Fixed cssHash ('svelte-tsvhash') + constant filename make output byte-identical across runs. +# Also: --target server|client (default server), --dev, --content , --stdin. Compile errors exit non-zero. + +# compile_compare - diff tsv's Svelte compile against the canonical compiler, comparing +# the CANONICALIZED JS of both sides (intent-erased reprint via tsv_svelte_compile::canonicalize_js). +# The parity bar tolerates a comment-POSITION difference (compare_canonical: same code + same comment +# sequence, no bundler annotation — tsv's comment placement vs the oracle's esrap), so a remaining diff +# is a real code difference, not incidental whitespace. Self-checks canonicalizer idempotence on the +# oracle side. Exit codes: 0 parity (byte-exact OR comment-position-tolerated), 1 real diff, 2 error +# (including a component shape tsv's compiler doesn't cover yet — that prints the oracle canonical form +# as the target). --json emits { target, parity, comment_position_tolerated, ours_status, hunks }. +cargo run -p tsv_debug compile_compare file.svelte # human diff / oracle canonical form +cargo run -p tsv_debug compile_compare --content '

hi

' --json # machine-readable report +# Also: --target server|client (default server), --content , --stdin. The ad-hoc one-file view; +# durable expectations live in the compile fixtures (tests/fixtures_compile, below). + +# compile_fixture_init - create or reinitialize a compile fixture (tests/fixtures_compile///): +# prettier-formats the runes component, compiles it with the deterministic oracle (server, non-dev), +# writes input.svelte + expected_server.js (the CANONICALIZED oracle JS) + expected.css (raw oracle +# CSS, styled components only). Expected files are ALWAYS oracle-generated, never hand-written. +cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case --content '

text

' +echo '

text

' | cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case --stdin +cargo run -p tsv_debug compile_fixture_init tests/fixtures_compile/feature/case # regenerate from existing input +# Also: --force (overwrite existing input). + +# compile_fixtures_validate - validate compile fixtures. Per fixture, all gating: (a) oracle +# freshness — canonicalize(oracle(input)) must equal the committed expected_server.js byte-exact (+ css +# match); (b) ours — tsv_svelte_compile::compile must succeed and its canonicalized JS must be PARITY +# with expected_server.js (byte-exact or comment-position-tolerated, compare_canonical) + CSS match; +# (c) the committed expected_server.js must be a canonicalize fixed point. +# The pure-Rust slice of the contract (input parses, expected idempotent, OURS-VS-EXPECTED parity — +# compile() needs no Deno) also runs sidecar-free in +# `cargo test --workspace --test compile_fixtures_tests`, the offline parity gate. +cargo run -p tsv_debug compile_fixtures_validate [pattern...] +# Also: --list, --json. + +# compile_corpus_compare - the compile-parity wide net: compile every .svelte under the given +# roots with the canonical compiler AND tsv and compare the canonical reprints, bucketing per file +# (parity / refused / fenced / oracle-rejected / MISMATCH / error). A MISMATCH or an +# OVER-ACCEPTANCE (the oracle rejected it, tsv compiled it) is a refusal-contract bug and gates. +# Exit codes: 0 clean, 1 FAILURE, 2 harness error. Sidecar-dependent, so NOT in `deno task check`. +cargo run -p tsv_debug compile_corpus_compare +# Also: --list, --json. Full detail: ./docs/compile_tooling.md + +# compile_corpus_compare --ratchet - the VALIDATION-SUITE GATE: the same pipeline over Svelte's +# own compiler-errors + validator suites (~2/3 deliberately INVALID), graded against the committed +# path-keyed known-bug snapshot `compile_validation_known.txt`. An over-acceptance is the ratcheted +# debt; a MISMATCH and an unpinnable HARNESS-ERROR fail unconditionally. ⚠️ Always a SEPARATE +# invocation from the ordinary corpus run — never extra roots on it. +cargo run -p tsv_debug compile_corpus_compare --ratchet # the gate +cargo run -p tsv_debug compile_corpus_compare --ratchet --update # re-pin +# Full reference: ./docs/compile_validation_ratchet.md + +# compile_fuzz - the DIFFERENTIAL compile fuzzer: generate feature CROSS-PRODUCTS from the compile +# fixtures (eleven AST/feature-level operators) and grade each mutant against the oracle — the +# adversarial leg the real-component corpus can't be, since it exercises every feature while +# missing nearly every feature PAIR. ⚠️ CURRENTLY RED BY DESIGN: a discovery tool with an open work +# list, not a regression gate. Deterministic per --seed, independent of --jobs. +cargo run --profile corpus -p tsv_debug compile_fuzz # tests/fixtures_compile +cargo run --profile corpus -p tsv_debug compile_fuzz --iterations 20000 --dump-dir /tmp/cf +# Also: --seed, --max-mutations N, --limit N, --jobs N, --max-findings N, --list, --json. Build with +# `--profile corpus` so a panic in tsv's compile is caught and REPORTED rather than killing the run. +# Findings cataloged in ./docs/checklist_svelte_compiler.md; full detail: ./docs/compile_tooling.md + +# erase_comment_census - size the type-eraser's comment-refusal haircut over a corpus (pure Rust, +# no Deno): per lang="ts" component, count the comments intersecting an erased span's refusal +# window. The rate is a LOWER BOUND (the census measures the forward half of a bidirectional +# window). Full detail: ./docs/compile_tooling.md +cargo run --release -p tsv_debug -- erase_comment_census ../fuz_ui ../zzz +# Also: --verbose (per exposed file), --json. + # format_prettier - format using prettier (shows line widths by default; --no-line-widths to hide) cargo run -p tsv_debug format_prettier file.svelte @@ -727,6 +823,23 @@ cargo run -p tsv_debug ts_fixture_audit [pattern...] # non-zero on any finding. Gated in `deno task check`. Full detail: ./docs/audits.md cargo run -p tsv_debug conformance_audit # Also: --json (machine-readable: {orphans, dead_links, missing_backlinks, stray_readmes}) + +# compile_conformance_audit - the compiler analog of conformance_audit, deliberately minimal: a +# _compiled_divergence compile fixture must be cataloged in docs/conformance_svelte_compiler.md and +# back-link it (the catalog is expected to stay EMPTY — a tripwire, not a standing gate), plus the +# check that DOES hold today: checklist ↔ `Refusal` drift (a bucket key +# docs/checklist_svelte_compiler.md quotes that the catalog can't produce GATES; the reverse is +# report-only). Pure Rust (no Deno); exits non-zero on any finding. Gated in `deno task check`. +cargo run -p tsv_debug compile_conformance_audit +# Also: --json. Full detail: ./docs/audits.md + +# canonicalize_audit - canonicalize_js (the compile-parity reprint) at corpus scale: run the +# canonicalizer twice per TS/JS file and bucket — input-rejected (informational), NON-IDEMPOTENT, +# CORRUPT-OUTPUT, COMMENT-LOSS (the last three all failures). Pure Rust (no Deno); exits 1 on any +# failure. Gated in `deno task check` over tests/fixtures + tests/fixtures_compile. +cargo run -p tsv_debug canonicalize_audit tests/fixtures tests/fixtures_compile # the check-gate scope +cargo run -p tsv_debug canonicalize_audit ~/dev/zzz/src ~/dev/gro/src # real-corpus sweep +# Also: --json. Full detail: ./docs/audits.md ``` > **Troubleshooting:** See ./docs/fixture_overview.md#quick-decision-tree @@ -754,6 +867,7 @@ cargo run -p tsv_debug profile ~/dev/zzz/src/lib # parse vs f cargo run --release -p tsv_debug -- json_profile ~/dev/zzz/src/lib # FFI parse path: parse vs the wire-JSON write (§2) cargo run -p tsv_debug buffer_sizes ~/dev/zzz/src ~/dev/gro/src # printer SmallVec sizing histograms (§8) cargo run -p tsv_debug arena_stats ~/dev/zzz/src/lib # DocArena node-population + memory audit (§7; --reuse, --list-errors) +cargo run --release -p tsv_debug -- compile_profile tests/fixtures_compile # Svelte compile against the format wall (§9) ``` **Codebase Metrics Commands:** @@ -769,7 +883,8 @@ deno task metrics # shorthand the print-once comment ledger, gap/blank injection, build-fanout, raw-find scan, authoring-independence, format→reparse round-trip, comment↔token binding, render-equivalence, layout-neutrality, the seeded mutational fuzzer, the F1 idempotency -sweep, the `audit:corpus` bundle, and the `lex_diff` differential lexer harness. Each is +sweep, the `audit:corpus` bundle, the `lex_diff` differential lexer harness, and the two +compiler audits (compile-fixture divergence integrity, the canonicalizer). Each is cataloged in ./docs/audits.md — what it proves, what it is blind to, flags, and where it gates; the `deno task` entry points are indexed in [Fixtures](#fixtures-rust--deno-based). Read the relevant section there before running or modifying an audit. @@ -979,6 +1094,8 @@ formatting behavior. Key files: `src/language-js/print/assignment.js` (assignmen - ./docs/cli.md - CLI architecture and command patterns - ./docs/audits.md - the standing audit gates: what each proves, blind spots, flags, gating - ./docs/comments.md - the detached comment model: ownership, the three axes, hazards, emitters +- ./docs/compile_tooling.md - the sidecar-dependent compiler harnesses: corpus compare, compile fuzz, erase census +- ./docs/compile_validation_ratchet.md - the validation-suite ratchet: snapshot, kinds, verdict, triage - ./docs/performance.md - profiling methodology, tooling, and results tracking - ./docs/workflow_corpus.md - corpus-driven formatting conformance workflow - ./docs/workflow_test262.md - test262 conformance workflow @@ -990,6 +1107,7 @@ formatting behavior. Key files: `src/language-js/print/assignment.js` (assignmen - ./docs/checklist_css.md - ./docs/checklist_svelte.md +- ./docs/checklist_svelte_compiler.md - ./docs/checklist_typescript.md ## Bash Tool Notes diff --git a/Cargo.lock b/Cargo.lock index 00d23b000..4e766e2a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,7 @@ dependencies = [ "tsv_debug", "tsv_lang", "tsv_svelte", + "tsv_svelte_compile", "tsv_ts", ] @@ -685,6 +686,7 @@ dependencies = [ "tsv_html", "tsv_lang", "tsv_svelte", + "tsv_svelte_compile", "tsv_ts", ] @@ -756,6 +758,20 @@ dependencies = [ "tsv_ts", ] +[[package]] +name = "tsv_svelte_compile" +version = "0.1.0" +dependencies = [ + "bumpalo", + "thiserror", + "tsv_css", + "tsv_html", + "tsv_lang", + "tsv_svelte", + "tsv_ts", + "unicode-ident", +] + [[package]] name = "tsv_ts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9b522cfd0..fa65c1790 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/tsv_ts", "crates/tsv_css", "crates/tsv_svelte", + "crates/tsv_svelte_compile", "crates/tsv_cli", "crates/tsv_debug", "crates/tsv_wasm", @@ -44,6 +45,9 @@ tsv_discover = { path = "crates/tsv_discover" } tsv_ts = { path = "crates/tsv_ts", default-features = false } tsv_css = { path = "crates/tsv_css", default-features = false } tsv_svelte = { path = "crates/tsv_svelte", default-features = false } +# Svelte→JS compiler + JS canonicalizer. Dev-tooling consumer only (tsv_debug); +# no shipped artifact links it yet. +tsv_svelte_compile = { path = "crates/tsv_svelte_compile" } # External dependencies argh = "0.1" @@ -174,6 +178,7 @@ serde_json.workspace = true [dev-dependencies] tsv_debug = { path = "crates/tsv_debug" } tsv_cli = { path = "crates/tsv_cli" } +tsv_svelte_compile.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } futures-util.workspace = true bumpalo.workspace = true diff --git a/README.md b/README.md index 5cae35a9d..8ce4590f1 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,12 @@ Future features (unknown order): but the lift to support it is small) - JS parsing diagnostics (test262 negative cases) - CSS error recovery (recover past invalid CSS per the spec) +- Svelte compiler (partially implemented, not ready for usage) - later: - TypeScript 7 integration (the Go impl), unlocking: - svelte-check replacement - LSP - linter - type aware, initially focused on serializable data-only plugins for extensibility - - Svelte compiler (exact mirror, maybe out of scope, see [rsvelte](https://github.com/baseballyama/rsvelte)) - bundling is probably out of scope - [discussion](https://github.com/fuzdev/tsv/discussions) welcome diff --git a/crates/tsv_css/src/ast/internal.rs b/crates/tsv_css/src/ast/internal.rs index bfae401ba..9cd0e510f 100644 --- a/crates/tsv_css/src/ast/internal.rs +++ b/crates/tsv_css/src/ast/internal.rs @@ -765,6 +765,27 @@ pub struct CssAtrule<'arena> { pub span: Span, } +impl CssAtrule<'_> { + /// The at-rule's **public-AST prelude** — the exact string Svelte's parser exposes as + /// `node.prelude` (its `read_value(...).trim()`, block comments elided). Routes through + /// the wire-JSON prelude reconstruction ([`convert_prelude_to_string`]), the single + /// fixture-gated definition of that shape, so a consumer that must agree with the + /// oracle's `node.prelude` byte-for-byte — the Svelte compiler's `@keyframes` name + /// collection and its `-global-` prefix test — reads the identical bytes rather than the + /// printer-facing [`PreludeValue::Raw`] `content`, which is CSS-whitespace-trimmed and + /// comment-preserving and so diverges on a prelude carrying a comment or JS-only + /// whitespace (`@keyframes spin /* c */`, a trailing vertical tab / NBSP). + /// + /// A pure visibility bridge over the writer's own reconstruction — no parse, printer, or + /// wire behavior of its own. + /// + /// [`convert_prelude_to_string`]: crate::ast::convert::convert_prelude_to_string + #[cfg(feature = "convert")] + pub fn public_prelude<'src>(&self, source: &'src str) -> std::borrow::Cow<'src, str> { + crate::ast::convert::convert_prelude_to_string(&self.prelude, source) + } +} + /// At-rule block - can contain rules, declarations, or nested at-rules #[derive(Debug, Clone)] pub struct CssAtruleBlock<'arena> { diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index 201a7771f..62789c0df 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -2,7 +2,7 @@ > Developer tooling crate — fixtures, comparisons, corpus runs. Embeds a Deno sidecar for canonical JS tools. -Unlike `tsv_cli` (pure Rust, production binary), `tsv_debug` exists to validate and debug `tsv` against external sources of truth: Prettier (formatting), Svelte's compiler (parsing), acorn-typescript (TS AST shape). The binary `tsv_debug` powers `deno task fixtures:*`, `deno task corpus:compare:format`, `deno task bench`, and friends. For the command catalog see [../../CLAUDE.md §Debug Tooling](../../CLAUDE.md#debug-tooling); the audit gates are cataloged in [../../docs/audits.md](../../docs/audits.md), the profiling tools in [../../docs/performance.md](../../docs/performance.md). +Unlike `tsv_cli` (pure Rust, production binary), `tsv_debug` exists to validate and debug `tsv` against external sources of truth: Prettier (formatting), Svelte's compiler (parsing), acorn-typescript (TS AST shape). The binary `tsv_debug` powers `deno task fixtures:*`, `deno task corpus:compare:format`, `deno task bench`, and friends. For the command catalog see [../../CLAUDE.md §Debug Tooling](../../CLAUDE.md#debug-tooling); the audit gates are cataloged in [../../docs/audits.md](../../docs/audits.md), the profiling tools in [../../docs/performance.md](../../docs/performance.md), the Svelte-compiler harnesses in [../../docs/compile_tooling.md](../../docs/compile_tooling.md). ## Architecture Position @@ -15,6 +15,7 @@ The Deno sidecar is a **long-running subprocess pool** spawned lazily on first u - `deno/` — Sidecar plumbing: `actor.rs` owns one spawned process (`mod.rs` holds the pool + round-robin dispatch), `protocol.rs` is the JSON-lines wire format, `sidecar.ts` is the embedded TypeScript that runs inside Deno and dispatches to prettier / svelte (parse **and** `compile`) / acorn / parseCss. Pinned npm versions live in `sidecar.ts`. The `svelte-render-key` tool compiles for the server target and reduces the baked template to its browser-visible render (block-aware whitespace collapse, `\n

x

\n" + ), + "a component named slot is an attribute-level fact the census cannot see" + ); + } + + #[test] + fn fence_containment_is_counted_but_never_subtracted() { + // The load-bearing property of the whole diagnostic: `fence_contained` + // reports how far `fenced` under-counts the containment population WITHOUT + // entering the denominator. Wiring it into `target_set` would raise the + // published parity rate with zero behavior change, on a signal that reaches + // one of three fence sources and over-detects in a dropped `{:catch}` — so + // this test is what REDS if a later slice subtracts it. + // + // 5 files: 1 parity, 1 fenced refusal, 2 fence-CONTAINING refusals, 1 plain + // refusal. Achievable stays (5 − 0 oracle-rejected) − 1 fenced = 4 — the two + // contained files stay IN the denominator. + let groups = one_group(5); + let report = Report::build( + &groups, + &[ + FileOutcome { + group: 0, + path: PathBuf::from("p.svelte"), + bucket: Bucket::Parity { tolerated: false }, + }, + refused("f.svelte", "template node special element ", true), + refused_containing_fence("c1.svelte", "css @keyframes in \n"; + let anchors = anchors_of(SOURCE); + assert!(!anchors.script_ws.is_empty(), "script whitespace runs"); + assert!(!anchors.attr_value_edges.is_empty(), "attribute edges"); + assert!(!anchors.css_ident_ends.is_empty(), "css ident ends"); + + // Every draw must still be a document tsv's parser accepts — the + // well-formedness-by-construction claim, exercised rather than asserted. + let mut seen = std::collections::BTreeSet::new(); + for seed in 0..200u64 { + let mut rng = Rng::new(seed); + let Some(out) = exotic_whitespace(SOURCE, &anchors, &mut rng) else { + continue; + }; + assert_ne!(out, SOURCE, "an insert always changes the document"); + assert!(Anchors::collect(&out).is_some(), "mutant reparses: {out:?}"); + for c in out.chars().filter(|c| !c.is_ascii()) { + seen.insert(c); + } + } + assert!( + seen.len() >= 4, + "several distinct code points reached: {seen:?}" + ); + } + + /// A string literal's `LineContinuation` is the one script position where the + /// by-construction argument fails: `"a\b"` is legal only while the `\` and + /// the `` stay adjacent, so inserting ANY whitespace at the run's start + /// re-reads the `\` as a `NonEscapeCharacter` escape and strands a raw + /// `` in the literal (ECMA-262 §12.9.4.1) — an oracle `js_parse_error`, + /// which tsv's permissive frontend then compiles into a false OVER-ACCEPTANCE. + /// The start edge must not be anchored; the end edge must still be. + #[test] + fn line_continuation_start_edge_is_not_anchored() { + const SOURCE: &str = "\n\n

{s}

\n"; + let backslash = SOURCE.find('\\').expect("the continuation backslash"); + let run_start = backslash + 1; + assert_eq!(SOURCE.as_bytes()[run_start], b'\n', "the run opens with LF"); + + let anchors = anchors_of(SOURCE); + #[allow(clippy::cast_possible_truncation)] + let run_start = run_start as u32; + assert!( + !anchors.script_ws.contains(&run_start), + "the continuation's start edge must not be an anchor: {:?}", + anchors.script_ws + ); + // The end edge is past the terminator — the pair is already complete, so + // an appended character is ordinary string content. + #[allow(clippy::cast_possible_truncation)] + let run_end = (backslash + 3) as u32; // `\` + LF + ' ' + assert!( + anchors.script_ws.contains(&run_end), + "the run's end edge stays anchored: {:?}", + anchors.script_ws + ); + + // An ordinary run in the same script keeps BOTH edges — the guard is + // scoped to the continuation, not a blanket start-edge drop. + let plain = anchors_of("\n\n

{s}

\n"); + assert!(plain.script_ws.len() > 4, "{:?}", plain.script_ws); + } + + /// `` special elements and `` carry attributes too, + /// and neither is reached by the regular-element arm of the node walk. + #[test] + fn special_element_attribute_values_are_anchored() { + let dynamic = anchors_of("y\n"); + assert!( + !dynamic.attr_value_edges.is_empty(), + "a 's quoted attribute values anchor" + ); + let options = anchors_of("\n

x

\n"); + assert!( + !options.attr_value_edges.is_empty(), + "a attribute value anchors" + ); + } + + /// `u8::is_ascii_whitespace` omits ``, which IS in the insertion set — the + /// scan must use the JS ASCII class or a VT-only run goes unanchored. + #[test] + fn vertical_tab_runs_are_anchored() { + let anchors = + anchors_of("\n

{a}{b}

\n"); + let vt = "\n

{name}

\n\n"; + let result = svelte_compile(COMPONENT, SvelteGenerate::Server, false).await; + assert!(result.is_ok(), "svelte_compile failed: {result:?}"); + let compiled = result.unwrap(); + assert!( + compiled.js.contains("$$renderer"), + "server compile should emit SSR output: {}", + compiled.js + ); + // The component has a \n"; + let keys = bucket_set(source); + assert!( + keys.iter() + .any(|k| k.contains("default export in ` + // and the instance `", "a constant"); + assert_unsupported("", "a constant"); + assert_unsupported( + "", + "a constant", + ); + assert_unsupported("", "a constant"); + assert_unsupported( + "", + "a constant", + ); + // A module-script const is unassignable on the same terms. + assert_unsupported( + "", + "a constant", + ); + // Inside a dropped event handler too — the oracle validates in phase 2, + // before it decides what SSR emits. + assert_unsupported( + "", + "a constant", + ); + // Destructuring assignment: the rule recurses through ArrayPattern elements + // and ObjectPattern property VALUES (`validator/samples/assignment-to-const-5` + // and `-7`). + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); +} + +#[test] +fn compile_shadowing_js_local_assignment_compiles() { + // The oracle resolves an assignment target through its SCOPE CHAIN + // (`phases/scope.js`), so a nested re-declaration shadows a component-level + // `const`/import and the write is to the local — not `constant_assignment`. + // The shape is ordinary enough to appear in Svelte's own suite + // (`runtime-runes/samples/mutation-local`). + // + // A function parameter shadowing a component `const`. + let _ = compile_js(""); + // A function-body `let`, written from a nested block — the suite's shape. + let _ = compile_js( + "", + ); + // An arrow parameter shadowing an IMPORT local. + let _ = compile_js(""); + // A plain nested block `let`. + let _ = compile_js(""); + // A `catch` parameter, and a `for` head binding. + let _ = compile_js( + "", + ); + let _ = + compile_js(""); +} + +#[test] +fn compile_shadow_scope_does_not_leak() { + // ⚠️ The UNSAFE direction: a shadow that outlives its scope would stop + // refusing a genuine `constant_assignment`. Each of these writes resolves to + // the component-level binding, so each must still refuse. + // + // A block-scoped `let` must not leak to the statements after its block. + assert_unsupported( + "", + "a constant", + ); + // A function's parameters must not leak past the function. + assert_unsupported( + "", + "a constant", + ); + // Nor must a sibling function's locals reach another function. + assert_unsupported( + "", + "a constant", + ); + // A `for` head binding must not leak past the loop. + assert_unsupported( + "", + "a constant", + ); + // A `catch` parameter must not leak past its handler. + assert_unsupported( + "", + "a constant", + ); + // And a TEMPLATE-scoped binding must keep its own rule: the `{#each}` item and + // the `{#snippet}` parameter are declared through the same walk, so neither may + // be absorbed into the JS-local set (which suppresses refusal). + assert_unsupported( + "\ + {#each arr as value}{/each}", + "an {#each} item", + ); + assert_unsupported( + "{#snippet s(p)}{/snippet}{@render s(1)}", + "a {#snippet} parameter", + ); +} + +#[test] +fn compile_each_item_assignment_refuses() { + // `validate_assignment` (`shared/utils.js:33`): a write to an `{#each}` + // context binding is `each_item_invalid_assignment` in runes mode, which this + // compiler unconditionally is. Reached from an assignment, an update, and a + // `bind:` alike (`BindDirective.js:181`). + assert_unsupported( + "\ + {#each arr as value}{/each}", + "an {#each} item", + ); + assert_unsupported( + "\ + {#each arr as value}{/each}", + "an {#each} item", + ); + assert_unsupported( + "\ + {#each arr as {element}}{/each}", + "an {#each} item", + ); + // The fallback shares the block's scope (`scope.js:1280`). + assert_unsupported( + "\ + {#each arr as value}

a

{:else}{/each}", + "an {#each} item", + ); +} + +#[test] +fn compile_template_scoped_const_assignment_refuses() { + // The TEMPLATE-scoped consts: a `{@const}` name (`phases/scope.js:1205`, whose + // `declaration_kind` is the `VariableDeclaration`'s own `const`), a + // `{:then}`/`{:catch}` value (`:1310`/`:1324`) and an `{#each}` INDEX + // (`:1273`). All three are `declaration_kind: 'const'` to the oracle, so a + // write to one is `constant_assignment` — live-verified, the oracle rejects + // every case below with `Cannot assign to constant`. + // + // ⚠️ These are OVER-ACCEPTANCES when unrecorded, not over-refusals: the names + // are purely template-local, so nothing falls through to a component-level set + // and no rule fires at all. + // + // ⚠️ Two of the three write POSITIONS below are load-bearing. An assignment + // sitting directly in an emitted template expression (`{(c = 2)}`) also trips + // `mutation inside a template expression`, an unrelated general rule that fires + // whatever the target is (verified: a plain `let` write there refuses too, + // while the oracle ACCEPTS it) — so that position MASKS this rule and has + // already been mistaken for a refutation. The event-handler arrow and the + // dropped `{:catch}` are the unmasked positions, and each form is covered in + // both. + // Position 1: an event-handler arrow — dropped from SSR, still validated in + // phase 2. + assert_unsupported( + "{#if true}{@const c = 1}{/if}", + "a constant", + ); + assert_unsupported( + "\ + {#await p then v}{/await}", + "a constant", + ); + assert_unsupported( + "\ + {#each xs as x, i}{/each}", + "a constant", + ); + // The same three forms written inside a dropped `{:catch}`, the second + // unmasked position: the branch never reaches SSR emission, so no emission + // refusal can fire there and the write is graded by this rule alone. + assert_unsupported( + "\ + {#await p}w{:catch e}{@const c = 1}{(c = 2)}{/await}", + "a constant", + ); + assert_unsupported( + "\ + {#await p}w{:catch e}{(e = 2)}{/await}", + "a constant", + ); + assert_unsupported( + "\ + {#await p}w{:catch e}{#each xs as x, i}{(i = 2)}{x}{/each}{/await}", + "a constant", + ); + // The oracle's scope PRE-PASS again (see + // `compile_nested_const_before_its_declaration_refuses`): a `{@const}` is + // declared into its fragment's scope before any node of that fragment is + // visited, so a write textually EARLIER still resolves to it. Live-verified. + assert_unsupported( + "{#if true}{@const c = 1}{/if}", + "a constant", + ); + // A destructured `{@const}` binds each name the pattern declares. + assert_unsupported( + "\ + {#if true}{@const {a} = o}{/if}", + "a constant", + ); +} + +#[test] +fn compile_each_index_is_not_an_each_item() { + // ⭐ The two bindings of ONE construct take DIFFERENT oracle rules, and + // conflating them is a bug in either direction. In `{#each xs as x, i}` the + // ITEM is declared `('each', 'const')` (`phases/scope.js:1244`) and + // `validate_no_const_assignment` EXCLUDES `kind === 'each'`, so it carries + // `each_item_invalid_assignment`; the INDEX is `('template' | 'static', + // 'const')` (`:1273`) and carries `constant_assignment`. Both live-verified + // against the oracle, which reports "Cannot reassign or bind to each block + // argument" for the first and "Cannot assign to constant" for the second. + assert_unsupported( + "\ + {#each xs as x, i}{/each}", + "an {#each} item", + ); + assert_unsupported( + "\ + {#each xs as x, i}{/each}", + "a constant", + ); +} + +#[test] +fn compile_template_scoped_const_boundary_accepts() { + // The acceptance half, so the new template-const scope cannot widen into an + // over-refusal. Each of these the oracle COMPILES (live-verified). + // + // A JS binding nests INSIDE the template scope, so a handler parameter of the + // same name shadows the `{@const}` and the write is to the parameter — which + // is why `js_scope` is consulted before the template-const set. + let _ = compile_js("{#if true}{@const c = 1}{/if}"); + // A MEMBER target writes THROUGH the binding, matching no branch of the + // oracle's validator. + let _ = compile_js( + "\ + {#if true}{@const c = o}{/if}", + ); + // The scopes END at their block: an `{#each}` index is out of scope after the + // block, a `{:then}` value is not in scope in the `{:catch}` branch, and a + // `{@const}` is confined to its own fragment. + let _ = compile_js( + "\ + {#each xs as x, idx}{x}{/each}", + ); + let _ = compile_js( + "\ + {#await p then v}{v}{:catch e}{/await}", + ); + let _ = compile_js( + "\ + {#if true}{@const c = 1}{c}{/if}", + ); +} + +#[test] +fn compile_snippet_parameter_assignment_refuses() { + // `validate_assignment` (`shared/utils.js:37`): a write to a `{#snippet}` + // parameter is `snippet_parameter_assignment` — and, unlike the each rule, + // NOT gated on runes mode. + assert_unsupported( + "{#snippet foo(value)}{/snippet}", + "a {#snippet} parameter", + ); +} + +#[test] +fn compile_invalid_assignment_boundary_accepts() { + // The acceptance half of the rule, so a widening over-refusal is caught. + // + // A MEMBER target writes THROUGH the binding and never rebinds it — it + // matches no branch of the oracle's validator. + let _ = compile_js( + "", + ); + let _ = compile_js( + "\ + {#each arr as item}{/each}", + ); + // A `let` is not a constant, and the block scopes END at their block. + let _ = compile_js(""); + let _ = compile_js( + "\ + {#each arr as a}

{a}

{/each}", + ); + let _ = compile_js( + "{#snippet foo(v)}

{v}

{/snippet}\ + ", + ); + // The pattern recursion stops exactly where the oracle's does: a RestElement + // and an AssignmentPattern match no branch, so a const there is accepted. + let _ = compile_js( + "", + ); +} + +#[test] +fn compile_nested_const_assignment_refuses() { + // `validate_no_const_assignment` reads the SCOPE CHAIN, so its `const` test is + // not confined to script scope: a NESTED `const` — a block-local, a function + // body local, a `for (const … of …)` head — is `declaration_kind: 'const'` to + // the oracle exactly as a top-level one is, and a write to it is + // `constant_assignment`. Live-verified against the oracle: each of these is + // rejected with `Cannot assign to constant`. + // + // A nested `const` colliding with a component-level `const`/import must refuse + // as the INNER binding's rule, not fall through to the outer name's. + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); + // And with NO collision at all — the nested `const` is the only binding of the + // name, so nothing but its own scope entry can refuse it. + assert_unsupported( + "", + "a constant", + ); + // The template reaches the same walk. + assert_unsupported( + "\ + ", + "a constant", + ); +} + +#[test] +fn compile_innermost_js_binding_wins() { + // ⚠️ The discriminating control on the nested-`const` rule: a `let` nested + // INSIDE a `const` of the same name is the innermost binding, so the write is + // to the `let` and nothing refuses. Live-verified: the oracle COMPILES this. + // A rule that merely asked "is this name const anywhere open?" would refuse it. + let _ = compile_js( + "", + ); + // The mirror ordering (a `const` inside a `let`) still refuses — same shape, + // opposite innermost kind, so the two together pin that the ORDER is what is + // read, not the mere presence of a `const`. + assert_unsupported( + "", + "a constant", + ); + // A nested `const`'s scope must not outlive it: after the block closes the + // write resolves to the enclosing `let` again and compiles. + let _ = compile_js( + "", + ); +} + +#[test] +fn compile_nested_const_before_its_declaration_refuses() { + // The oracle builds its scopes in a PRE-PASS (`phases/scope.js`'s + // `create_scopes`), so every binding of a block exists before any reference in + // it is validated. A write textually EARLIER than the `const` therefore still + // resolves to it and is `constant_assignment` — a TDZ error at runtime, but a + // compile error to Svelte either way. Live-verified: the oracle rejects this + // with `Cannot assign to constant`. + // + // ⚠️ It is an OVER-ACCEPTANCE, not an over-refusal, when the walk misses it: + // the name is purely LOCAL, so leaving it unrecorded yields no rule at all + // rather than falling through to a component-level one. + assert_unsupported( + "", + "a constant", + ); + // With a component-level collision the fall-through WOULD have caught it — + // the control that shows the local-only case above is the load-bearing one. + assert_unsupported( + "", + "a constant", + ); + // The hoist is `const`-only and block-scoped, so it must not reach past its + // block: after the block closes the write resolves to the enclosing `let`. + let _ = compile_js( + "", + ); +} + +#[test] +fn compile_switch_case_const_shares_one_scope() { + // `phases/scope.js:1173` — `SwitchStatement: create_block_scope`. The oracle + // gives a `switch` ONE block scope shared by every case, so a `const` in one + // case is in scope for a write in another and refuses. Live-verified: the + // oracle rejects both orderings with `Cannot assign to constant`. + // + // ⚠️ Also an OVER-ACCEPTANCE when missed — `w` is purely local. + assert_unsupported( + "", + "a constant", + ); + // The reverse ordering (write first) needs the hoist as well as the shared + // scope, so it pins both halves of the fix at once. + assert_unsupported( + "", + "a constant", + ); + // The switch scope must not outlive the statement. + let _ = compile_js( + "", + ); +} + +#[test] +fn compile_each_item_assignment_refuses_the_checklist_repro() { + // The exact repro `docs/checklist_svelte_compiler.md` carried as an open + // over-acceptance until this rule landed — pinned so the doc's claim that the + // row is closed cannot silently rot. + assert_unsupported( + "{#each [0] as b}{/each}", + "an {#each} item", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/attributes.rs b/crates/tsv_svelte_compile/src/tests/attributes.rs new file mode 100644 index 000000000..c9f8fb3e9 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/attributes.rs @@ -0,0 +1,269 @@ +//! Plain attribute emission: folding, boolean/void attributes, event handlers. + +use super::support::*; + +#[test] +fn compile_mixed_attribute_full_fold_emits_static() { + // Every part of a mixed attribute folding statically emits a STATIC + // attribute (oracle-probed), not a $.attr*/$.attr_class call: value + // attr-escaped [&"<] (> stays raw), no trim, boolean attributes keep + // the folded value, null → ''. + let js = compile_js( + "\n\n
\n", + ); + assert!(js.contains("`
`"), "{js}"); + assert!(!js.contains("$.attr_class"), "must be static: {js}"); + let js = compile_js( + "\n\n
\n", + ); + assert!( + js.contains("`
&wq1\">
`"), + "folded value must attr-escape [&\"<] with > raw: {js}" + ); + let js = compile_js( + "\n\n\n", + ); + assert!( + js.contains("disabled=\"x1\""), + "boolean attr keeps folded value; null folds to '': {js}" + ); + // A folded-empty class stays `class=""` (the empty-class drop is + // static-path-only, probe-verified). + let js = compile_js( + "\n\n
\n", + ); + assert!(js.contains("`
`"), "{js}"); + // One non-foldable part keeps the whole attribute dynamic with the + // known parts folded inline (the pre-existing path). + let js = compile_js( + "\n\n
\n", + ); + assert!( + js.contains("$.attr('title', `x1y${$.stringify(b)}`)"), + "partial fold must stay dynamic: {js}" + ); +} + +#[test] +fn compile_empty_class_attribute_drops() { + // A static string-valued class that collapses+trims to empty is + // dropped entirely (oracle-probed); a bare `class` (boolean form) + // keeps `class=""`, and empty style/id stay. + let js = compile_js("
\n
\n"); + assert!(js.contains("`
`"), "{js}"); + let js = compile_js("
\n"); + assert!(js.contains("`
`"), "{js}"); + let js = compile_js("
\n"); + assert!( + js.contains("`
`"), + "only class drops: {js}" + ); +} + +#[test] +fn compile_void_and_boolean_attributes() { + let out = compile_checked("

text1
text2

\n"); + assert!( + out.js + .contains("`

text1
text2

`"), + "void self-close / boolean attribute wrong: {}", + out.js + ); +} + +#[test] +fn compile_drops_event_handler_attribute() { + // An `on*` single-expression handler is omitted from SSR output. + let out = compile_checked(""); + assert!( + out.js.contains("``") && !out.js.contains("onclick"), + "event handler not dropped: {}", + out.js + ); +} + +#[test] +fn compile_event_handler_new_forces_wrapper() { + // A `new` inside a dropped handler still triggers the component wrapper + // (needs_context walks the handler even though its markup is dropped). + let out = compile_checked(""); + assert!( + out.js.contains("$$renderer.component("), + "needs_context wrapper missing: {}", + out.js + ); +} + +#[test] +fn compile_event_handler_decision_uses_raw_name() { + // The oracle's `is_event_attribute` tests the RAW authored name + // (case-sensitive `startsWith('on')`); lowercasing happens at emission + // only. So `onClick` drops but `ONCLICK` emits `$.attr('onclick', …)`. + let out = + compile_checked(""); + assert!( + out.js.contains("$.attr('onclick', h)"), + "ONCLICK must emit, not drop: {}", + out.js + ); + let out = + compile_checked(""); + assert!( + out.js.contains("``") && !out.js.contains("onclick"), + "onClick must drop: {}", + out.js + ); + // Raw `onLoad` on a load-error element is a plain drop (the capture + // exception matches the raw name exactly). + let out = compile_checked(""); + assert!( + out.js.contains("``"), + "onLoad on img must plain-drop: {}", + out.js + ); + // A mixed-value `ONCLICK` is not an event (raw test) and emits through + // the normal interpolated-attribute path. + let out = compile_checked( + "", + ); + assert!( + out.js.contains("$.attr('onclick'"), + "mixed ONCLICK must emit: {}", + out.js + ); +} + +#[test] +fn compile_handler_shadow_never_masks_the_outer_fold_wrongly() { + // A handler-local binding (param, destructured/default param, let-decl, + // function-expr param, nested-arrow param) may own the mutation target, + // so the outer binding goes Opaque: reads REFUSE (the script side's + // shadow envelope) rather than fold or escape on a guess. + for source in [ + "

{a}

", + "

{a}

", + "

{a}

", + "

{a}

", + "

{a}

", + ] { + assert_unsupported(source, "binding a is not statically modeled"); + } + // The non-shadow direction still masks: `(x) => a++` reassigns the + // OUTER `a`, so its read escapes instead of folding. + let out = compile_checked( + "

{a}

", + ); + assert!( + out.js.contains("$.escape(a)"), + "outer mutation must escape: {}", + out.js + ); + // Partial shadow: the shadowed name refuses only when read; the + // non-shadowed co-mutated name still masks. + let out = compile_checked( + "

{b}

", + ); + assert!( + out.js.contains("$.escape(b)"), + "co-mutated b must escape: {}", + out.js + ); +} + +#[test] +fn compile_rejects_load_error_event_capture() { + // `onload`/`onerror` on a load-error element needs `this.__e=event` + // capture markup, not a clean drop. + assert_unsupported("", "load-error element"); + assert_unsupported( + "", + "load-error element", + ); +} + +#[test] +fn compile_attr_whitespace_collapse_trims_the_js_class_not_the_narrow_one() { + // The oracle is `chunk.data.replace(/[ \t\n\r\f]+/g, ' ').trim()` + // (`server/visitors/shared/utils.js:208` + `phases/patterns.js:11`): a NARROW + // ASCII collapse, then JavaScript's WIDE `String.prototype.trim`. Fusing them + // into one narrow-class pass left every boundary character that is JS + // whitespace but not `[ \t\n\r\f]` in place — 7 oracle-verified MISMATCHes. + // All four affected sites, with two distinct exotic characters each. + + // Site 1 — an unscoped plain `class` attribute. + let js = compile_js("
t
"); + assert!(js.contains("class=\"x\""), "U+00A0 must trim:\n{js}"); + let js = compile_js("
t
"); + assert!(js.contains("class=\"x\""), "U+FEFF must trim:\n{js}"); + + // Site 2 — a plain `style` attribute. + let js = compile_js("
t
"); + assert!( + js.contains("style=\"color: red;\""), + "U+00A0 must trim:\n{js}" + ); + let js = compile_js("
t
"); + assert!( + js.contains("style=\"color: red;\""), + "U+3000 must trim:\n{js}" + ); + + // Site 3 — a SCOPED `class` attribute (the hash concat runs on the trimmed + // value, so a stray boundary character would sit between value and hash). + let js = compile_js( + "
t
\n", + ); + assert!( + js.contains("class=\"x svelte-tsvhash\""), + "scoped class must trim before the hash concat:\n{js}" + ); + + // Site 4 — the `class:` directive BASE (`$.attr_class(base, …)`). + let js = compile_js("
t
"); + assert!( + js.contains("$.attr_class('x'"), + "directive base must trim:\n{js}" + ); + // U+000B is ASCII yet outside the narrow collapse class — the same bug. + let js = compile_js("
t
"); + assert!(js.contains("$.attr_class('x'"), "U+000B must trim:\n{js}"); + + // The spread-object path and a `style:` directive value share the function. + let js = compile_js( + "\n\n
t
", + ); + assert!(js.contains("class: 'x'"), "spread object must trim:\n{js}"); + let js = compile_js("
t
"); + assert!( + js.contains("color: 'red'"), + "style directive value must trim:\n{js}" + ); + + // ⚠️ THE DISCRIMINATOR. `U+0085` () and `U+180E` carry Unicode's + // `White_Space` property but are NOT ECMAScript whitespace, so JS `.trim()` + // KEEPS them — and so must tsv. This is what a `str::trim` would get wrong in + // the opposite direction; both are oracle-verified at parity. + let js = compile_js("
t
"); + assert!( + js.contains("class=\"\u{85}y\u{85}\""), + "U+0085 must NOT trim (not ECMAScript whitespace):\n{js}" + ); + let js = compile_js("
t
"); + assert!( + js.contains("class=\"\u{180e}y\u{180e}\""), + "U+180E must NOT trim (not ECMAScript whitespace):\n{js}" + ); + + // Interior narrow-class runs still collapse to one space, and an interior + // ` ` still survives (why the oracle's collapse is not `\s+`). + let js = compile_js("
t
"); + assert!( + js.contains("class=\"a b\""), + "interior run must collapse:\n{js}" + ); + let js = compile_js("
t
"); + assert!( + js.contains("class=\"a\u{a0}b\""), + "interior U+00A0 must survive the narrow collapse:\n{js}" + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/bind.rs b/crates/tsv_svelte_compile/src/tests/bind.rs new file mode 100644 index 000000000..5f07ce24f --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/bind.rs @@ -0,0 +1,340 @@ +//! The `bind:` directives: the supported core kinds and every refusal. + +use super::support::*; + +#[test] +fn compile_bind_this_omits() { + // `bind:this` is omitted on any regular element (the oracle's early + // `continue`) and works for any variable — no `$state` gate, nothing emitted. + let js = compile_js("\n
t
"); + assert!(js.contains("`
t
`"), "{js}"); +} + +#[test] +fn compile_bind_value_and_member_emit_attr() { + // `bind:value` on `` → `$.attr('value', expr)`; a member target rides + // through (`obj.x`), a dynamic `type={x}` is fine for `value`. + let js = compile_js("\n"); + assert!(js.contains("$.attr('value', v)"), "{js}"); + let js = compile_js("\n"); + assert!(js.contains("$.attr('value', obj.x)"), "{js}"); + let js = compile_js( + "\n", + ); + assert!(js.contains("$.attr('value', v)"), "{js}"); +} + +#[test] +fn compile_bind_checked_checkbox_emits_boolean_attr() { + // `bind:checked` on a static `type="checkbox"` → `$.attr('checked', c, true)`. + let js = compile_js( + "\n", + ); + assert!(js.contains("$.attr('checked', c, true)"), "{js}"); +} + +#[test] +fn compile_bind_group_synthesizes_checked() { + // `bind:group` synthesizes a `checked`: `group === value` (radio/other static + // type) or `group.includes(value)` (checkbox). The companion `value` attribute + // still emits at its own slot. + let js = compile_js( + "\n", + ); + assert!(js.contains("$.attr('checked', g === 'a', true)"), "{js}"); + assert!( + js.contains(" value=\"a\""), + "companion value still emits: {js}" + ); + let js = compile_js( + "\n", + ); + assert!( + js.contains("$.attr('checked', g.includes('a'), true)"), + "{js}" + ); + // A dynamic companion `value={x}`: the synthesis reads `x` AND `value={x}` + // still emits its own `$.attr('value', x)`. + let js = compile_js( + "\n", + ); + assert!( + js.contains("$.attr('checked', g.includes(x), true)"), + "{js}" + ); + assert!(js.contains("$.attr('value', x)"), "{js}"); +} + +#[test] +fn compile_bind_group_no_companion_value_drops() { + // No companion `value` attribute → the oracle silently drops the group bind. + let js = + compile_js("\n"); + assert!(js.contains("``"), "{js}"); +} + +#[test] +fn compile_bind_coexists_with_class_directive() { + // `bind:value` (inline) and `class:x` (pre-scanned, fused, synthetic slot) both + // emit — the value attr first, then the fused class call after all plain attrs. + let js = compile_js( + "\n", + ); + assert!( + js.contains("$.attr('value', v)}${$.attr_class('', void 0, { x: c })}"), + "{js}" + ); +} + +#[test] +fn compile_bind_invalid_target_refuses() { + // A `value`/`checked` bind on a non-`` element, or `value` on + // `", + "bind: directive value", + ); +} + +#[test] +fn compile_bind_checked_requires_static_checkbox_type() { + // `bind:checked` requires a static `type="checkbox"` — a missing / non-checkbox + // type is `bind_invalid_target` (an oracle error). + assert_unsupported( + "\n", + "bind: directive checked", + ); + assert_unsupported( + "\n", + "bind: directive checked", + ); +} + +#[test] +fn compile_bind_group_dynamic_type_refuses() { + // A dynamic `type={x}` with `bind:group` is `attribute_invalid_type` (an oracle + // error) — refuse rather than over-accept. + assert_unsupported( + "\n", + "bind: directive group", + ); +} + +#[test] +fn compile_bind_value_bare_type_and_file_refuse() { + // A BARE `type` with `bind:value` is `attribute_invalid_type` (an oracle error); + // a static `type="file"` is the files trap the oracle silently drops the bind + // for — refuse rather than emit a divergent `$.attr('value', …)`. + assert_unsupported( + "\n", + "bind: directive value", + ); + assert_unsupported( + "\n", + "bind: directive value", + ); +} + +#[test] +fn compile_bind_omit_in_ssr_and_special_targets_refuse() { + // The `omit_in_ssr` media/dimension binds, `bind:open` on `
`, and the + // content-editable trio are all deferred → the collapsing `bind:` bucket. + assert_unsupported( + "\n
", + "bind: directive clientWidth", + ); + assert_unsupported( + "\n
", + "bind: directive open", + ); + assert_unsupported( + "\n
", + "bind: directive innerHTML", + ); +} + +#[test] +fn compile_bind_non_state_expression_refuses() { + // The expression-validity gate: a non-lvalue target (a call) and a bind rooted + // at a non-`$state` binding (a `$derived`) both refuse — tsv emits only a + // `$state`-rooted lvalue (the SAFE side of the oracle's assignable rule). + assert_unsupported( + "\n", + "bind: directive value", + ); + assert_unsupported( + "\n", + "bind: directive value", + ); +} + +#[test] +fn compile_bind_this_non_lvalue_refuses() { + // `bind:this` binds any variable (no `$state` gate), but the target must still + // be an assignable lvalue — an Identifier or member chain. A non-lvalue target + // (call, literal, logical) is the oracle's `bind_invalid_expression`; refuse + // rather than silently omit the bind. + assert_unsupported( + "\n
", + "bind: directive this", + ); + assert_unsupported("
", "bind: directive this"); + assert_unsupported("
", "bind: directive this"); + // A plain `let` (no `$state`) is a valid `bind:this` target and still omits, as + // does a member-chain lvalue — both compile with no `this` attribute. + let js = compile_js("\n
t
"); + assert!(js.contains("`
t
`"), "{js}"); + let js = compile_js("\n
t
"); + assert!(js.contains("`
t
`"), "{js}"); + // A `{get, set}` pair (the oracle's third valid bind form) also omits in SSR — + // it is not an lvalue but is a legal bind target, so refuse-don't-omit would + // over-refuse a valid component (the corpus's `bind-getter-setter-loop`). + let js = + compile_js("\n
el, (v) => (el = v)}>t
"); + assert!(js.contains("`
t
`"), "{js}"); +} + +#[test] +fn compile_const_bind_target_refuses() { + // The oracle's `constant_binding` rejects a bind whose target IDENTIFIER is + // `const`-declared or an import — keyed on the declaration keyword, so a + // reactive `const c = $state(0)` is refused too. Corpus-invisible (idiomatic + // bindables are `let`), so these tests are the only guard. + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); + // `bind:this` takes any lvalue with no `$state` gate, so it needs the same + // guard — including for a plain (non-rune) const and an import. + assert_unsupported( + "
", + "a constant", + ); + assert_unsupported( + "
", + "a constant", + ); + assert_unsupported( + "
", + "a constant", + ); + // The SSR-inert special-element path shares the primitive. + assert_unsupported( + "", + "a constant", + ); + // A MODULE-script const or import is unassignable on exactly the same terms — + // the rule reads the declaration keyword, not which script declared it. Only + // `bind:this` exposes this (the `$state`-gated binds refuse a module binding + // anyway, since it never reaches `state_names`), and it exposes it on all + // three bind paths. + assert_unsupported( + "
", + "a constant", + ); + assert_unsupported( + "
", + "a constant", + ); + assert_unsupported( + "\ + ", + "a constant", + ); + assert_unsupported( + "", + "a constant", + ); +} + +#[test] +fn compile_const_bind_target_through_member_is_allowed() { + // The oracle refuses only REBINDING a const name. Writing THROUGH one mutates + // the object, so a member-chain target rooted at a const is accepted — its + // `validate_no_const_assignment` tests `Identifier` and lets a + // `MemberExpression` fall through. Walking the chain to its root here would + // over-refuse a common shape, so this pins the boundary. + for source in [ + "", + "
", + // A plain `let` target is untouched by the const gate. + "", + "
", + // Same boundary for a MODULE-script const — the member chain writes + // through it, so the oracle accepts and the widened set must not refuse. + "
", + // A top-level class or function is `declaration_kind` 'class'/'function', + // never 'const', so the oracle's test does not fire — in either script. + "
", + "
", + "
", + "
", + ] { + let _ = compile_js(source); + } +} + +#[test] +fn compile_optional_chain_bind_target_refuses() { + // acorn wraps a chain containing an optional link in a `ChainExpression`, and + // the oracle's bind-expression test admits only `Identifier` / + // `MemberExpression` / a `{get, set}` pair — so `bind:this={o?.el}` is + // `bind_invalid_expression`. tsv has no chain wrapper, so `bind_target_root` + // refuses any optional link and the recursion propagates a deeper one up. + assert_unsupported( + "
", + "bind: directive this", + ); + assert_unsupported( + "
", + "bind: directive this", + ); + assert_unsupported( + "
", + "bind: directive this", + ); + assert_unsupported( + "", + "bind: directive value", + ); + assert_unsupported( + "", + "bind: directive innerWidth", + ); + // The non-optional chain is untouched. + compile_checked("
"); +} + +#[test] +fn compile_template_scoped_const_bind_target_refuses() { + // A `bind:` reaches the SAME validator as an assignment + // (`BindDirective.js:181`), so the template-scoped consts obey the rule there + // too — the oracle raises `constant_binding` on each of these (live-verified: + // "Cannot bind to constant"). This was a TRACKED over-acceptance until the + // template scopes were modeled; it is the bind half of + // `compile_template_scoped_const_assignment_refuses`. + assert_unsupported( + "{#await p then v}
{/await}", + "a constant", + ); + assert_unsupported( + "{#if c}{@const o = {}}
{/if}", + "a constant", + ); + assert_unsupported( + "{#each xs as x, i}
{/each}", + "a constant", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/blocks.rs b/crates/tsv_svelte_compile/src/tests/blocks.rs new file mode 100644 index 000000000..7d6dd4ca8 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/blocks.rs @@ -0,0 +1,244 @@ +//! Control-flow blocks: `{#if}`, `{#each}`, `{#await}`, `{#key}`, `{@const}`. + +use super::support::*; + +#[test] +fn compile_if_else_block() { + // Branch anchors are single-quoted string pushes; the closer `` + // is its own template push. A missing branch synthesizes nothing here. + let js = compile_js("{#if a}

1

{:else}

2

{/if}"); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tif (a) {\n\ + \t\t$$renderer.push('');\n\ + \t\t$$renderer.push(`

1

`);\n\ + \t} else {\n\ + \t\t$$renderer.push('');\n\ + \t\t$$renderer.push(`

2

`);\n\ + \t}\n\ + \t$$renderer.push(``);\n\ + }\n" + ); +} + +#[test] +fn compile_if_synthesizes_missing_else() { + // No `{:else}` → an anchor-only `else` branch with ``. + let js = compile_js("{#if a}

1

{/if}"); + assert!( + js.contains("} else {\n\t\t$$renderer.push('');\n\t}"), + "missing else must be synthesized: {js}" + ); +} + +#[test] +fn compile_else_if_chain_numbers_branches() { + // Consequents number 0,1,…; the terminal else is -1; `else if` nests. + let js = compile_js("{#if a}

1

{:else if b}

2

{:else}

3

{/if}"); + assert!(js.contains("if (a) {"), "{js}"); + assert!(js.contains("} else if (b) {"), "{js}"); + assert!(js.contains("$$renderer.push('');"), "{js}"); + assert!(js.contains("$$renderer.push('');"), "{js}"); + assert!(js.contains("$$renderer.push('');"), "{js}"); +} + +#[test] +fn compile_each_block() { + let js = compile_js( + "\n{#each items as item}
  • {item}
  • {/each}", + ); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \tlet { items } = $$props;\n\ + \t$$renderer.push(``);\n\ + \tconst each_array = $.ensure_array_like(items);\n\ + \tfor (let $$index = 0, $$length = each_array.length; $$index < $$length; $$index++) {\n\ + \t\tlet item = each_array[$$index];\n\ + \t\t$$renderer.push(`
  • ${$.escape(item)}
  • `);\n\ + \t}\n\ + \t$$renderer.push(``);\n\ + }\n" + ); +} + +#[test] +fn compile_refuses_each_key_without_as() { + // The oracle's `each_key_without_as` (`EachBlock.js:26-34`): a `(key)` with no + // `as` clause, when keyed. The comma-index form `{#each x, i (key)}` is what + // reaches here — a bare `{#each x (k)}` parses as a call `x(k)`, no key. + // A member/expression key is always keyed. + assert_unsupported( + "\n{#each items, i (items[i].id)}

    {items[i].id}

    {/each}", + "{#each} with a key but no `as` clause", + ); + // An identifier key naming something OTHER than the index is keyed. + assert_unsupported( + "\n{#each items, j (i)}

    x

    {/each}", + "{#each} with a key but no `as` clause", + ); + // Discriminating controls, all COMPILE: + // key === index → a plain indexed block, NOT keyed (the oracle's carve-out). + let _ = compile_js("\n{#each items, i (i)}

    {i}

    {/each}"); + // a key WITH an `as` clause is the ordinary keyed each. + let _ = compile_js( + "\n{#each items as item (item.id)}

    {item.id}

    {/each}", + ); + // no key at all. + let _ = + compile_js("\n{#each items as item}

    {item}

    {/each}"); +} + +#[test] +fn compile_each_with_else_hoists_and_uses_authored_index() { + // `{:else}` hoists `each_array` before an `if (…length !== 0)`; the + // authored index name replaces `$$index` everywhere. + let js = compile_js( + "\n{#each items as item, i}
  • {i}
  • {:else}

    none

    {/each}", + ); + assert!( + js.contains( + "const each_array = $.ensure_array_like(items);\n\tif (each_array.length !== 0) {" + ), + "each_array must hoist before the if: {js}" + ); + assert!(js.contains("$$renderer.push('');"), "{js}"); + assert!(js.contains("$$renderer.push('');"), "{js}"); + assert!( + js.contains("for (let i = 0, $$length = each_array.length; i < $$length; i++) {"), + "authored index must replace $$index: {js}" + ); +} + +#[test] +fn compile_sibling_each_blocks_number_names() { + // Sibling eachs get suffixed names in source order. + let js = compile_js( + "\n{#each a as x}

    {x}

    {/each}{#each b as y}

    {y}

    {/each}", + ); + assert!( + js.contains("const each_array = $.ensure_array_like(a);"), + "{js}" + ); + assert!( + js.contains("const each_array_1 = $.ensure_array_like(b);"), + "second each must be each_array_1: {js}" + ); + assert!(js.contains("let x = each_array[$$index];"), "{js}"); + assert!(js.contains("let y = each_array_1[$$index_1];"), "{js}"); +} + +#[test] +fn compile_await_block_drops_catch() { + // Always 4-arg `$.await`; the `{:catch}` branch is dropped entirely. + let js = compile_js( + "\n{#await p}

    load

    {:then v}

    {v}

    {:catch e}

    err

    {/await}", + ); + assert!(js.contains("$.await("), "{js}"); + assert!( + js.contains("(value) => {") || js.contains("(v) => {"), + "then param: {js}" + ); + assert!(js.contains("`

    load

    `"), "{js}"); + assert!(js.contains("$.escape(v)"), "{js}"); + assert!(!js.contains("err"), "catch content must be dropped: {js}"); + assert!(js.contains("$$renderer.push(``);"), "{js}"); +} + +#[test] +fn compile_await_pending_only_has_empty_then() { + // Pending-only await still emits 4 args with an empty `() => {}` then. + let js = compile_js("\n{#await p}

    load

    {/await}"); + assert!(js.contains("() => {}"), "empty then arrow expected: {js}"); + assert!(js.contains("`

    load

    `"), "{js}"); +} + +#[test] +fn compile_key_block() { + let js = compile_js("{#key a}

    c

    {/key}"); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \t$$renderer.push(``);\n\ + \t{\n\ + \t\t$$renderer.push(`

    c

    `);\n\ + \t}\n\ + \t$$renderer.push(``);\n\ + }\n" + ); +} + +#[test] +fn compile_const_tag_folds_static_read() { + // A `{@const}` enters the evaluator: a statically-known init folds a read + // into the template while the declaration still emits. + let js = compile_js("{#if true}{@const x = 2}

    {x}

    {/if}"); + assert!(js.contains("const x = 2;"), "const decl must emit: {js}"); + assert!( + js.contains("`

    2

    `"), + "static const read must fold: {js}" + ); + assert!( + !js.contains("$.escape(x)"), + "known read must not stay dynamic: {js}" + ); +} + +#[test] +fn compile_const_tag_dynamic_read_stays_escaped() { + // A `{@const}` over an unknown (each-local) value stays dynamic. + let js = compile_js( + "\n{#each items as item}{@const d = item}

    {d}

    {/each}", + ); + assert!(js.contains("const d = item;"), "{js}"); + assert!( + js.contains("$.escape(d)"), + "dynamic const read must escape: {js}" + ); +} + +#[test] +fn compile_marks_text_first_each_body_not_if_branch() { + // The each body gets a `` text-first marker; the if branch does not. + let each = compile_js( + "\n{#each items as item}hi {item}{/each}", + ); + assert!(each.contains("`hi ${$.escape(item)}`"), "{each}"); + let iff = compile_js("\n{#if a}hi {a}{/if}"); + assert!( + iff.contains("$$renderer.push(`hi ${$.escape(a)}`);"), + "if branch must NOT get a text-first marker: {iff}" + ); +} + +#[test] +fn compile_rejects_nested_each() { + assert_unsupported( + "\n{#each m as row}{#each row as cell}

    {cell}

    {/each}{/each}", + "nested {#each}", + ); +} + +#[test] +fn compile_rejects_const_at_root() { + assert_unsupported( + "{@const x = 1}

    text

    ", + "{@const} at the component root", + ); +} + +#[test] +fn compile_carries_comments_with_blocks() { + // A script comment carries through as a leading comment of its surviving + // statement, unaffected by a template block: the block emits template-region + // spans only, so no comment window sweeps the script comment. + let js = compile_js("\n{#if a}

    x

    {/if}"); + assert!( + js.contains("// note"), + "the script comment must carry through: {js}" + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/boundary.rs b/crates/tsv_svelte_compile/src/tests/boundary.rs new file mode 100644 index 000000000..86b8a4e7d --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/boundary.rs @@ -0,0 +1,79 @@ +//! ``: attribute validation, anchors, snippet forms. + +use super::support::*; + +#[test] +fn compile_boundary_invalid_attributes_refuse() { + // The oracle's phase-2 `SvelteBoundary` visitor accepts a CLOSED list of three + // names, each carrying exactly one `{expression}`. tsv's parser accepts all of + // the shapes below, so without the guard each would be an over-acceptance of + // input the oracle rejects — the refusal contract's hard bar. + // `svelte_boundary_invalid_attribute`: an unknown plain attribute, a spread, + // and every directive kind (a modern event attribute included). + for source in [ + "

    a

    ", + "

    a

    ", + "

    a

    ", + "

    a

    ", + "

    a

    ", + "

    a

    ", + "

    a

    ", + ] { + assert_unsupported(source, "invalid attribute on "); + } + // `svelte_boundary_invalid_attribute_value`: a valid NAME whose value is not + // exactly one expression tag — a DIFFERENT refusal, pinned separately so the + // two cannot collapse into each other. + for source in [ + "

    a

    ", + "

    a

    ", + "

    a

    ", + ] { + assert_unsupported( + source, + "non-expression value for attribute onerror", + ); + } +} + +#[test] +fn compile_boundary_snippet_attribute_forms_refuse() { + // The `failed={expr}` / `pending={expr}` ATTRIBUTE forms compile in the oracle + // but are a deliberate v1 gap (asymmetric precedence against a same-named + // snippet, plus the statically-nullish `pending` if/else fork). + assert_unsupported( + "

    a

    ", + "attribute form", + ); + assert_unsupported( + "

    a

    ", + "attribute form", + ); +} + +#[test] +fn compile_boundary_onerror_is_dropped_but_guarded() { + // A valid `onerror={handler}` never reaches SSR output, but the oracle still + // analyzes it — so it is guard-walked like an event-handler attribute. + let out = compile_checked(" e}>

    a

    "); + assert!(!out.js.contains("onerror"), "onerror must drop: {}", out.js); + assert_unsupported( + " $state(1)}>

    a

    ", + "rune $state", + ); +} + +#[test] +fn compile_boundary_anchors_are_isolated_pushes() { + // Unlike `{#key}`'s `` marker, a boundary's anchors do NOT merge into an + // adjacent sibling's template — the oracle's `build_template` starts a fresh + // push for each. A merge would be invisible to the fixtures if no fixture put a + // sibling beside a boundary, so pin it here too. + let out = compile_checked("a

    hi

    z"); + assert!( + out.js.contains("$$renderer.push(`a`)") + && out.js.contains("$$renderer.push(``)"), + "anchors must not merge with siblings: {}", + out.js + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/canonicalize.rs b/crates/tsv_svelte_compile/src/tests/canonicalize.rs new file mode 100644 index 000000000..f0a4f5138 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/canonicalize.rs @@ -0,0 +1,173 @@ +//! The JS canonicalizer: idempotence, intent erasure, and comment losslessness. + +use super::support::*; +use crate::*; + +#[test] +fn multiline_but_fitting_object_collapses() { + // A short object authored expanded and the same object authored inline + // must reach the SAME canonical form (expansion intent erased). + let expanded = canonicalize_js("const x = {\n\ta: 1,\n\tb: 2\n};\n").unwrap(); + let inline = canonicalize_js("const x = {a: 1, b: 2};\n").unwrap(); + assert_eq!( + expanded, inline, + "multiline-but-fitting object must collapse" + ); + assert!( + !expanded.contains("a: 1,\n"), + "should be single-line: {expanded:?}" + ); +} + +#[test] +fn blank_lines_are_dropped() { + let with_blanks = canonicalize_js("const a = 1;\n\n\nconst b = 2;\n").unwrap(); + let without = canonicalize_js("const a = 1;\nconst b = 2;\n").unwrap(); + assert_eq!(with_blanks, without, "blank lines must be erased"); + assert!( + !with_blanks.contains("\n\n"), + "no blank line survives: {with_blanks:?}" + ); +} + +#[test] +fn over_width_construct_still_breaks() { + // An object whose inline form exceeds the 100-col print width must break, + // and both authorings (inline vs expanded) canonicalize identically. + let long = "const config = {alpha: 1, bravo: 2, charlie: 3, delta: 4, echo: 5, \ + foxtrot: 6, golf: 7, hotel: 8};\n"; + let inline = canonicalize_js(long).unwrap(); + assert!( + inline.contains('\n'), + "over-width object must break across lines" + ); + // Same content, authored expanded, reaches the same canonical form. + let expanded = canonicalize_js( + "const config = {\n\talpha: 1,\n\tbravo: 2,\n\tcharlie: 3,\n\tdelta: 4,\n\techo: 5,\n\ + \tfoxtrot: 6,\n\tgolf: 7,\n\thotel: 8\n};\n", + ) + .unwrap(); + assert_eq!( + inline, expanded, + "width-broken forms must be authoring-independent" + ); +} + +#[test] +fn trailing_comment_survives() { + let out = canonicalize_js("const x = 1; // keep me\n").unwrap(); + assert!(out.contains("// keep me"), "trailing comment lost: {out:?}"); +} + +#[test] +fn leading_comment_survives() { + let out = canonicalize_js("// heading\nconst x = 1;\n").unwrap(); + assert!(out.contains("// heading"), "leading comment lost: {out:?}"); +} + +#[test] +fn consecutive_line_comments_do_not_merge() { + // The losslessness edge case: two own-line line comments must stay on two + // lines (never merge onto one, which would swallow the second `//`). + let out = canonicalize_js("// first\n// second\nconst x = 1;\n").unwrap(); + assert!(out.contains("// first"), "first comment lost: {out:?}"); + assert!(out.contains("// second"), "second comment lost: {out:?}"); + // "// first // second" on one line would be the merge bug. + assert!( + !out.contains("// first // second"), + "comments merged: {out:?}" + ); +} + +#[test] +fn template_interpolation_chain_trailing_comment_stays_valid() { + // D1: a `+` chain inside a template interpolation with an operand-trailing + // `//` comment. Collapsing would trail the comment inside `${...}` and + // swallow the closer (`${x + y // c})z`), making the output unparseable — + // the chain must stay broken so the comment ends at a real line end. + let out = assert_comments_lossless("const r = `(${x + // c\n\ty})z`;\n", &["// c"]); + // The output must reparse (canonicalize_js validates this itself, but pin + // the invariant explicitly at the test level too). + canonicalize_js(&out).expect("D1 output must reparse"); +} + +#[test] +fn binary_chain_multiple_trailing_comments_do_not_merge() { + // D2 (`+` chain): two operand-trailing comments must not merge onto one + // trailing line (which also reorders them: `a + b + c; // two // one`). + assert_comments_lossless( + "const q = a + // one\n\tb + // two\n\tc;\n", + &["// one", "// two"], + ); +} + +#[test] +fn logical_chain_multiple_trailing_comments_do_not_merge() { + // D2 (`||` chain): same class through the logical-expression path. + assert_comments_lossless( + "const ok = first || // one\n\tsecond || // two\n\tthird;\n", + &["// one", "// two"], + ); +} + +#[test] +fn chain_with_trailing_comments_as_call_arg_stays_lossless() { + // Not-statement-final variant: the commented chain is a call argument, so + // there is no statement end for a trailing comment to legally land on. + assert_comments_lossless("f(a + // one\n\tb + // two\n\tc);\n", &["// one", "// two"]); +} + +#[test] +fn chain_with_trailing_comments_as_array_element_stays_lossless() { + // Not-statement-final variant: the commented chain is an array element + // followed by another element — trailing past the `,` must not swallow it. + assert_comments_lossless( + "const xs = [a + // one\n\tb, // two\n\tc];\n", + &["// one", "// two"], + ); +} + +#[test] +fn block_comment_survives() { + let out = canonicalize_js("const x = /* inline */ 1;\n").unwrap(); + assert!(out.contains("/* inline */"), "block comment lost: {out:?}"); +} + +#[test] +fn idempotent_on_samples() { + assert_idempotent("const x = {\n\ta: 1\n};\n"); + assert_idempotent("const a = 1;\n\nconst b = 2;\n"); + assert_idempotent("// lead\nexport function f(x) {\n\treturn x + 1;\n}\n"); + assert_idempotent("import {a, b} from 'mod';\nconst t = `line\nbreak`;\n"); + assert_idempotent("const x = 1; // trailing\n// own line\nconst y = 2;\n"); +} + +#[test] +fn template_literal_newline_is_content_not_intent() { + // A real newline inside a template literal is content, not layout intent — + // it must survive canonicalization verbatim. + let out = canonicalize_js("const t = `a\nb`;\n").unwrap(); + assert!( + out.contains("`a\nb`"), + "template literal newline not preserved: {out:?}" + ); +} + +#[test] +fn blank_between_call_args_dropped() { + // A blank line between call arguments is authoring intent — the canonical + // reprint must erase it, exactly as it does between statements. Guards the + // canonical gate on `is_next_line_empty` (a raw-`source` blank detector that, + // ungated, would still see the authored blank on the canonical pass and force + // expansion — the exact break the main-merge resolution of #534 introduced). + let with_blank = canonicalize_js("f(a,\n\nb);\n").unwrap(); + let without = canonicalize_js("f(a, b);\n").unwrap(); + assert_eq!( + with_blank, without, + "blank between call args must be erased" + ); + assert!( + !with_blank.contains("\n\n"), + "no blank line survives: {with_blank:?}" + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/class_directives.rs b/crates/tsv_svelte_compile/src/tests/class_directives.rs new file mode 100644 index 000000000..5fb0442cc --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/class_directives.rs @@ -0,0 +1,138 @@ +//! The `class:` directive and the fused `$.attr_class(...)` call. + +use super::support::*; + +#[test] +fn compile_class_clsx_rule() { + // The oracle's needs_clsx rule (oracle-probed): only a BARE + // `class={expr}` wraps in $.clsx, and only when the expression is not + // a Literal, TemplateLiteral, or ESTree BinaryExpression — logical + // operators are LogicalExpression there and DO wrap. The quoted form + // `class="{expr}"` is a one-chunk array in the oracle's AST and NEVER + // wraps. (Quoted shapes live here, not in a fixture — prettier strips + // the redundant quotes from fixture inputs.) + let wraps = |src: &str| compile_js(src).contains("$.clsx("); + // Bare: identifier / conditional / logical / object / array wrap. + assert!(wraps( + "\n
    " + )); + assert!(wraps( + "\n
    " + )); + assert!(wraps( + "\n
    " + )); + assert!(wraps( + "\n
    " + )); + assert!(wraps( + "\n
    " + )); + // Bare exclusions: template literal / arithmetic binary / number literal. + assert!(!wraps( + "\n
    " + )); + assert!(!wraps( + "\n
    " + )); + assert!(!wraps("
    ")); + // Quoted: never wraps, regardless of expression shape. + assert!(!wraps( + "\n
    " + )); + assert!(!wraps( + "\n
    " + )); + // Non-class dynamic attributes never wrap. + assert!(!wraps( + "\n
    " + )); +} + +#[test] +fn compile_class_directive_basic() { + // A `class:` directive on a regular element fuses with the authored `class` + // attribute into `$.attr_class(base, void 0, { name: expr })` (the oracle's + // `build_attr_class`). The directive key is a (canonicalized) identifier and + // the value is the borrowed expression. + let js = compile_js( + "\n
    text
    ", + ); + assert!( + js.contains("`text`"), + "{js}" + ); +} + +#[test] +fn compile_class_directive_synthetic_and_shorthand() { + // No authored `class`: the synthetic empty `''` base, and the fused call + // emits after all plain attributes. A shorthand `class:active` carries the + // auto-generated identifier as its value (`{ active: active }`, not collapsed). + let js = compile_js("\n
    x
    "); + assert!( + js.contains("`x`"), + "{js}" + ); +} + +#[test] +fn compile_class_directive_ordering() { + // Plain attributes emit inline in source order; the synthetic-`class` fused + // call emits at the END (after `id` and `title`). + let js = compile_js( + "\n
    t
    ", + ); + assert!( + js.contains("`
    t
    `"), + "{js}" + ); + // An authored `class` after the directive: the fused call takes the `class` + // slot (before the later `id`). + let js = compile_js( + "\n
    t
    ", + ); + assert!( + js.contains("`t`"), + "{js}" + ); +} + +#[test] +fn compile_class_directive_scoping() { + // Scoped via a static-class token: the hash concatenates into the string base. + let js = compile_js( + "\n
    t
    \n", + ); + assert!( + js.contains("$.attr_class('foo svelte-tsvhash', void 0, { active: x })"), + "static-token scope concat: {js}" + ); + // Scoped via the directive NAME: the empty base concatenates to just the hash. + let js = compile_js( + "\n
    t
    \n", + ); + assert!( + js.contains("$.attr_class('svelte-tsvhash', void 0, { active: x })"), + "directive-name scope: {js}" + ); + // Scoped with a dynamic base: the hash rides the 2nd argument. + let js = compile_js( + "\n
    t
    \n", + ); + assert!( + js.contains("$.attr_class($.clsx(w), 'svelte-tsvhash', { foo: x })"), + "dynamic-base scope: {js}" + ); +} + +#[test] +fn compile_class_directive_mixed_class_refuses() { + // A `class:` directive alongside a mixed-value `class="a {b}"` attribute is + // deferred — the oracle passes the mixed value to `build_attr_class` as the + // base, a shape this slice does not build. + assert_unsupported( + "\n
    t
    ", + "class: directive alongside a mixed-value class attribute", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/comments.rs b/crates/tsv_svelte_compile/src/tests/comments.rs new file mode 100644 index 000000000..f5e0b16a0 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/comments.rs @@ -0,0 +1,171 @@ +//! Script-comment carry, drop, and the divergent-placement refusals. + +use super::support::*; +use crate::*; + +#[test] +fn compile_comment_in_import_only_script() { + // No surviving body statement (the import hoists to the comment-free module + // program), so the carried comment leads the first synthetic statement instead. + // The oracle trails it after that statement — a position difference the parity + // bar tolerates, with the comment carried exactly once on both sides. + let js = + compile_js("\n"); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + import Foo from './Foo.svelte';\n\ + export default function Input($$renderer) {\n\ + \t// note\n\ + \tFoo($$renderer, {});\n\ + }\n" + ); +} + +#[test] +fn compile_refuses_comment_after_last_statement_with_a_nested_block() { + // A template that emits a synthetic (loc-less) block makes the oracle's printer + // reset its monotonic comment index to the end, DROPPING every comment not yet + // written — so an after-last comment vanishes from the oracle's output while + // tsv keeps it. A drop is graded (unlike a position difference), so refuse. + let what = "template that emits a nested block"; + for template in [ + "{#if x}

    a

    {/if}", + "{#each [x] as n}

    {n}

    {/each}", + "{#await x}

    a

    {:then v}

    {v}

    {/await}", + "{#key x}

    a

    {/key}", + "
    {#if x}

    a

    {/if}
    ", + "t", + ] { + assert_unsupported( + &format!("\n{template}"), + what, + ); + } + // A component's children become a `children: ($$renderer) => { … }` block. + assert_unsupported( + "\n{x}", + what, + ); +} + +#[test] +fn compile_carries_comment_after_last_statement_without_a_nested_block() { + // The boundary of the refusal above: a component with no children (or only + // whitespace) emits a bare call, not a block — probed against the oracle, which + // keeps the comment in both forms. A `{@render}` is likewise a bare call. + for template in ["", "\n"] { + let js = compile_js(&format!( + "\n{template}" + )); + assert!(js.contains("// note"), "comment must carry: {js}"); + } + let js = compile_js( + "\n{@render children()}", + ); + assert!(js.contains("// note"), "comment must carry: {js}"); +} + +#[test] +fn compile_comment_before_dropped_effect() { + // The last SURVIVING statement is `let x = 1`; the `$effect` drops in SSR, so + // the comment between them has no statement left to lead and falls to the + // template emission that follows — inside the `needs_context` wrapper the + // dropped effect forces. + let js = compile_js( + "\n

    {x}

    ", + ); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\tlet x = 1;\n\ + \t\t// note\n\ + \t\t$$renderer.push(`

    1

    `);\n\ + \t});\n\ + }\n" + ); +} + +#[test] +fn compile_refuses_multiline_block_comment() { + // The oracle re-indents a block comment's interior lines to the emit position; + // tsv carries them verbatim, so they diverge. Refuse until the printer + // re-indents block-comment interiors. + assert_unsupported( + "\n

    {x}

    ", + "multi-line block comment in script", + ); +} + +#[test] +fn compile_refuses_comment_with_store() { + // A store reference injects `var $$store_subs;` as a synthetic (appendix-span) + // component-body statement whose leading comment window would sweep the carried + // script comment (a double-print). Refuse. A script-position write mints + // `$.store_set`/`$.store_get`, which sweep the same way. + assert_unsupported( + "\n", + "references a store", + ); + // A template-only `$name` read still injects the var, so it refuses too. + assert_unsupported( + "\n

    {$count}{x}

    ", + "references a store", + ); +} + +#[test] +fn compile_carries_script_comments_losslessly() { + // Leading, trailing-same-line, and between-statement comments carry + // through: each present exactly once, relative order preserved, and + // the output is a canonicalize fixed point. + let out = compile_checked( + "\n\n

    {prop}

    \n", + ); + let mut prev = 0; + for comment in [ + "// leading", + "// trailing", + "// between one", + "// between two", + ] { + let pos = out + .js + .find(comment) + .unwrap_or_else(|| panic!("comment {comment:?} lost:\n{}", out.js)); + assert_eq!( + out.js.matches(comment).count(), + 1, + "comment {comment:?} duplicated:\n{}", + out.js + ); + assert!(pos >= prev, "comment {comment:?} reordered:\n{}", out.js); + prev = pos + comment.len(); + } + assert_eq!(canonicalize_js(&out.js).unwrap(), out.js); +} + +#[test] +fn compile_carries_comment_after_last_statement() { + // A comment past the last script statement leads the first synthetic statement + // (the template flush). The oracle instead trails it after that statement — + // position-tolerated, same single comment. + let js = compile_js("\n

    text

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tlet a = 1;\n\ + \t// after last\n\ + \t$$renderer.push(`

    text

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_rejects_template_expression_comments() { + // Template-expression comments aren't carried yet. + assert_unsupported("

    {/* c */ 1}

    ", "template comments"); +} diff --git a/crates/tsv_svelte_compile/src/tests/components.rs b/crates/tsv_svelte_compile/src/tests/components.rs new file mode 100644 index 000000000..c2931d798 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/components.rs @@ -0,0 +1,325 @@ +//! Component invocation: props, children snippets, anchors, refusals. + +use super::support::*; + +#[test] +fn compile_self_closing_component() { + // A plain component invocation compiles to `Name($$renderer, {})`. As the + // sole root child it is standalone — no trailing `` anchor. + let js = compile_js(""); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tFoo($$renderer, {});\n\ + }\n" + ); +} + +#[test] +fn compile_component_prop_value_shapes() { + // string → 's'; expr(prop) → the reference; shorthand `{value}` collapses + // to `value`; boolean → `true`. The component declares props, so `$$props` + // is injected, but no `$$renderer.component` wrapper (a bare prop + // reference is not `needs_context`-unsafe). + let js = compile_js( + "\n", + ); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \tlet { x, value } = $$props;\n\ + \tFoo($$renderer, { a: 's', b: x, value, disabled: true });\n\ + }\n" + ); +} + +#[test] +fn compile_component_shorthand_collapses_when_names_match() { + // `b={b}` → `{ b }` (key === value identifier); `b={x}` → `{ b: x }`. + let js = compile_js("\n"); + assert!(js.contains("Foo($$renderer, { b });"), "{js}"); + let js = compile_js("\n"); + assert!(js.contains("Foo($$renderer, { a: b });"), "{js}"); +} + +#[test] +fn compile_component_derived_prop_reads_as_call() { + // A bare `$derived` read in a prop value becomes `d()` — so a `{d}` + // shorthand is NOT collapsed (the value is a call, not the identifier). + let js = compile_js( + "\n", + ); + assert!(js.contains("Foo($$renderer, { a: d(), d: d() });"), "{js}"); +} + +#[test] +fn compile_component_mixed_and_string_value_semantics() { + // Mixed text+expr → a template literal with `$.stringify`; a single static + // text value entity-decodes but is NOT HTML-escaped (a JS value, not + // markup); an all-fold mixed value collapses to a string literal. + let js = compile_js("\n"); + assert!( + js.contains("Foo($$renderer, { a: `x ${$.stringify(y)} z` });"), + "{js}" + ); + let js = compile_js(""); + assert!(js.contains("Foo($$renderer, { a: '& < >' });"), "{js}"); + let js = compile_js("\n"); + assert!(js.contains("Foo($$renderer, { t: 'x1y2' });"), "{js}"); +} + +#[test] +fn compile_component_non_identifier_key_quotes() { + let js = compile_js(""); + assert!( + js.contains("Foo($$renderer, { 'data-x': '1', 'aria-label': 'hi' });"), + "{js}" + ); +} + +#[test] +fn compile_component_spread_props() { + // Consecutive props group into object literals; spreads break the run, + // wrapping the whole thing in `$.spread_props([...])`. + let js = compile_js("\n"); + assert!( + js.contains("Foo($$renderer, $.spread_props([{ a: 1 }, r, { b: 2 }]));"), + "{js}" + ); + let js = compile_js("\n"); + assert!( + js.contains("Foo($$renderer, $.spread_props([r, s]));"), + "{js}" + ); +} + +#[test] +fn compile_component_event_handler_is_a_plain_prop() { + // Unlike an element `on*` handler (dropped), a component `onclick={fn}` is + // an ordinary prop. + let js = compile_js("\n"); + assert!(js.contains("Foo($$renderer, { onclick: fn });"), "{js}"); +} + +#[test] +fn compile_component_anchor_when_not_standalone() { + // Inside an element the component is not standalone → trailing ``. + let js = compile_js("
    "); + assert!( + js.contains("$$renderer.push(`
    `);") + && js.contains("Foo($$renderer, {});") + && js.contains("$$renderer.push(`
    `);"), + "{js}" + ); + // Two sibling components each get an anchor (not a sole child). + let js = compile_js(""); + assert!( + js.contains("Foo($$renderer, {});") + && js.contains("$$renderer.push(``);") + && js.contains("Bar($$renderer, {});"), + "{js}" + ); +} + +#[test] +fn compile_component_sole_block_child_is_standalone() { + // `{#if a}{/if}` — the component is the branch's sole child, so it + // reuses the branch anchor and emits no trailing ``. + let js = compile_js("{#if a}{/if}"); + assert!(js.contains("Foo($$renderer, {});"), "{js}"); + assert!( + !js.contains("$$renderer.push(``)"), + "sole block-child component must not add an anchor: {js}" + ); +} + +#[test] +fn compile_refuses_dynamic_components() { + // A member component and a component named after a reactive binding + // (prop / $state / $derived / each-local) all compile to the oracle's + // truthiness guard — refused in this slice. + assert_unsupported("", "dynamic component"); + assert_unsupported( + "\n", + "dynamic component", + ); + assert_unsupported( + "\n", + "dynamic component", + ); + assert_unsupported( + "\n", + "dynamic component", + ); + // A plain local / import is NOT dynamic — it compiles. + compile_checked("\n"); +} + +#[test] +fn compile_component_children_snippet_prop() { + // Default-slot children compile to a `children: ($$renderer) => {…}` + // snippet prop plus `$$slots: { default: true }`. A text-first body gets + // the `` marker. + let js = compile_js("

    hi

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tFoo($$renderer, {\n\ + \t\tchildren: ($$renderer) => {\n\ + \t\t\t$$renderer.push(`

    hi

    `);\n\ + \t\t},\n\ + \t\t$$slots: { default: true }\n\ + \t});\n\ + }\n" + ); + // Text-first children get the `` anchor inside the arrow. + let js = compile_js("hi x"); + assert!( + js.contains("$$renderer.push(`hi x`);"), + "{js}" + ); + // An empty / whitespace-only body is NOT children (no `children` prop). + let js = compile_js(""); + assert_eq!(js.matches("children").count(), 0, "{js}"); + let js = compile_js(" "); + assert_eq!(js.matches("children").count(), 0, "{js}"); +} + +#[test] +fn compile_component_children_after_attrs_and_spread() { + // The `children` prop appends after attribute props. + let js = compile_js("

    hi

    "); + assert!( + js.contains("a: 'x'") && js.contains("children: ($$renderer) =>"), + "{js}" + ); + // With a trailing spread the children go to their own object element. + let js = compile_js("\n

    hi

    "); + assert!(js.contains("$.spread_props(["), "{js}"); + assert!(js.contains("children: ($$renderer) =>"), "{js}"); + assert!(js.contains("$$slots: { default: true }"), "{js}"); +} + +#[test] +fn compile_component_named_snippet_props() { + // A `{#snippet}` child compiles to a `function` in a wrapping block plus a + // `{ name }` shorthand prop and a `$$slots: { name: true }` entry. + let js = compile_js("{#snippet header()}

    t

    {/snippet}
    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \t{\n\ + \t\tfunction header($$renderer) {\n\ + \t\t\t$$renderer.push(`

    t

    `);\n\ + \t\t}\n\ + \t\tFoo($$renderer, { header, $$slots: { header: true } });\n\ + \t}\n\ + }\n" + ); + // Multiple snippets: functions and slot entries in source order. + let js = + compile_js("{#snippet a()}1{/snippet}{#snippet b()}2{/snippet}"); + assert!( + js.contains("Foo($$renderer, { a, b, $$slots: { a: true, b: true } });"), + "{js}" + ); + // A snippet named `children` keeps the `children` prop but a `default` + // slot key. + let js = compile_js("{#snippet children()}

    c

    {/snippet}
    "); + assert!( + js.contains("Foo($$renderer, { children, $$slots: { default: true } });"), + "{js}" + ); +} + +#[test] +fn compile_component_snippet_and_default_children() { + // Mixed named snippet + default children: the `children` arrow holds only + // the default children (the snippet is in the wrapping block), and + // `$$slots` carries both keys. + let js = compile_js("text{#snippet header()}

    t

    {/snippet}
    "); + assert!(js.contains("function header($$renderer) {"), "{js}"); + assert!(js.contains("header,"), "{js}"); + assert!(js.contains("children: ($$renderer) =>"), "{js}"); + assert!(js.contains("$$renderer.push(`text`);"), "{js}"); + assert!( + js.contains("$$slots: { header: true, default: true }"), + "{js}" + ); +} + +#[test] +fn compile_refuses_deferred_component_children() { + // A `slot="…"` child (named slot) is a later slice; an explicit `children` + // prop + default children is the oracle's `$$slots.default` divergence. + assert_unsupported( + "

    hi

    ", + "named slot on component", + ); + assert_unsupported( + "\n

    hi

    ", + "both a children prop and default children", + ); +} + +#[test] +fn compile_refuses_component_directives_and_css_vars() { + // `--custom-property` → `$.css_props`; `bind:` → a settle loop; other + // directives are (mostly) oracle-rejected — all refused here. + assert_unsupported( + "", + "--custom-property attribute on component", + ); + assert_unsupported( + "\n", + "bind: directive on component", + ); +} + +#[test] +fn compile_carries_comments_with_component() { + // Carried script comments alongside a component invocation carry through: the + // component call's prop values are template-region borrows, so the comment + // stays a leading comment of its script statement. + let js = compile_js("\n"); + assert!( + js.contains("// note"), + "the script comment must carry through: {js}" + ); +} + +#[test] +fn compile_component_prop_new_expression_wraps() { + // A `new` in a prop value drives `needs_context` (walked in + // needs_context.rs), wrapping the body and injecting `$$props`. + let js = compile_js(""); + assert!( + js.contains("$$renderer.component(($$renderer) =>") + && js.contains("Foo($$renderer, { a: new Date() });"), + "{js}" + ); +} + +#[test] +fn compile_component_spread_member_on_prop_wraps() { + // A member access inside a component spread must feed needs_context. + let js = compile_js("\n"); + assert!( + js.contains("$$renderer.component(($$renderer) =>"), + "spread member-on-prop must wrap: {js}" + ); +} + +#[test] +fn compile_refuses_const_tag_shadowing_derived() { + // A `{@const}` that shadows a top-level `$derived` refuses (the + // name-based derived-read rewrite would wrongly call the const as `d()`). + assert_unsupported( + "\n{#each items as item}{@const d = item.x}

    {d}

    {/each}", + "shadows a $derived binding", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/context_wrapper.rs b/crates/tsv_svelte_compile/src/tests/context_wrapper.rs new file mode 100644 index 000000000..29c04f2ac --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/context_wrapper.rs @@ -0,0 +1,93 @@ +//! The `$$renderer.component(...)` wrapper: `needs_context` and import hoisting. + +use super::support::*; + +#[test] +fn compile_member_on_prop_wraps() { + // A member/call rooted in a prop is `needs_context`-unsafe — the whole + // body wraps in `$$renderer.component(($$renderer) => …)`. + let js = compile_js("\n

    {a.b}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\tlet { a } = $$props;\n\ + \t\t$$renderer.push(`

    ${$.escape(a.b)}

    `);\n\ + \t});\n\ + }\n" + ); +} + +#[test] +fn compile_member_on_local_does_not_wrap() { + // A member rooted in a plain local binding is safe — no wrapper, and the + // `$$props` parameter stays absent. + let js = compile_js("\n

    {a.b}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tlet a = { b: 1 };\n\ + \t$$renderer.push(`

    ${$.escape(a.b)}

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_new_expression_wraps_and_injects_props() { + // A `new` expression sets `needs_context` even with no props; the wrapper + // and the `$$props` parameter are both injected. + let js = compile_js("

    {new Date()}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\t$$renderer.push(`

    ${$.escape(new Date())}

    `);\n\ + \t});\n\ + }\n" + ); +} + +#[test] +fn compile_refuses_member_on_shadowed_prop() { + // A prop name reused as a nested binding makes a member/call root + // ambiguous for this name-based analysis — refuse rather than guess. + assert_unsupported( + "\n

    {f(1)}

    ", + "also bound in a nested scope", + ); +} + +#[test] +fn compile_hoists_instance_imports() { + // A side-effect import hoists to module scope (an import inside the + // component function is invalid JS). + let js = compile_js("\n

    text

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + import './x.js';\n\ + export default function Input($$renderer) {\n\ + \t$$renderer.push(`

    text

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_hoists_import_and_wraps_on_member_use() { + // A named import hoists to module scope; a member access on the import + // root also triggers the wrapper — the two fixes compose. + let js = compile_js("\n

    {x.y}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + import { x } from './x.js';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\t$$renderer.push(`

    ${$.escape(x.y)}

    `);\n\ + \t});\n\ + }\n" + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/css_scope.rs b/crates/tsv_svelte_compile/src/tests/css_scope.rs new file mode 100644 index 000000000..ae8c0f326 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/css_scope.rs @@ -0,0 +1,967 @@ +//! CSS scoping: selector matching against the element census. + +use super::support::*; + +#[test] +fn compile_css_type_selector_synthesizes_class() { + // A bare `
    ` scoped by a type selector gains a synthetic + // `class="svelte-tsvhash"` (no class markup of its own), and the type selector + // splices the hash after the tag name. + let out = compile_checked("
    x
    \n"); + assert!( + out.js.contains(r#"
    x
    "#), + "synthetic scoped class: {}", + out.js + ); + assert_eq!(out.css.as_deref(), Some("div.svelte-tsvhash{ color: red }")); +} + +#[test] +fn compile_css_type_selector_extends_existing_class() { + // A type-scoped element with an authored static `class` appends the hash to + // the existing value (the element is scoped by the type, not the class token). + let js = compile_js("
    x
    \n"); + assert!( + js.contains(r#"
    x
    "#), + "{js}" + ); +} + +#[test] +fn compile_css_id_and_attribute_selectors() { + // Id selector: synthetic class after the authored `id` attribute. + let js = compile_js("
    y
    \n"); + assert!( + js.contains(r#"
    y
    "#), + "{js}" + ); + // Attribute presence selector matches any value (here static). + let js = compile_js("

    y

    \n"); + assert!( + js.contains(r#"

    y

    "#), + "{js}" + ); + // Attribute value + explicit `i` flag matches case-insensitively. + let js = compile_js("

    y

    \n"); + assert!( + js.contains(r#"

    y

    "#), + "{js}" + ); +} + +#[test] +fn compile_css_universal_replaces_span() { + // A bare `*` is REPLACED by the hash class (not appended). + let out = compile_checked("
    x
    \n"); + assert_eq!(out.css.as_deref(), Some(".svelte-tsvhash{ color: red }")); + // `*.c` appends on `.c` (only a bare trailing `*` replaces). + let out = compile_checked("
    x
    \n"); + assert_eq!(out.css.as_deref(), Some("*.c.svelte-tsvhash{ color: red }")); +} + +#[test] +fn compile_css_compound_needs_same_element() { + // `.a.b` matches an element carrying BOTH classes. + let out = compile_checked("
    x
    \n"); + assert!( + out.js.contains(r#"class="a b svelte-tsvhash""#), + "{}", + out.js + ); + // `.a` and `.b` on DIFFERENT elements — no element carries both, so the + // compound matches nothing and refuses (the oracle would comment-wrap it). + assert_unsupported( + "
    x
    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_open_whitelist_on_details() { + // `[open]` on `
    ` matches unconditionally (no `open` attribute needed). + let js = compile_js("
    x
    \n"); + assert!( + js.contains(r#"
    x
    "#), + "{js}" + ); +} + +#[test] +fn compile_css_type_matching_no_element_refuses() { + // A type selector for an element that isn't present matches nothing → refuse. + assert_unsupported( + "
    x
    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_combinator_selectors() { + // Descendant: both compounds scope (each matched element gains the hash), the + // first bump is a plain class, the second a zero-specificity `:where(...)`. + let out = compile_checked("

    hi

    \n"); + assert!( + out.js + .contains(r#"

    hi

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("div.svelte-tsvhash p:where(.svelte-tsvhash){ color: red }") + ); + // Child `>`, next-sibling `+`, subsequent-sibling `~` all splice the same way. + assert_eq!( + compile_css("

    hi

    \n"), + "div.svelte-tsvhash > p:where(.svelte-tsvhash){ color: red }" + ); + assert_eq!( + compile_css("\n"), + "a.svelte-tsvhash + b:where(.svelte-tsvhash){ color: red }" + ); + assert_eq!( + compile_css("\n"), + "a.svelte-tsvhash ~ b:where(.svelte-tsvhash){ color: red }" + ); +} + +#[test] +fn compile_css_combinator_block_descent_and_each_wrap() { + // A preceding sibling reached through a `{#if}` block (block descent) still + // matches `a + b`. + assert_eq!( + compile_css("{#if x}{/if}\n"), + "a.svelte-tsvhash + b:where(.svelte-tsvhash){ color: red }" + ); + // The `{#each}` self-adjacency wrap-around: a later-in-source sibling is a + // possible runtime preceding sibling. + assert_eq!( + compile_css("{#each xs as x}{/each}\n"), + "a.svelte-tsvhash ~ b:where(.svelte-tsvhash){ color: red }" + ); +} + +#[test] +fn compile_css_combinator_no_match_refuses() { + // A combinator chain that matches no element is pruned by the oracle — tsv + // refuses (no `` for `span + b`). + assert_unsupported( + "\n", + "matches no element", + ); +} + +#[test] +fn compile_css_global_leading_trailing_and_bare() { + // Leading `:global(.x) .y`: `.x` is global (no hash, wrapper stripped), `.y` + // scopes (the first bump, plain class). The `.y` element gains the class. + let out = + compile_checked("
    hi
    \n"); + assert!(out.js.contains(r#"class="y svelte-tsvhash""#), "{}", out.js); + assert_eq!( + out.css.as_deref(), + Some(".x .y.svelte-tsvhash{ color: red }") + ); + // Trailing `.a :global(.x)`: truncate drops `:global(.x)` from matching, but its + // wrapper still strips in output; `.a` scopes. + assert_eq!( + compile_css("
    hi
    \n"), + ".a.svelte-tsvhash .x{ color: red }" + ); + // A fully-global `:global(.x)` is never pruned and scopes no element. + assert_eq!( + compile_css("
    hi
    \n"), + ".x{ color: red }" + ); + // A bare `:global` combinator: `:global` (and the preceding space) strips. + assert_eq!( + compile_css( + "
    hi
    \n" + ), + "div.svelte-tsvhash.x{ color: red }" + ); +} + +#[test] +fn compile_css_specificity_bump_resets_per_comma() { + // Bump state resets per comma `ComplexSelector`: the `.a` after the comma gets a + // plain class again, not `:where(...)`. + assert_eq!( + compile_css("

    hi

    \n"), + "div.svelte-tsvhash p:where(.svelte-tsvhash), .a.svelte-tsvhash{ color: red }" + ); +} + +#[test] +fn compile_css_refused_selector_shapes() { + // The refuse-list held after slice 5: the `||` column combinator, the logical/ + // relational pseudos (`:is`/`:where`/`:has`/`:not`), `:root`, and bare + // pseudo-only compounds. (The four real combinators and basic `:global` now + // compile — see `compile_css_combinator_selectors` / `compile_css_global_*`.) + assert_unsupported( + "
    x
    \n", + "combinator", + ); + for selector in [ + ":is(.a, .b)", + ":where(.a)", + ":has(.a)", + ":not(.a)", + ":root", + ":hover", + ] { + assert_unsupported( + &format!("
    x
    \n"), + "unsupported css selector", + ); + } + // A `:global{}` global block stays refused — it is a nested rule, so it lands on + // the nested-rule guard (global blocks are a deferred slice either way). + assert_unsupported( + "
    x
    \n", + "nested css rule", + ); +} + +#[test] +fn compile_css_dynamic_attribute_value_enumerated_no_match_prunes() { + // A VALUED attribute selector against a same-named dynamic attribute value the + // oracle `get_possible_values`-enumerates (an all-literal ternary → {'a','b'}) + // is now ported: neither branch matches `[data-x="z"]`, so the selector matches + // no element and refuses `CssSelectorNoMatch` (the oracle comment-wraps it) — + // NOT `CssDynamicAttributeMatch`. Confirms the ternary was enumerated, not + // assume-matched. + assert_unsupported( + "\n

    y

    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_dynamic_attribute_logical_and_falsy_bounded() { + // A non-`class` `&&` injects the falsy quartet (`''`/`false`/`NaN`/`0`), so + // `[data-x=""]` matches (the `''` member) and the element scopes — but the set + // is BOUNDED, so a selector for a value not in it (`[data-x="q"]`) still prunes. + let css = compile_css( + "\n

    y

    \n", + ); + assert_eq!(css, "[data-x=\"\"].svelte-tsvhash{ color: red }"); + assert_unsupported( + "\n

    y

    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_dynamic_attribute_ts_wrapper_erased_before_gather() { + // The oracle erases TypeScript in phase 1, BEFORE CSS analysis, so `{false as + // true}` is gathered as `false` → {"false"} — it does NOT match `[data-active= + // 'true']`, so the selector matches no element and refuses `CssSelectorNoMatch`. + // Reading the raw `TSAsExpression` (not stripping the wrapper) would fall to + // `else → UNKNOWN → assume-match` and OVER-scope the element (a MISMATCH the + // corpus caught). This pins the wrapper strip. Shape = the `unused-ts-as- + // expression` Svelte suite fixture. + assert_unsupported( + "\n
    \n\t\n
    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_dynamic_attribute_ts_wrapper_nested_enumerates() { + // A wrapper nested inside a conditional branch is stripped by the recursion's + // per-entry strip: `c ? (0 as any) : (1 as any)` enumerates {"0","1"}. `[data-x= + // "0"]` matches (scopes) while `[data-x="9"]` prunes — confirming the branches + // were ENUMERATED through the wrappers, not assume-matched. + let css = compile_css( + "\n

    y

    \n", + ); + assert_eq!(css, "[data-x=\"0\"].svelte-tsvhash{ color: red }"); + assert_unsupported( + "\n

    y

    \n", + "matches no element", + ); +} + +#[test] +fn compile_css_dynamic_attribute_unstringifiable_literal_refuses() { + // An otherwise-enumerable set carrying a literal tsv cannot stringify byte-exactly + // refuses the whole compile (a safe over-refusal — the oracle enumerates it, e.g. + // `String(0.5)="0.5"`, so tsv declines rather than drop the value and under-match). + // A non-integer number, a BigInt, and a regex each hit the `CssDynamicAttributeMatch` + // arm (narrowed to exactly this un-stringifiable residual). + for value in ["0.5", "10n", "/x/"] { + assert_unsupported( + &format!("

    y

    \n"), + "un-stringifiable dynamic value", + ); + } +} + +#[test] +fn compile_css_class_split_matches_js_whitespace() { + // The `~=` class token split must match JS `/\s/` exactly, not Rust's + // `char::is_whitespace`. BOM (U+FEFF) is JS whitespace (not Rust's), so it + // splits the value → `.foo` matches and the element scopes. + let js = + compile_js("
    x
    \n"); + assert!( + js.contains("class=\"foo\u{feff}bar svelte-tsvhash\""), + "BOM must split the class token (JS \\s): {js:?}" + ); + // NEL (U+0085) is Rust whitespace but NOT JS's, so it must NOT split → + // `foo\u{85}bar` is one token, `.foo` does not match it (only the plain + // `
    ` matches), so the NEL element is left unscoped. + let js = compile_js( + "
    a
    \n
    b
    \n", + ); + assert!( + js.contains("class=\"foo svelte-tsvhash\">a
    "), + "plain foo scopes: {js:?}" + ); + assert!( + js.contains("class=\"foo\u{85}bar\">b
    "), + "NEL token must NOT match .foo (no hash): {js:?}" + ); +} + +#[test] +fn compile_css_non_ascii_case_insensitive_refuses() { + // A case-insensitive attribute match with a non-ASCII operand refuses (the + // oracle folds case with full Unicode; tsv folds ASCII-only — a safe + // over-refusal). Selector value, element value, and the `i` flag all reach it. + assert_unsupported( + "

    y

    \n", + "non-ASCII operand", + ); + // An HTML case-insensitive attribute (`type`) with a non-ASCII value refuses + // even without an explicit flag. + assert_unsupported( + "

    y

    \n", + "non-ASCII operand", + ); + // A case-SENSITIVE compare (no flag, not an HTML ci attr) is a byte test and + // stays supported with a non-ASCII value. + let out = compile_checked( + "

    y

    \n", + ); + assert!(out.js.contains("class=\"svelte-tsvhash\""), "{}", out.js); +} + +#[test] +fn compile_css_spread_element_scoped_by_type() { + // A spread element scoped by a type selector carries the hash in the + // `css_hash` (2nd) `$.attributes` argument (assume-match on the spread too). + let js = compile_js( + "\n
    x
    \n", + ); + assert!( + js.contains("$.attributes({ ...props }, 'svelte-tsvhash')"), + "{js}" + ); +} + +#[test] +fn compile_css_group_at_rule_scopes_inner_rules() { + // A non-keyframes group at-rule recurses into its block and scopes the inner + // rules exactly like top-level ones (the oracle's generic `next()` recursion); + // the at-rule prelude is untouched. @media with a class inner + a type inner. + assert_eq!( + compile_css( + "
    x
    \n" + ), + "@media (min-width: 500px) { .a.svelte-tsvhash { color: red } div.svelte-tsvhash { color: blue } }" + ); + // A combinator inside an at-rule bumps specificity per ComplexSelector just like + // at top level (`.svelte-tsvhash` then `:where(...)`). + assert_eq!( + compile_css("

    x

    \n"), + "@media screen { div.svelte-tsvhash p:where(.svelte-tsvhash) { color: red } }" + ); + // Nested at-rules recurse arbitrarily deep. + assert_eq!( + compile_css( + "
    x
    \n" + ), + "@media screen { @supports (display: grid) { .a.svelte-tsvhash { color: red } } }" + ); +} + +#[test] +fn compile_css_statement_and_descriptor_at_rules_pass_through_verbatim() { + // A statement at-rule (`@import`, `block: None`) and a descriptor block + // (`@font-face`, declarations only) scope nothing and are copied through + // verbatim by the splicer (it applies edits only from matched inner rules); a + // sibling `div` rule still scopes. + assert_eq!( + compile_css("
    x
    \n"), + "@import 'x.css'; div.svelte-tsvhash { color: red }" + ); + assert_eq!( + compile_css( + "
    x
    \n" + ), + "@font-face { font-family: Foo } div.svelte-tsvhash { color: red }" + ); +} + +#[test] +fn compile_css_at_rule_prelude_is_never_scoped() { + // `@scope (.a) to (.b)` — the prelude selectors are a raw string the oracle + // never scopes; only the inner `.a` rule gains the hash. + assert_eq!( + compile_css( + "
    x
    \n" + ), + "@scope (.a) to (.b) { .a.svelte-tsvhash { color: red } }" + ); +} + +#[test] +fn compile_css_at_rule_unused_inner_selector_refuses() { + // An inner selector matching no element is the oracle's comment-wrap (unused); + // tsv's existing posture — refuse `CssSelectorNoMatch` — carries through the + // at-rule descent unchanged (a safe over-refusal). + assert_unsupported( + "
    x
    \n", + "matches no element", + ); +} + +// ── @keyframes scoping (the oracle's is_keyframes_node name-prefix + animation-value +// rewrite; every expected string below is the live oracle's CSS, canonical_compile) ── + +#[test] +fn compile_css_keyframes_basic_name_prefix_and_animation_ref() { + // The keyframes NAME is prefixed and every `animation` value token referencing a + // collected name is rewritten to the prefixed spelling (probe 1). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } to { opacity: 1 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_global_prefix_is_stripped_and_uncollected() { + // A `-global-` prelude STRIPS the 8-byte prefix (leaving the bare name, un-scoped) + // and is NOT collected — so `animation: foo` is left alone (probe 2). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes foo { from { opacity: 0 } to { opacity: 1 } } .a.svelte-tsvhash { animation: foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_vendor_at_rule_collects() { + // A vendor-prefixed keyframes at-rule (`@-webkit-keyframes`) DOES collect; the name + // start is `@` + decoded name (`-webkit-keyframes`, 17 bytes) + 1 (probe 3). + assert_eq!( + compile_css( + "
    x
    " + ), + "@-webkit-keyframes svelte-tsvhash-spin { from { opacity: 0 } to { opacity: 1 } } .a.svelte-tsvhash { animation: svelte-tsvhash-spin 1s }" + ); +} + +#[test] +fn compile_css_keyframes_multi_name_list_in_both_declarations() { + // Two names, rewritten in BOTH an `animation` list (commas + other tokens) and an + // `animation-name` list (probe 4). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-a { from { opacity: 0 } } @keyframes svelte-tsvhash-b { from { opacity: 0 } } .x.svelte-tsvhash { animation: svelte-tsvhash-a 1s ease, svelte-tsvhash-b 2s; animation-name: svelte-tsvhash-a, svelte-tsvhash-b }" + ); +} + +#[test] +fn compile_css_keyframes_unused_is_kept_and_prefixed() { + // Keyframes are never pruned — an unreferenced keyframes is still kept and prefixed + // (probe 5). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-unused { from { opacity: 0 } to { opacity: 1 } } .a.svelte-tsvhash { color: red }" + ); +} + +#[test] +fn compile_css_keyframes_inside_media_collects_and_prefixes() { + // Keyframes nested in `@media` are prefixed and collected — the `@media` descent + // reaches them (probe 6). + assert_eq!( + compile_css( + "
    x
    " + ), + "@media (min-width: 100px) { @keyframes svelte-tsvhash-m { from { opacity: 0 } to { opacity: 1 } } } .a.svelte-tsvhash { animation: svelte-tsvhash-m 1s }" + ); +} + +#[test] +fn compile_css_keyframes_declarations_inside_block_are_untouched() { + // The transform returns without descending into a keyframes block, so an + // `animation-name` INSIDE it stays verbatim (probe 7). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { animation-name: foo; opacity: 0 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_property_compare_lowercases_but_source_preserved() { + // The property compare lowercases (`ANIMATION`), but the source property text is + // preserved — only the value is rewritten (probe 8). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { ANIMATION: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_tab_after_name_glues_the_insert() { + // Only LITERAL SPACE bytes are skipped after the name, so a TAB leaves the insert + // glued right after `@keyframes` — GLUED garbage the oracle also emits. The prelude + // is still trimmed (`foo` collects, `animation: foo` rewrites) (probe 10). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframessvelte-tsvhash-\tfoo { from { opacity: 0 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_empty_prelude_collects_the_empty_string() { + // An EMPTY prelude collects the empty string: the name insert lands on `{` after the + // space-skip (probe 13), and the collected `''` matches at every boundary whose + // accumulated token is empty — here the first boundary after `:` and the final `}` + // (probe 18). One test pins both. + assert_eq!( + compile_css("
    x
    "), + "@keyframes svelte-tsvhash-{ from { opacity: 0 } }" + ); + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-{ from { opacity: 0 } } .a.svelte-tsvhash { animation:svelte-tsvhash- x 1s svelte-tsvhash-}" + ); +} + +#[test] +fn compile_css_keyframes_quoted_name_not_rewritten() { + // Quotes are part of the accumulated token, so `"foo"` never equals the collected + // `foo` — not rewritten (probe 14). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { animation-name: \"foo\" }" + ); +} + +#[test] +fn compile_css_keyframes_declaration_scan_reaches_font_face_descriptor() { + // The `Declaration` visitor reaches a descriptor block (`@font-face`), so an + // `animation-name` there is rewritten (probe 15). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } @font-face { font-family: x; animation-name: svelte-tsvhash-foo }" + ); +} + +#[test] +fn compile_css_keyframes_reference_before_declaration_is_a_full_prepass() { + // Collection is a full pre-pass, so an `animation` referencing a keyframes declared + // LATER in source still rewrites (probe 16). + assert_eq!( + compile_css( + "
    x
    " + ), + ".a.svelte-tsvhash { animation: svelte-tsvhash-late 1s } @keyframes svelte-tsvhash-late { from { opacity: 0 } }" + ); +} + +#[test] +fn compile_css_keyframes_no_space_after_colon() { + // No space between `:` and the name — the token scan still finds it (probe 17). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { animation:svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_two_word_prelude_collects_the_whole_string() { + // A two-word prelude collects `'foo bar'` (the whole trimmed prelude), so + // `animation: foo` does NOT match — but the name TOKEN is still prefixed (probe 19). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo bar { from { opacity: 0 } } .a.svelte-tsvhash { animation: foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_only_animation_and_animation_name_rewrite() { + // `animation-duration` is neither `animation` nor `animation-name`, so its value is + // left alone (probe 26). + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { animation-duration: foo }" + ); +} + +#[test] +fn compile_css_keyframes_vendor_prefixed_property_rewrites() { + // A vendor-prefixed PROPERTY (`-webkit-animation`) is stripped before the compare, + // so its value rewrites (probe 11); the name-start arithmetic uses the raw property + // length (17) so the scan begins at the right byte. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { -webkit-animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_uppercase_keyframes_is_a_group_at_rule() { + // The keyframes discriminator is case-SENSITIVE (the oracle's `is_keyframes_node` + // tests `=== 'keyframes'`), so `@KEYFRAMES` is NOT keyframes: it recurses as a + // group at-rule and its `from`/`to` are element selectors matching no element → + // `CssSelectorNoMatch` (the oracle likewise comment-wraps them as unused). Either + // verdict is safe; this pins the one tsv reaches. + assert_unsupported( + "
    x
    \n", + "matches no element", + ); +} + +// ── @keyframes prelude = the oracle's `node.prelude` (comment-elided, JS-whitespace trim), +// via `CssAtrule::public_prelude` — NOT the printer-facing `Raw::content` (comment-preserving, +// CSS-whitespace trim). When the two diverge, the wrong name silently fails to rewrite the +// `animation` reference while the at-rule name still gets prefixed (renamed keyframes, +// un-renamed reference). Every expected string below is the live oracle's CSS +// (canonical_compile on the exact single-line input). ── + +#[test] +fn compile_css_keyframes_prelude_comment_is_elided_for_collection() { + // Natural real-world CSS: a comment after the name. The oracle's `read_value` elides the + // comment for `node.prelude` (→ `spin`), so `animation: spin` IS rewritten; the comment + // survives verbatim in the emitted at-rule (only the name is spliced). `Raw::content` + // preserved the comment (`spin /* clockwise */`), so the token compare missed `spin`. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-spin /* clockwise */ { from { opacity: 0 } to { opacity: 1 } } .spinner.svelte-tsvhash { animation: svelte-tsvhash-spin 1s linear infinite }" + ); +} + +#[test] +fn compile_css_keyframes_prelude_vertical_tab_is_trimmed_for_collection() { + // A vertical tab (U+000B) after the name: JS whitespace (`read_value().trim()` → `foo`) + // but NOT CSS whitespace, so `Raw::content` kept it (`foo\u{000b}`) and the token compare + // missed `foo`. The VT survives verbatim after the spliced name. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo\u{000b} { from { opacity: 0 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_prelude_nbsp_is_trimmed_for_collection() { + // A no-break space (U+00A0) after the name: JS whitespace (trimmed → `foo`) but a CSS + // *ident* code point, so `Raw::content` kept it and the token compare missed `foo`. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo\u{a0} { from { opacity: 0 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_prelude_glued_comment_is_elided_for_collection() { + // A comment glued to the name (`foo/* c */`): the oracle elides it (→ `foo`), the token + // compare hits, and `animation: foo` is rewritten; the glued comment survives verbatim. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes svelte-tsvhash-foo/* c */ { from { opacity: 0 } } .a.svelte-tsvhash { animation: svelte-tsvhash-foo 1s }" + ); +} + +#[test] +fn compile_css_keyframes_animation_in_style_attribute_is_never_rewritten() { + // The control: `animation` in a `style=` ATTRIBUTE value is never scanned (only `", + ); + assert!( + out.js.contains("style=\"animation: foo 1s\""), + "style-attribute animation must stay un-prefixed:\n{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some( + "@keyframes svelte-tsvhash-foo { from { opacity: 0 } } .a.svelte-tsvhash { color: red }" + ) + ); +} + +#[test] +fn compile_css_keyframes_global_hidden_behind_a_comment_still_strips() { + // `-global-` hides behind a leading comment (`@keyframes /* c */-global-foo`). The oracle + // elides the comment (`node.prelude` → `-global-foo`), so BOTH the collection exclusion + // and the STRIP branch fire: the name edit removes 8 bytes at the space-skipped start + // (`/* c */-`, comment + dash), leaving `global-foo`, and `animation: foo` is left alone. + // `Raw::content` (`/* c */-global-foo`) missed the `-global-`, so tsv wrongly prefixed. + assert_eq!( + compile_css( + "
    x
    " + ), + "@keyframes global-foo { from { opacity: 0 } } .a.svelte-tsvhash { animation: foo 1s }" + ); +} + +#[test] +fn compile_refuses_global_pseudo_class_with_a_non_ascii_ident_char() { + // A CSS name must NOT be trimmed with a Unicode-whitespace notion: every code + // point at or above U+0080 is a CSS *ident* code point, so `:global\u{a0}` is + // the pseudo-class `global\u{a0}`, not `:global`. Rust's `str::trim` stripped + // the NBSP, tsv read `:global`, and scoped the element while the oracle pruned + // the rule as unused — an oracle-verified MISMATCH, now a safe over-refusal + // (the selector matches no element). + assert_unsupported( + "
    a
    \n", + "matches no element", + ); +} + +// ── @keyframes STEP matching (the oracle's prune walk descends into keyframes blocks and +// matches each step rule's selectors against every element — the transform never descends, +// so a step scopes elements without ever splicing the block). Every expected string below +// is the live oracle's output (canonical_compile). ── + +#[test] +fn compile_css_keyframes_percentage_step_matches_every_element() { + // s1: a percentage-only step compound (`0%`/`100%`) has an empty predicate list after the + // per-simple Percentage skip, so it matches EVERY element (the oracle's fallthrough) — + // both `

    ` and `

    ` gain the hash though no selector targets them. The step block is + // never spliced (only the name is prefixed). + let out = compile_checked( + "

    x

    y

    ", + ); + assert!( + out.js + .contains(r#"

    x

    y

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { 0% { opacity: 0 } 100% { opacity: 1 } }") + ); +} + +#[test] +fn compile_css_keyframes_from_to_steps_scope_only_named_elements() { + // s2: `from`/`to` are TYPE selectors (they match elements NAMED `from`/`to`), so with a + // plain `

    ` present nothing scopes. + let out = compile_checked( + "

    x

    ", + ); + assert!(out.js.contains("

    x

    "), "{}", out.js); + assert!(!out.js.contains("svelte-tsvhash\">x"), "{}", out.js); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { from { opacity: 0 } to { opacity: 1 } }") + ); +} + +#[test] +fn compile_css_keyframes_global_percentage_step_still_matches_every_element() { + // s3: step matching runs for a `-global-` keyframes too (the prune walk is name-blind) — + // the `0%` step scopes `

    `. The NAME edit strips `-global-` (leaving the bare, + // un-prefixed `k`), independent of step matching. + let out = + compile_checked("

    x

    "); + assert!( + out.js.contains(r#"

    x

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes k { 0% { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_media_nested_percentage_step_matches_every_element() { + // s4: an `@media`-nested keyframes is reached by the group-at-rule descent; its `0%` step + // scopes `

    ` and the name is prefixed inside the `@media`. + let out = compile_checked( + "

    x

    ", + ); + assert!( + out.js.contains(r#"

    x

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@media (min-width: 10px) { @keyframes svelte-tsvhash-k { 0% { opacity: 0 } } }") + ); +} + +#[test] +fn compile_css_keyframes_from_step_scopes_a_from_element() { + // s5: a `from` step is the TYPE selector `from`, so it scopes a `` element — the + // step selector matches by tag name, exactly like a top-level type selector. + let out = compile_checked("x"); + assert!( + out.js.contains(r#"x"#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { from { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_comma_step_scopes_matching_element_only() { + // t2: `from, to` is two step ComplexSelectors — `` matches `to`, `

    ` matches + // neither, so only `` scopes. + let out = compile_checked( + "x

    y

    ", + ); + assert!( + out.js + .contains(r#"x

    y

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { from, to { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_type_step_scopes_matching_element_only() { + // t3: a `div` type step scopes `
    ` and leaves `

    ` bare. + let out = + compile_checked("

    x

    y

    "); + assert!( + out.js + .contains(r#"
    x

    y

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { div { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_class_step_scopes_matching_element_only() { + // t4: a `.c` class step scopes the `class="c"` div (appending the hash) and leaves `

    ` + // bare. + let out = compile_checked( + "

    x

    y

    ", + ); + assert!( + out.js + .contains(r#"
    x

    y

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { .c { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_percentage_class_compound_narrows_per_simple() { + // t5: `0%.c` — the Percentage is skipped PER-SIMPLE within the compound, but the `.c` + // predicate remains, so it matches ONLY `class="c"` (the div), NOT `

    `. This proves the + // skip is per-simple, not a blanket "contains a percentage ⇒ scope everything". + let out = compile_checked( + "

    x

    y

    ", + ); + assert!( + out.js + .contains(r#"
    x

    y

    "#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { 0%.c { opacity: 0 } }") + ); +} + +#[test] +fn compile_css_keyframes_empty_step_scopes_and_keeps_block_verbatim() { + // t6: an EMPTY step (`from {}`) does NOT hit the empty-rule refusal (steps are never + // refused for emptiness) — it still scopes ``, and the CSS keeps `from {}` verbatim + // (no empty-rule comment-wrap, the transform never descends). + let out = compile_checked("x"); + assert!( + out.js.contains(r#"x"#), + "{}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { from {} }") + ); +} + +#[test] +fn compile_css_keyframes_no_match_step_neither_scopes_nor_refuses() { + // t7: a `from` step with only an `

    ` present matches nothing — it NEITHER scopes + // anything NOR refuses `CssSelectorNoMatch` (steps are never pruned). The component + // compiles and the keyframes name is still prefixed. + let out = compile_checked("

    x

    "); + assert!(out.js.contains("

    x

    "), "{}", out.js); + assert!( + !out.js.contains("svelte-tsvhash"), + "nothing scoped: {}", + out.js + ); + assert_eq!( + out.css.as_deref(), + Some("@keyframes svelte-tsvhash-k { from { opacity: 0 } }") + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/dollar_bindings.rs b/crates/tsv_svelte_compile/src/tests/dollar_bindings.rs new file mode 100644 index 000000000..102511031 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/dollar_bindings.rs @@ -0,0 +1,467 @@ +//! The `$`-prefixed identifier rules and the oracle exemptions. + +use super::support::*; + +#[test] +fn compile_rejects_bare_rune_reference() { + // A bare $-prefixed identifier reference is oracle-rejected input — + // refuse instead of compiling a broken passthrough. + assert_unsupported( + "\n

    text

    ", + "$state", + ); + assert_unsupported("

    {$foo}

    ", "$foo"); +} + +#[test] +fn compile_refuses_dollar_prefixed_bindings() { + // The oracle's `dollar_prefix_invalid` + // (`phases/2-analyze/visitors/shared/utils.js:278` — + // `node.name.startsWith('$')` on a Binding) is a Svelte reserved-prefix rule + // on a BINDING, not on a reference and not a JS early error. `$$slots` is + // the sharp edge: a *reference* is the real runtime value the transform + // injects (`$.sanitize_slots`), so the guard exempts it, but a + // *declaration* of that name is a compile error and must not inherit the + // reference's exemption. Every case below was probe-verified oracle-rejected + // against the pinned compiler. + // + // Variable declarators — any declaration kind, any function depth + // (the `VariableDeclarator` visitor validates with no `function_depth` + // argument, so the depth gate never applies), destructured or not. + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    {f()}

    ", + "$$slots", + ); + assert_unsupported( + "\n

    {a()}

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + // A function / class declaration id, a function-expression id, and a catch + // parameter are bindings too. + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    {f.name}

    ", + "$$slots", + ); + assert_unsupported( + "\n

    {r}

    ", + "$$slots", + ); + // An import's local — the oracle's message names imports explicitly. + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + assert_unsupported( + "\n

    x

    ", + "$$slots", + ); + // The rule is the `$` prefix, not the name — the same positions refuse for + // any `$`-prefixed binding. These cover the positions the pre-existing + // reference-side refusal never reached (a class declaration id, an import + // local), which over-accepted for EVERY name before the binding rule + // existed, not only for `$$slots`. + assert_unsupported("\n

    x

    ", "$Foo"); + assert_unsupported( + "\n

    x

    ", + "$foo", + ); + assert_unsupported( + "\n

    x

    ", + "$$props", + ); +} + +#[test] +fn compile_refuses_escaped_reserved_bindings() { + // The crate's standing escaped-identifier residual, CLOSED. A `\u`-escaped + // identifier decodes to a reserved name the oracle rejects (it reads the + // DECODED `node.name`), but the name-extraction helpers were span-identity and + // bailed on `escaped_name`, so every guarded position over-accepted. Each rule + // now decodes via `Identifier::name`, matching the oracle. + // + // `dollar_prefix_invalid` across all six binding positions — `$` = `$`. + for (source, name) in [ + // A declarator leaf (via the decode-aware pattern collector). + ( + "\n

    a

    ", + "$x", + ), + // A function-declaration id. + ("\n

    a

    ", "$f"), + // A class-declaration id. + ("\n

    a

    ", "$C"), + // A function-expression id. + ( + "\n

    a

    ", + "$g", + ), + // An import specifier's local. + ( + "\n

    a

    ", + "$x", + ), + // A catch-clause parameter (via the decode-aware pattern collector). + ( + "\n

    {r}

    ", + "$x", + ), + ] { + assert_unsupported(source, name); + } + // `props_illegal_name`, declare-site — an escaped `$$` key (`$$x`). + assert_unsupported( + "\n

    {a}

    ", + "prop name starting with `$$`", + ); + // `props_illegal_name`, reference-site — an escaped `$$` property on a rest_prop. + assert_unsupported( + "\n

    x

    ", + "prop name starting with `$$`", + ); + // `invalid_arguments_usage` — an escaped `arguments` (`arguments`) reference + // outside a function. + assert_unsupported( + "\n

    x

    ", + "arguments referenced outside a function", + ); +} + +#[test] +fn compile_refuses_dollar_prefixed_binding_on_the_rewrite_path() { + // `script_rewrite::rewrite_script_statement` rewrites a top-level instance-script + // declaration instead of guard-walking it, so it needs the binding rule at + // its OWN chokepoint — the guard walk never applies it here. Two halves, + // both probe-verified oracle-rejected (`dollar_prefix_invalid`), both + // over-accepted before the check moved ahead of the rune dispatch: + // + // (a) a non-rune declarator sharing a statement with a rune one. Its id took + // the STORE-READ exemption, which this path's `WalkCtx` enables — so it + // needed the base name (`x`) to be a binding, and any plain import supplies + // that. Kept per rune kind: the trigger is the shared-declaration rune path, + // and each kind reaches it. + for init in ["$state(1)", "$state.raw(1)", "$props()", "$derived(1 + 1)"] { + assert_unsupported( + &format!( + "\n

    {{$x}}

    " + ), + "$x", + ); + } + // (b) the rune declarator's OWN id, which was not walked at all — so unlike + // (a) it never depended on a bound base name, and no template read is needed + // to reach it. + for init in [ + "$state(1)", + "$state.raw(1)", + "$derived(1 + 1)", + "$props.id()", + ] { + assert_unsupported( + &format!("\n

    hi

    "), + "$x", + ); + } +} + +#[test] +fn compile_refuses_dollar_prefixed_class_expression_id() { + // A class EXPRESSION id is the one `$`-prefixed binding name the oracle + // ACCEPTS (it declares no binding, so `dollar_prefix_invalid` never fires), + // and tsv over-refuses it deliberately. Reproducing the oracle here means + // reproducing two defects: its reference analysis is name-based and counts + // the id as a READ, so `class $$slots {}` injects `$.sanitize_slots`, and + // `class $Foo {}` drives its store rewrite to emit `class $.store_get(…) {}` + // — invalid JS. Pinned so the over-refusal is a decision, not a drift. + assert_unsupported( + "\n

    {C.name}

    ", + "$$slots", + ); + assert_unsupported( + "\n

    {C.name}

    ", + "$Foo", + ); +} + +#[test] +fn compile_refuses_dollar_prefixed_binding_the_oracle_exempts_by_depth() { + // Two of the guarded positions are oracle-rejected only at the TOP LEVEL: a + // function-expression id and a catch-clause parameter both declare through + // `scope.js:695`, the one `validate_identifier_name` call path that passes + // `function_depth`, and the instance script's top-level scope sits at depth + // 1. So the oracle ACCEPTS these three inside a function body + // (probe-verified against the pinned compiler) and tsv refuses them anyway. + // + // The over-refusal is deliberate, not a missing depth check. tsv's + // `WalkCtx::fn_depth` is NOT the oracle's `function_depth`: the oracle's + // non-porous increment happens at a function's `BlockStatement` + // (`scope.js:1174-1188`), so an expression-bodied arrow does not increment + // it and does increment tsv's. Gating on `fn_depth == 0` would compile the + // oracle-REJECTED `const h = () => function $$slots() {}` — an + // over-acceptance, which is a refusal-contract bug, where this is only an + // over-refusal. Pinned so the trade is a decision, not a drift. + for (source, name) in [ + ( + "\n

    {f()}

    ", + "$$slots", + ), + ( + "\n

    {f().name}

    ", + "$$slots", + ), + ( + "\n

    {f()}{$x}

    ", + "$x", + ), + ] { + assert_unsupported(source, name); + } +} + +#[test] +fn compile_allows_dollar_prefixed_binding_positions_the_oracle_exempts() { + // The complement of the rule above, and the real risk of adding it: the + // oracle EXEMPTS a `$`-prefixed name in a parameter position + // (`declaration_kind` `param`/`rest_param`) and in a template binding + // (declared in scopes past its `function_depth <= 1` gate), so over-refusing + // there would break components the oracle compiles. Probe-verified + // oracle-accepted; the `$$slots` cases also carry a genuine `$$slots` + // reference, the cross the name-keyed carve-out used to conflate. + // + // ⚠️ Read the coverage narrowly: every case below is named `$$slots`, and + // that is not a stylistic choice — it is the ONLY `$`-prefixed name this + // test CAN cover. For any other name in these same oracle-exempt positions + // (`function f($p)`, `{#each … as $x}`, `{@const $c = 1}`) tsv already + // over-refuses, via the unrelated pre-existing reference-side store + // refusal (`Refusal::DollarPrefixedIdentifier` — a `$`-prefixed read whose + // base is not a binding), which fires long before any binding rule. That + // over-refusal is pre-existing and contract-safe (refusing is never a + // refusal-contract bug), but it means these seven cases pin the exemption + // only for the one name the reference carve-out already lets through. They + // are NOT evidence that the binding rule leaves the oracle's exempt + // positions generally intact — nothing here can be, until that store + // refusal narrows. + for source in [ + "\n{#if $$slots.a}

    {f(1)}

    {/if}", + "\n

    {g(1)}

    ", + "\n

    {f(1)}

    ", + "{#each [1] as n}{@const $$slots = n}

    {$$slots}

    {/each}", + "{#each [1] as $$slots}

    {$$slots}

    {/each}", + "{#await Promise.resolve(1) then $$slots}

    {$$slots}

    {/await}", + "{#snippet s($$slots)}

    {$$slots}

    {/snippet}", + ] { + let _ = compile_js(source); + } +} + +#[test] +fn compile_allows_dollar_member_names() { + // A `$`-prefixed *name* (non-computed member property) is not a rune + // reference — it stays compilable. The member access roots in the prop + // `a`, so `needs_context` wraps the body. Full-string equality (not a + // substring check) so the wrapper can't silently regress. + let js = compile_js("\n

    {a.$foo}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\tlet { a } = $$props;\n\ + \t\t$$renderer.push(`

    ${$.escape(a.$foo)}

    `);\n\ + \t});\n\ + }\n" + ); +} + +#[test] +fn compile_refuses_props_illegal_name_declare_site() { + // The oracle's `props_illegal_name` (VariableDeclarator.js:94-103): a `$props()` + // destructure property whose non-computed Identifier key starts with `$$` is + // reserved for Svelte internals and rejected. Reaches the check even without a + // rest/bindable (the plain `{ $$slots: a }` form). + assert_unsupported( + "\n

    {a}

    ", + "prop name starting with `$$`", + ); + assert_unsupported( + "\n

    {a}

    ", + "prop name starting with `$$`", + ); + // Mixed with a normal prop — still caught. + assert_unsupported( + "\n

    {a}{b}

    ", + "prop name starting with `$$`", + ); + // ⭐ No reason-stealing: a `$$`-prefixed BINDING (shorthand / default) declares a + // `$$` binding, refused UPSTREAM as `DollarPrefixedBinding` (the oracle's + // `dollar_prefix_invalid`, which fires first) — NOT props_illegal_name. + assert_unsupported( + "\n

    x

    ", + "$-prefixed binding $$foo", + ); + assert_unsupported( + "\n

    x

    ", + "$-prefixed binding $$foo", + ); + // Discriminating controls, all COMPILE: a single-`$` key (not `$$`), a plain key, + // a string-literal `$$` key (the oracle's check is Identifier-only), and a plain + // rest destructure. + let _ = compile_js("\n

    {a}

    "); + let _ = compile_js("\n

    {a}

    "); + let _ = compile_js("\n

    {a}

    "); + let _ = compile_js("\n

    {a}

    "); +} + +#[test] +fn compile_refuses_props_invalid_pattern() { + // The oracle's `props_invalid_pattern` (VariableDeclarator.js:97-110): a `$props()` + // ObjectPattern property that is COMPUTED or whose value (after stripping a default) + // is not a plain Identifier. The three per-property checks fire in source order, + // first-wins (`e.*` throws): computed → props_invalid_pattern, then a `$$` key → + // props_illegal_name, then a non-Identifier value → props_invalid_pattern. + let reason = "$props() destructure with a computed key or nested pattern"; + // Computed key. + assert_unsupported( + "\n

    {a}

    ", + reason, + ); + // Computed key with a rest present (does not early-return; caught before the rewrite). + assert_unsupported( + "\n

    {a}

    ", + reason, + ); + // Nested object-pattern value. + assert_unsupported( + "\n

    {b}

    ", + reason, + ); + // Nested array-pattern value. + assert_unsupported( + "\n

    {b}

    ", + reason, + ); + // Nested value carrying a default (the default is stripped, its `left` is the pattern). + assert_unsupported( + "\n

    {b}

    ", + reason, + ); + // Nested value with a `$bindable()` default — the value (default-stripped) is still an + // ObjectPattern, so props_invalid_pattern fires FIRST (before the guard sees $bindable). + assert_unsupported( + "\n

    {b}

    ", + reason, + ); + // ⭐ Source-order first-wins. A computed key BEFORE a `$$` key → the computed check + // wins (props_invalid_pattern), matching the oracle. + assert_unsupported( + "\n

    {a}{b}

    ", + reason, + ); + // …but a `$$` key BEFORE a computed key → props_illegal_name wins (source order). + assert_unsupported( + "\n

    {a}{b}

    ", + "prop name starting with `$$`", + ); + // Discriminating controls, all COMPILE: plain, renamed, default, rest, and bindable + // (both plain and renamed) — every value is a plain Identifier after default-strip. + let _ = compile_js("\n

    {a}{b}

    "); + let _ = compile_js("\n

    {b}

    "); + let _ = compile_js("\n

    {a}

    "); + let _ = compile_js("\n

    {a}

    "); + let _ = compile_js( + "\n

    {value}

    ", + ); + let _ = + compile_js("\n

    {v}

    "); +} + +#[test] +fn compile_refuses_props_illegal_name_member_site() { + // The oracle's `props_illegal_name` REFERENCE-site rule + // (`MemberExpression.js:11-16`): a non-computed `.$$…` access on a plain + // Identifier bound to a `$props()` rest_prop. A `rest_prop` arises from the + // whole-object `let props = $props()` (`VariableDeclarator.js:87-90`) and from + // the REST element of `let { a, ...rest } = $props()` (`:46-47`); the NAMED + // props are `prop`, never `rest_prop`. + for source in [ + // The three declaration forms: destructure rest, whole-object, mixed. + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + // Nested member — the INNER `rest.$$foo` fires (its object is the + // Identifier `rest`; the outer `.bar`'s object is a MemberExpression). + "\n

    x

    ", + // Template position. + "\n{rest.$$slots}", + // Optional chain: tsv's internal AST drops `ChainExpression`, so `rest?.$$foo` + // is a plain `MemberExpression{optional:true}` — the arm does NOT gate on + // `optional`, matching the oracle (which has no such gate). + "\n

    x

    ", + // Bare `$$` property (`\"$$\".starts_with(\"$$\")` is true). + "\n

    x

    ", + // Snippet body — the walk descends into `{#snippet}` bodies. + "\n{#snippet s()}{rest.$$foo}{/snippet}", + // Script arrow body. + "\n

    x

    ", + // Dropped event handler — the walk reaches dropped handlers. + "\n", + // Dropped `{:catch}` — the `in_dropped_catch` walk. + "\n{#await p}a{:catch e}{rest.$$foo}{/await}", + // ⭐ Computed IDENTIFIER key `rest[$$slots]` / `props[$$slots]` — the + // oracle's condition is `node.property.type === 'Identifier'` (NO computed + // gate), so a computed identifier key matches. This is the case a `!computed` + // gate would LEAK: `$$slots` is exempt from tsv's own `$$`-ref rule + // (`rune_guard.rs`, the sanitize_slots ref), so nothing else would fire. + "\n

    x

    ", + "\n

    x

    ", + ] { + assert_unsupported(source, "prop name starting with `$$`"); + } +} + +#[test] +fn compile_allows_member_access_that_is_not_rest_prop_illegal() { + // Controls that MUST keep compiling — the member-site rule must not over-refuse. + // A computed STRING key: the property is a Literal, not an Identifier, so the + // `Expression::Identifier(prop)` arm fails and it never matches — the oracle + // also compiles it. (Contrast the computed IDENTIFIER key `rest[$$slots]`, + // which DOES match and refuses — in the refuse test above.) + let _ = compile_js( + "\n

    x

    ", + ); + // A NAMED prop (`a` is `prop`, not `rest_prop`) — `a.$$foo` is legal; it wraps + // in `$$renderer.component` (prop-rooted member), matching the oracle. + let _ = compile_js("\n

    x

    "); + // A non-`$$` property on a rest_prop. + let _ = + compile_js("\n

    x

    "); + // A plain object, not a props binding. + let _ = compile_js("\n

    x

    "); +} diff --git a/crates/tsv_svelte_compile/src/tests/dropped_and_special.rs b/crates/tsv_svelte_compile/src/tests/dropped_and_special.rs new file mode 100644 index 000000000..8ee2c59c7 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/dropped_and_special.rs @@ -0,0 +1,420 @@ +//! Dropped regions and the SSR-inert special elements. + +use super::support::*; + +#[test] +fn dropped_fragments_are_walked() { + // A fragment the emitter DISCARDS without visiting — + // today only `{:catch}`, plus an event handler's expression — must still be + // walked for the things the oracle decides BEFORE it chooses what to emit. + // Dropping the region cannot make the component valid. + // + // This test covers the two facts carried by an EXPRESSION inside the region: + // its reference counting (`needs_context`) and its analysis-phase errors (a + // misplaced rune). The third fact — one the oracle reads from a node's mere + // PRESENCE, independent of any expression — is a different walk over node + // KINDS, pinned separately by `dropped_fragment_refuses_presence_read_nodes`. + // + // A new emission-dropped fragment that skips that walk fails here (and, for + // TypeScript, in `compile_refuses_template_typescript_without_lang_ts`). + + // 1. References inside a dropped `{:catch}` still reach `needs_context`: a + // prop-rooted member access there forces the `$$renderer.component` + // wrapper, exactly as the oracle counts it. + let js = compile_js( + "\n\ + {#await p then v}

    {v}

    {:catch e}

    {obj.field}

    {/await}", + ); + assert!( + js.contains("$$renderer.component(($$renderer) => {"), + "a prop-rooted access in a dropped {{:catch}} must still fire needs_context: {js}" + ); + + // 2. An analysis-phase error inside a dropped region still refuses — the + // oracle rejects `{:catch e}{$state(1)}` with `state_invalid_placement`. + assert_unsupported( + "\n\ + {#await p then v}

    {v}

    {:catch e}

    {$state(1)}

    {/await}", + "$state", + ); + assert_unsupported( + "\n\ + ", + "$state", + ); + + // 3. …but a shape the oracle merely DROPS must still compile: a derived read + // inside a dropped `{:catch}` is emitted nowhere, so the oracle accepts + // it. Guarding a dropped region must not over-refuse. + let derived = compile_js( + "\n\ + {#await p then v}

    {v}

    {:catch e}

    {d}

    {/await}", + ); + assert!( + !derived.contains("catch"), + "the {{:catch}} branch is dropped from SSR: {derived}" + ); +} + +/// The dropped-region walk over node KINDS, and the rule that scopes it. +/// +/// `guard_dropped_fragment`'s expression walk asks what a dropped region +/// *references*. It never asks what a dropped node *is* — so a fact the oracle +/// reads out of a node's mere presence reaches no guard at all. `` is that +/// fact: the oracle's phase-2 analysis records every `` in `slot_names` +/// (`2-analyze/visitors/SlotElement.js`) and phase 3 folds `slot_names.size > 0` +/// into `should_inject_props`, so a `` in a `{:catch}` widens the component +/// signature to `($$renderer, $$props)` even though SSR emits nothing from the +/// branch. Without the node walk this pins, tsv fires no refusal there and emits +/// the bare signature — a MISMATCH. +/// +/// The rule is NOT "every fenced construct refuses everywhere". It is: a construct +/// refuses everywhere it can **affect the result**. A dropped region suppresses a +/// construct's emission but not a phase-2 fact keyed on its presence — and there +/// are TWO such kinds of fact, which is the part that is easy to get wrong: +/// +/// - **emission** — the fact rides into the generated code (`` → the widened +/// signature, above). Measurable one construct at a time. +/// - **validation** — the fact feeds a whole-component check that can turn an +/// otherwise-valid component into a compile error (a dropped `on:` + an emitted +/// `onclick` → `mixed_event_handler_syntaxes`). Invisible in isolation: it needs +/// a SECOND construct elsewhere in the component to fire, so a per-construct +/// probe reports the dropped one as inert and is simply asking the wrong +/// question. +/// +/// The third loop pins the validation axis; the second pins that everything on +/// neither axis keeps compiling, which is what makes the distinction load-bearing +/// rather than a slogan. +#[test] +fn dropped_fragment_refuses_presence_read_nodes() { + // A `$state` promise, deliberately NOT `$props()`: the fact under test is a + // widened component signature, so the baseline must be the bare + // `($$renderer)` one that a `$props()` destructure would mask. + let await_ = |catch: &str| { + format!( + "\n\ + {{#await p}}a{{:then v}}b{{:catch e}}{catch}{{/await}}" + ) + }; + + // ── refuses: ``, the one presence-read construct ──────────────── + // + // Same bucket label as the emitted path (`template node special element + // `), so the refusal is the fence firing in a second position rather + // than a new reason — and the corpus census keeps one bucket for the tag. + for catch in [ + "", + // Nested under an element: the walk must recurse into child fragments. + "
    ", + // Named — `slot_names` keys by name, but any key makes the map non-empty. + "", + // Nested under a BLOCK inside the dropped branch: recursion has to ride + // the structural seam, not just element children. + "{#if e}{/if}", + "{#each [e] as x}{/each}", + // The other container kinds `each_child_fragment` enumerates — a + // component's children, a `{#snippet}` body, a nested `{#await}`, and a + // special element's own fragment. Each is a distinct arm of that match. + "", + "{#snippet s()}{/snippet}", + "{#await p}{:then q}x{/await}", + "", + ] { + assert_unsupported(&await_(catch), "template node special element "); + } + + // ── refuses: the legacy directives, on the VALIDATION axis ──────────── + // + // A dropped `on:` is not inert. `2-analyze/visitors/OnDirective.js` sets + // `analysis.event_directive_node` wherever the directive sits, an + // `onclick`-style attribute on an EMITTED element sets + // `analysis.uses_event_attributes` (`visitors/Attribute.js`), and + // `2-analyze/index.js` errors `mixed_event_handler_syntaxes` when both are + // set. So `{:catch}", "on:"), + ("
    y
    ", "let:"), + // Nested, and on a component / special element — the directive hangs off + // an attribute list, so the walk has to reach every host that has one. + ("
    ", "on:"), + (" e} />", "on:"), + ("y", "let:"), + ("{#if e}{/if}", "on:"), + ( + " e}>x", + "on:", + ), + ] { + assert_unsupported( + &await_(catch), + &format!("legacy {refusal} directive (runes-only fence)"), + ); + } + + // The full mixed configuration, verbatim — the shape the isolated probes + // report as inert and the oracle rejects. `await_` cannot express it (the + // sibling sits OUTSIDE the block), so it is spelled out. + assert_unsupported( + "\n\ + {#await p}a{:then v}b{:catch e}{/await}\n\ +
    p}>y
    ", + "legacy on: directive (runes-only fence)", + ); + + // ── must NOT over-refuse: everything else at the same position ──────── + // + // Each of these is emission-refused in an EMITTED position, and each reaches + // parity with the oracle inside `{:catch}` today. Refusing them to satisfy a + // uniform "fenced refuses everywhere" rule would trade correct output for + // nothing. + // + // This set is clean on BOTH axes, and the validation half is verified by + // reading the writers rather than by probing: the whole-component fields a + // phase-2 validation reads are `slot_names`, `uses_slots`, `uses_render_tags`, + // `event_directive_node`, `uses_event_attributes`, and `snippets` — written + // ONLY by `SlotElement` / an `$$slots` `Identifier` / `RenderTag` / + // `OnDirective` / an event `Attribute` / `SnippetBlock`. No construct below + // writes any of them, so no combination can make one of them matter. + // (`` is not even fenced — a first-class Svelte 5 feature and + // the next implementation target — so refusing it would actively obstruct + // that work.) + for catch in [ + "x", + "", + "", + // `` only in a nesting the oracle permits — bare inside + // `{:catch}` it is `svelte_self_invalid_placement`, so unreachable. + "{#if e}{/if}", + // `` and a `slot=\"…\"` child likewise want a component + // parent; the named-slot fence is the CONSUMER half of the slot system + // whose declaring half (``) refuses two loops above. + "y", + "

    y

    ", + ] { + let js = compile_js(&await_(catch)); + assert!( + js.contains("export default function Input($$renderer) {"), + "a dropped `{{:catch}}` {catch} must compile with the bare signature: {js}" + ); + } +} + +#[test] +fn compile_ssr_inert_special_elements() { + // ``/``/`` are SSR-inert: their + // events/binds are client-only, so the oracle emits NOTHING for them. A bare + // one leaves only the empty exported function. + assert_eq!( + compile_js(""), + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {}\n" + ); + // Beside real content: the content still emits, the window drops (its only + // template output is the sibling's push — no window marker). + let beside = compile_js("\n

    real

    "); + assert!( + beside.contains("$$renderer.push(`

    real

    `)") && !beside.contains("svelte:window"), + "window drops, sibling content emits: {beside}" + ); + // The attribute expressions are still WALKED by needs_context: a prop-rooted + // member in a window handler fires the `$$renderer.component` wrapper, exactly + // as the oracle counts it. + let wrapped = compile_js( + "\n", + ); + assert!( + wrapped.contains("$$renderer.component(($$renderer) => {"), + "a prop-rooted access in a window handler must fire needs_context: {wrapped}" + ); + // A `bind:` marks its target reassigned, so a later `{y}` read stays dynamic + // (not folded to its initial value). + let bound = compile_js( + "\n{y}", + ); + assert!( + bound.contains("$.escape(y)"), + "a window bind must keep a later read dynamic (not folded): {bound}" + ); + // A stray rune inside a window attribute still refuses (the oracle rejects it + // as `state_invalid_placement` at analysis). + assert_unsupported("", "$state"); + + // A valid mix of MODERN-runes attributes compiles to nothing: a modern event + // attribute (guard-dropped), two whitelisted binds (`focused`/`innerWidth`), + // and a `class:` directive — all oracle-accepted, all dropped from SSR output, + // so the body is just the (rewritten) script declarations, no window markup. + let combined = compile_js( + "\n\ + {}} bind:focused={f} bind:innerWidth={w} class:c={x} />", + ); + assert!( + !combined.contains("$$renderer.push") && !combined.contains("svelte:window"), + "a valid inert element with modern attrs compiles to nothing: {combined}" + ); + + // A whitelisted bind with a VALID target compiles (dropped): a `$state`-rooted + // lvalue for a normal bind (`innerWidth`), and ANY lvalue for `bind:this` (no + // `$state` gate — even an uninitialized `let el`), matching the regular-element + // fork. + // a whitelisted bind with a valid $state / bind:this target must compile + let _ = compile_js( + "\n\ + ", + ); + + // The no-op drop family is oracle-accepted on these elements and guard-dropped + // (`class:`/`style:`/`use:`/`transition:`/`in:`/`out:`/`animate:`/`{@attach}`). + for attr in [ + "class:c={ok}", + "style:color={ok}", + "use:ok", + "transition:ok", + "in:ok", + "out:ok", + "animate:ok", + "{@attach ok}", + ] { + // drop-family directive must compile on an inert element + let src = format!("\n"); + let _ = compile_js(&src); + } +} + +#[test] +fn compile_refuses_invalid_ssr_inert_special_elements() { + // Invalid-input shapes the oracle rejects at analysis; tsv's parser accepts + // them, so the compiler must refuse (never emit nothing for oracle-rejected + // input, which would surface as a corpus OVER-ACCEPTANCE). + // + // PLACEMENT: legal only at the component root — nested inside an element/block/ + // snippet is `svelte_meta_invalid_placement`. + assert_unsupported( + "
    {}} />
    ", + "must be a top-level element", + ); + assert_unsupported( + "{#if true}{/if}", + "must be a top-level element", + ); + // DUPLICATE: at most one of each kind (`svelte_meta_duplicate`). + assert_unsupported( + "", + "duplicate element", + ); + // Different kinds side-by-side are fine (not a duplicate). + let _ = compile_js(""); + + // CHILDREN: `disallow_children` — these cannot have children + // (`svelte_meta_invalid_content`). tsv's parser DOES parse them into the + // fragment, so refuse. + assert_unsupported("hi", "cannot have children"); + assert_unsupported( + "

    x

    ", + "cannot have children", + ); + + // ILLEGAL ATTRIBUTE: only a modern event attribute (`on*={expr}`) is legal; a + // non-event plain attribute, a string-valued handler, a bare handler, and a + // spread refuse (`illegal_element_attribute` / `svelte_body_illegal_attribute`). + assert_unsupported("", "invalid attribute"); + assert_unsupported("", "invalid attribute"); + assert_unsupported("", "invalid attribute"); + assert_unsupported("", "invalid attribute"); + assert_unsupported("", "invalid attribute"); + + // INVALID BIND: a name outside the per-kind whitelist refuses. `bind:scrollY` + // is window-only (`bind_invalid_target` on body); `bind:nonexistent` is not a + // binding (`bind_invalid_name`); `bind:clientWidth` is invalid on window + // (`bind_invalid_name`). Valid $state target isolates the NAME check. + assert_unsupported( + "\n", + "bind: directive scrollY", + ); + assert_unsupported( + "\n", + "bind: directive nonexistent", + ); + assert_unsupported( + "\n", + "bind: directive clientWidth", + ); + + // INVALID BIND TARGET: a whitelisted NAME with a target that is not a + // `$state`-rooted lvalue refuses — the SAME reassignable-lvalue rule regular + // elements enforce (`validate_inert_bind_target`). A non-lvalue (call / literal), + // a `const`, and an undefined identifier the oracle also rejects + // (`bind_invalid_expression` / `constant_binding` / `bind_invalid_value`) — this + // closes the target over-acceptance the blanket guard-drop had left open. + assert_unsupported( + "\n", + "bind: directive innerWidth", + ); + assert_unsupported( + "", + "bind: directive innerWidth", + ); + // A `const` target now refuses one step EARLIER, in the whole-component + // `validate_assignment` port (which reaches every `bind:` the oracle's own + // validator does, dropped regions included) — so it carries the sharper + // `constant_assignment` bucket rather than the bind-shaped one. The + // reassignable-lvalue rule in the bind path is unchanged and still stands + // behind it. + assert_unsupported( + "\n", + "a constant", + ); + assert_unsupported( + "", + "bind: directive innerWidth", + ); + + // LEGACY DIRECTIVES: a legacy `on:` event directive and `let:` refuse + // (`RunesOnlyFence`) — the runes-only fence, matching the regular-element + // path. The oracle ACCEPTS `on:` here, so this is a deliberate safe + // over-refusal, not an oracle-parity claim. + assert_unsupported( + " {}} />", + "legacy on: directive (runes-only fence)", + ); + assert_unsupported( + "", + "legacy let: directive (runes-only fence)", + ); +} + +/// Pin every refused special-element kind to its OWN bucket label. +/// +/// `special_element_kind_table!` expands one row set into the mapping and +/// `SPECIAL_ELEMENT_REFUSAL_KINDS` together, so a NEW kind cannot reach either +/// without the other, and pairing each pattern with its label in the row rules out +/// an index that a reorder could re-point. What none of that catches is a row +/// written with the WRONG label — which would silently relabel one tag as another. +/// That matters beyond cosmetics: `Refusal::is_deliberate_fence` keys the runes-only +/// fence set on these labels, so a swap would move a tag in or out of the +/// achievable-parity denominator. +#[test] +fn refused_special_elements_carry_their_own_bucket_label() { + for (source, tag) in [ + ( + "\n", + "", + ), + ("", ""), + ("", ""), + ("", ""), + // `` is deliberately absent — it COMPILES, so it carries + // no `TemplateNode` label at all. + ] { + assert_unsupported(source, &format!("template node special element {tag}")); + } +} diff --git a/crates/tsv_svelte_compile/src/tests/element_refusals.rs b/crates/tsv_svelte_compile/src/tests/element_refusals.rs new file mode 100644 index 000000000..1324650c8 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/element_refusals.rs @@ -0,0 +1,137 @@ +//! Per-element refusals: duplicate `$props()`, the select family, ``. + +use super::support::*; + +#[test] +fn compile_duplicate_props_rune_refuses() { + // The oracle's `props_duplicate` rejects a second `$props()`. Corpus-invisible + // (no real component writes it), so this test is the only guard. + assert_unsupported( + "{a}{b}", + "$props() used more than once", + ); + assert_unsupported( + "", + "$props() used more than once", + ); + // Both declarators in ONE statement is the same duplicate. + assert_unsupported( + "{a}{b}", + "$props() used more than once", + ); + // A non-destructured second `$props()` duplicates just the same. + assert_unsupported( + "{a}", + "$props() used more than once", + ); + // A single `$props()` still compiles — the guard must not fire on one. + compile_checked("{a}"); + // `$props()` and `$props.id()` are tracked separately (the oracle keeps two + // flags), so one of each is NOT a duplicate. + compile_checked("{a}{i}"); +} + +#[test] +fn compile_use_directive_on_load_error_element_refuses() { + // `use:` on a load-error element makes the oracle add onload/onerror capture + // attributes (its `events_to_capture` set) — not implemented, so refuse. + // Only `use:` (and a spread) triggers this; the other drop-family kinds drop. + assert_unsupported("", "load-error element"); + assert_unsupported("", "load-error element"); + // `transition:`/`{@attach}` on the same element are a plain drop. + let out = compile_checked(""); + assert!( + out.js.contains("``"), + "transition on img must plain-drop: {}", + out.js + ); + let out = compile_checked(""); + assert!( + out.js.contains("``"), + "attach on img must plain-drop: {}", + out.js + ); +} + +#[test] +fn compile_await_in_dropped_directive_expression_refuses() { + // The oracle rejects `await` inside a directive expression + // (`illegal_await_expression` / the async gate); tsv's dropped-expression + // guard refuses the top-level await, the correct analog. + assert_unsupported("
    ", "top-level await"); + assert_unsupported("
    ", "top-level await"); +} + +#[test] +fn compile_rune_in_dropped_directive_expression_refuses() { + // A dropped directive expression is still validated: a misplaced rune is an + // oracle analysis-phase error (`state_invalid_placement`), so tsv refuses. + assert_unsupported("
    ", "rune $state"); + assert_unsupported("
    ", "rune $derived"); +} + +#[test] +fn compile_select_family_spread_and_bind_refuse() { + // The `` / `", "{...spread} on ", + "bind: directive value", + ); +} + +#[test] +fn compile_svelte_element_const_tag_direct_child_refuses() { + // The oracle rejects a `{@const}` as a direct `` child + // (`const_tag_invalid_placement`; a `` is not among its valid + // `{@const}` parents). Without a guard tsv would over-accept: the children + // closure pushes a block-scope overlay (load-bearing for snippet hoisting) that + // `emit_const_tag` reads as "inside a block". Pin the refusal. + assert_unsupported( + "{@const y = 1}{y}", + "{@const} outside a block scope", + ); + // A `{#snippet}` direct child stays valid (proves the guard didn't drop the + // overlay the hoist analysis needs). + compile_checked( + "{#snippet s()}x{/snippet}{@render s()}", + ); +} + +#[test] +fn compile_svelte_element_specific_refusals() { + // A `bind:` other than `bind:this` refuses — the dynamic tag has no static + // `` identity, so the oracle rejects `bind:value`/etc. + // (`bind_invalid_target`). + assert_unsupported( + "", + "bind: directive value", + ); + // Legacy `on:`/`let:` refuse (the runes-only fence). + assert_unsupported( + "", + "legacy on: directive (runes-only fence)", + ); + // A scoping `"); + assert!( + out.js.contains(r#" class="svelte-tsvhash""#), + "expected synthesized hash class, got: {}", + out.js + ); + assert!( + out.css + .as_deref() + .is_some_and(|css| css.contains("div.svelte-tsvhash")), + "expected scoped selector, got: {:?}", + out.css + ); + // A `bind:this` omits and the element compiles. + compile_checked(""); +} diff --git a/crates/tsv_svelte_compile/src/tests/element_spread.rs b/crates/tsv_svelte_compile/src/tests/element_spread.rs new file mode 100644 index 000000000..f6fd1608a --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/element_spread.rs @@ -0,0 +1,214 @@ +//! A regular element `{...spread}`: the fused `$.attributes(...)` call. + +use super::support::*; + +#[test] +fn compile_element_spread_object() { + // A regular element `{...spread}` routes the WHOLE attribute set through one + // fused `$.attributes({ … })` call, source order: plain attrs become object + // properties, spreads become `...expr` elements. + let js = compile_js( + "\n
    ", + ); + assert!( + js.contains("$.attributes({ class: 'foo', id: 'a', ...props })"), + "{js}" + ); + // A single-expression event handler drops from the object; a bare boolean and + // a `data-*` key (quoted, lowercased) survive. + let js = compile_js( + "\n
    ", + ); + assert!( + js.contains("$.attributes({ datafoo: x, disabled: true, ...props })"), + "{js}" + ); +} + +#[test] +fn compile_element_spread_flags_and_elision() { + // `` → the `ELEMENT_IS_INPUT` flag (4) with interior `void 0` padding. + let js = compile_js("\n"); + assert!( + js.contains("$.attributes({ ...props }, void 0, void 0, void 0, 4)"), + "{js}" + ); + // A custom element (hyphenated tag) → `ELEMENT_PRESERVE_ATTRIBUTE_CASE` (2). + let js = compile_js("\n"); + assert!( + js.contains("$.attributes({ ...props }, void 0, void 0, void 0, 2)"), + "{js}" + ); +} + +#[test] +fn compile_element_spread_scope_hash_rides_second_arg() { + // In spread mode the scope hash is NOT concatenated into the class value — it + // rides the `css_hash` (2nd) argument. + let out = compile_checked( + "\n
    ", + ); + assert!( + out.js + .contains("$.attributes({ class: 'foo', ...props }, 'svelte-tsvhash')"), + "{}", + out.js + ); +} + +#[test] +fn compile_element_spread_prop_root_forces_context_wrapper() { + // A member access rooted at a prop inside a `{...spread}` must fire the + // `$$renderer.component` wrapper (the reference feeds `needs_context`). + let out = compile_checked("\n
    "); + assert!( + out.js.contains("$$renderer.component(($$renderer) =>"), + "prop-rooted spread must wrap: {}", + out.js + ); +} + +#[test] +fn compile_element_spread_with_class_and_style_directives() { + // A `class:`/`style:` directive co-present with a `{...spread}` folds into the + // `classes` (3rd) / `styles` (4th) `$.attributes` arguments — an identifier-key + // object with shorthand collapse for `classes`, a FLAT object (no `|important` + // partition) for `styles`. + let js = compile_js( + "\n
    ", + ); + assert!( + js.contains("$.attributes({ ...props }, void 0, { a: x }, { color: v })"), + "{js}" + ); + // A shorthand `class:active` collapses to `{ active }`. + let js = compile_js( + "\n
    ", + ); + assert!( + js.contains("$.attributes({ ...props }, void 0, { active })"), + "{js}" + ); + // `|important` is validated but does NOT partition in spread mode. + let js = compile_js( + "\n
    ", + ); + assert!( + js.contains("$.attributes({ ...props }, void 0, void 0, { c: v })"), + "{js}" + ); +} + +#[test] +fn compile_element_spread_bind_folds_into_object() { + // A `bind:value` folds into the object at the bind's source slot (before the + // spread); `` still sets the flags argument. + let js = compile_js( + "\n", + ); + assert!( + js.contains("$.attributes({ value: w, ...props }, void 0, void 0, void 0, 4)"), + "{js}" + ); + // `bind:group` synthesizes a `checked` entry; the companion `value` still emits + // as its own object property. + let js = compile_js( + "\n", + ); + assert!( + js.contains( + "$.attributes({ type: 'radio', checked: x === 'a', value: 'a', ...props }, void 0, void 0, void 0, 4)" + ), + "{js}" + ); + // All together: bind entry in the object, class/style args, input flags. + let js = compile_js( + "\n", + ); + assert!( + js.contains("$.attributes({ value: w, ...props }, void 0, { a: x }, { color: v }, 4)"), + "{js}" + ); +} + +#[test] +fn compile_element_spread_directive_scoping_and_drops() { + // A `class:` directive NAME matching a scoped selector scopes the element — the + // hash rides the `css_hash` (2nd) argument, the classes object the 3rd. + let js = compile_js( + "\n
    \n", + ); + assert!( + js.contains("$.attributes({ ...props }, 'svelte-tsvhash', { foo: x })"), + "{js}" + ); + // The drop family (`use:`/`transition:`) contributes nothing — a bare + // `$.attributes({ ...props })`. + let js = + compile_js("\n
    "); + assert!(js.contains("$.attributes({ ...props })"), "{js}"); + let js = compile_js( + "\n
    ", + ); + assert!(js.contains("$.attributes({ ...props })"), "{js}"); +} + +#[test] +fn compile_element_spread_refuses_invalid_directives() { + // A `bind:value` on a non-`` element is `bind_invalid_target` (an oracle + // error) — the slice-3 gate still applies with a spread. + assert_unsupported( + "\n
    ", + "bind: directive value", + ); + // A `style:` directive with an invalid modifier still refuses. + assert_unsupported( + "\n
    ", + "style: directive with an invalid modifier", + ); + // A deferred (content-editable) bind still refuses. + assert_unsupported( + "\n
    ", + "bind: directive innerHTML", + ); + // A legacy `on:` directive and `let:` alongside a spread stay refused (the + // oracle drops them, but tsv declines to reproduce that). + assert_unsupported( + "\n
    {}} {...props}>
    ", + "legacy on: directive (runes-only fence)", + ); + assert_unsupported( + "\n
    ", + "legacy let: directive (runes-only fence)", + ); +} + +#[test] +fn compile_element_spread_refuses_omit_in_ssr_binds() { + // An `omit_in_ssr` bind (media/dimension/window binding) co-present with a + // `{...spread}` refuses on the spread path too — consistent with the inline + // path, and the SAFE side (the oracle rejects these shapes; tsv declines rather + // than silently drop them). Well-formed `omit_in_ssr`+spread parity is deferred. + let prefix = + "\n"; + // `bind:files` needs `type=\"file\"` (an oracle `bind_invalid_target`). + assert_unsupported( + &format!("{prefix}"), + "bind: directive files", + ); + // A dimension binding on a non-matching element (oracle `bind_invalid_target`). + assert_unsupported( + &format!("{prefix}
    "), + "bind: directive clientWidth", + ); + // A window binding on a non-window element (oracle `bind_invalid_target`). + assert_unsupported( + &format!("{prefix}
    "), + "bind: directive scrollX", + ); + // A non-lvalue target on an `omit_in_ssr` bind (oracle `bind_invalid_expression`). + assert_unsupported( + &format!("{prefix}
    "), + "bind: directive clientWidth", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/errors.rs b/crates/tsv_svelte_compile/src/tests/errors.rs new file mode 100644 index 000000000..9f1643527 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/errors.rs @@ -0,0 +1,48 @@ +//! Error surfacing: parse errors and the output self-check. + +use crate::*; + +#[test] +fn compile_surfaces_parse_errors() { + let err = compile("", &CompileOptions::default()).unwrap_err(); + assert!( + matches!(err, CompileError::Parse(_)), + "expected Parse, got {err:?}" + ); +} + +#[test] +fn canonicalize_surfaces_parse_errors() { + let err = canonicalize_js("const x = ;").unwrap_err(); + assert!( + matches!(err, CanonicalizeError::Parse(_)), + "expected Parse, got {err:?}" + ); +} + +#[test] +fn validate_output_js_rejects_corrupt_output_loudly() { + // The self-validation seam: hypothetical corrupt generated JS (the + // divergent-shape-slipped-every-guard class, e.g. a nested `export`) + // must surface as CorruptOutput, not as a silently invalid module. + // Note the net's reach: it catches output the parser REJECTS; output + // that parses as TypeScript (a passed-through type annotation) is not + // a parse rejection and is caught at parity-comparison time instead. + for corrupt in [ + // Invalid nesting the transform must never emit. + "export default function Input($$renderer) {\n\texport const a = 1;\n}\n", + // A hard syntax error. + "export default function Input($$renderer) {\n\tconst x = ;\n}\n", + ] { + let err = validate_output_js(corrupt).unwrap_err(); + assert!( + matches!(err, CompileError::CorruptOutput(_)), + "expected CorruptOutput for {corrupt:?}, got {err:?}" + ); + } + // Valid generated-shaped JS passes. + validate_output_js( + "import * as $ from 'svelte/internal/server';\nexport default function Input($$renderer) {\n\t$$renderer.push(`

    x

    `);\n}\n", + ) + .expect("valid output must validate"); +} diff --git a/crates/tsv_svelte_compile/src/tests/mod.rs b/crates/tsv_svelte_compile/src/tests/mod.rs new file mode 100644 index 000000000..dd2190359 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/mod.rs @@ -0,0 +1,39 @@ +//! The compiler test suite, split one concern per file. +//! +//! Shared assertion helpers live in `support`; a new test belongs in the file +//! matching the feature it exercises. + +mod support; + +mod assignment; +mod attributes; +mod bind; +mod blocks; +mod boundary; +mod canonicalize; +mod class_directives; +mod comments; +mod components; +mod context_wrapper; +mod css_scope; +mod dollar_bindings; +mod dropped_and_special; +mod element_refusals; +mod element_spread; +mod errors; +mod module_script; +mod refusal_buckets; +mod rune_store_collision; +mod runes_derived; +mod runes_misc; +mod runes_state; +mod script_rewrite; +mod slots_and_head; +mod snippets; +mod specifier_normalize; +mod stores; +mod style_directives; +mod text_emission; +mod transitions; +mod typescript; +mod validate; diff --git a/crates/tsv_svelte_compile/src/tests/module_script.rs b/crates/tsv_svelte_compile/src/tests/module_script.rs new file mode 100644 index 000000000..bc3e566b1 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/module_script.rs @@ -0,0 +1,540 @@ +//! The `\n

    hi

    ", + "default export in \n

    {x}

    ", + "context attribute other than context=\"module\"", + ); + // `context="default"` — a plausible mistake, still rejected. + assert_unsupported( + "\n

    {x}

    ", + "context attribute other than context=\"module\"", + ); + // A boolean `context` (no value). + assert_unsupported( + "\n

    {x}

    ", + "context attribute other than context=\"module\"", + ); + // An expression value `context={…}` — not a text attribute. + assert_unsupported( + "\n

    {x}

    ", + "context attribute other than context=\"module\"", + ); + // Checked on the MODULE script too: `\n

    x

    ", + "context attribute other than context=\"module\"", + ); + // Discriminating controls, all COMPILE: the legacy `context="module"`, the + // modern `module`, and a plain instance script. + let _ = compile_js("\n

    x

    "); + let _ = compile_js("\n

    x

    "); + let _ = compile_js("\n

    {x}

    "); +} + +#[test] +fn compile_refuses_valued_module_attribute() { + // The oracle's parse-time `script_invalid_attribute_value` (read/script.js:57-64): + // the `module` attribute must be a plain BOOLEAN; any value refuses. + assert_unsupported( + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "context attribute other than context=\"module\"", + ); + // Control: the bare boolean `module` is the valid module-script spelling. + let _ = compile_js("\n

    x

    "); +} + +#[test] +fn compile_refuses_reserved_script_attribute() { + // The oracle's parse-time `script_reserved_attribute` (read/script.js:49-51): + // the FIRST check in the attribute loop — a `\n

    {{x}}

    "), + &format!("reserved \n

    {x}

    ", + "reserved \n

    x

    ", + "reserved \n

    x

    ", + "reserved \n

    x

    ", + "\n

    x

    ", + "reserved \n

    {x}

    "); + let _ = compile_js("\n

    {x}

    "); + let _ = compile_js("\n

    {x}

    "); + let _ = compile_js("\n

    {x}

    "); +} + +#[test] +fn compile_module_refuses_export_as_default() { + // The oracle's single `module_illegal_default_export` fires from its + // `ExportNamedDeclaration` visitor too: an `export { x as default }` specifier + // (`ExportNamedDeclaration.js:15-23`). Identifier form. + assert_unsupported( + "\n

    hi

    ", + "default export in \n

    hi

    ", + "default export in \n

    hi

    ", + "default export in \n

    hi

    ", + "default export in "); + let _ = compile_js(""); + let _ = compile_js(""); +} + +#[test] +fn compile_module_refuses_state_rune() { + // v1 defers the oracle's module `$state`→v rewrite (the corpus is module-rune- + // free), so a module-scope rune refuses via the guard — a safe over-refusal. + assert_unsupported( + "\n

    hi

    ", + "rune $state", + ); +} + +#[test] +fn compile_module_refuses_store_read() { + // A module-scope `$name` store read is the oracle's `store_invalid_subscription` + // error — the guard refuses it (no store exemption in a module). + assert_unsupported( + "\n

    hi

    ", + "$-prefixed identifier $c", + ); +} + +#[test] +fn compile_module_refuses_top_level_await() { + // Top-level `await` forces the oracle's async-component shapes (not implemented), + // so a module top-level await refuses — a safe over-refusal (the oracle compiles it). + assert_unsupported( + "\n

    hi

    ", + "top-level await", + ); +} + +#[test] +fn compile_module_body_follows_hoisted_snippet() { + // Emission order (probe-verified): the module block prints AFTER the hoisted + // snippets, NOT merged into the instance import group — imports, hoisted + // snippet, module body, then the component function. + let js = compile_js( + "\n{#snippet foo()}

    {SHARED}

    {/snippet}\n{@render foo()}", + ); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + function foo($$renderer) {\n\ + \t$$renderer.push(`

    5

    `);\n\ + }\n\ + const SHARED = 5;\n\ + export default function Input($$renderer) {\n\ + \tfoo($$renderer);\n\ + }\n" + ); +} + +#[test] +fn compile_module_sets_document_ts_flag() { + // A `lang="ts"` module sets the document-wide TypeScript flag, so the instance + // script's TypeScript erases even though it carries no `lang` of its own. + let js = compile_js( + "\n\n

    {a}{K}

    ", + ); + assert!( + !js.contains(": number"), + "instance TypeScript must erase under the module's lang=\"ts\": {js}" + ); +} + +#[test] +fn compile_module_refuses_name_collision_with_instance() { + // A name declared in BOTH scripts: the oracle resolves `{K}` to the instance + // (inner-scope) binding (`$.escape(K)`), but the name-based table would fold + // the module `const K = 5` — a real MISMATCH, so refuse. + assert_unsupported( + "\n\n

    {K}

    ", + "declared in both the module and instance scripts", + ); +} + +#[test] +fn compile_module_before_instance_comment_carries() { + // A whitespace-only text run between the module `` and the instance + // `\n\n\n

    {a}{K}

    ", + ); + assert!( + js.contains("// instance comment"), + "the instance comment must carry through past the module script: {js}" + ); +} + +#[test] +fn compile_refuses_module_comment_after_instance_script() { + // A module script placed AFTER the instance script puts its comments at + // offsets the oracle's printer re-seeks BACKWARD over (the component body + // block carries the instance script's `loc`), so esrap re-attaches them into + // whatever loc-bearing node it reaches next — a template expression it has + // nothing to do with. tsv drops the comment, which is a comment PRESENCE + // difference the parity bar grades as a MISMATCH. Refuse. + for source in [ + // The minimal shape: instance script, module script, template expression. + "{w(1)}", + // A block comment lands the same way. + "{w(1)}", + // The comment past the module body's last statement lands the same way. + "{w(1)}", + // An import-only instance script still supplies the `loc` that seeks back. + "{a}", + ] { + assert_unsupported(source, "module script placed after the instance script"); + } +} + +#[test] +fn compile_module_comment_before_instance_script_still_drops() { + // The mirror of the refusal above: with the module script FIRST, the body + // block's seek moves FORWARD past the module comment, so the oracle drops it + // too — tsv's drop is parity and must keep compiling. + let js = compile_js( + "{w(1)}", + ); + assert!( + !js.contains("MYC"), + "a module comment before the instance script must drop: {js}" + ); +} + +// ── The "open half": a module comment the oracle KEEPS, recovered by esrap's +// comment-index re-seek over a preceding block-bearing statement. The keep +// condition (both must hold): (1) a `BlockStatement`/`ClassBody`/static block +// STARTS before the comment; (2) a flush target exists — a non-empty module +// statement extending past the comment, OR an instance script. Each case is +// derived from `canonical_compile`, and paired with a discriminating drop so the +// assertion cannot pass vacuously. + +#[test] +fn compile_module_comment_after_block_carries() { + // A `function` declaration (its body block) precedes the comment, and a later + // module statement flushes it — the oracle keeps it, so tsv must too. + let js = compile_js( + "

    hi

    ", + ); + assert!( + js.contains("// MODMARK"), + "a module comment after a block must carry: {js}" + ); +} + +#[test] +fn compile_module_comment_before_block_drops() { + // The discriminating control for the case above: the SAME block, but the + // comment sits BEFORE it — so esrap's re-seek moves past the comment and the + // oracle drops it. tsv must drop too (dropping one the oracle keeps, or keeping + // one it drops, are both mismatches). + let js = compile_js( + "

    hi

    ", + ); + assert!( + !js.contains("MODMARK"), + "a module comment before every block must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_no_block_drops() { + // No block at all (a plain `const` init): condition 1 fails, so the comment + // drops even with a flush target present. + let js = compile_js( + "

    hi

    ", + ); + assert!( + !js.contains("MODMARK"), + "a module comment with no preceding block must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_arrow_expression_drops() { + // An arrow with an EXPRESSION body has no `BlockStatement`, so it is not a + // block — the comment drops. (An arrow with a `{}` block body WOULD keep it.) + let js = compile_js( + "

    hi

    ", + ); + assert!( + !js.contains("MODMARK"), + "an arrow expression body is not a block; the comment must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_switch_drops() { + // A `switch` has no `BlockStatement` node (its braces are syntactic), so it does + // not trigger the re-seek — the comment drops. + let js = compile_js( + "

    hi

    ", + ); + assert!( + !js.contains("MODMARK"), + "a switch is not a block; the comment must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_class_body_carries() { + // A `ClassBody` — even a field-only class with no method body — is a block the + // oracle re-seeks on, so a following comment carries. + let js = compile_js( + "

    hi

    ", + ); + assert!( + js.contains("// MODMARK"), + "a comment after a class body must carry: {js}" + ); +} + +#[test] +fn compile_module_comment_inside_block_carries() { + // A comment INSIDE the only block, with no later statement and no instance: + // condition 1 holds (the `{` precedes it) and the block's closing `}` is the + // flush target — the oracle keeps it. + let js = compile_js("

    hi

    "); + assert!( + js.contains("// MODMARK"), + "a comment inside a block (flushed by its close) must carry: {js}" + ); +} + +#[test] +fn compile_module_comment_after_last_no_flush_drops() { + // A block precedes the comment, but the comment is past the last module + // statement with NO instance script — no flush target, so it drops. + let js = compile_js("

    hi

    "); + assert!( + !js.contains("MODMARK"), + "a module comment past the last statement with no flush target must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_after_last_with_instance_carries() { + // The same after-last comment, but WITH an instance script present — the + // instance supplies the flush, so the oracle keeps it. The oracle re-attaches + // it into the component signature while tsv keeps it in the module body: a + // POSITION difference the parity bar tolerates, but the comment is present. + let js = compile_js( + "

    {x}

    ", + ); + assert!( + js.contains("// MODMARK"), + "an after-last module comment with an instance script must carry: {js}" + ); +} + +#[test] +fn compile_module_comment_in_param_list_before_block_drops() { + // The block's `{` sits AFTER the comment (the comment is in the parameter + // list), so the re-seek anchors on the BLOCK's start, not the statement's — the + // oracle drops it. The discriminating control for `..._after_block_carries`. + let js = compile_js( + "

    hi

    ", + ); + assert!( + !js.contains("MODMARK"), + "a comment before the block (in the param list) must drop: {js}" + ); +} + +#[test] +fn compile_module_comment_multi_split_before_and_after_block() { + // Two comments, one before the block and one after: only the second carries. + // Confirms the per-comment independence of the keep rule. + let js = compile_js( + "

    hi

    ", + ); + assert!( + js.contains("// KEEPMARK") && !js.contains("DROPMARK"), + "the pre-block comment drops and the post-block comment carries: {js}" + ); +} + +#[test] +fn compile_module_multiline_block_comment_refuses() { + // A KEPT module comment that would reprint divergently refuses (safe): esrap + // re-indents a multi-line block comment's interior lines. + assert_unsupported( + "

    hi

    ", + "multi-line block comment", + ); +} + +#[test] +fn compile_module_format_ignore_comment_refuses() { + // A KEPT module `prettier-ignore` refuses (would switch the printer to + // raw-source emission of the following statement). + assert_unsupported( + "

    hi

    ", + "format-ignore directive", + ); +} + +#[test] +fn compile_module_comment_in_erased_region_refuses() { + // A KEPT module comment intersecting an erased TypeScript region refuses — the + // oracle's surviving placement there is an emergent stale-span artifact. + assert_unsupported( + "

    hi

    ", + "erased TypeScript region", + ); +} + +#[test] +fn compile_rejects_exporting_a_non_hoistable_snippet() { + // `snippet_invalid_export` (`2-analyze/index.js:831`): the snippet references + // the INSTANCE script, so the oracle cannot hoist it into module scope and the + // export names nothing there. + assert_unsupported( + "{#snippet foo()}{x}{/snippet}", + "exported {#snippet} foo is not module-hoistable", + ); + // A snippet below the root fragment never hoists either — and the oracle still + // reports the SPECIFIC error, because `analysis.snippets` is unfiltered by + // top-level-ness. + assert_unsupported( + "
    {#snippet foo()}s{/snippet}
    ", + "exported {#snippet} foo is not module-hoistable", + ); +} + +#[test] +fn compile_rejects_exporting_an_undeclared_name() { + // `export_undefined` (`2-analyze/index.js:833`). + assert_unsupported( + "", + "module script exports blah, which it does not declare", + ); + // ⚠️ An INSTANCE declaration does not count: the instance scope is a CHILD of + // the module scope, and `scope.get` never walks down. Live-probed. + assert_unsupported( + "", + "module script exports x, which it does not declare", + ); +} + +#[test] +fn compile_accepts_the_module_export_rule_s_exemptions() { + // ⚠️ The hoist interaction: a hoistable top-level snippet's binding is written + // INTO the module scope (`SnippetBlock.js:40-44`), so the export resolves and + // there is no error — the reason module scope must be consulted BEFORE the + // snippet-name set. Live-probed, and the case `checklist_svelte_compiler.md` + // previously described the wrong way round. + let _ = compile_js("{#snippet foo()}static{/snippet}"); + // A snippet referencing only the MODULE script still hoists. + let _ = compile_js( + "{#snippet foo()}{m}{/snippet}", + ); + // Every ordinary module declaration form resolves. + let _ = compile_js(""); + let _ = compile_js(""); + let _ = compile_js(""); + // `export … from 'y'` is exempt (the oracle's `node.source == null` gate). + let _ = compile_js(""); + // A type-only export never reaches the rule: erasure drops it, exactly as the + // oracle's own phase-1 `remove_typescript_nodes` does. + let _ = compile_js(""); + let _ = + compile_js(""); +} diff --git a/crates/tsv_svelte_compile/src/tests/refusal_buckets.rs b/crates/tsv_svelte_compile/src/tests/refusal_buckets.rs new file mode 100644 index 000000000..dc8e391ff --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/refusal_buckets.rs @@ -0,0 +1,171 @@ +//! The refusal catalog's completeness guard: every [`Refusal`] variant must have +//! a representative in [`Refusal::every_variant`]. +//! +//! `every_variant` is hand-maintained and, unlike +//! [`bucket_key`](Refusal::bucket_key), is not an exhaustive `match` — a new +//! variant compiles fine while missing from it, and the declared bucket universe +//! ([`Refusal::all_bucket_keys`]) then silently narrows. +//! +//! `tsv_debug`'s `all_bucket_keys_covers_the_catalog` pins that universe as a +//! full key SET, which catches a rename, a deletion, a wrong placeholder, and two +//! representatives collapsing onto one key. It cannot catch an **omission**: the +//! pin sits downstream of `every_variant`, so a variant absent from both changes +//! no key and passes. This closes that one hole from the other side, deriving the +//! variant list from the enum's own source rather than from a second hand-written +//! mirror. +//! +//! ⚠️ The derivation is a textual scan of `refusal.rs`, not a parse — it is sound +//! only while the enum body keeps one variant per line at four-space indent, which +//! is what `cargo fmt` produces. A scan that stopped finding variants would fail +//! loudly here (the derived set would shrink against `every_variant`), never +//! silently pass. + +use crate::Refusal; + +/// Every variant name declared by the `Refusal` enum, read from its source. +/// +/// Scans the `pub enum Refusal {` body for lines that open at the enum's own +/// indent with an identifier — a variant. Field lines sit one level deeper, and +/// doc comments, attributes, section rules, and a struct variant's closing brace +/// all fail the leading-uppercase test. +fn variant_names_from_source() -> Vec { + const SOURCE: &str = include_str!("../refusal.rs"); + + SOURCE + .lines() + .skip_while(|line| !line.starts_with("pub enum Refusal {")) + .skip(1) + .take_while(|line| *line != "}") + .filter_map(|line| { + let name = line.strip_prefix(" ")?; + if !name.starts_with(|c: char| c.is_ascii_uppercase()) { + return None; + } + Some( + name.chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(), + ) + }) + .collect() +} + +/// Every `Refusal` variant declared without a doc comment of its own, read from +/// the enum's source. +/// +/// A doc block attached to the wrong item is syntactically valid, so `cargo doc` +/// reports nothing for it — the failure mode is silent by construction. It has +/// bitten twice: a module split stranded `is_deliberate_fence`'s explanation onto +/// `every_variant`, and `DuplicateAttribute`'s block ended up stacked above +/// `AttributeInvalidName`, leaving the duplicate rule undocumented and the name +/// rule reading with two descriptions. +/// +/// The *orphan* is not detectable — two stacked doc blocks parse fine. The +/// **victim** is: an undocumented variant among documented siblings. That is the +/// signature this scans for, and in this catalog it carries no false positives. +/// +/// ⚠️ Only a `///` at the enum's own four-space indent counts. A struct variant's +/// FIELD docs sit one level deeper, and counting those would let a field doc mask +/// the very next variant — the case that would matter most. +fn undocumented_variants() -> Vec { + const SOURCE: &str = include_str!("../refusal.rs"); + + let mut undocumented = Vec::new(); + let mut has_doc = false; + for line in SOURCE + .lines() + .skip_while(|line| !line.starts_with("pub enum Refusal {")) + .skip(1) + .take_while(|line| *line != "}") + { + let Some(rest) = line.strip_prefix(" ") else { + continue; + }; + if rest.starts_with("///") { + has_doc = true; + } else if rest.starts_with(|c: char| c.is_ascii_uppercase()) { + if !has_doc { + undocumented.push( + rest.chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect(), + ); + } + has_doc = false; + } + } + undocumented +} + +/// The variant name of a representative, via its `Debug` derive. +/// +/// `Debug` prints the variant name first for every shape — a unit variant is the +/// bare name, a struct variant is `Name { … }`, a tuple variant `Name(…)`. +fn variant_name_of(refusal: &Refusal) -> String { + format!("{refusal:?}") + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '_') + .collect() +} + +#[test] +fn every_variant_covers_the_enum() { + let declared = variant_names_from_source(); + assert!( + declared.len() > 100, + "the source scan found only {} variant(s) — the enum's layout changed and \ + the scan no longer reads it", + declared.len() + ); + + let represented: Vec = Refusal::every_variant() + .iter() + .map(variant_name_of) + .collect(); + + let missing: Vec<&String> = declared + .iter() + .filter(|name| !represented.contains(name)) + .collect(); + assert!( + missing.is_empty(), + "`Refusal` variant(s) with no `every_variant` representative: {missing:#?}\n\ + Add one per variant (parameters spelled as the placeholder the bucket key \ + collapses to, e.g. `name: \"{{name}}\".to_string()`), then regenerate \ + `EXPECTED_BUCKET_KEYS` in tsv_debug's compile_conformance_audit." + ); + + // The reverse direction is a scan failure rather than a catalog one: a + // represented name the source has no variant for means the scan misread the + // enum, since a nonexistent variant could not have been constructed. + let unknown: Vec<&String> = represented + .iter() + .filter(|name| !declared.contains(name)) + .collect(); + assert!( + unknown.is_empty(), + "the source scan missed variant(s) `every_variant` constructs: {unknown:#?}" + ); +} + +#[test] +fn every_variant_is_documented() { + // Shares the vacuous-pass guard of the scan above: a layout change that made + // the scan read nothing would report zero undocumented variants and pass. + let declared = variant_names_from_source(); + assert!( + declared.len() > 100, + "the source scan found only {} variant(s) — the enum's layout changed and \ + the scan no longer reads it", + declared.len() + ); + + let undocumented = undocumented_variants(); + assert!( + undocumented.is_empty(), + "`Refusal` variant(s) with no doc comment: {undocumented:#?}\n\ + Every variant carries one, so a missing doc usually means a preceding \ + variant's block was stranded onto the wrong item during a move — check \ + whether the neighbour above now reads with two stacked descriptions." + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/rune_store_collision.rs b/crates/tsv_svelte_compile/src/tests/rune_store_collision.rs new file mode 100644 index 000000000..b2295d011 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/rune_store_collision.rs @@ -0,0 +1,276 @@ +//! The rune-vs-store name collision pre-pass and the static-block fence. + +use super::support::*; + +#[test] +fn compile_refuses_rune_name_bound_as_store() { + // The oracle's `analyze_component` reclassifies `$state` as a STORE + // subscription on the imported `state` binding — `$.store_get(($$store_subs + // ??= {}), '$state', state)()` — because the binding's initializer is not a + // rune call. tsv would compile it as the rune (`const x = void 0`), so refuse. + assert_unsupported( + "\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); + // No store import is needed — ANY instance binding of the stem collides. + assert_unsupported( + "\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); + // Every rune keyword, not just `$state`. + assert_unsupported( + "\n

    {d}

    ", + "rune $derived whose base is also an instance binding", + ); + assert_unsupported( + "\n

    {a}

    ", + "rune $props whose base is also an instance binding", + ); + // A function or class declaration binds the stem just as an import does. + assert_unsupported( + "\n

    {x}{state}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_name_bound_in_module_scope() { + // `instance.scope.get(stem)` walks UP (`phases/scope.js:748`) and the instance + // scope's parent IS the module scope (`2-analyze/index.js:337`), so a MODULE + // binding reclassifies an INSTANCE `$state` too. Oracle (verified): + // `const x = $.store_get($$store_subs ??= {}, '$state', state)(1)`. + assert_unsupported( + "\n\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); + // The module's own exempting initializer counts too — a module binding whose + // init is a rune call is not reclassified. (A module `$state` refuses on its + // own path, so the reachable exempt shape is a module-scope declaration + // consumed only by the instance script.) + assert_unsupported( + "\n\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_name_bound_by_hoisted_var() { + // `var` is FUNCTION-scoped, so a `var state` in any block or for-head of the + // instance script lands in `instance.scope` exactly like a top-level one. + // Oracle (verified): both reclassify to `$.store_get(…)`. + assert_unsupported( + "\n

    {x}{state}

    ", + "rune $state whose base is also an instance binding", + ); + assert_unsupported( + "\n

    {a}{props}

    ", + "rune $props whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_name_bound_in_class_static_block() { + // ⚠️ A class STATIC BLOCK is NOT a scope in the oracle: `phases/scope.js` has + // no `StaticBlock` visitor (and none for `ClassBody`/`MethodDefinition`), so a + // `var` there declares directly in the ENCLOSING script scope. ECMAScript + // disagrees — a static block is its own VariableEnvironment — but the oracle + // is the parity target. Oracle (verified, all three): reclassified to + // `$.store_get(($$store_subs ??= {}), '$state', state)(1)` / `'$props'`. + assert_unsupported( + "\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); + // The same class as an EXPRESSION in a declarator initializer. + assert_unsupported( + "\n

    {x}{C}

    ", + "rune $state whose base is also an instance binding", + ); + // A class expression nested deeper than a declarator init — here an + // assignment RHS. Oracle (verified): reclassifies. The fence reaches it + // because it never asks WHERE the class sits. + assert_unsupported( + "\n

    {x}{y}

    ", + "rune $state whose base is also an instance binding", + ); + // The `$props` stem, to show the rule is not `$state`-specific. + assert_unsupported( + "\n

    {a}

    ", + "rune $props whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_name_bound_by_porous_hoisted_var_with_rune_init() { + // ⚠️ A `var` hoisting through a POROUS scope arrives with NO initializer: + // `phases/scope.js:673-681` re-declares it on the parent via the 3-argument + // call, leaving `initial` at its `null` default. So `get_rune(binding.initial)` + // is null and the rune EXEMPTION does not apply, even though the declarator + // was written with a rune init. Oracle (verified): emits + // `var state = $.store_get(($$store_subs ??= {}), '$state', state)(0)` — it + // reclassifies. tsv also refuses this via its rune guard, but that is an + // ACCIDENTAL save on an unrelated path; this pins the modelled one. + assert_unsupported( + "\n

    {x}{state}

    ", + "rune $state whose base is also an instance binding", + ); + assert_unsupported( + "\n

    {a}{props}

    ", + "rune $props whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_every_class_expression_position_holding_a_static_block() { + // The fence's whole point: a static block declares at script scope from ANY + // position a class can occupy, and enumerating those positions is what shipped + // holes twice. Each shape below was a live MISMATCH under the enumerated walk + // (tsv compiled it as the rune; the oracle emitted `$.store_get`), and none + // needs its own arm now — the scan never asks where the class sits. + // + // A representative per structural family, since the fix is categorical: a + // for-head (the `init`/`test`/`update`/`right` positions all behaved alike), a + // class-DECLARATION member position (a property initializer is NOT a function + // scope — `phases/scope.js` has no `PropertyDefinition` visitor — and neither + // is a computed key or a `super_class`), and a function PARAMETER DEFAULT (the + // oracle gives a function's parameters a POROUS scope, `scope.js:1143/1155/1163`). + for source in [ + // for-head init expression + "\n

    {count}

    ", + // for-of right + "\n

    {count}

    ", + // class-declaration super_class + "\n

    {count}

    ", + // class-declaration property initializer + "\n

    {count}

    ", + // class-declaration computed member key + "\n

    {count}

    ", + // function parameter default + "\n

    {count}

    ", + ] { + assert_unsupported(source, "rune $state whose base is also an instance binding"); + } +} + +#[test] +fn compile_refuses_a_static_block_that_declares_nothing() { + // The deliberate over-refusal the fence buys, pinned so it is a choice rather + // than a surprise: this static block binds no rune stem at all and the oracle + // compiles it fine, but the fence cannot tell without traversing the positions + // it exists to avoid traversing. Measured cost: zero — none of the ~4900 + // `.svelte` files under the compile-corpus roots contains a static block. + assert_unsupported( + "\n

    {x}{C}

    ", + "rune $state whose base is also an instance binding", + ); + // ⚠️ It also swallows the oracle's rune EXEMPTION inside a static block — a + // `var state = $state(0)` there keeps its initializer (no scope, so no + // re-declare) and the oracle does NOT reclassify. tsv refused this already, on + // its rune-guard path (a `$state` call inside a class body); the fence now + // refuses it first. Either way it is a refusal, never a wrong compile, and the + // exemption arm for this shape was unreachable even before the fence. + assert_unsupported( + "\n

    {x}{C}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_allows_ordinary_class_using_code_with_no_static_block() { + // The other side of the fence, and the one that protects parity: ordinary + // class-using code — including `static` MEMBERS, which are not static blocks — + // must not trip it. Only `static` followed by `{` does. + let _ = compile_js( + "\n

    {count}{C.label}

    ", + ); + // A class EXPRESSION in the shapes the enumerated walk used to descend into, + // with no static block anywhere: still compiles. + let _ = compile_js( + "\n

    {count}{K}

    ", + ); +} + +#[test] +fn compile_refuses_static_block_separated_by_zwnbsp() { + // The static-block fence's whitespace class must be ECMAScript's, not Rust's. + // U+FEFF () is ECMAScript `WhiteSpace` but carries no Unicode + // `White_Space` property, so `char::is_whitespace` says NO and a + // `static\u{feff}{ … }` block was INVISIBLE to the scan — the fence never + // fired, `script_declarations_of` stopped at the class body, and tsv compiled + // the rune where the oracle emits a store read. Oracle (verified live, both + // script kinds): `$.store_get(($$store_subs ??= {}), '$state', state)(0)`. + // + // The reverse code point needs no handling: U+0085 () is Unicode + // whitespace but NOT ECMAScript whitespace, so treating it as a boundary only + // ever OVER-reports — an extra refusal, on source the JS lexer rejects anyway. + assert_unsupported( + "\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); + // The module script is scanned by the same fence — the collision pre-pass + // tests `instance.scope.get`, which walks up into module scope. + assert_unsupported( + "\n\n

    {x}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_name_bound_by_escaped_identifier() { + // An ESCAPED binding identifier binds the decoded name. `plain_identifier_name` + // reports `None` for one, so the walk decodes via `Identifier::name` instead — + // the reference here is plain, so refusing the escaped *reference* elsewhere + // does not cover this. Oracle (verified): `$.store_get(…, '$state', state)(1)`. + assert_unsupported( + "\n

    {x}{state}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_refuses_rune_reference_separated_by_unicode_whitespace() { + // NBSP (U+00A0) is ECMAScript whitespace, so `$state\u{a0}(1)` is a genuine + // `$state` reference. A byte-level boundary test that counts every byte + // `>= 0x80` as identifier text reads NBSP's `0xC2` lead byte as a continuation + // and MISSES it — an under-refusal. Oracle (verified): reclassifies. + assert_unsupported( + "\n

    {x}{state}

    ", + "rune $state whose base is also an instance binding", + ); +} + +#[test] +fn compile_allows_rune_stem_bound_only_in_a_child_scope() { + // ⚠️ The lookup walks UP, never DOWN. A function parameter, a block-scoped + // `let`, and a `var` inside a nested FUNCTION all live in CHILD scopes + // `instance.scope.get` never reaches, so none collide — refusing any of these + // would be a spurious over-refusal on ordinary code. All three verified at + // parity against the oracle. + let _ = compile_js( + "\n

    {x}{f(2)}

    ", + ); + let _ = compile_js( + "\n

    {x}

    ", + ); + let _ = compile_js( + "\n

    {x}{f()}

    ", + ); +} + +#[test] +fn compile_allows_rune_binding_initialized_by_its_own_rune() { + // ⚠️ THE EXEMPTION. `get_rune(init) !== null` is the oracle's carve-out and + // covers the overwhelmingly common real-world shapes — refusing them would + // crater corpus parity, so they are pinned here. + let _ = compile_js("\n

    {state}

    "); + let _ = compile_js("\n

    {derived}

    "); + let _ = compile_js("\n

    {props.a}

    "); + let _ = compile_js("\n

    {state}

    "); + // A binding of a rune stem with NO `$stem` reference anywhere is untouched: + // the oracle's loop only ever sees names that are actually referenced. + let _ = compile_js("\n

    {state}

    "); + // `$derived` beside `import { derived } from 'svelte/store'` is the oracle's + // explicit exception ("one is not a subscription to the other"). + let _ = compile_js( + "\n

    {d}{derived}

    ", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/runes_derived.rs b/crates/tsv_svelte_compile/src/tests/runes_derived.rs new file mode 100644 index 000000000..ceae32633 --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/runes_derived.rs @@ -0,0 +1,465 @@ +//! The `$derived` family: init rewrite, read positions, writes. + +use super::support::*; + +#[test] +fn compile_derived_rune_rewrites_init_and_read() { + // `$derived(e)` → `$.derived(() => e)`; a bare template read of the + // (non-foldable) derived binding becomes `d()`. + let out = compile_checked( + "\n

    {d}

    ", + ); + assert!( + out.js.contains("let d = $.derived(() => a * 2);"), + "derived init not rewritten: {}", + out.js + ); + assert!( + out.js.contains("`

    ${$.escape(d())}

    `"), + "derived read must become a call: {}", + out.js + ); +} + +#[test] +fn compile_derived_read_refuses_deferred_positions() { + // The template VALUE walk and the script-position rewrite (`store_rewrite`) + // turn a derived read into `d()` (the fixtures `runes/derived_read_*` and + // `runes/derived_read_script_*`). Positions NOT routed through either keep + // refusing the derived read (`DerivedBindingRead`, "read of derived binding") + // — never a MISMATCH. + // + // A `{#each}` context pattern default: the oracle emits a BARE `d` here + // (`let { v = d }`), so tsv could match by borrowing verbatim — but patterns are + // not rewritten this slice, so refusing is a deferred safe over-refusal. + assert_unsupported( + "\n{#each xs as { v = d }}{v}{/each}", + "read of derived binding", + ); + // A `{:then}` value pattern default: here the oracle emits `d()` + // (`({ x = d() }) => …`), so borrowing the pattern verbatim WOULD emit a bare `d` + // — a MISMATCH. Refusing is mandatory until patterns route through the walk. + assert_unsupported( + "\n{#await p then { x = d }}{x}{/await}", + "read of derived binding", + ); + // A derived assignment target (`{d = 1}`) — the guard refuses the derived + // WRITE (a template mutation would refuse too). A derived write is out of scope + // on every path (the oracle lowers it to `d(v)`). + assert_unsupported( + "\n{d = 1}", + "read of derived binding", + ); + // A derived read under an ObjectExpression (`{f({ x: d })}`) — a wrapper kind the + // value walk does not descend, so it never reaches the rewrite and the guard + // refuses it (a safe over-refusal). + assert_unsupported( + "\n{f({ x: d })}", + "read of derived binding", + ); + // An ESCAPED-identifier read of a `$derived` name: the six source bytes + // `d` are the escaped spelling of the identifier `d`, which the oracle emits + // as `d()`. The value-walk can't rewrite an escaped read (classification not + // ported), so the rune guard refuses it rather than emit a bare `d` — a MISMATCH. + // Both bare and nested. + assert_unsupported( + "\n{\\u0064}", + "read of derived binding", + ); + assert_unsupported( + "\n{\\u0064 + 1}", + "read of derived binding", + ); +} + +#[test] +fn compile_derived_read_script_position_rewrites() { + // A `$derived` read in a SCRIPT position (a top-level initializer, a function + // body, a `$.derived(() => …)` thunk) rewrites to `d()`, the same lowering the + // template value walk applies — extended to the script by `store_rewrite`. + // Script positions never fold (only template text folds), so it is always + // `d()`, never the derived's value. + let out = compile_checked( + "\n", + ); + // The top-level initializer (no fold), the function-body read, and the read + // inside the `$.derived` thunk all become `d()`; the derived declarations keep + // their bare binding names. + assert!( + out.js.contains("let e = d() + 1;"), + "top-level init: {}", + out.js + ); + assert!(out.js.contains("return d() + 1;"), "fn body: {}", out.js); + assert!( + out.js.contains("let d2 = $.derived(() => d() + 1);"), + "nested-in-derived: {}", + out.js + ); + assert!( + out.js.contains("let d = $.derived(() => a * 2);"), + "binding id bare: {}", + out.js + ); +} + +#[test] +fn compile_derived_read_name_only_positions_stay_bare() { + // Name-only positions are NOT reads: a non-computed member property (`o.d`) and + // an object key (`{ d: 1 }`) stay verbatim, exactly like the store rewrite. + let out = compile_checked( + "\n", + ); + assert!( + out.js.contains("const o = { d: 1 };"), + "object key stays: {}", + out.js + ); + assert!( + out.js.contains("return o.d + d();"), + "member stays, read rewrites: {}", + out.js + ); +} + +#[test] +fn compile_derived_read_shadowed_refuses() { + // A `$derived` name shadowed by a nested-scope binding (a param/local) is + // ambiguous for the name-based rewrite (`return d` inside `f(d)` is the param, + // not the derived). Refuse the whole compile — a safe over-refusal (shadowing a + // derived is legal, so this is never a MISMATCH). + assert_unsupported( + "\n", + "shadowed in a nested scope", + ); + // A nested local (not a parameter) shadows too. + assert_unsupported( + "\n", + "shadowed in a nested scope", + ); +} + +#[test] +fn compile_derived_write_refuses() { + // A write to the derived binding ITSELF (`d = v` / `d++`) is out of scope — the + // oracle lowers it to `d(v)` / `$.update_derived(d)`, which this slice does not + // emit. The rune guard refuses the bare-identifier target (`DerivedBindingRead`). + assert_unsupported( + "\n", + "read of derived binding", + ); + assert_unsupported( + "\n", + "read of derived binding", + ); + // A destructuring assignment whose leaf binds the derived (`[d] = …`, + // `({ d } = …)`, `[z, d] = …`) is a derived write too — the oracle lowers it to + // an `$.to_array` IIFE / `d(obj.d)`. The guard refuses the binding leaf. + assert_unsupported( + "\n", + "read of derived binding", + ); + assert_unsupported( + "\n", + "read of derived binding", + ); + assert_unsupported( + "\n", + "read of derived binding", + ); +} + +#[test] +fn compile_derived_member_write_compiles() { + // A member/index target READS the derived (its object / computed index), never + // binds it — `d.x = v` → `d().x = v` and `x[d] = v` → `x[d()] = v` compile via + // the read rewrite (the narrower binding-leaf refusal stops at members). + let out = compile_checked( + "\n", + ); + assert!( + out.js.contains("d().x = 5;"), + "member write reads derived: {}", + out.js + ); + + let out = compile_checked( + "\n", + ); + assert!( + out.js.contains("arr[d()] = 5;"), + "index write reads derived: {}", + out.js + ); +} + +#[test] +fn compile_derived_by_bare_read_compiles() { + // `$derived.by(d)` (a bare derived argument) compiles: `.by` passes `d` straight + // through as the compute function (`$.derived(d)`) and the read rewrite lowers + // it to `$.derived(d())`, the oracle's output. (Contrast `$derived(d)`, which the + // oracle unthunk-collapses to `$.derived(d)` — refused as unreproducible.) + let out = compile_checked( + "\n", + ); + assert!( + out.js.contains("let e = $.derived(d());"), + "$derived.by(d): {}", + out.js + ); +} + +#[test] +fn compile_escaped_local_read_still_compiles() { + // An escaped identifier is NOT auto-refused — only one decoding to a `$derived` + // name is. An escaped read of a plain (non-derived) local compiles, reading the + // binding bare (`d`, never `d()`). + let out = compile_checked( + "\n{\\u0064}", + ); + assert!( + out.js.contains("$.escape(d)"), + "escaped plain-local read must compile bare: {}", + out.js + ); +} + +#[test] +fn compile_derived_read_state_stays_bare() { + // Only names in `derived_names` rewrite. A reassigned `$state` binding is NOT + // derived, so a nested read of it stays bare (`s + 1`, never `s() + 1`). + let out = compile_checked( + "\n{s + 1}", + ); + assert!( + out.js.contains("$.escape(s + 1)"), + "state read must stay bare: {}", + out.js + ); +} + +// ── Destructured `$derived` / `$derived.by` (the 1→N lowering) ────────────── + +#[test] +fn compile_destructured_derived_object_joins_and_reads_call() { + // `{a, b} = $derived(o)` → ONE joined declaration, one `$.derived(() => o.KEY)` + // per leaf; template AND script reads become calls (`a()`). + let out = compile_checked( + "\n{a}{b}", + ); + assert!( + out.js + .contains("let a = $.derived(() => o.a),\n\t\tb = $.derived(() => o.b);"), + "destructured derived must be one joined declaration: {}", + out.js + ); + assert!( + out.js.contains("const s = a() + b();"), + "script-position leaf reads must become calls: {}", + out.js + ); + assert!( + out.js.contains("$.escape(a())") && out.js.contains("$.escape(b())"), + "template leaf reads must become calls: {}", + out.js + ); +} + +#[test] +fn compile_destructured_derived_renamed_and_nested_keys() { + // A renamed key binds the VALUE (`x`) projecting the KEY (`o.a`); a nested + // pattern chains the member (`o.a.c`). + let renamed = compile_js( + "\n{x}{b}", + ); + assert!( + renamed.contains("let x = $.derived(() => o.a),"), + "renamed key must bind the value projecting the key: {renamed}" + ); + let nested = compile_js( + "\n{c}", + ); + assert!( + nested.contains("let c = $.derived(() => o.a.c);"), + "nested pattern must chain the projection: {nested}" + ); +} + +#[test] +fn compile_destructured_derived_object_rest_and_default() { + // A rest projects `$.exclude_from_object(o, [])`; a simple default + // wraps `$.fallback`; a non-simple default thunks + collapses (`f()` → `f`). + let rest = compile_js( + "\n{a}{r}", + ); + assert!( + rest.contains("r = $.derived(() => $.exclude_from_object(o, ['a']));"), + "rest must exclude the sibling keys: {rest}" + ); + let simple = + compile_js("\n{a}"); + assert!( + simple.contains("let a = $.derived(() => $.fallback(o.a, 9));"), + "a simple default is a 2-arg fallback: {simple}" + ); + let complex = compile_js( + "\n{a}", + ); + assert!( + complex.contains("$.fallback(o.a, f, true)"), + "a non-simple default thunks + unthunk-collapses: {complex}" + ); +} + +#[test] +fn compile_destructured_derived_array_and_collision() { + // An array mints a `$$derived_array` derived intermediate (read as a call), + // projecting `()[i]`; a second array collides to `$$derived_array_1`. + let out = compile_js( + "\n{a}{b}{c}{d}", + ); + assert!( + out.contains("let $$derived_array = $.derived(() => $.to_array(o, 2)),") + && out.contains("a = $.derived(() => $$derived_array()[0]),"), + "array must project through a $$derived_array intermediate: {out}" + ); + assert!( + out.contains("let $$derived_array_1 = $.derived(() => $.to_array(o, 2)),"), + "a second array must collide to $$derived_array_1: {out}" + ); +} + +#[test] +fn compile_destructured_derived_array_rest_omits_length() { + // A trailing rest omits the `$.to_array` length and slices from the index. + let out = compile_js( + "\n{a}{rest}", + ); + assert!( + out.contains("$.to_array(o)") + && out.contains("rest = $.derived(() => $$derived_array().slice(1));"), + "array rest must omit length and slice: {out}" + ); +} + +#[test] +fn compile_destructured_derived_by_and_non_identifier_arg_mint_intermediate() { + // `$derived.by` (and any non-identifier `$derived` arg) mints `$$d`, projecting + // from `$$d()`; the `.by` compute fn rides `$.derived(() => …)`. + let by = compile_js( + "\n{a}", + ); + assert!( + by.contains("let $$d = $.derived(() => o),\n\t\ta = $.derived(() => $$d().a);"), + "$derived.by destructure must mint $$d: {by}" + ); + // A member argument mints `$$d` and forces the needs_context wrapper. + let member = + compile_js("\n{a}"); + assert!( + member.contains("let $$d = $.derived(() => o.x),") + && member.contains("$$renderer.component("), + "a member arg mints $$d and wraps: {member}" + ); + // A call argument unthunk-collapses the intermediate init (`$.derived(getObj)`). + let call = compile_js( + "\n{a}", + ); + assert!( + call.contains("let $$d = $.derived(getObj),"), + "a call arg collapses the $$d init via unthunk: {call}" + ); +} + +#[test] +fn compile_destructured_derived_from_derived_base_calls_it() { + // `{x} = $derived(base)` where `base` is itself a derived: no `$$d` (bare + // identifier), and the store rewrite lowers the projected `base` read to + // `base()` — `$.derived(() => base().x)`. + let out = compile_js( + "\n{m}", + ); + assert!( + out.contains("let m = $.derived(() => obj().m);"), + "a derived base must be read as a call inside the projection: {out}" + ); +} + +#[test] +fn compile_destructured_derived_refuses_comments() { + // A carried script comment alongside a destructured derived refuses (the 1→N + // split is not comment-safe) — a safe over-refusal. + assert_unsupported( + "\n{a}{b}", + "comments in a script with a destructured $derived declarator", + ); +} + +#[test] +fn compile_destructured_derived_refuses_in_multi_declarator() { + // A destructured derived alongside another declarator in one `let` needs + // per-source-declarator grouping tsv doesn't reproduce — refuse (the oracle + // compiles it; a safe over-refusal). + assert_unsupported( + "\n{x}{a}{b}", + "destructuring a $derived declarator", + ); +} + +#[test] +fn compile_destructured_derived_leaf_folds_through_scalar_arg() { + // A destructured-derived LEAF folds through the rune's argument, exactly like an + // identifier target — the oracle declares every leaf with the whole `$derived(…)` + // call as its initial (`scope.js:1204-1213`) and evaluates it through the arg. So + // with `d`→5 (a bounded scalar), `{a}` folds to the CONTAINER value `5` (ignoring + // the `.a` projection), NOT a dynamic `$.escape(a())`. This was the committed + // MISMATCH before the leaf-initial fix (leaves were wrongly `Initial::None`). + let out = compile_checked( + "\n

    {a}

    ", + ); + assert!( + out.js.contains("

    5

    "), + "destructured-derived scalar-arg leaf must fold to the container value: {}", + out.js + ); + assert!( + !out.js.contains("$.escape"), + "the folded leaf read must not stay dynamic: {}", + out.js + ); + // The transform still lowers each leaf to its own `$.derived(() => path)`; only + // the template READ folds. + assert!( + out.js.contains("let a = $.derived(() => d().a);"), + "leaf declarator must still be a projecting derived: {}", + out.js + ); +} + +#[test] +fn compile_destructured_derived_object_arg_leaf_stays_dynamic() { + // The corpus-common case is UNCHANGED by the leaf-initial fix: an object/array + // argument evaluates to UNKNOWN, so the leaf does NOT fold and reads as `a()`. + let out = compile_checked( + "\n

    {a}{b}

    ", + ); + assert!( + out.js.contains("${$.escape(a())}${$.escape(b())}"), + "object-arg destructured-derived leaves must stay dynamic calls: {}", + out.js + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/runes_misc.rs b/crates/tsv_svelte_compile/src/tests/runes_misc.rs new file mode 100644 index 000000000..2f7bf562c --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/runes_misc.rs @@ -0,0 +1,343 @@ +//! `$effect`, `$inspect`, `$props.id`, `$state.snapshot`, and rune misuse. + +use super::support::*; + +#[test] +fn compile_effect_forces_component_wrapper() { + // Statement-position `$effect(…)` is dropped; the whole body moves + // inside `$$renderer.component(($$renderer) => { … })`. + let out = compile_checked( + "\n

    {a}

    ", + ); + assert_eq!( + out.js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \t$$renderer.component(($$renderer) => {\n\ + \t\tlet { a } = $$props;\n\ + \t\t$$renderer.push(`

    ${$.escape(a)}

    `);\n\ + \t});\n\ + }\n" + ); +} + +#[test] +fn compile_inspect_with_wrong_arity_refuses() { + // `$inspect(a).with(cb)` drops only with EXACTLY one `.with` argument. A + // wrong outer arity is a hard oracle error (`rune_invalid_arguments_length`: + // "`$inspect().with` must be called with exactly one argument"), so the + // recognizer must not drop it — it falls through to the rune guard. + assert_unsupported( + "\n

    {a}

    ", + "$inspect", + ); + assert_unsupported( + "\n

    {a}

    ", + "$inspect", + ); +} + +#[test] +fn compile_rune_call_with_spread_refuses() { + // The oracle's `rune_invalid_spread` (`CallExpression.js:24`): any rune but + // `$inspect` called with a spread argument. It fires wherever the call sits — + // the oracle checks it on every call before dispatch — so all three positions + // below over-accepted (each is rewritten/dropped, so the spread rode into + // valid-but-wrong JS or a dropped effect) until this rule. + // Script declarator init (the validation-suite pin): + assert_unsupported( + "", + "spread argument", + ); + // Statement-position effect: + assert_unsupported( + "", + "spread argument", + ); + // Template position: + assert_unsupported( + "\n{$state.snapshot(...args)}", + "spread argument", + ); + + // Controls. `$inspect` is EXEMPT (the oracle allows a spread there), so the + // whole component still compiles. And a NON-spread rune call is untouched. + let _ = compile_js(""); + let _ = + compile_js("\n

    {count}

    "); +} + +#[test] +fn compile_rejects_rune_in_nested_function() { + assert_unsupported( + "\n

    text

    ", + "$state", + ); +} + +#[test] +fn compile_state_raw_drops_wrapper() { + // `$state.raw(v)` is a sanctioned init: the wrapper drops; an array + // value isn't statically foldable, so the read stays dynamic. + let out = compile_checked("\n

    {a}

    "); + assert!(out.js.contains("let a = [1];"), "got: {}", out.js); + assert!( + out.js.contains("`

    ${$.escape(a)}

    `"), + "got: {}", + out.js + ); +} + +#[test] +fn compile_rejects_member_form_rune_misuse() { + // A bare `$props` reference (destructuring the rune itself) refuses. + assert_unsupported( + "\n

    text

    ", + "$props", + ); + // A non-sanctioned member-form rune call still refuses (`$props.id()` and + // `$state.snapshot(x)` are the sanctioned member-form runes; `$state.foo()` + // is not). + assert_unsupported( + "\n

    {b}

    ", + "$state", + ); +} + +#[test] +fn compile_props_id_hoists_declaration() { + // `const id = $props.id()` is skipped in place; a `const id = + // $.props_id($$renderer)` is hoisted to the top of the component body, and a + // `{id}` read stays dynamic (`$.escape(id)`, never a fold). + let out = compile_checked("\n

    {id}

    "); + assert_eq!( + out.js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tconst id = $.props_id($$renderer);\n\ + \t$$renderer.push(`

    ${$.escape(id)}

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_props_id_hoists_before_other_declarators() { + // In `const a = 1, id = $props.id()` the hoisted `id` decl leads the body, + // then the surviving `const a = 1` (the oracle's shape). + let out = compile_checked( + "\n

    {a}{id}

    ", + ); + assert!( + out.js + .contains("const id = $.props_id($$renderer);\n\tconst a = 1;"), + "props.id decl must lead: {}", + out.js + ); +} + +#[test] +fn compile_props_id_refuses_misuse() { + // Arguments (`rune_invalid_arguments`) — not recognized as `$props.id()`, so + // the guard refuses the stray `$props`-rooted call. + assert_unsupported( + "\n

    {id}

    ", + "$props", + ); + // A destructured target (`props_id_invalid_placement`). + assert_unsupported( + "\n

    {x}

    ", + "$props.id()", + ); + // A template position (`props_id_invalid_placement`) — the guard refuses it. + assert_unsupported("

    {$props.id()}

    ", "$props"); + // A second `$props.id()` (`props_duplicate`). + assert_unsupported( + "\n

    {a}{b}

    ", + "more than once", + ); + // In a module script (`props_id_invalid_placement` — module scope). A plain + // module now compiles, so the module guard refuses the stray `$props`-rooted + // call (a module-scope rune) rather than declining the whole module up front. + assert_unsupported( + "\n

    text

    ", + "$props", + ); +} + +#[test] +fn compile_state_snapshot_declarator_unwraps() { + // `const s = $state.snapshot(obj)` unwraps to `const s = obj`; the `{s.a}` + // read stays dynamic. + let out = compile_checked( + "\n

    {s.a}

    ", + ); + assert!(out.js.contains("const s = obj;"), "got: {}", out.js); + assert!( + out.js.contains("`

    ${$.escape(s.a)}

    `"), + "got: {}", + out.js + ); +} + +#[test] +fn compile_state_snapshot_template_rewrites_to_runtime_call() { + // A `$state.snapshot(x)` in a template value becomes `$.snapshot(x)`, at the + // root and nested inside a wrapper expression. + let bare = compile_checked( + "\n{$state.snapshot(obj)}", + ); + assert!( + bare.js.contains("$.escape($.snapshot(obj))"), + "bare snapshot: {}", + bare.js + ); + let nested = compile_checked( + "\n{2 in $state.snapshot(state)}", + ); + assert!( + nested.js.contains("$.escape(2 in $.snapshot(state))"), + "nested snapshot: {}", + nested.js + ); +} + +#[test] +fn compile_state_snapshot_derived_arg_becomes_call() { + // A bare derived read as the snapshot argument becomes `d()` inside the + // `$.snapshot(...)` call. + let out = compile_checked( + "\n{$state.snapshot(d)}", + ); + assert!( + out.js.contains("$.escape($.snapshot(d()))"), + "got: {}", + out.js + ); + // A NESTED derived read inside the snapshot argument (`d + 1`) also rewrites — + // the snapshot walk and the derived-read walk compose on one node set. + let nested = compile_checked( + "\n{$state.snapshot(d + 1)}", + ); + assert!( + nested.js.contains("$.escape($.snapshot(d() + 1))"), + "nested derived in snapshot arg: {}", + nested.js + ); +} + +#[test] +fn compile_state_snapshot_refuses_wrong_arity_and_deferred_positions() { + // Arity ≠ 1 (`rune_invalid_arguments_length`) — not recognized as snapshot, + // so the guard refuses the stray `$state`-rooted call. + assert_unsupported( + "\n{$state.snapshot()}", + "$state", + ); + assert_unsupported( + "\n{$state.snapshot(o, 1)}", + "$state", + ); + // A destructured `$state.snapshot` declarator now COMPILES via the oracle's + // temp-destructure lowering (`create_state_declarators`) — covered in + // `runes_state`; only the exotic-key / comment / multi-declarator corners still + // refuse there. + // + // A script non-declarator position (deferred this slice) — the guard refuses it. + assert_unsupported( + "\n

    text

    ", + "$state", + ); +} + +#[test] +fn compile_rune_optional_chain_declarator_refuses() { + // An optional-chained rune init (`$state.snapshot?.(x)`, `$state?.snapshot(x)`, + // `$props.id?.()`, `$state?.(1)`, …) is a ChainExpression the oracle's + // `get_rune` does not see through, so its declarator-unwrap never applies. tsv + // refuses to classify the optional form (a safe over-refusal) — closing a + // net-new MISMATCH (`$state.snapshot?.()`, where the oracle emits + // `$.snapshot(x)` and unwrapping to `x` diverged) and a pre-existing + // optional-chain over-acceptance family for the placement-restricted runes + // (the oracle rejects those, tsv used to compile them). Both the + // optional-call and optional-member spellings, over every declarator-unwrap + // rune. + // Each source is paired with the rune the refusal must name: an + // `is_err()`-style assertion would pass on a PARSE error too, and would merge + // these three distinct refusals into one. + for (src, rune) in [ + ( + "\n

    {s.a}

    ", + "rune $state", + ), + ( + "\n

    {s.a}

    ", + "rune $state", + ), + ( + "\n

    {id}

    ", + "rune $props", + ), + ( + "\n

    {x}

    ", + "rune $state", + ), + ( + "\n

    text

    ", + "rune $props", + ), + ( + "\n

    {d}

    ", + "rune $derived", + ), + ] { + assert_unsupported(src, rune); + } +} + +#[test] +fn compile_state_snapshot_optional_chain_template_still_parity() { + // In a TEMPLATE value position the optional form is fine: the oracle emits + // `$.snapshot(x)` regardless of the `?.`, and `snapshot_call_arg` matches it, + // so tsv emits the same — the declarator guard above does NOT reach here. + let out = + compile_checked("\n{$state.snapshot?.(o)}"); + assert!( + out.js.contains("$.escape($.snapshot(o))"), + "got: {}", + out.js + ); +} + +#[test] +fn compile_rejects_rune_in_arrow_and_template_expression() { + assert_unsupported( + "\n

    text

    ", + "$inspect", + ); + assert_unsupported("

    {$state(0)}

    ", "$state"); + // A rune buried inside a foldable expression must refuse — the guard + // runs before evaluation, so the fold can't paper over it. + assert_unsupported("

    {true ? 1 : $state(2)}

    ", "$state"); +} + +#[test] +fn compile_exponentiation_fold_matches_js_semantics() { + // ECMAScript `**` special cases (oracle-verified): a NaN exponent and + // |base| == 1 with an infinite exponent both fold to NaN, where IEEE + // `pow` would give 1. + for source in [ + "

    {1 ** (1 / 0)}

    ", + "

    {(0 - 1) ** (1 / 0)}

    ", + "

    {1 ** (0 / 0)}

    ", + ] { + let out = compile_checked(source); + assert!( + out.js.contains("`

    NaN

    `"), + "{source} must fold to NaN: {}", + out.js + ); + } + // The plain case stays IEEE. + let out = compile_checked("

    {2 ** 3}

    "); + assert!(out.js.contains("`

    8

    `"), "got: {}", out.js); +} diff --git a/crates/tsv_svelte_compile/src/tests/runes_state.rs b/crates/tsv_svelte_compile/src/tests/runes_state.rs new file mode 100644 index 000000000..937708c3d --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/runes_state.rs @@ -0,0 +1,315 @@ +//! The `$state` family: folding, class state fields, and their refusals. + +use super::support::*; + +#[test] +fn compile_state_rune_folds_known_read() { + // `$state(0)` drops the wrapper; the never-updated binding is + // statically known, so `{a}` folds into the template (the oracle's + // evaluator behavior). + let out = compile_checked("\n

    {a}

    "); + assert_eq!( + out.js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tlet a = 0;\n\ + \t$$renderer.push(`

    0

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_state_rune_escapes_updated_read() { + // A mutated state binding is not foldable — the read stays dynamic. + let out = compile_checked( + "\n

    {a}

    ", + ); + assert!( + out.js.contains("`

    ${$.escape(a)}

    `"), + "updated state read must stay dynamic: {}", + out.js + ); +} + +#[test] +fn compile_class_state_field_unwraps() { + // A top-level class `$state` field unwraps exactly like a top-level `$state` + // declarator: `count = $state(0)` → `count = 0`. The `new Counter()` forces + // the component wrapper. (Durable coverage is `runes/class_state_*`.) + let out = compile_checked( + "\n

    {c.count}

    ", + ); + assert!( + out.js.contains("count = 0;"), + "class $state field not unwrapped: {}", + out.js + ); + assert!( + !out.js.contains("$state"), + "no $state reference may survive: {}", + out.js + ); +} + +#[test] +fn compile_class_derived_field_refuses() { + // A `$derived` class field is the SEPARATE v2 slice (the `#f = $.derived(…)` + + // get/set accessor transform). The oracle ACCEPTS it, so this is a deliberate + // over-refusal — it must refuse as `rune $derived`, not silently unwrap. + assert_unsupported( + "\n

    {c.double}

    ", + "$derived", + ); +} + +#[test] +fn compile_class_static_state_field_refuses() { + // A `static` rune field is oracle-rejected placement (`state_invalid_placement`) + // — unwrapping it would be an over-acceptance. It must keep refusing. + assert_unsupported( + "\n

    {C}

    ", + "$state", + ); +} + +#[test] +fn compile_class_computed_key_state_field_refuses() { + // A computed-key rune field is oracle-rejected placement. It must keep refusing + // (the `!computed` guard keeps it off the unwrap path). + assert_unsupported( + "\n

    {c.x}

    ", + "$state", + ); +} + +#[test] +fn compile_class_constructor_state_assignment_refuses() { + // A constructor first-assignment `this.x = $state(0)` is a method-body + // assignment (the oracle accepts it → `this.x = 0`), deferred this slice — the + // method body takes the normal refusing guard walk. + assert_unsupported( + "\n

    {c.x}

    ", + "$state", + ); +} + +#[test] +fn compile_nested_class_state_field_refuses() { + // A `$state` field in a NESTED class (inside a function body) is NOT reached by + // the top-level-only transform, so the guard walk refuses it. This pins the + // reach boundary: the exempt set (unwrapped fields) must equal the transform's + // reach, or a guard exemption without a matching unwrap would emit an undefined + // `$state` reference (a MISMATCH). + assert_unsupported( + "\n

    {c.x}

    ", + "$state", + ); +} + +#[test] +fn compile_class_state_lone_store_arg_refuses() { + // A class-field `$state($count)` / `$state.raw($count)` whose WHOLE argument is + // a lone store read: the oracle keeps it BARE (`x = $count`), but tsv's store + // rewrite descends into class bodies and would rewrite the kept argument to + // `$.store_get(…)` — a corpus-invisible MISMATCH. Refuse (a narrow, safe + // over-refusal; a compound `$state($count + 1)` still compiles at parity). + let store = "import { writable } from 'svelte/store';\n\tconst count = writable(0);"; + assert_unsupported( + &format!( + "\n

    {{c.x}}

    " + ), + "lone store/$derived argument", + ); + assert_unsupported( + &format!( + "\n

    {{c.x}}

    " + ), + "lone store/$derived argument", + ); +} + +#[test] +fn compile_class_state_lone_escaped_store_arg_refuses() { + // The escaped spelling of a lone store argument (`$state($count)`) must refuse + // exactly as the plain `$state($count)` does: the store rewrite now DECODES an + // escaped `$`-identifier, so the lone-argument check decodes too — otherwise the + // escaped argument slips the refusal and the store rewrite subscribes it + // (`$.store_get(…)`) where the oracle keeps the field bare (a MISMATCH). + let store = "import { writable } from 'svelte/store';\n\tconst count = writable(0);"; + assert_unsupported( + &format!( + "\n

    {{c.x}}

    " + ), + "lone store/$derived argument", + ); +} + +#[test] +fn compile_class_state_lone_derived_arg_refuses() { + // A class-field `$state(d)` / `$state.raw(d)` whose WHOLE argument is a lone + // `$derived` read: the oracle keeps it bare (`x = d`), but tsv's store rewrite + // would rewrite the kept argument to `d()` — a MISMATCH. Refuse (a compound + // `$state(d + 1)` still compiles at parity). + let derived = "let n = $state(1);\n\tconst d = $derived(n * 2);"; + assert_unsupported( + &format!( + "\n

    {{c.x}}

    " + ), + "lone store/$derived argument", + ); + assert_unsupported( + &format!( + "\n

    {{c.x}}

    " + ), + "lone store/$derived argument", + ); +} + +// ── Destructured `$state` / `$state.raw` / `$state.snapshot` ──────────────────── +// The 1→N `create_state_declarators` lowering. Durable coverage is the +// `state_destructure/*` compile fixtures; these pin the shape + fold + refusals. + +#[test] +fn compile_state_destructure_object_projects_from_tmp() { + // `let { a, b } = $state({…})` → `let tmp = {…}, a = tmp.a, b = tmp.b` (no + // `$.derived` wrap — the leaves are plain `let`s). An object arg is UNKNOWN, so + // the reads stay dynamic (`$.escape`), not folded. + let out = compile_checked( + "\n

    {a}{b}

    ", + ); + assert!( + out.js + .contains("let tmp = { a: 1, b: 2 },\n\t\ta = tmp.a,\n\t\tb = tmp.b;"), + "state object destructure not lowered to a temp-destructure: {}", + out.js + ); + assert!( + !out.js.contains("$state"), + "no $state may survive: {}", + out.js + ); + assert!( + out.js.contains("${$.escape(a)}${$.escape(b)}"), + "object-arg leaves must stay dynamic: {}", + out.js + ); +} + +#[test] +fn compile_state_destructure_array_uses_bare_array_reads() { + // `let [ a, b ] = $state([…])` mints a PLAIN-`const` `$$array = $.to_array(tmp, + // 2)`, and the reads are BARE `$$array[i]` — no `()` call (the state array + // intermediate is not a derived, unlike `$$derived_array()`). + let out = + compile_checked("\n

    {a}{b}

    "); + assert!( + out.js.contains("$$array = $.to_array(tmp, 2)"), + "state array intermediate must be a plain $.to_array: {}", + out.js + ); + assert!( + out.js.contains("a = $$array[0]") && out.js.contains("b = $$array[1]"), + "state array reads must be bare (no call): {}", + out.js + ); + assert!( + !out.js.contains("$$array()"), + "a state $$array read must never be a call: {}", + out.js + ); +} + +#[test] +fn compile_state_snapshot_destructure_drops_wrapper() { + // `let { a } = $state.snapshot(obj)` lowers exactly like `$state` — the snapshot + // wrapper is dropped (`let tmp = obj, a = tmp.a`). The snapshot binding stays + // UNKNOWN, so `{a}` reads dynamically even for a known-ish arg. + let out = compile_checked( + "\n

    {a}

    ", + ); + assert!( + out.js.contains("let tmp = obj,\n\t\ta = tmp.a;"), + "snapshot destructure must drop the wrapper into a temp-destructure: {}", + out.js + ); + assert!( + !out.js.contains("$.snapshot") && !out.js.contains("$state"), + "no snapshot/$state syntax may survive: {}", + out.js + ); + assert!( + out.js.contains("${$.escape(a)}"), + "a snapshot leaf never folds: {}", + out.js + ); +} + +#[test] +fn compile_state_destructure_leaf_folds_through_scalar_arg() { + // The oracle's over-fold: `let n = 5; let { a } = $state(n)` folds `{a}` to the + // CONTAINER value `5` (using `eval(arg)`, ignoring the `.a` projection) — every + // leaf takes the rune's computed initial (`Expr(n)` → 5). Reproduced byte-exact. + let out = + compile_checked("\n

    {a}

    "); + assert!( + out.js.contains("

    5

    ") && !out.js.contains("$.escape"), + "state scalar-arg leaf must fold to the container value: {}", + out.js + ); +} + +#[test] +fn compile_state_destructure_default_and_rest() { + // `$.fallback` (default) and `$.exclude_from_object` (object rest) are shared + // with the derived lowering, byte-identical. + let dflt = compile_js( + "\n

    {a}

    ", + ); + assert!( + dflt.contains("a = $.fallback(tmp.a, 1)"), + "state default must use $.fallback: {dflt}" + ); + let rest = compile_js( + "\n

    {a}

    ", + ); + assert!( + rest.contains("rest = $.exclude_from_object(tmp, ['a'])"), + "state object rest must use $.exclude_from_object: {rest}" + ); +} + +#[test] +fn compile_state_destructure_refuses_exotic_key() { + // A computed object key inside the pattern is a safe over-refusal (the corpus has + // identifier keys only). The extractor's `object_member` refuses. + assert_unsupported( + "\n{x}", + "destructuring a $state declarator", + ); + assert_unsupported( + "\n{x}", + "destructuring a $state.snapshot declarator", + ); +} + +#[test] +fn compile_state_destructure_refuses_multi_declarator() { + // A destructured state alongside another declarator in one `let` needs + // per-source-declarator grouping tsv doesn't reproduce — refuse (the oracle + // compiles it; a safe over-refusal, mirroring the derived arm). + assert_unsupported( + "\n{x}{a}{b}", + "destructuring a $state declarator", + ); +} + +#[test] +fn compile_state_destructure_refuses_comments() { + // A carried script comment alongside a destructured state refuses — the 1→N + // split scatters leaves across `tmp`/`$$array` intermediates whose comment + // windows sweep. A safe over-refusal. + assert_unsupported( + "\n{a}", + "comments in a script with a destructured $state declarator", + ); +} diff --git a/crates/tsv_svelte_compile/src/tests/script_rewrite.rs b/crates/tsv_svelte_compile/src/tests/script_rewrite.rs new file mode 100644 index 000000000..90fcaf84b --- /dev/null +++ b/crates/tsv_svelte_compile/src/tests/script_rewrite.rs @@ -0,0 +1,251 @@ +//! Instance-script rewrites: declarator splits, props injection, export refusals. + +use super::support::*; + +#[test] +fn compile_splits_multi_declarator_declaration() { + // The oracle splits a multi-declarator top-level declaration into one + // declaration per declarator, source order preserved. + let js = compile_js("\n

    x

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer) {\n\ + \tlet a = 1;\n\ + \tlet b = a + 1;\n\ + \t$$renderer.push(`

    x

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_splits_mixed_rune_and_plain_declarators() { + // The per-declarator rune rewrites compose with the split. + let js = compile_js( + "\n

    {d}

    ", + ); + assert!( + js.contains("\tlet a = 1;\n\tlet d = $.derived(() => a * 2);\n"), + "mixed declarators must split with rewrites applied: {js}" + ); +} + +#[test] +fn compile_keeps_nested_multi_declarator_joined() { + // Only instance-script top-level declarations split; a declaration + // inside a function body stays joined as ONE statement (the oracle + // leaves it alone). The canonical reprint breaks its declarators across + // continuation lines (multi-init declarations always break) — the same + // on both sides of the parity diff, so still one `let`. + let js = compile_js( + "\n

    {f()}

    ", + ); + assert!( + js.contains("let a = 1,\n\t\t\tb = 2;"), + "nested declaration must stay one statement: {js}" + ); + assert_eq!( + js.matches("let").count(), + 1, + "nested declaration must not split: {js}" + ); +} + +#[test] +fn compile_refuses_comment_with_multi_declarator() { + // The oracle re-anchors a comment INSIDE the split (`let // c` then the + // declarator on the next line) — not reproducible, refuse. + assert_unsupported( + "\n

    x

    ", + "multi-declarator declaration", + ); +} + +#[test] +fn compile_refuses_instance_script_exports() { + // Every instance-script export form refuses: the oracle compiles + // `export const`/`function`/`{a}` via `$.bind_props` (not implemented), + // rejects `export default`/`export let` (runes mode), and drops + // `export * from` — a verbatim passthrough would nest an `export` + // inside the component function (invalid JS). + for source in [ + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + "\n

    x

    ", + ] { + assert_unsupported(source, "instance-script export"); + } +} + +#[test] +fn compile_refuses_top_level_legacy_reactive_statement() { + // A top-level `$:` label is a legacy reactive statement — the oracle + // rejects it in runes mode (legacy_reactive_statement_invalid), so + // cloning it through as a dead JS label would be a silent mis-compile. + for source in [ + "\n

    {c}

    ", + "\n

    {c}

    ", + "\n

    {c}

    ", + ] { + assert_unsupported(source, "legacy reactive statement"); + } +} + +#[test] +fn compile_refuses_runes_invalid_imports() { + // The oracle's runes-mode import rules: `svelte/internal*` sources are + // forbidden outright; `beforeUpdate`/`afterUpdate` cannot be imported + // from `svelte`. Other `svelte` imports stay valid. + for source in [ + "\n

    x

    ", + "\n

    x

    ", + ] { + assert_unsupported(source, "import from svelte/internal"); + } + for source in [ + "\n

    x

    ", + "\n

    x

    ", + ] { + assert_unsupported(source, "runes-invalid import"); + } + let js = compile_js("\n

    x

    "); + assert!( + js.contains("import { mount } from 'svelte';"), + "valid svelte import must hoist through: {js}" + ); +} + +#[test] +fn compile_clones_plain_and_nested_dollar_labels() { + // A plain label anywhere, and a `$` label INSIDE a function, are + // ordinary JS the oracle accepts and clones through verbatim — only + // the top-level `$:` form is the legacy reactive statement. + let js = compile_js( + "\n

    x

    ", + ); + assert!( + js.contains("outer: for"), + "plain label must clone through: {js}" + ); + let js = compile_js( + "\n

    {c}

    ", + ); + assert!( + js.contains("$: y = c;"), + "nested $ label must clone through: {js}" + ); +} + +#[test] +fn compile_injects_slots_events_before_props_rest() { + // A rest element in the `$props()` pattern gains the oracle's + // `$$slots, $$events` injection immediately before it. + let js = compile_js("\n

    {a}

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \tlet { a, $$slots, $$events, ...rest } = $$props;\n\ + \t$$renderer.push(`

    ${$.escape(a)}

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_wraps_non_destructured_props_in_rest_pattern() { + // `let props = $props()` becomes the oracle's + // `let { $$slots, $$events, ...props } = $$props;`. + let js = compile_js("\n

    x

    "); + assert_eq!( + js, + "import * as $ from 'svelte/internal/server';\n\ + export default function Input($$renderer, $$props) {\n\ + \tlet { $$slots, $$events, ...props } = $$props;\n\ + \t$$renderer.push(`

    x

    `);\n\ + }\n" + ); +} + +#[test] +fn compile_plain_props_destructure_gets_no_injection() { + // No rest element → no `$$slots`/`$$events` (probe-verified). + let js = compile_js("\n

    {a}

    "); + assert!( + !js.contains("$$slots") && !js.contains("$$events"), + "plain destructure must not gain the injection: {js}" + ); +} + +#[test] +fn compile_refuses_props_injection_with_comments() { + // The injected properties' appendix spans between host-span siblings + // would sweep host comments — refuse. + assert_unsupported( + "\n

    {a}

    ", + "rest-element $props()", + ); + assert_unsupported( + "\n

    x

    ", + "non-destructured $props()", + ); +} + +#[test] +fn compile_refuses_array_pattern_props() { + // The oracle rejects a non-identifier/non-object `$props()` binding + // (props_invalid_identifier) — refuse rather than compile it. + assert_unsupported( + "\n

    x

    ", + "$props() binding pattern", + ); +} + +#[test] +fn compile_allows_lang_js_and_empty() { + // The oracle compiles `lang="js"` and `lang=""` exactly like no lang + // attribute; other values stay refused. + for source in [ + "\n

    text

    ", + "\n

    text

    ", + ] { + let _ = compile_js(source); + } + assert_unsupported( + "\n

    text

    ", + "lang=\"coffee\"", + ); +} + +#[test] +fn compile_rejects_option_and_populated_select() { + // The oracle compiles
    ", + "top-level", + ); + // Any container counts, including a `` — the placement test is + // "direct child of Root", so a boundary between makes it invalid. + assert_unsupported( + "x", + "top-level", + ); + assert_unsupported( + "ab", + "duplicate", + ); + // One at the root is of course still fine. + let _ = compile_js("a"); +} + +#[test] +fn compile_unknown_svelte_meta_tag_refuses() { + // The oracle's parse-time `svelte_meta_invalid_tag` (`element.js:142`): a + // `svelte:`-prefixed element whose name is not a known meta tag. tsv's parser + // routes every known `svelte:` name to a `SpecialElementKind` (and + // `svelte:options` to `Root.options`), so an unknown one reaches a regular + // element and the compiler refuses it. + assert_unsupported("", "meta tag"); + assert_unsupported("", "meta tag"); + // Fires wherever the tag sits — inside a block, and inside a region SSR DROPS + // (a `{:catch}` branch), the whole reason this rule lives in `validate.rs` + // rather than at an emitter. + assert_unsupported("{#if x}{/if}", "meta tag"); + assert_unsupported( + "{#await p}a{:then v}b{:catch e}{/await}", + "meta tag", + ); + + // Controls — the rule is `svelte:`-specific and never touches a KNOWN meta tag. + // ``/``/``/`` all + // parse to special kinds, so they compile (or refuse for their OWN reasons, not + // this one); and a non-`svelte:` namespaced tag is an ordinary regular element. + let _ = compile_js(""); + let _ = compile_js(""); + let _ = compile_js("x"); + let _ = compile_js(""); +} + +#[test] +fn compile_invalid_node_placement_refuses() { + // The oracle's `node_invalid_placement` — markup a browser would REPAIR, which + // breaks Svelte's assumptions about component structure. Every case below was + // confirmed oracle-REJECTED with `canonical_compile`. + // ⚠️ The DIRECT `

    ` case is unreachable: both parsers apply HTML + // tag-omission and auto-close the `

    `, so no parent/child relation forms + // (tsv then parse-errors on the stray `

    `). That is why every oracle sample + // puts an element in between and trips the ANCESTOR rule instead. + assert_unsupported("

    x

    ", "descendant"); + assert_unsupported( + "", + "descendant", + ); + assert_unsupported("
    ", "must be the child of a"); + // The fallback switch: a tag legal only under a special-parsing parent. + assert_unsupported("
    x
    ", "cannot be a child of"); + // `#text` and `{expression}` reach the parent test through their own visitors. + assert_unsupported( + "text
    ", + "only allows these children", + ); + assert_unsupported( + "{v}
    ", + "only allows these children", + ); + // It fires in an SSR-DROPPED region too — the oracle validates before it + // decides what to emit. + assert_unsupported( + "{#await p}x{:catch e}

    y

    {/await}", + "descendant", + ); +} + +#[test] +fn compile_invalid_node_placement_respects_the_oracle_s_escape_hatches() { + // ⚠️ The must-NOT-over-refuse half. Each case is oracle-ACCEPTED (verified with + // `canonical_compile`), and a plausible over-eager port breaks one of them. + + // A block downgrades the violation to a WARNING (`node_invalid_placement_ssr`): + // Svelte compiles each block into its own template string, so it works + // client-side. A refusal here would reject ordinary real components. The + // control is the identical shape MINUS the block, asserted refused above — + // so this pair isolates the downgrade rather than merely finding a legal + // document. + let _ = compile_js("

    {#if true}

    y
    {/if}

    "); + + // A custom element may contain anything and go anywhere. + let _ = compile_js("
    x
    "); + let _ = compile_js("

    x

    "); + + // `