diff --git a/.gitignore b/.gitignore index c22b97610..941e685b4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ node_modules !/benches/js/results/report.conformance.bun.md !/benches/js/results/report.json !/benches/js/results/report.md +!/benches/js/results/report.tsc-conformance.json +!/benches/js/results/report.tsc-conformance.md # Env .env* diff --git a/CLAUDE.md b/CLAUDE.md index 8d7f0a915..9d371a3d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,7 +151,7 @@ cargo run -p tsv_cli format --content '
x
' --parser svelte # forma ```bash deno task check # full committed-tree gate: fmt, audits, typecheck, tests, clippy (benches/js/CLAUDE.md §Gate map) -deno task doctor # one-pass setup check: runtimes, canonical pins + checkout alignment, node_modules freshness, oracle checkouts, corpus entries, build artifacts. Exit 1 only on MISLEADING state (pin drift, skew, stale deps); absences are warnings (--strict promotes warnings to failures) +deno task doctor # one-pass setup check: runtimes, canonical pins + checkout alignment, node_modules freshness, oracle checkouts, corpus entries, build artifacts. Exit 1 only on MISLEADING state (pin drift, skew, stale deps); absences are warnings (--strict promotes warnings to failures) — except in the explicitly optional experimental-typechecker tier, whose absences are informational at any strictness (a BROKEN checkout there still warns) deno task typecheck # cargo check deno task test # cargo test deno task lint # cargo clippy @@ -331,11 +331,11 @@ deno task conformance:ts-repo # tsv's TS parser vs the tsc corpus (../t # The three gates above accept: -v, --json, . -deno task conformance # the pre-release aggregate: the three gates above + corpus:compare:parse -# --all + corpus:compare:format --all, in ONE process (benches/js/conformance.ts; oracle modules load -# once, fail-fast, corpus FFI built once), then render:audit over the version-pinned checkouts (a -# subprocess — it drives its own sidecar). The external-oracle correctness gates that can't live in -# `deno task check`. The format leg's prettier calls ride a content-addressed cache +deno task conformance # the pre-release aggregate: the three gates above + +# corpus:compare:parse --all + corpus:compare:format --all, in ONE process (benches/js/conformance.ts; +# oracle modules load once, fail-fast, corpus FFI built once), then render:audit over the version-pinned +# checkouts (a subprocess — it drives its own sidecar). The external-oracle correctness gates that +# can't live in `deno task check`. The format leg's prettier calls ride a content-addressed cache # (benches/js/lib/prettier_cache.ts; TSV_PRETTIER_CACHE=0 disables). deno task conformance:test262 # tsv's JS parser vs test262 POSITIVES (pure Rust, `test262 --gate`); @@ -514,6 +514,7 @@ tsv/ │ ├── 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_check/ # EXPERIMENTAL TypeScript binder + checker — may never ship (tsgo-conformance target; consumed only 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) @@ -867,10 +868,28 @@ cargo run -p tsv_debug test262 language/expressions # filter by path pattern See ./docs/conformance_test262.md (command interface; §Differential for the tsv-vs-oxc comparison). +**Typechecker conformance (`tsc_conformance`) — EXPERIMENTAL, may never ship.** +`tsv_check` is a from-scratch TypeScript binder + checker in development; no shipped +artifact links it (`cargo tree -i tsv_check` → only `tsv_debug`), and the parser and +formatter are never modified in service of it. `tsv_debug tsc_conformance` grades it +against tsgo's committed `.errors.txt` baselines (`../typescript-go`, pin `168e7015`), +surfaced as **on-demand** tasks: + +```bash +deno task conformance:tsc-roundtrip # baseline parse → re-render → byte-compare (zero checker code) +deno task conformance:tsc-check # the tsv_check conformance sweep + committed report +deno task conformance:tsc-check:update # re-pin the run's snapshot counts after deliberate drift +``` + +None is in `deno task check`, in `deno task conformance`, or release-gating, and +`../typescript-go` is not a release-required oracle — until the typechecker ships, no +ordinary dev or release flow pays for it. Full reference: ./docs/typechecker.md. + **Performance Profiling Commands** (all pure Rust, no Deno — full reference: ./docs/performance.md): ```bash cargo run -p tsv_debug profile ~/dev/zzz/src/lib # parse vs format phase timing (--iterations, --json) +cargo run -p tsv_debug profile --bind ~/dev/zzz/src # parse vs lower+bind timing (TS-only) + peak RSS (§1) 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) @@ -1059,13 +1078,13 @@ cases; prettier, oxfmt and biome all get the JSDoc-cast paren binding wrong — ### Rust Crates (minimal deps) - `serde_json` — wire-JSON emission: the writer's exact string-escape / `f64` formatting, and reparsing bytes to a `Value` (CLI `--pretty`, tests). The language crates no longer depend on `serde` directly (only transitively, without its `derive`); `serde`'s derive is dev-tooling only (`tsv_debug` / `tsv_cli`) -- `smallvec` — Stack-allocated vectors +- `smallvec` — Stack-allocated vectors (printers + `tsv_check`) - `thiserror` — Error type derivation - `phf` — Compile-time perfect hash maps (keywords, entities) - `unicode-ident` — Unicode XID_Start/XID_Continue for identifiers - `unicode-segmentation` — Grapheme clustering for visual width measurement - `unicode-width` — Character display width (CJK, zero-width) -- `bumpalo` — Bump arena for the internal AST (and, via the `tsv_arena` crate, the bindings' per-thread `reset()` reuse — `tsv_ffi`/`tsv_napi`/`tsv_wasm`) +- `bumpalo` — Bump arena for the internal AST (and, via the `tsv_arena` crate, the bindings' per-thread `reset()` reuse — `tsv_ffi`/`tsv_napi`/`tsv_wasm`; `tsv_check`'s caller-owned check arenas follow the same contract) - `talc` — WASM global allocator (`tsv_wasm` only, wasm32-only target dep): pure-Rust `no_std` allocator replacing std's default dlmalloc; the `WasmGrowAndExtend` source keeps the warm instance's linear-memory high-water at dlmalloc parity. Pulls `lock_api` + `allocator-api2` (+ `scopeguard`) into the wasm32 graph only; native builds unaffected - `napi` / `napi-derive` / `napi-build` — N-API bindings for `tsv_napi` (Node/Bun native addon; tsv-scoped carve-out) @@ -1111,6 +1130,7 @@ formatting behavior. Key files: `src/language-js/print/assignment.js` (assignmen - ./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/typechecker.md - the experimental `tsv_check` typechecker (may never ship) + its on-demand tsgo-conformance harness - ./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 diff --git a/Cargo.lock b/Cargo.lock index 1255eee43..c504da1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,16 @@ dependencies = [ "tsv_lang", ] +[[package]] +name = "tsv_check" +version = "0.2.0" +dependencies = [ + "bumpalo", + "smallvec", + "tsv_lang", + "tsv_ts", +] + [[package]] name = "tsv_cli" version = "0.2.0" @@ -681,6 +691,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tsv_check", "tsv_cli", "tsv_css", "tsv_html", diff --git a/Cargo.toml b/Cargo.toml index a373998a0..6e93e8e62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/tsv_css", "crates/tsv_svelte", "crates/tsv_svelte_compile", + "crates/tsv_check", "crates/tsv_cli", "crates/tsv_debug", "crates/tsv_wasm", @@ -49,6 +50,11 @@ tsv_svelte = { path = "crates/tsv_svelte", default-features = false } # no shipped artifact links it yet. tsv_svelte_compile = { path = "crates/tsv_svelte_compile" } +# Experimental TypeScript type-checking. A consumer crate of tsv_ts's concrete +# types; referenced only by the tsv_debug harness so it never touches the +# shipped format/parse artifacts (a crate-boundary zero-cost exclusion). +tsv_check = { path = "crates/tsv_check" } + # External dependencies argh = "0.1" # N-API (Node.js/Bun) bindings — the native path for the `tsv_napi` crate, the diff --git a/README.md b/README.md index 1130f4f6a..c74c035ee 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Future features (unknown order): - svelte-check replacement - LSP - linter - type aware, initially focused on serializable data-only plugins for extensibility + - includes an experimental first-party typechecker `tsv_check` (which may never ship) - bundling is probably out of scope - [discussion](https://github.com/fuzdev/tsv/discussions) welcome @@ -260,6 +261,7 @@ tsv/ │ ├── tsv_ts/ # TypeScript parser/formatter (standalone) │ ├── tsv_css/ # CSS parser/formatter (standalone) │ ├── tsv_svelte/ # Svelte parser/formatter (uses tsv_ts + tsv_css) +│ ├── tsv_check/ # experimental TypeScript binder/checker (may never ship; not in any shipped artifact) │ ├── tsv_cli/ # unified CLI (binary: `tsv`) │ ├── tsv_debug/ # dev utilities (binary: `tsv_debug`, uses Deno) │ ├── tsv_ffi/ # C FFI bindings diff --git a/benches/js/CLAUDE.md b/benches/js/CLAUDE.md index 03fbb5940..e73430835 100644 --- a/benches/js/CLAUDE.md +++ b/benches/js/CLAUDE.md @@ -454,23 +454,27 @@ green-skipping (the baselines are the oracle; publish Step 3b's probe is the tolerance point). Full-corpus runs freshness-check `KNOWN_GAPS` (stale entries fail). **Pre-release aggregate — `deno task conformance` (+ `conformance:test262` = `conformance:all`).** The three parse-conformance -gates (svelte-fixtures, ts-fixtures, ts-repo), plus -`corpus:compare:parse --all` and `corpus:compare:format --all`, are the -release-cadence correctness gates that run against external JS oracles (and so can't -live in `deno task check`). `deno task conformance:all` runs the pure-Rust **test262 +gates (svelte-fixtures, ts-fixtures, ts-repo), plus `corpus:compare:parse --all` +and `corpus:compare:format --all`, are the release-cadence correctness gates that +run against external oracles (and so can't live in `deno task check`). The +typechecker's `tsc_conformance` tasks are deliberately NOT legs here — `tsv_check` +is experimental and may never ship, so its gates stay on-demand (see +../../docs/typechecker.md). `deno task conformance:all` runs the pure-Rust **test262 positive gate** FIRST (`conformance:test262` — `tsv_debug test262 --gate`, gating the exact positive-pass count; the ~2.5k negatives are the deferred early-error frontier, reported not gated), THEN the aggregate — fail-fast, so a positive-parse regression trips the ~1-min gate before the multi-minute FFI legs run. That superset is what publish Step 3b runs. `deno task conformance` builds the corpus FFI once and -runs all five legs in **ONE process** (`conformance.ts`, the driver): the canonical +runs all six legs in **ONE process** (`conformance.ts`, the driver): the canonical oracle modules (prettier, the svelte plugin, svelte/compiler, acorn, acorn-ts) load -once via the module cache instead of once per leg, each leg gets a timing line, and -failure semantics match a `&&` chain exactly (every leg exits the process on a -finding — fail-fast). The driver takes no arguments; the per-leg tasks remain the -scoped/triage entries. `conformance:all` is wired into `scripts/publish.ts` **Step 3b** -(skipped by `--no-check`). Step 3b preflights the oracles (`../svelte`, -`../acorn-typescript`, `../typescript`, `../test262`, this dir's `node_modules`): a missing one +once via the module cache instead of once per leg (`render:audit`, the lone non-JS +leg, is a `cargo` subprocess — which is why the task carries `--allow-run=cargo`), +each leg gets a timing line, and failure semantics match a `&&` chain exactly +(every leg exits the process on a finding — fail-fast). The driver takes no +arguments; the per-leg tasks remain the scoped/triage entries. `conformance:all` +is wired into `scripts/publish.ts` **Step 3b** (skipped by `--no-check`). Step 3b +preflights the oracles (`../svelte`, `../acorn-typescript`, `../typescript`, +`../test262`, this dir's `node_modules`): a missing one **FAILS a `--wetrun`** (only the explicit `--no-check` releases without gates), warn-and-skips a dry-run, and any skip is re-warned in the run's final summary. The gates themselves fail closed on a missing checkout (0 scanned = FAIL), so a @@ -510,6 +514,17 @@ every real move in a number is a deliberate, visible edit. test262 (discovered + graded-manifest), `fixtures_validate` (total fixtures — protects the primary gate against a discovery collapse), and `swallow_audit` (formatted files — closes its vacuous-pass). +- `tsc_conformance` (the largest set) splits its pins by what they mean, and + gates the ON-DEMAND experimental-typechecker tasks, not a release leg. The + drifting tsv-side counts (denominators, parse-divergence census, family + partitions, carve-outs) live in the machine-regenerated snapshot + `crates/tsv_debug/src/cli/commands/tsc_conformance_pins.txt`, rewritten by + `deno task conformance:tsc-check:update`. The oracle-side pins + (baseline/roundtrip/pretty + the `INDEX_*` denominators) and the + semantically-zero invariant gates stay hand-edited consts in + `cli/commands/tsc_conformance/pins.rs`; the crash-exclusion count sits beside its + ledger in `tsc_conformance/runner/grade.rs`. Re-pin ritual: + ../../docs/typechecker.md. **Semantics — three pin categories, chosen per surface:** @@ -793,9 +808,9 @@ deno task bench:deno:run -- --compare-baseline # Compare against saved baseline # Wipe local-only bench state (gitignored): baseline.json, timestamped # results pairs, and the harvest caches (benches/js/.cache). Preserves the -# committed `report..{json,md}` / `report.conformance.node.*` -# because the glob is anchored on a leading digit (timestamped files start -# with a year). +# committed `report..{json,md}` / `report.conformance.node.*` / +# `report.tsc-conformance.{json,md}` because the glob is anchored on a +# leading digit (timestamped files start with a year). deno task bench:clean # Environment variables (apply to any runtime's :run) @@ -1232,7 +1247,7 @@ benches/js/ ├── install_deps.ts # `bench:install`: npm install + force-fetch the oxc wasi binding ├── harvest_test262.ts # `bench:harvest:test262`: graded positives → .cache/test262_files.json (Deno-only) ├── bench.ts # Benchmark entry point (runtime-neutral — runs under Deno AND Node) -├── conformance.ts # Single-process pre-release aggregate driver (deno task conformance): all five legs, one module cache +├── conformance.ts # Single-process pre-release aggregate driver (deno task conformance): all six legs, one module cache ├── smoke.ts # Smoke test for formatters and parsers (runtime-neutral: smoke / smoke:node / smoke:bun) ├── compose_reports.ts # Fold report.{deno,node,bun}.json → combined report.{json,md} (bench:compose) ├── idempotency_sweep.ts # F1 sweep over the `perf` corpus view — format(format(x)) == format(x) on real code (deno task idempotency:sweep; drives tsv_debug `fuzz --iterations 0`) diff --git a/benches/js/results/report.tsc-conformance.json b/benches/js/results/report.tsc-conformance.json new file mode 100644 index 000000000..34563572e --- /dev/null +++ b/benches/js/results/report.tsc-conformance.json @@ -0,0 +1,104 @@ +{ + "oracle": "tsgo committed .errors.txt baselines (bind + merge + flow family)", + "denominators": { + "in_scope_tests": 9389, + "in_scope_variants": 9888, + "expect_clean_graded": 4436, + "clean_pass": 4436, + "baselined_parsed": 4464, + "family_graded_variants": 4085, + "family_positive_variants": 140 + }, + "family": { + "match": 573, + "dup_match": 539, + "flow_match": 34, + "missing": { + "total": 37, + "dup": 11, + "flow": 26, + "merge_path": 0, + "lib_conflict": 0, + "late_bound": 11, + "cfa": 26, + "other": 0 + }, + "extra": 0, + "span_mismatch": 0 + }, + "per_code": { + "match": { + "2300": 415, + "2397": 4, + "2451": 56, + "2528": 35, + "2567": 26, + "2664": 3, + "7027": 31, + "7028": 3 + }, + "missing": { + "2300": 11, + "7027": 26 + } + }, + "related": { + "match": 51, + "missing": 0, + "extra": 0, + "span_mismatch": 0 + }, + "carve_outs": { + "recovery_ast_rule_a": 379, + "recovery_ast_rule_a_family": 11, + "module_detection_variants": 1 + }, + "census": { + "parse_rejected_total": 988, + "parse_rejected_no_baseline": 44, + "parse_rejected_ts1xxx_only": 456, + "parse_rejected_other": 488, + "script_retry": 25, + "crash_excluded": 0 + }, + "lib": { + "files_bound": 107, + "sets_folded": 50 + }, + "pins": { + "in_scope_tests": 9389, + "in_scope_variants": 9888, + "expect_clean": 4436, + "baselined_parsed": 4464, + "parse_rejected": 988, + "parse_rejected_no_baseline": 44, + "parse_rejected_ts1xxx_only": 456, + "parse_rejected_other": 488, + "family_graded": 4085, + "family_positive": 140, + "family_match": 573, + "family_missing": 37, + "dup_match": 539, + "dup_missing": 11, + "flow_match": 34, + "flow_missing": 26, + "missing_merge": 0, + "missing_lib": 0, + "missing_deferred_late_bound": 11, + "missing_deferred_cfa": 26, + "missing_other": 0, + "family_extra": 0, + "family_span_mismatch": 0, + "related_match": 51, + "related_missing": 0, + "related_extra": 0, + "related_span_mismatch": 0, + "carve_out_rule_a": 379, + "carve_out_rule_a_family": 11, + "module_detection": 1, + "script_retry": 25, + "crash_excluded": 0, + "lib_files_bound": 107, + "lib_sets": 50 + } +} diff --git a/benches/js/results/report.tsc-conformance.md b/benches/js/results/report.tsc-conformance.md new file mode 100644 index 000000000..512a6ac32 --- /dev/null +++ b/benches/js/results/report.tsc-conformance.md @@ -0,0 +1,51 @@ +# tsc_conformance run — committed report + +Oracle: tsgo committed `.errors.txt` baselines (bind + merge + flow family). Deterministic — wall-clock excluded. + +## Denominators + +- in-scope tests: 9389 +- in-scope variants: 9888 +- expect-clean graded / clean pass: 4436 / 4436 +- baselined + parsed: 4464 +- family graded / family-positive: 4085 / 140 + +## Family (dup 2300 / 2451 / 2567 / 2528 + merge 2397 / 2649 / 2664 / 2671; flow 7027 / 7028) + +- match: 573 (dup 539, flow 34) +- missing: 37 (dup 11, flow 26) + - by cause: merge-path 0, lib-conflict 0, late-bound 11, cfa 26, other 0 +- extra (GATE=0): 0 +- span mismatch: 0 + +## Per-code table + +| code | match | missing | +| --- | --- | --- | +| TS2300 | 415 | 11 | +| TS2397 | 4 | 0 | +| TS2451 | 56 | 0 | +| TS2528 | 35 | 0 | +| TS2567 | 26 | 0 | +| TS2664 | 3 | 0 | +| TS7027 | 31 | 26 | +| TS7028 | 3 | 0 | + +## Related-info channel (matched primaries) + +- match / missing / extra / span-mismatch: 51 / 0 / 0 / 0 + +## Carve-outs + +- recovery-AST rule (a): 379 (family-positive 11) +- moduleDetection variants (inert for family): 1 + +## Parse-divergence census + +- parse-rejected: 988 (no baseline 44, TS1xxx-only 456, other 488) +- script-goal retries: 25 +- crash-excluded (tracked): 0 + +## Lib base + +- lib files bound / sets folded: 107 / 50 diff --git a/crates/tsv_check/CLAUDE.md b/crates/tsv_check/CLAUDE.md new file mode 100644 index 000000000..3e1b26a2e --- /dev/null +++ b/crates/tsv_check/CLAUDE.md @@ -0,0 +1,266 @@ +# tsv_check + +> **EXPERIMENTAL — may never ship.** TypeScript binder + checker targeting +> exact TS7/tsgo error conformance — early scaffolding: the pipeline skeleton +> is real (parse → lower+bind → check → sort/dedup), the semantic phases are +> landing family by family. + +## Position & invariants + +- **Experimental, and it may never ship.** This is a research crate, not a + committed product surface. Nothing tsv publishes depends on it, its + conformance gates are on-demand only (not in `deno task check`, not in + `deno task conformance`, not release-gating — see ../../docs/typechecker.md), + and the bet is allowed to come out negative. +- **Zero cost to shipped artifacts.** No format/parse artifact links this + crate — `tsv_cli`/`tsv_ffi`/`tsv_wasm`/`tsv_napi` never reference it; the + only consumer is `tsv_debug` (the conformance harness). Verify with + `cargo tree -i tsv_check`. The existing parser and formatter are never + modified in service of checking. +- **Faithful semantics, novel engine.** Observable behavior (which + diagnostics exist, their codes/spans/order, budget limits, circularity + outcomes) is ported from tsgo — the reference checkout is a pinned + `../typescript-go` — while representation (dense u32 ids, SoA side + columns, arena borrowing) is tsv's own. +- **Reference anchors.** Semantic-core functions carry a + `// tsgo: ` comment tying them to their counterpart + in the pinned checkout. A deliberate structural departure is documented + at the departure site; drift from the reference is always intentional, + never incidental. +- **The oracle is tsgo's committed `.errors.txt` baselines** over the tsc + test corpus, graded by `tsv_debug tsc_conformance` (see + ../../docs/typechecker.md). +- `unsafe_code = "forbid"` (workspace lints inherited). + +## Module map + +- `lib.rs` — public API surface. +- `program.rs` — pipeline assembly, ported from tsgo + `harnessutil.go CompileFilesEx` (the baseline-oracle **parity path**): + per-unit parse with the goal rule (`Goal::Module` first, `Goal::Script` + retry on failure, both-fail = that unit parse-rejects), then the + **unconditional** per-unit bind-then-check concatenation — a rejected unit + contributes nothing (no AST), but never suppresses a sibling's + diagnostics — and the final sort+dedup. The **product-mode short-circuit** + (`program.go GetDiagnosticsOfAnyProgram`: syntactic errors ⇒ skip semantic + program-wide) is a deliberate mode distinction deferred to the `tsv check` + path, NOT modelled here (see the module header). +- `merge.rs` — the single-threaded globals merge between bind and check, + ported from tsgo `initializeChecker`'s phase order (script locals + + globalThis check → global augmentations → undefined check → deferred + ambient modules → module augmentations), with `mergeSymbol` / + `reportMergeSymbolError` (the same three-way conflict selection as the + binder cascade), `getExcludedSymbolFlags`, and the `lookupOrIssueError` + dedup. Operates on scripts' file locals and `declare global` / + ambient-module augmentation exports — never an external module's locals. + Per-file `FileMerge` inputs are program-independent (owned strings, + declaration-order iteration — never map order). Also owns the lib layer: + `LibFile` (a lib's bound product), `LibBase::build` (fold a resolved lib + set in priority order into an immutable global base, `globalThis` + seeded), and the base-aware merge (`merge_symbol_against_base` — programs + consult overlay-then-base; test symbols never mutate the base; base decls + translate into program FileId space only on a conflict). +- `binder/` — **two cooperating walks per file** (deliberately NOT one + fused walk — functions-first symbol binding reorders symbol creation + within a statement list, which would break strict pre-order id + intervals; see `binder/mod.rs`'s header), plus a **third flow walk** + (`flow/`) invoked separately from `program.rs`: + - `mod.rs` — the SoA lowering walk: dense 1-based `NodeId`s, side columns + (`parents`/`kinds`/`spans`/`subtree_end` — the latter makes descendant + tests O(1) interval checks), the address→NodeId map, per-file facts + (module-ness via the `isAnExternalModuleIndicatorNode` port). The + `SoaWalk` struct, its `add`/`close`/`leaf` id-recording primitives, and + `bind_file` live here; the per-node visitor methods live in `lower/` + (`statement.rs`/`expression.rs`/`types.rs` — additional `impl SoaWalk` + blocks, split by the AST shape each visitor descends), unchanged in + responsibility. + - `sym/` — the container-threaded, functions-first symbol-bind walk: + `getContainerFlags`, `declareSymbolEx` + the duplicate/conflict cascade + (TS2451/2300/2567/2528 with per-prior-declaration related info), + internal-name mangling (incl. private `#` names), the dual local/export + collapse (documented at the site; revisited at multi-file). A + directory-module split by concern (unchanged responsibilities): `mod.rs` + (the `SymbolBinder` struct, its lifecycle — `new`/`bind_program`/`finish` + — the table/symbol/atom primitives every descendant shares, the + member-key resolver, the functions-first statement-list driver + (`bind_statement_list`), and the scope helpers + (`with_function_scope`/`with_block_scope`) every descendant calls into), + `statement.rs` (the statement-shaped bind-descent — `visit_statement` and + every `bind_*_statement`, the param/binding helpers, and the + module/import/export-specifier binds), `expression.rs` + (`visit_expression` and `bind_object_expression`), `members.rs` (the + class/interface/type-literal/enum member and type descents), and + `declare.rs` (the `declareSymbolEx` cascade and the container routing). + - `symbols.rs` — `Symbol`, `SymbolFlags` + the `*Excludes` conflict-mask + const tables (ported bit-for-bit from tsgo's `symbolflags.go`), pooled + declaration lists, `TableId` symbol tables. + - `atoms.rs` — the checker's own **per-file** name interner (a small + hand-rolled table over `tsv_lang`'s `FxHashMap`, no external interning + crate; the parser is span-identity and holds no interner), reserved + internal-name atoms. Atoms are file-local (bind products stay relocatable); + cross-file identity is reconciled at merge via owned name strings, with a + merge-time atom-remap as the planned multi-file replacement. + - `flow/` — the **third walk**, a directory-module split by concern: + `flags.rs` (the `FlowFlags` bitset), `graph.rs` (`FlowGraph`'s SoA + storage + read API, plus the `FlowSwitchClause`/`FlowReduceLabel` + payload types), `product.rs` (the owned `FlowProduct`/`FlowStats` + + the `render_flow_dot` DOT renderer), `build/` (`FlowBuilder` + the + `pub fn build_flow` entry point, itself a directory-module split by the + AST shape each visitor descends: `mod.rs` holds the struct and the + flow-node/container/statement-list driver, `statements.rs` and + `expressions.rs` each contribute an `impl FlowBuilder` block of per-node + visitors, and `predicates.rs` holds the pure AST predicates the walk + dispatches on), and `tests/` (a directory-module split mirroring + `build/`'s own: `mod.rs` holds the shared fixtures/helpers plus the + flow-node/container/label-pool tests that exercise `build/mod.rs` itself, + `statements.rs` and `expressions.rs` cover the tests for their `build/` + namesakes, and `predicates.rs` covers `build/predicates.rs`). The + per-file control-flow graph + (`build_flow`) is a faithful port of tsgo's binder flow construction + (`bind`/`bindContainer`/`bindChildren` + the per-statement flow shapers). + A `FlowGraph` in SoA form (`u16` `FlowFlags`, kind-discriminated + `subject`/`antecedent`, length-prefixed pool runs, switch/reduce payload + side tables; the `unreachableFlow` singleton is `FlowNodeId` 1) plus + `flow_of_node`, the `node_flags` `Unreachable` bit, and the + `end`/`return`/`fallthrough` anchors. Covers conditions/if/loops/switch + (clauses reachable from the head)/try-finally (ReduceLabels)/IIFE + inlining/initializer forks/labeled statements, and renders DOT for + `check-test --dump-flow`. Invoked from `program.rs`'s per-unit loop (NOT + `bind_file`) so **lib files skip flow construction** (recorded deviation). + NodeId resolution has **two paths by design**: the flow builder resolves + through the SoA walk's strict `require_node_id` (a miss aborts — a silent + fallback would mis-attribute graph edges), while the unreachable candidate + walk uses the lenient lookup (a miss just means "not a candidate"). The + flow product rides `BoundUnit`, + consumed by `check/unreachable.rs` (TS7027/7028) and, later, the CFA type + engine. The address map keys on `(address, NodeKind)`, so the one offset-0 + collision pair — a `MethodDefinition` and its inline + `value: FunctionExpression` (same address) — stays distinctly resolvable + (pinned by `method_and_value_resolve_distinctly`); the kind disambiguates, + and no same-kind collisions exist. + + **Borrow-only discipline**: visitors take + `&'arena` references and never clone AST nodes — the AST derives `Clone`, + and one accidental `.clone()` silently mints differently-addressed copies + that break the address map; nothing type-level enforces this, so it is a + reviewed convention — enforced by `tests/clone_discipline.rs`, which fails on + any clone-shaped call in `src/` that isn't in its reviewed non-AST allow-list + (and on any allow-list entry gone stale). +- `check/` — the post-bind **syntactic** check pass (`check_file_members`), a + standalone `CheckWalk` over `&Program` that never consults the binder's + symbol tables (walking the shared interface member table would break + declaration-merging). It descends every syntactic position a type literal or + type-parameter list can hide — class / interface / type-literal bodies, every + type-annotation / assertion / predicate / function-type site (a general + `TSType` recursion), class/interface heritage type arguments, decorators + (class / member / parameter), and template-literal-type interpolations — and + runs the per-node check-time checks: `duplicate_members.rs` ports + `checkObjectTypeForDuplicateDeclarations` (the two-map property/accessor + state machine → TS2300, disjoint from the bind cascade by construction) and + `checkTypeParameters` (per-declaration duplicate type-param identity). Its + output folds into each file's diagnostics in `program.rs` before the + program-wide sort/dedup. The traversal's `visit_type_params` is the seam + future per-node checks hook into. `check/` also holds `unreachable.rs` — the + TS7027 (unreachable-code) + TS7028 (unused-label) **flow shim**, a separate + consumer of the binder's flow product (`binder/flow/`): it reads the + fast-path `NODE_FLAGS_UNREACHABLE` bit (tsgo's binder-set-bit branch of + `checkSourceElementUnreachable`), building a bind-time, variant-independent + candidate table (so `BoundProgram` stays owned/relocatable) that per-variant + emit filters by the `allowUnreachableCode` / `allowUnusedLabels` / + `preserveConstEnums` options — routing explicit-`False` runs to `diagnostics` + and the default (suggestion) runs to a separate `suggestions` sink. The + type-dependent `isReachableFlowNode` fallback (never-returning signatures, + assertion predicates, exhaustive switches) is out of scope — deferred to the + CFA type engine. +- `diag.rs` — `Diagnostic` (code, file, span, category, message + args, + nested chain + related-info) and the canonical ordering kernels, ported + from tsgo `internal/ast/diagnostic.go`: `compare_diagnostics` + (path → start → end → code → args → chain size → chain content → + related-info), `equal_no_related_info` (full-chain equality, related-info + excluded), `sort_and_deduplicate` (+ related-info merge). Pure kernels, + unit-tested per comparator leg. +- `ids.rs` — `NodeId` / `FlowNodeId` (`NonZeroU32`, 1-based; `Option` + niche-packs to 4 bytes) and `FileId` newtypes. +- `options.rs` — the checker's option surface (tsv_check's first): `Tristate` + (`Unknown`/`False`/`True`, mirroring `core.Tristate`, default `Unknown`) and + `CheckOptions { allow_unreachable_code, allow_unused_labels, + preserve_const_enums }`, threaded into `check_bound`. Default everywhere + outside the conformance harness. +- Hashing has no module here — the tables use `tsv_lang`'s `FxHashMap` (the + workspace's one dep-free multiply-xor hasher, shared with the printers and the + wire writer). The address map, symbol tables and flow-label scratch are + integer-keyed; the atom interner and merge globals key on **names (`str`)**, + so unlike the printer/writer tables they exercise the hasher's byte path. + ⚠️ Its contract is the + constraint to preserve: **substituting it for SipHash is behavior-preserving + only while every consumer stays order-free.** Every table here honors that — + the atom interner, the address map, the symbol tables, the merge globals and + the duplicate-member state machine are used through + `get`/`insert`/`entry`/`contains_key` alone, and the two sites that *do* + iterate sort first, each on a **total** order so no tie can fall back to the + map's order (`SymbolBinder::resolve_table` by first-declaration span then + name, `FlowBuilder::finish`'s label flush by `FlowNodeId`). A future consumer that + let a map's iteration order reach a diagnostic's order, span, or the flow + pool layout would make the hasher observable — the canonical + `compare_diagnostics` sort is the backstop, not a license. The former + crate-private hasher documented a wasm/native hash-equality property; that + note is retired. `tsv_lang`'s integer methods widen to a 64-bit word exactly + as it did (so integer keys are target-independent regardless) and its byte + path folds native-endian words rather than little-endian ones — a difference + only a big-endian target could observe, and one no output depends on, since + hashes never leave the process. (Where tsgo reaches for xxh3-128 — + variable-arity list hashing — the Fx fold is tsv's dep-free substitute.) + +## Public API + +```rust +let arena = bumpalo::Bump::new(); +let units = [SourceUnit::new("a.ts", source)]; +let result: CheckResult = check_program(&units, &arena, &CheckOptions::default()); +// result.diagnostics — canonically sorted + deduplicated (error category) +// result.suggestions — suggestion-category diagnostics (default-option TS7027/8), +// a SEPARATE sink the parity/expect-clean grading never reads +// result.files[i].parse — ParseReport::Parsed(ParsedFacts) | Rejected +// result.parse_rejected — the short-circuit fired +``` + +The caller owns the arena (the same contract as `tsv_ts::parse`); the +result is fully owned — nothing borrows out. For lib-aware checking: +`bind_program` (parse+bind+flow once, variant-independent, fully owned) → +`check_bound(&bound, Some(&lib_base), &options)`; `bind_lib` produces a cacheable +`LibFile`; `check_program_with_lib` is the one-shot form. `CheckOptions` (the two +unreachable/unused-label tri-states + `preserve_const_enums`) is `default()` for +every non-conformance caller. + +## Which tool answers which question + +- `tsv_debug tsc_conformance run` — the conformance sweep: sweeps the in-scope + corpus (single-file, non-JSX, non-JS-flavored, non-skipped), grades + expect-clean variants AND two graded families as codes+spans multisets — the + **duplicate-conflict** family (`dup`: TS2300/2451/2567/2528 + merge-path + TS2397/2649/2664/2671) from bind + merge + lib **and** the check-time TS2300 + subset (duplicate members / type parameters, from the `check` pass), plus the + **flow** family (`flow`: TS7027 unreachable code + TS7028 unused label) from + the `check/unreachable` shim. `--family {dup,flow,all}` isolates a sub-family + for triage. extra = 0 is a hard gate; a missing is classified `merge` / `lib` / + `deferred_late_bound` (an exact pin — the type-engine-dependent `lateBindMember` + residual) / `deferred_cfa` (an exact pin — the type-engine-dependent + `isReachableFlowNode` residual: never-returning signatures / assertion + predicates / switch exhaustiveness / structural reachability fallback) / + `other` (a HARD-zero invariant — any unclassified family miss fails the run). + It also grades related-info on matched primaries as its own pinned channel + and publishes the parse-divergence census. The exact pins split by meaning: + the drifting counts live in the machine-regenerated snapshot + `tsc_conformance_pins.txt` (`--update` rewrites it; it refuses a narrowed or + red run), the zero-valued invariant gates and the oracle-side pins stay Rust + consts. Triage filters (`--test`/`--code`/`--variant`/`--family`) skip the pins; + `--emit-manifest` and `--report` (the committed + `benches/js/results/report.tsc-conformance.{json,md}`) serve tooling. + On-demand only — `deno task conformance:tsc-check`, never a release leg. +- `tsv_debug profile --bind ` — parse vs lower+bind timing + peak + RSS (VmHWM); the binder's standing perf anchor form. +- `tsv_debug tsc_conformance check-test [--variant k=v] [--json]` — + the inner dev loop: one test, our diagnostics vs the baseline summary. +- `tsv_debug tsc_conformance query|roundtrip|index` — the oracle-side + surfaces (baseline aggregations; parser/renderer byte-identity; corpus + index self-checks). diff --git a/crates/tsv_check/Cargo.toml b/crates/tsv_check/Cargo.toml new file mode 100644 index 000000000..f23d9146e --- /dev/null +++ b/crates/tsv_check/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tsv_check" +version.workspace = true +edition.workspace = true +description = "Experimental TypeScript type-checking for tsv (the tsv_check checker crate)" + +[dependencies] +# Foundation (spans, comments, diagnostics substrate) + the TypeScript parser +# whose internal AST the checker binds and checks. No convert feature — the +# checker consumes the internal AST directly, never the wire JSON. +tsv_lang = { workspace = true } +tsv_ts = { workspace = true } +# The per-file parse arena the caller owns (caller-owns-arena, the tsv_ts +# contract). Already a workspace dependency. +bumpalo = { workspace = true } +# The per-symbol pooled declaration list. A vetted workspace dep. (The binder's +# own name interner is a hand-rolled table over `tsv_lang`'s FxHashMap — no +# external interning crate.) +smallvec = { workspace = true } + +[lints] +workspace = true diff --git a/crates/tsv_check/src/binder/atoms.rs b/crates/tsv_check/src/binder/atoms.rs new file mode 100644 index 000000000..8ead1b772 --- /dev/null +++ b/crates/tsv_check/src/binder/atoms.rs @@ -0,0 +1,104 @@ +//! The binder's per-file name interner. +//! +//! Binding needs cross-occurrence name identity: two `x` at different spans must +//! resolve to one symbol-table key. Span-identity identifier names give equality +//! only per occurrence, so at bind time each declared name resolves to a dense +//! [`Atom`] through one interner pass — the common case slices `source[name_span]` +//! (no allocation beyond the interner's own copy), escaped names go through the +//! parser's decoded channel. +//! +//! **Scope: one file's bind.** Each `bind_file` runs a fresh instance, so an +//! [`Atom`] is comparable only within its own file — a deliberate deviation from +//! a program-scoped interner, keeping every bind product program-independent +//! (relocatable/cacheable across programs and lib folds). Cross-file name +//! identity is reconciled at merge time, today by resolving atoms to owned name +//! strings in `FileMerge`; a merge-time atom-remap table (old→new integer map) +//! is the planned replacement when multi-file volume makes the string bridge +//! measurable. +//! +//! This is the checker's **own** interner — a small hand-rolled table over +//! `tsv_lang`'s `FxHashMap` (no external interning crate; the parser is +//! span-identity and holds no interner). The reserved internal names tsgo +//! mangles (`"default"`, `"export="`, `"__constructor"`, ambient-module +//! `"name"`, private `\xFE#…`) intern through the same table on demand; the hot +//! reserved ones are pre-interned so their [`Atom`]s are `const`-cheap to +//! compare. +// +// tsgo: internal/ast/symbol.go InternalSymbolName* (the mangled reserved names) + +use tsv_lang::FxHashMap; + +/// A dense interned name identity, valid within one file's bind. +/// +/// Equal atoms mean equal names — the symbol-table key. Wraps a `u32` index so +/// distinct-name lookups are integer compares. Atoms from different files never +/// compare (each `bind_file` has its own interner). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct Atom(u32); + +/// The binder's per-file name interner (its own hand-rolled table). +pub struct Atoms { + /// `Atom` index → owned name (the resolve channel). Owned rather than + /// source-borrowed so bind products stay relocatable across programs/folds. + names: Vec>, + /// name → `Atom` (the get-or-intern channel). + lookup: FxHashMap, Atom>, + /// tsgo's `InternalSymbolNameDefault` — the forced name of every default + /// export, so multiple default exports collide under one table key. + default: Atom, + /// tsgo's `InternalSymbolNameExportEquals` — the `export =` self-merge name. + export_equals: Atom, +} + +impl Atoms { + /// Build the interner and pre-intern the hot reserved names. + #[must_use] + pub fn new() -> Atoms { + let mut atoms = Atoms { + names: Vec::new(), + lookup: FxHashMap::default(), + default: Atom(0), + export_equals: Atom(0), + }; + atoms.default = atoms.intern("default"); + atoms.export_equals = atoms.intern("export="); + atoms + } + + /// Intern a name to its [`Atom`] — one string hash, then integer identity. + pub fn intern(&mut self, name: &str) -> Atom { + if let Some(&atom) = self.lookup.get(name) { + return atom; + } + let atom = Atom(self.names.len() as u32); + let owned: Box = name.into(); + self.names.push(owned.clone()); + self.lookup.insert(owned, atom); + atom + } + + /// Resolve an [`Atom`] back to its name (for diagnostic display). + #[must_use] + pub fn resolve(&self, atom: Atom) -> &str { + // Sound: every `Atom` was minted by this interner's `intern`. + self.names.get(atom.0 as usize).map_or("", |name| &**name) + } + + /// The forced-default-export name atom (`"default"`). + #[must_use] + pub fn default_export(&self) -> Atom { + self.default + } + + /// The `export =` self-merge name atom (`"export="`). + #[must_use] + pub fn export_equals(&self) -> Atom { + self.export_equals + } +} + +impl Default for Atoms { + fn default() -> Atoms { + Atoms::new() + } +} diff --git a/crates/tsv_check/src/binder/flow/build/expressions.rs b/crates/tsv_check/src/binder/flow/build/expressions.rs new file mode 100644 index 000000000..9ffcd41e8 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/build/expressions.rs @@ -0,0 +1,664 @@ +//! The expression-shaped flow visitors — `visit_expression`, the `bindCondition` +//! machinery (conditions / logical / conditional expressions), the function / +//! class-expression containers, and the pattern visitors. Contributes its own +//! `impl FlowBuilder` block; the struct and traversal driver live in the parent +//! module. Purely a locality split — no behavior distinction. + +use super::super::*; +use super::FlowBuilder; +use super::predicates::*; +use crate::binder::{NodeKind, addr_of, expression_addr_kind}; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, AssignmentOperator, BinaryExpression, BinaryOperator, ClassExpression, + ConditionalExpression, Decorator, Expression, FunctionExpression, Identifier, + ObjectPatternProperty, ObjectProperty, Property, UnaryOperator, +}; + +impl<'a> FlowBuilder<'a> { + /// `maybeBindExpressionFlowIfCall` (binder.go:2143): a top-level dotted-name + /// (non-`super`) call is a potential assertion → `createFlowCall`. + pub(super) fn maybe_bind_expression_flow_if_call(&mut self, expr: &Expression<'_>) { + if let Expression::CallExpression(c) = expr + && !matches!(c.callee, Expression::Super(_)) + && is_dotted_name(c.callee) + { + let call_id = self.require(addr_of(c), NodeKind::CallExpression); + self.current_flow = self.create_flow_call(self.current_flow, call_id); + } + } + + // --- condition binding (the bindCondition machinery) ------------------ + + /// `doWithConditionalBranches` (binder.go:1789) — bind `value` with the given + /// true/false targets installed, restored on exit. A `None` value is the + /// nil-node no-op (`for (;;)`). + fn do_with_conditional_branches( + &mut self, + value: Option<&Expression<'_>>, + true_target: FlowNodeId, + false_target: FlowNodeId, + ) { + let saved_true = self.current_true_target; + let saved_false = self.current_false_target; + self.current_true_target = Some(true_target); + self.current_false_target = Some(false_target); + if let Some(v) = value { + self.visit_expression(v); + } + self.current_true_target = saved_true; + self.current_false_target = saved_false; + } + + /// `bindCondition` (binder.go:1799) — bind the condition through the + /// true/false targets, then, **only for an atomic condition** (not a logical + /// `&&`/`||`/`??` or logical compound-assignment, whose sub-binder already + /// wired the targets), add the true/false condition edges. `parent_is_nullish` + /// is the caller-supplied `IsNullishCoalesce(parent)` guard (the operands of a + /// `??`); `is_optional_chain_root` is always `false` here (optional chains are + /// atomic — their dedicated short-circuit machinery is F2). + pub(super) fn bind_condition( + &mut self, + node: Option<&Expression<'_>>, + true_target: FlowNodeId, + false_target: FlowNodeId, + parent_is_nullish: bool, + ) { + self.do_with_conditional_branches(node, true_target, false_target); + if node.is_none_or(|e| !is_logical_condition(e)) { + let with_id = node.map(|e| (e, self.expr_id(e))); + let is_narrowing = node.is_some_and(is_narrowing_expression); + let tc = self.create_flow_condition( + FlowFlags::TRUE_CONDITION, + self.current_flow, + with_id, + is_narrowing, + false, + parent_is_nullish, + ); + self.add_antecedent(true_target, tc); + let fc = self.create_flow_condition( + FlowFlags::FALSE_CONDITION, + self.current_flow, + with_id, + is_narrowing, + false, + parent_is_nullish, + ); + self.add_antecedent(false_target, fc); + } + } + + /// `bindBinaryExpressionFlow`'s logical branch (binder.go:2219) — decides + /// value-context (top-level → a `hasFlowEffects` post-label materializes only + /// if the subtree had flow effects) vs condition-context (nested → the + /// enclosing true/false targets). The pointer-free + /// `isTopLevelLogicalExpression` test is `current_true_target.is_none()` (see + /// the module header). `assign_target` is the LHS for a logical + /// compound-assignment (`&&=`/`||=`/`??=`), else `None`. + fn bind_binary_expression_flow( + &mut self, + node: &Expression<'_>, + left: &Expression<'_>, + right: &Expression<'_>, + is_and: bool, + is_nullish: bool, + assign_target: Option<&Expression<'_>>, + ) { + if self.current_true_target.is_none() { + let post = self.create_branch_label(); + let saved_flow = self.current_flow; + let saved_effects = self.has_flow_effects; + self.has_flow_effects = false; + self.bind_logical_like_expression( + node, + left, + right, + is_and, + is_nullish, + assign_target, + post, + post, + ); + self.current_flow = if self.has_flow_effects { + self.finish_flow_label(post) + } else { + saved_flow + }; + self.has_flow_effects = self.has_flow_effects || saved_effects; + } else { + let t = self.current_true_target.unwrap_or(self.unreachable_flow); + let f = self.current_false_target.unwrap_or(self.unreachable_flow); + self.bind_logical_like_expression( + node, + left, + right, + is_and, + is_nullish, + assign_target, + t, + f, + ); + } + } + + /// `bindLogicalLikeExpression` (binder.go:2261) — narrow the left operand + /// against a fresh `preRight` label (vs the false target for `&&`/`&&=`, vs + /// the true target otherwise), then the right against the original targets; + /// a logical compound-assignment additionally mutates its target and tests + /// the whole node. + #[allow(clippy::too_many_arguments)] // faithful port of the tsgo signature + fn bind_logical_like_expression( + &mut self, + node: &Expression<'_>, + left: &Expression<'_>, + right: &Expression<'_>, + is_and: bool, + is_nullish: bool, + assign_target: Option<&Expression<'_>>, + true_target: FlowNodeId, + false_target: FlowNodeId, + ) { + let pre_right = self.create_branch_label(); + if is_and { + self.bind_condition(Some(left), pre_right, false_target, is_nullish); + } else { + self.bind_condition(Some(left), true_target, pre_right, is_nullish); + } + self.current_flow = self.finish_flow_label(pre_right); + if let Some(target) = assign_target { + // Logical compound-assignment (binder.go:2271-2275): bind the RHS, mutate + // the target, then test `node` (never a boolean keyword → the parent-nullish + // guard is irrelevant). tsgo binds the RHS with `doWithConditionalBranches` + // (targets = the outer true/false), but the value-vs-condition split is then + // taken by `isTopLevelLogicalExpression(right)` on `right`'s PARENT — which + // is this `&&=`/`||=`/`??=` node, not a logical operator — so `right` is + // classified TOP-LEVEL and its internal conditions never thread into the + // outer targets (only the whole-node truthiness below does). tsv emulates + // `isTopLevelLogicalExpression` pointer-free as `current_true_target.is_none()`, + // so binding the RHS with the targets SET would misclassify a logical `right` + // (`a &&= x && y`) as nested. The faithful adaptation is to CLEAR the targets + // (not set them) around the RHS bind: a logical `right` then sees itself + // top-level (its own discarded post-label), and a non-logical `right` is + // identical either way (the targets are only read by the logical branch). + let saved_true = self.current_true_target.take(); + let saved_false = self.current_false_target.take(); + self.visit_expression(right); + self.current_true_target = saved_true; + self.current_false_target = saved_false; + self.bind_assignment_target_flow(target); + let node_id = self.expr_id(node); + let is_narrowing = is_narrowing_expression(node); + let tc = self.create_flow_condition( + FlowFlags::TRUE_CONDITION, + self.current_flow, + Some((node, node_id)), + is_narrowing, + false, + false, + ); + self.add_antecedent(true_target, tc); + let fc = self.create_flow_condition( + FlowFlags::FALSE_CONDITION, + self.current_flow, + Some((node, node_id)), + is_narrowing, + false, + false, + ); + self.add_antecedent(false_target, fc); + } else { + self.bind_condition(Some(right), true_target, false_target, is_nullish); + } + } + + /// `bindConditionalExpressionFlow` (binder.go:2289) — a `?:` as a value: the + /// condition splits to true/false labels feeding the two arms, which merge at + /// a `hasFlowEffects`-gated post label. + fn bind_conditional_expression_flow(&mut self, c: &ConditionalExpression<'_>) { + let true_label = self.create_branch_label(); + let false_label = self.create_branch_label(); + let post = self.create_branch_label(); + let saved_flow = self.current_flow; + let saved_effects = self.has_flow_effects; + self.has_flow_effects = false; + self.bind_condition(Some(c.test), true_label, false_label, false); + self.current_flow = self.finish_flow_label(true_label); + self.visit_expression(c.consequent); + self.add_antecedent(post, self.current_flow); + self.current_flow = self.finish_flow_label(false_label); + self.visit_expression(c.alternate); + self.add_antecedent(post, self.current_flow); + self.current_flow = if self.has_flow_effects { + self.finish_flow_label(post) + } else { + saved_flow + }; + self.has_flow_effects = self.has_flow_effects || saved_effects; + } + + /// `bindAssignmentTargetFlow` (binder.go:1821), **default branch only**: a + /// narrowable-reference target gets an `Assignment` mutation. The + /// array/object-literal destructuring recursion (the `inAssignmentPattern` + /// per-element machinery) is F2, alongside parameter-default forks — a + /// destructuring target is not a narrowable reference, so it mints no mutation + /// here, and its sub-references were already visited. + pub(super) fn bind_assignment_target_flow(&mut self, target: &Expression<'_>) { + if is_narrowable_reference(target) { + let id = self.expr_id(target); + self.current_flow = + self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, id); + } + } + + /// The F0 [`NodeId`] of an expression node — its variant payload's + /// `(address, kind)` in the address map, via the shared + /// [`expression_addr_kind`] mapping (the same one `visit_expression`'s + /// lockstep guard pins in `binder/mod.rs`). Condition / mutation subjects are + /// always value expressions F0 lowered, so this never misses. + fn expr_id(&self, e: &Expression<'_>) -> NodeId { + let (addr, kind) = expression_addr_kind(e); + self.require(addr, kind) + } + + // --- function-like / class expression containers ---------------------- + + /// A function expression. `is_iife` marks a call callee (an IIFE): the + /// container is entered **transparently** (no fresh `Start`, `current_flow` + /// not restored on exit) with its own return target, so the body joins the + /// containing control flow (binder.go:1525-1544). The return-flow anchor + /// stays off (`is_ctor_or_static = false`) — tsgo writes it only for + /// constructors / static blocks, never a plain IIFE. + fn visit_function_expression( + &mut self, + f: &FunctionExpression<'_>, + node_id: NodeId, + is_iife: bool, + ) { + // The function-expression flow write is captured at the OUTER flow, + // before the body's Start (binder.go:915). Unconditional: the container + // path does not nil it in dead code. + self.set_flow_leaf(node_id); + let saved = self.enter_container(Some(node_id), is_iife, is_iife); + self.bind_params(f.params); + self.visit_statement_list(f.body.body); + self.exit_container(saved, is_iife, true, true, node_id, false); + } + + fn visit_arrow( + &mut self, + a: &tsv_ts::ast::internal::ArrowFunctionExpression<'_>, + node_id: NodeId, + is_iife: bool, + ) { + self.set_flow_leaf(node_id); // binder.go:915 (arrows dispatch here too) + let saved = self.enter_container(Some(node_id), is_iife, is_iife); + self.bind_params(a.params); + match &a.body { + ArrowFunctionBody::Expression(e) => self.visit_expression(e), + ArrowFunctionBody::BlockStatement(block) => self.visit_statement_list(block.body), + } + self.exit_container(saved, is_iife, true, true, node_id, false); + } + + fn visit_class_expr(&mut self, c: &ClassExpression<'_>) { + self.visit_class_common( + c.id.as_ref(), + c.decorators, + c.super_class, + c.body.body, + true, + ); + } + + // --- expressions ------------------------------------------------------ + + pub(super) fn visit_expression(&mut self, expr: &Expression<'_>) { + use Expression as E; + // A **value** sub-position resets the condition targets, so a logical + // expression nested inside one (`if (f(x && y))`, `if (c ? x && y : z)`, + // `if (g([x && y]))`) is classified top-level — a value with its own + // post-label — not a sub-condition of the enclosing `bind_condition`. This + // is the pointer-free `isTopLevelLogicalExpression` (binder.go:2782): only + // the *threading* variants (`!`, `&&`/`||`/`??`, logical-assignment, parens) + // propagate the targets into their operands; every other expression is a + // value boundary. Without the reset, `current_true_target.is_none()` stays + // false through the whole condition subtree and mis-wires nested logicals. + let restore = if is_condition_threading(expr) { + None + } else { + Some(( + self.current_true_target.take(), + self.current_false_target.take(), + )) + }; + match expr { + E::Identifier(idn) => self.visit_identifier(idn), + E::ThisExpression(t) => { + let id = self.require(addr_of(t), NodeKind::ThisExpression); + self.set_flow_leaf(id); + } + E::Super(s) => { + let id = self.require(addr_of(s), NodeKind::Super); + self.set_flow_leaf(id); + } + E::MetaProperty(m) => { + // Non-leaf write (nil'd in dead code). tsv models `import`/`new` + // and `meta`/`target` as identifiers; they are keyword-ish, not + // references, so only the MetaProperty node is stamped. + let id = self.require(addr_of(m), NodeKind::MetaProperty); + self.set_flow_nonleaf(id); + } + E::MemberExpression(m) => self.bind_member_expression_flow(expr, m), + E::Literal(_) | E::PrivateIdentifier(_) | E::RegexLiteral(_) => {} + E::ObjectExpression(o) => { + for prop in o.properties { + self.visit_object_property(prop); + } + } + E::ArrayExpression(a) => { + for el in a.elements.iter().flatten() { + self.visit_expression(el); + } + } + E::UnaryExpression(u) if u.operator == UnaryOperator::Bang => { + self.bind_prefix_unary_expression_flow(u); + } + E::UnaryExpression(u) => self.visit_expression(u.argument), + E::UpdateExpression(u) => self.visit_expression(u.argument), + E::BinaryExpression(b) if b.operator.is_logical() => { + self.bind_logical_binary_expression_flow(expr, b); + } + E::BinaryExpression(b) => { + self.visit_expression(b.left); + self.visit_expression(b.right); + } + E::CallExpression(c) => self.bind_call_expression_flow(c), + E::NewExpression(n) => self.visit_new_expression(n), + E::ConditionalExpression(c) => self.bind_conditional_expression_flow(c), + E::ArrowFunctionExpression(a) => { + let id = self.require(addr_of(a), NodeKind::ArrowFunctionExpression); + self.visit_arrow(a, id, false); + } + E::FunctionExpression(f) => { + let id = self.require(addr_of(f), NodeKind::FunctionExpression); + self.visit_function_expression(f, id, false); + } + E::ClassExpression(c) => self.visit_class_expr(c), + E::SpreadElement(s) => self.visit_expression(s.argument), + E::TemplateLiteral(t) => { + for e in t.expressions { + self.visit_expression(e); + } + } + E::TaggedTemplateExpression(t) => self.visit_tagged_template_expression(t), + E::AwaitExpression(a) => self.visit_expression(a.argument), + E::YieldExpression(y) => { + if let Some(a) = y.argument { + self.visit_expression(a); + } + } + E::SequenceExpression(s) => self.bind_sequence_expression_flow(s), + E::AssignmentExpression(a) if is_logical_assign_op(a.operator) => { + self.bind_logical_assignment_expression_flow(expr, a); + } + E::AssignmentExpression(a) => self.bind_assignment_expression_flow(a), + E::ObjectPattern(op) => self.visit_object_pattern(op), + E::ArrayPattern(ap) => self.visit_array_pattern(ap), + E::AssignmentPattern(a) => self.visit_assignment_pattern(a), + E::RestElement(r) => self.visit_expression(r.argument), + E::TSTypeAssertion(t) => self.visit_expression(t.expression), + E::TSAsExpression(t) => self.visit_expression(t.expression), + E::TSSatisfiesExpression(t) => self.visit_expression(t.expression), + E::TSInstantiationExpression(t) => self.visit_expression(t.expression), + E::TSNonNullExpression(t) => self.visit_expression(t.expression), + E::TSParameterProperty(pp) => self.visit_expression(pp.parameter), + E::ImportExpression(i) => self.visit_import_expression(i), + E::JsdocCast(c) => self.visit_expression(c.inner), + E::ParenthesizedExpression(p) => self.visit_expression(p.expression), + } + if let Some((t, f)) = restore { + self.current_true_target = t; + self.current_false_target = f; + } + } + + #[inline] + fn bind_member_expression_flow( + &mut self, + expr: &Expression<'_>, + m: &tsv_ts::ast::internal::MemberExpression<'_>, + ) { + // The access flow write (binder.go:618): non-leaf, reachable- + // only, gated on `isNarrowableReference`. + if is_narrowable_reference(expr) { + let id = self.require(addr_of(m), NodeKind::MemberExpression); + self.set_flow_nonleaf(id); + } + self.visit_expression(m.object); + self.visit_expression(m.property); + } + + #[inline] + fn bind_prefix_unary_expression_flow( + &mut self, + u: &tsv_ts::ast::internal::UnaryExpression<'_>, + ) { + // `bindPrefixUnaryExpressionFlow` (binder.go:2174): swap the + // condition targets around the operand so `!x` narrows inversely. + // The pre/post swaps are symmetric — any sub-binder restores the + // targets to their entry value (via `do_with_conditional_branches` + // / the `!`-swap), so the second swap is a faithful restore. + std::mem::swap( + &mut self.current_true_target, + &mut self.current_false_target, + ); + self.visit_expression(u.argument); + std::mem::swap( + &mut self.current_true_target, + &mut self.current_false_target, + ); + } + + #[inline] + fn bind_logical_binary_expression_flow( + &mut self, + expr: &Expression<'_>, + b: &BinaryExpression<'_>, + ) { + // `bindBinaryExpressionFlow` logical branch (binder.go:2219). + let is_and = b.operator == BinaryOperator::AmpersandAmpersand; + let is_nullish = b.operator == BinaryOperator::QuestionQuestion; + self.bind_binary_expression_flow(expr, b.left, b.right, is_and, is_nullish, None); + } + + #[inline] + fn bind_call_expression_flow(&mut self, c: &tsv_ts::ast::internal::CallExpression<'_>) { + use Expression as E; + // IIFE detection (`GetImmediatelyInvokedFunctionExpression`, + // utilities.go:1834; `bindCallExpressionFlow`, binder.go:2419): + // a non-async (non-generator) function/arrow callee — through any + // grouping parens — is inlined into the containing flow. Its + // arguments bind FIRST so the callee's flow write captures the + // post-argument flow. + let mut unwrapped = c.callee; + while let E::ParenthesizedExpression(p) = unwrapped { + unwrapped = p.expression; + } + match unwrapped { + E::ArrowFunctionExpression(a) if !a.r#async => { + for arg in c.arguments { + self.visit_expression(arg); + } + let id = self.require(addr_of(a), NodeKind::ArrowFunctionExpression); + self.visit_arrow(a, id, true); + } + E::FunctionExpression(f) if !f.r#async && !f.generator => { + for arg in c.arguments { + self.visit_expression(arg); + } + let id = self.require(addr_of(f), NodeKind::FunctionExpression); + self.visit_function_expression(f, id, true); + } + _ => { + self.visit_expression(c.callee); + for arg in c.arguments { + self.visit_expression(arg); + } + } + } + } + + #[inline] + fn visit_new_expression(&mut self, n: &tsv_ts::ast::internal::NewExpression<'_>) { + self.visit_expression(n.callee); + for a in n.arguments { + self.visit_expression(a); + } + } + + #[inline] + fn visit_tagged_template_expression( + &mut self, + t: &tsv_ts::ast::internal::TaggedTemplateExpression<'_>, + ) { + self.visit_expression(t.tag); + for e in t.quasi.expressions { + self.visit_expression(e); + } + } + + #[inline] + fn bind_sequence_expression_flow(&mut self, s: &tsv_ts::ast::internal::SequenceExpression<'_>) { + // `bindBinaryExpressionFlow` comma branch: each operand's value + // is discarded (statement-like), so a top-level dotted-name call + // is a potential assertion — apply maybe-call per operand + // (visit-then-maybe, like `ExpressionStatement`). tsgo nests + // comma as left-assoc `BinaryExpression`s applying maybe-call to + // both `Left`/`Right` at each level; the flattened form applies + // it once per leaf operand (intermediate comma nodes are no-op + // non-calls), so the effect matches. + // tsgo: binder.go bindBinaryExpressionFlow (comma branch) + for e in s.expressions { + self.visit_expression(e); + self.maybe_bind_expression_flow_if_call(e); + } + } + + #[inline] + fn bind_logical_assignment_expression_flow( + &mut self, + expr: &Expression<'_>, + a: &tsv_ts::ast::internal::AssignmentExpression<'_>, + ) { + // `bindBinaryExpressionFlow` logical compound-assignment branch. + let is_and = a.operator == AssignmentOperator::LogicalAndAssign; + let is_nullish = a.operator == AssignmentOperator::NullishAssign; + self.bind_binary_expression_flow(expr, a.left, a.right, is_and, is_nullish, Some(a.left)); + } + + #[inline] + fn bind_assignment_expression_flow( + &mut self, + a: &tsv_ts::ast::internal::AssignmentExpression<'_>, + ) { + // `bindBinaryExpressionFlow` assignment branch (binder.go:2249) — + // bind operands, then the target's `Assignment` mutation. + self.visit_expression(a.left); + self.visit_expression(a.right); + self.bind_assignment_target_flow(a.left); + } + + #[inline] + fn visit_object_pattern(&mut self, op: &tsv_ts::ast::internal::ObjectPattern<'_>) { + self.visit_decorators(op.decorators); + for prop in op.properties { + self.visit_object_pattern_property(prop); + } + } + + #[inline] + fn visit_array_pattern(&mut self, ap: &tsv_ts::ast::internal::ArrayPattern<'_>) { + self.visit_decorators(ap.decorators); + for el in ap.elements.iter().flatten() { + self.visit_expression(el); + } + } + + #[inline] + fn visit_assignment_pattern(&mut self, a: &tsv_ts::ast::internal::AssignmentPattern<'_>) { + self.visit_decorators(a.decorators); + self.visit_expression(a.left); + self.visit_expression(a.right); + } + + #[inline] + fn visit_import_expression(&mut self, i: &tsv_ts::ast::internal::ImportExpression<'_>) { + self.visit_expression(i.source); + if let Some(o) = i.options { + self.visit_expression(o); + } + } + + pub(super) fn visit_identifier(&mut self, ident: &Identifier<'_>) { + // Identifier flow write (binder.go:602): a leaf — unconditional, so a + // dead identifier keeps `Some(unreachable)`. Its decorators (parameter + // decorators) are value expressions; its type annotation is a type + // position (skipped). + let id = self.require(addr_of(ident), NodeKind::Identifier); + self.set_flow_leaf(id); + self.visit_decorators(ident.decorators()); + } + + pub(super) fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) { + if let Some(decs) = decorators { + for d in decs { + self.visit_expression(&d.expression); + } + } + } + + fn visit_object_property(&mut self, prop: &ObjectProperty<'_>) { + match prop { + ObjectProperty::Property(pr) => self.visit_object_expr_property(pr), + ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument), + } + } + + fn visit_object_expr_property(&mut self, pr: &Property<'_>) { + let is_method_or_accessor = + pr.method || pr.kind != tsv_ts::ast::internal::PropertyKind::Init; + if let (true, Expression::FunctionExpression(f)) = (is_method_or_accessor, &pr.value) { + // An object-literal method/accessor is a control-flow container + // anchored on its value FunctionExpression — the body-bearing node + // (unlike `MethodDefinition`, a `Property` does NOT share its value's + // address, so this is a consistency choice with `visit_method`, not a + // collision workaround). The PROPERTY node — tsv's analog of tsgo's + // object-literal MethodDeclaration — gets the outer-flow write + // (bindPropertyOrMethodOrAccessor, binder.go:981) and becomes the + // body Start's subject (binder.go:1534) — the P3 narrowing hint + // (`IsObjectLiteralOrClassExpressionMethodOrAccessor`, + // utilities.go:566; the class-expression half lives in `visit_method`). + self.visit_expression(&pr.key); + let anchor = self.require(addr_of(f), NodeKind::FunctionExpression); + let prop_id = self.require(addr_of(pr), NodeKind::Property); + self.set_flow_leaf(prop_id); + let saved = self.enter_container(Some(prop_id), false, false); + self.bind_params(f.params); + self.visit_statement_list(f.body.body); + self.exit_container(saved, false, true, true, anchor, false); + } else { + self.visit_expression(&pr.key); + self.visit_expression(&pr.value); + } + } + + fn visit_object_pattern_property(&mut self, prop: &ObjectPatternProperty<'_>) { + match prop { + ObjectPatternProperty::Property(pr) => { + self.visit_expression(&pr.key); + self.visit_expression(&pr.value); + } + ObjectPatternProperty::RestElement(r) => self.visit_expression(r.argument), + } + } +} diff --git a/crates/tsv_check/src/binder/flow/build/mod.rs b/crates/tsv_check/src/binder/flow/build/mod.rs new file mode 100644 index 000000000..1ddaaf824 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/build/mod.rs @@ -0,0 +1,602 @@ +//! The flow-graph construction walk — `FlowBuilder` and the `build_flow` entry. +//! +//! Split by the AST shape each visitor descends: this parent module owns the +//! `FlowBuilder` struct (with `SavedFlow` / `ActiveLabelEntry`), the +//! flow-node-minting constructors (`newFlowNode*` / `createFlow*` / +//! `finishFlowLabel` / `addAntecedent` family), and the container / statement-list +//! traversal driver (`enter_container` / `exit_container` / `run` / +//! `visit_statement_list`). The per-node visitors live in the submodules — each +//! contributes its own `impl FlowBuilder` block: `statements` (statement dispatch, +//! the per-statement flow shapers, and the declaration-container descents), +//! `expressions` (`visit_expression`, the `bindCondition` machinery, the +//! function/class-expression containers, and the pattern visitors) — and the pure +//! AST predicates the walk dispatches on live in `predicates`. Purely a locality +//! split — no behavior distinction between the files. + +mod expressions; +mod predicates; +mod statements; + +use super::*; +use crate::binder::{BoundFile, NodeKind, addr_of}; +use predicates::{is_false_keyword, is_true_keyword}; +use smallvec::SmallVec; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{Expression, Statement}; + +#[cfg(test)] +pub(super) use predicates::is_narrowable_reference; + +/// Build the flow product for one parsed file, from its `Program` and the F0 +/// [`BoundFile`] (the node-identity source). Invoked from `bind_program`'s +/// per-unit loop for parsed non-lib units (lib files skip flow construction — +/// no consumer reads lib flow and ambient files have no executable code). +#[must_use] +pub fn build_flow<'a>(program: &Program<'_>, source: &'a str, bound: &'a BoundFile) -> FlowProduct { + let mut b = FlowBuilder::new(bound, source); + b.run(program); + b.finish() +} + +// --- FlowBuilder ----------------------------------------------------------- + +/// Saved control-flow state restored at a flow-container boundary +/// (binder.go:1517-1524, the F1b subset — `activeLabelList` / `seenThisKeyword` +/// stay F2/unported). The true/false targets are **not** in tsgo's container +/// save set; F1b adds them (see the module header — the pointer-free +/// `isTopLevelLogicalExpression` heuristic needs a container to reset the +/// condition context). +struct SavedFlow { + current_flow: FlowNodeId, + current_return_target: Option, + current_exception_target: Option, + current_break_target: Option, + current_continue_target: Option, + current_true_target: Option, + current_false_target: Option, + has_explicit_return: bool, + /// `saveActiveLabelList` (binder.go:1522) — the active labeled statements, + /// cleared at every control-flow container (a label can't be jumped to from a + /// nested function, even a flow-transparent IIFE) and restored on exit. + active_label_list: Vec, +} + +/// An entry in the active-label stack (`ActiveLabel`, binder.go:85-94), used LIFO +/// (innermost last). The name is recovered on demand from the label identifier's +/// span (`spans[label_node_id]`) rather than stored owned. +struct ActiveLabelEntry { + /// The label's post-statement break target (`postStatementLabel`). + break_target: FlowNodeId, + /// The continue target, set by `set_continue_target` when the label directly + /// encloses a loop (`None` for a label on a non-loop statement). + continue_target: Option, + /// Whether a labeled `break`/`continue` resolved to this label — an + /// unreferenced label's identifier gets the `Unreachable` stamp (the TS7028 + /// signal, binder.go:2167). + referenced: bool, + /// The label identifier's `NodeId` (the `Unreachable`-stamp target + the + /// name-lookup key). + label_node_id: NodeId, +} + +/// The flow-graph construction walk. +pub(super) struct FlowBuilder<'a> { + bound: &'a BoundFile, + /// The host document — the label-name lookup extracts `spans[id]` slices. + source: &'a str, + + // graph columns + pub(super) flags: Vec, + subject: Vec, + antecedent: Vec, + pool: Vec, + + /// Per-active-label scratch antecedent lists, keyed by the label's + /// `FlowNodeId`, flushed to `pool` at `finish_flow_label` + /// (the `newFlowList` cons-list analog). + label_scratch: tsv_lang::FxHashMap>, + + // products + flow_of_node: Vec>, + node_flags: Vec, + end_flow: Vec<(NodeId, FlowNodeId)>, + return_flow: Vec<(NodeId, FlowNodeId)>, + /// Case-clause fallthrough anchors (`FallthroughFlowNode`, binder.go:2121), + /// sorted by `NodeId` in `finish()` like `end_flow`/`return_flow`. + fallthrough_flow: Vec<(NodeId, FlowNodeId)>, + /// Switch-clause payloads (`createFlowSwitchClause`); a `SwitchClause` node's + /// `subject` slot is a 1-based index into this. + switch_payloads: Vec, + /// Reduce-label payloads (`createReduceLabel`, try/finally); a `ReduceLabel` + /// node's `subject` slot is a 1-based index into this. + reduce_payloads: Vec, + /// The active labeled-statement stack (`activeLabelList`), used LIFO — + /// innermost is the last element. Saved/cleared/restored at every container. + active_label_list: Vec, + + // construction state (the F1b subset of the container-boundary set) + current_flow: FlowNodeId, + pub(super) unreachable_flow: FlowNodeId, + current_return_target: Option, + /// Always `None` in F1b (`createFlowMutation` reads it; only try/finally sets + /// it, which is F2), but ported so the exception hook is faithful. + current_exception_target: Option, + /// Unlabeled-`break` / `continue` targets (binder.go:1546-1547) — set by the + /// loop/switch binders, `None` outside a loop/switch, reset at a container. + current_break_target: Option, + current_continue_target: Option, + /// `preSwitchCaseFlow` (binder.go:67) — the switch-head flow every clause + /// forks from. Set by `bind_switch_statement` after the discriminant is + /// bound, saved/restored there (not in the container set — it is only live + /// while binding a switch's case block), `None` otherwise. + pre_switch_case_flow: Option, + /// The condition-branch targets (binder.go:1790-1793). Set only inside + /// `do_with_conditional_branches` and swapped by the `!`-prefix; their + /// `Some`-ness is the pointer-free `isTopLevelLogicalExpression` signal (see + /// the module header). Reset at a container so a nested function body binds + /// its own logicals as top-level. + current_true_target: Option, + current_false_target: Option, + /// `hasExplicitReturn` (binder.go:1549) — set by `return`, saved+reset at a + /// container. Dark plumbing in F1b (the `HasExplicitReturn` node-flag write is + /// F3-consumed reachability), ported for the faithful container-boundary set. + has_explicit_return: bool, + /// `hasFlowEffects` (binder.go:501/516) — set by `createFlowMutation` / + /// `createFlowCall` / `return` / `throw` / `break` / `continue`; read by the + /// logical/conditional post-label save/restore family to decide whether a + /// post-expression label materializes. Not saved at a container (the family + /// wrappers always reset-then-`OR`, isolating each subtree). + has_flow_effects: bool, + + // stats + branch_labels: u32, + dead_labels: u32, +} + +impl<'a> FlowBuilder<'a> { + pub(super) fn new(bound: &'a BoundFile, source: &'a str) -> FlowBuilder<'a> { + let n = bound.node_count as usize; + let mut b = FlowBuilder { + bound, + source, + flags: Vec::new(), + subject: Vec::new(), + antecedent: Vec::new(), + pool: Vec::new(), + label_scratch: tsv_lang::FxHashMap::default(), + flow_of_node: vec![None; n], + node_flags: vec![0u8; n], + end_flow: Vec::new(), + return_flow: Vec::new(), + fallthrough_flow: Vec::new(), + switch_payloads: Vec::new(), + reduce_payloads: Vec::new(), + active_label_list: Vec::new(), + current_flow: FlowNodeId::UNREACHABLE, + unreachable_flow: FlowNodeId::UNREACHABLE, + current_return_target: None, + current_exception_target: None, + current_break_target: None, + current_continue_target: None, + pre_switch_case_flow: None, + current_true_target: None, + current_false_target: None, + has_explicit_return: false, + has_flow_effects: false, + branch_labels: 0, + dead_labels: 0, + }; + // Mint the unreachableFlow singleton FIRST → id 1 by construction + // (binder.go:126); tsgo's pointer-identity test becomes id equality. + b.unreachable_flow = b.new_flow_node(FlowFlags::UNREACHABLE); + debug_assert_eq!(b.unreachable_flow, FlowNodeId::UNREACHABLE); + b.current_flow = b.unreachable_flow; + b + } + + pub(super) fn finish(mut self) -> FlowProduct { + // Flush any label whose antecedents still live in scratch: the **loop + // labels** (`preWhile`/`preDo`/`preLoop` — referenced via their condition + // flow and a back/continue edge, but the loop binders never call + // `finish_flow_label` on them since a back edge can be added after the label + // is already used, so their entry + back edges never reach the pool via the + // collapse path), AND the **un-finished value-context post labels** — a + // top-level logical / conditional whose subtree had no flow effects keeps + // `current_flow` at the saved pre-expression flow and never finishes its + // `post` label, leaving a dead, unreferenced row (matching tsgo's + // un-finished label object). Deterministic order (sort by id) so the pool + // layout is reproducible; the per-label edge order is push-order. + let mut pending: Vec = self.label_scratch.keys().copied().collect(); + pending.sort_unstable(); + for label in pending { + let list = self.label_scratch.remove(&label).unwrap_or_default(); + if list.is_empty() { + continue; + } + let off = self.pool.len() as u32; + self.pool.push(list.len() as u32); + self.pool.extend(list.iter().map(|e| e.get())); + self.antecedent[label.index()] = off + 1; // 1-based pool-run index + } + let mut end_flow = self.end_flow; + let mut return_flow = self.return_flow; + let mut fallthrough_flow = self.fallthrough_flow; + end_flow.sort_unstable_by_key(|&(n, _)| n); + return_flow.sort_unstable_by_key(|&(n, _)| n); + fallthrough_flow.sort_unstable_by_key(|&(n, _)| n); + FlowProduct { + graph: FlowGraph { + flags: self.flags, + subject: self.subject, + antecedent: self.antecedent, + pool: self.pool, + switch_payloads: self.switch_payloads, + reduce_payloads: self.reduce_payloads, + }, + flow_of_node: self.flow_of_node, + node_flags: self.node_flags, + end_flow, + return_flow, + fallthrough_flow, + stats: FlowStats { + branch_labels: self.branch_labels, + dead_labels: self.dead_labels, + }, + } + } + + // --- flow node constructors (binder.go:454-575) ----------------------- + + /// `newFlowNode` (binder.go:454) — a bare node with only flags. + pub(super) fn new_flow_node(&mut self, flags: FlowFlags) -> FlowNodeId { + let id = FlowNodeId::from_index(self.flags.len()); + self.flags.push(flags); + self.subject.push(0); + self.antecedent.push(0); + id + } + + /// `newFlowNodeEx` (binder.go:460) — a node with a subject + single + /// antecedent. + fn new_flow_node_ex( + &mut self, + flags: FlowFlags, + subject: Option, + antecedent: FlowNodeId, + ) -> FlowNodeId { + let id = self.new_flow_node(flags); + self.subject[id.index()] = subject.map_or(0, NodeId::get); + self.antecedent[id.index()] = antecedent.get(); + id + } + + /// `createBranchLabel` (binder.go:471). + pub(super) fn create_branch_label(&mut self) -> FlowNodeId { + self.branch_labels += 1; + self.new_flow_node(FlowFlags::BRANCH_LABEL) + } + + /// `createLoopLabel` (binder.go:467). + fn create_loop_label(&mut self) -> FlowNodeId { + self.new_flow_node(FlowFlags::LOOP_LABEL) + } + + /// `createFlowMutation` (binder.go:499). The `currentExceptionTarget` hook + /// is a no-op in F1b (that field is always `None`; try/finally sets it, F2). + /// Sets `hasFlowEffects` (binder.go:501) — the condition/logical post-label + /// family reads it to decide whether a post-expression label materializes. + fn create_flow_mutation( + &mut self, + flags: FlowFlags, + antecedent: FlowNodeId, + node: NodeId, + ) -> FlowNodeId { + self.set_flow_node_referenced(antecedent); + self.has_flow_effects = true; + let result = self.new_flow_node_ex(flags, Some(node), antecedent); + if let Some(target) = self.current_exception_target { + self.add_antecedent(target, result); + } + result + } + + /// `createFlowCall` (binder.go:514). Sets `hasFlowEffects = true` + /// (binder.go:516) — see `create_flow_mutation`. + fn create_flow_call(&mut self, antecedent: FlowNodeId, node: NodeId) -> FlowNodeId { + self.set_flow_node_referenced(antecedent); + self.has_flow_effects = true; + self.new_flow_node_ex(FlowFlags::CALL, Some(node), antecedent) + } + + /// `createFlowSwitchClause` (binder.go:509) — a `SwitchClause` flow node + /// carrying the switch node + the matched half-open `[clause_start, + /// clause_end)` clause range as a `FlowSwitchClause` payload. The `subject` + /// slot holds a **1-based index** into `switch_payloads` (not a `NodeId`) — + /// read via [`FlowGraph::switch_clause_data`], never [`FlowGraph::subject`]. + /// Unlike the mutation/call constructors this does **not** set + /// `hasFlowEffects` (a switch clause is a junction, not an effect). + fn create_flow_switch_clause( + &mut self, + antecedent: FlowNodeId, + switch: NodeId, + clause_start: u32, + clause_end: u32, + ) -> FlowNodeId { + self.set_flow_node_referenced(antecedent); + self.switch_payloads.push(FlowSwitchClause { + switch, + clause_start, + clause_end, + }); + let payload_index = self.switch_payloads.len() as u32; // 1-based + let id = self.new_flow_node(FlowFlags::SWITCH_CLAUSE); + self.subject[id.index()] = payload_index; + self.antecedent[id.index()] = antecedent.get(); + id + } + + /// `createReduceLabel` (binder.go:475) — a `ReduceLabel` node carrying a + /// `target` label + a snapshot of a **reduced** antecedent list (flushed to + /// the pool as a length-prefixed run, like a label). Unlike every other flow + /// constructor this does **not** `setFlowNodeReferenced` its antecedent (tsgo + /// `newFlowNodeEx` without the reference bump). The `subject` slot holds a + /// **1-based index** into `reduce_payloads` (not a `NodeId`) — read via + /// [`FlowGraph::reduce_label_data`], never [`FlowGraph::subject`]. + fn create_reduce_label( + &mut self, + target: FlowNodeId, + antecedents_snapshot: &[FlowNodeId], + antecedent: FlowNodeId, + ) -> FlowNodeId { + // Flush the reduced antecedent snapshot as a length-prefixed pool run. + let off = self.pool.len() as u32; + self.pool.push(antecedents_snapshot.len() as u32); + self.pool + .extend(antecedents_snapshot.iter().map(|e| e.get())); + self.reduce_payloads.push(FlowReduceLabel { + target, + antecedents: off + 1, // 1-based pool-run index + }); + let payload_index = self.reduce_payloads.len() as u32; // 1-based + let id = self.new_flow_node(FlowFlags::REDUCE_LABEL); + self.subject[id.index()] = payload_index; + self.antecedent[id.index()] = antecedent.get(); + id + } + + /// `createFlowCondition` (binder.go:479) — the condition-binding constructor. + /// The `expression.Parent` guards (optional-chain root / nullish coalesce) are + /// supplied by the caller, which has the parent context tsv's AST does not + /// carry on an `Expression`; `is_narrowing` is the caller's + /// `is_narrowing_expression` verdict. + pub(super) fn create_flow_condition( + &mut self, + flags: FlowFlags, + antecedent: FlowNodeId, + expression: Option<(&Expression<'_>, NodeId)>, + is_narrowing: bool, + is_optional_chain_root: bool, + parent_is_nullish: bool, + ) -> FlowNodeId { + if self.flags[antecedent.index()].contains(FlowFlags::UNREACHABLE) { + return antecedent; + } + let Some((expr, expr_id)) = expression else { + return if flags.contains(FlowFlags::TRUE_CONDITION) { + antecedent + } else { + self.unreachable_flow + }; + }; + if (is_true_keyword(expr) && flags.contains(FlowFlags::FALSE_CONDITION) + || is_false_keyword(expr) && flags.contains(FlowFlags::TRUE_CONDITION)) + && !is_optional_chain_root + && !parent_is_nullish + { + return self.unreachable_flow; + } + if !is_narrowing { + return antecedent; + } + self.set_flow_node_referenced(antecedent); + self.new_flow_node_ex(flags, Some(expr_id), antecedent) + } + + /// `setFlowNodeReferenced` (binder.go:538) — first reference sets + /// `Referenced`, thereafter `Shared`. + fn set_flow_node_referenced(&mut self, flow: FlowNodeId) { + let f = &mut self.flags[flow.index()]; + if f.contains(FlowFlags::REFERENCED) { + f.insert(FlowFlags::SHARED); + } else { + f.insert(FlowFlags::REFERENCED); + } + } + + /// `addAntecedent` (binder.go:547) — order-preserving, first-write-wins + /// **id-equality** dedup append; unreachable edges are dropped; + /// `setFlowNodeReferenced` fires only on a genuine append. + pub(super) fn add_antecedent(&mut self, label: FlowNodeId, antecedent: FlowNodeId) { + if self.flags[antecedent.index()].contains(FlowFlags::UNREACHABLE) { + return; + } + let list = self.label_scratch.entry(label).or_default(); + if list.contains(&antecedent) { + return; + } + list.push(antecedent); + self.set_flow_node_referenced(antecedent); + } + + /// `finishFlowLabel` (binder.go:567) — 0 antecedents → `unreachableFlow` + /// (a dead label row), exactly 1 → the antecedent itself (the label never + /// enters the graph, dead row), 2+ → flush the run to the pool and keep the + /// label. + pub(super) fn finish_flow_label(&mut self, label: FlowNodeId) -> FlowNodeId { + let list = self.label_scratch.remove(&label).unwrap_or_default(); + match list.as_slice() { + [] => { + self.dead_labels += 1; + self.unreachable_flow + } + [single] => { + self.dead_labels += 1; + *single + } + edges => { + let off = self.pool.len() as u32; + self.pool.push(edges.len() as u32); + self.pool.extend(edges.iter().map(|e| e.get())); + self.antecedent[label.index()] = off + 1; // 1-based pool-run index + label + } + } + } + + // --- helpers ---------------------------------------------------------- + + #[inline] + fn require(&self, address: usize, kind: NodeKind) -> NodeId { + self.bound.require_node_id(address, kind) + } + + #[inline] + fn current_unreachable(&self) -> bool { + self.current_flow == self.unreachable_flow + } + + /// Stamp `flow_of_node[id] = current_flow` (a leaf write — unconditional, + /// so a dead leaf keeps `Some(unreachable)`, matching tsgo's token nodes + /// that bypass `bindChildren`). + #[inline] + fn set_flow_leaf(&mut self, id: NodeId) { + self.flow_of_node[id.index()] = Some(self.current_flow); + } + + /// Stamp `flow_of_node[id]` for a **non-leaf** node whose bind()-switch + /// write is nil'd by `bindChildren` in dead code — so it lands only when + /// reachable (dead → left `None`). + #[inline] + fn set_flow_nonleaf(&mut self, id: NodeId) { + if !self.current_unreachable() { + self.flow_of_node[id.index()] = Some(self.current_flow); + } + } + + // --- container save/restore (binder.go:1516-1591, F1 subset) ---------- + + /// Enter a control-flow container: fresh `Start` (unless flow-transparent), + /// optional return target, exception target reset. + fn enter_container( + &mut self, + start_subject: Option, + transparent: bool, + wants_return_target: bool, + ) -> SavedFlow { + let saved = SavedFlow { + current_flow: self.current_flow, + current_return_target: self.current_return_target, + current_exception_target: self.current_exception_target, + current_break_target: self.current_break_target, + current_continue_target: self.current_continue_target, + current_true_target: self.current_true_target, + current_false_target: self.current_false_target, + has_explicit_return: self.has_explicit_return, + // Cleared even for a flow-transparent IIFE: a label outside the + // callee can't be `break`/`continue`-targeted from inside it. + active_label_list: std::mem::take(&mut self.active_label_list), + }; + if !transparent { + let start = self.new_flow_node(FlowFlags::START); + if let Some(s) = start_subject { + self.subject[start.index()] = s.get(); + } + self.current_flow = start; + } + self.current_return_target = if wants_return_target { + Some(self.create_branch_label()) + } else { + None + }; + self.current_exception_target = None; + self.current_break_target = None; + self.current_continue_target = None; + // Reset the condition context so a nested body binds its own logicals as + // top-level (see the module header — the pointer-free + // `isTopLevelLogicalExpression` heuristic). tsgo leaves these untouched. + self.current_true_target = None; + self.current_false_target = None; + self.has_explicit_return = false; + saved + } + + /// Exit a control-flow container: the postlude (end-of-flow anchor, return + /// target merge, restore). `is_ctor_or_static` gates the `return_flow` + /// anchor; `function_like && body_present` gates `end_flow`. + fn exit_container( + &mut self, + saved: SavedFlow, + transparent: bool, + function_like: bool, + body_present: bool, + anchor: NodeId, + is_ctor_or_static: bool, + ) { + if !self.current_unreachable() && function_like && body_present { + self.end_flow.push((anchor, self.current_flow)); + } + if let Some(rt) = self.current_return_target { + self.add_antecedent(rt, self.current_flow); + self.current_flow = self.finish_flow_label(rt); + if is_ctor_or_static { + self.return_flow.push((anchor, self.current_flow)); + } + } + if !transparent { + self.current_flow = saved.current_flow; + } + self.current_return_target = saved.current_return_target; + self.current_exception_target = saved.current_exception_target; + self.current_break_target = saved.current_break_target; + self.current_continue_target = saved.current_continue_target; + self.current_true_target = saved.current_true_target; + self.current_false_target = saved.current_false_target; + self.has_explicit_return = saved.has_explicit_return; + self.active_label_list = saved.active_label_list; + } + + // --- entry (SourceFile container) ------------------------------------- + + fn run(&mut self, program: &Program<'_>) { + // The SourceFile is a control-flow container: fresh Start (id 2), no + // return target (not an IIFE/constructor), no Start subject. + let root = self.require(addr_of(program), NodeKind::Program); + let start = self.new_flow_node(FlowFlags::START); + self.current_flow = start; + self.current_return_target = None; + self.current_exception_target = None; + self.current_break_target = None; + self.current_continue_target = None; + self.current_true_target = None; + self.current_false_target = None; + self.has_explicit_return = false; + self.visit_statement_list(program.body); + // SourceFile end_flow is unconditional (binder.go:1567-1569). + self.end_flow.push((root, self.current_flow)); + } + + // --- statement lists (functions-first, binder.go:1766) ---------------- + + fn visit_statement_list(&mut self, stmts: &[Statement<'_>]) { + for stmt in stmts { + if matches!(stmt, Statement::FunctionDeclaration(_)) { + self.visit_statement(stmt); + } + } + for stmt in stmts { + if !matches!(stmt, Statement::FunctionDeclaration(_)) { + self.visit_statement(stmt); + } + } + } +} diff --git a/crates/tsv_check/src/binder/flow/build/predicates.rs b/crates/tsv_check/src/binder/flow/build/predicates.rs new file mode 100644 index 000000000..68560154f --- /dev/null +++ b/crates/tsv_check/src/binder/flow/build/predicates.rs @@ -0,0 +1,318 @@ +//! The pure AST predicates the flow walk dispatches on (`binder.go` / +//! `utilities.go` ports) — free functions with no `FlowBuilder` state, factored +//! out of the visitor modules. Purely a locality split — no behavior change. + +use tsv_ts::ast::internal::{ + AssignmentOperator, BinaryExpression, BinaryOperator, Expression, LiteralValue, Statement, + UnaryOperator, +}; + +/// `is_potentially_executable` (utilities.go:4210) — the statement range (minus +/// `Block`/`Empty`, which are below the range), with `VariableStatement` gated +/// on block-scoping or an initializer, plus class/enum/module declarations. +pub(super) fn is_potentially_executable(stmt: &Statement<'_>) -> bool { + use Statement as S; + match stmt { + S::ExpressionStatement(_) + | S::IfStatement(_) + | S::DoWhileStatement(_) + | S::WhileStatement(_) + | S::ForStatement(_) + | S::ForInStatement(_) + | S::ForOfStatement(_) + | S::ContinueStatement(_) + | S::BreakStatement(_) + | S::ReturnStatement(_) + | S::SwitchStatement(_) + | S::LabeledStatement(_) + | S::ThrowStatement(_) + | S::TryStatement(_) + | S::DebuggerStatement(_) => true, + S::VariableDeclaration(d) => { + use tsv_ts::ast::internal::VariableDeclarationKind as K; + d.kind != K::Var || d.declarations.iter().any(|decl| decl.init.is_some()) + } + S::ClassDeclaration(_) | S::TSEnumDeclaration(_) | S::TSModuleDeclaration(_) => true, + _ => false, + } +} + +/// Whether a statement kind is in tsc's `[FirstStatement, LastStatement]` range +/// (binder.go:1663) — the entry-flow write set. Excludes `Block`/`Empty` (below +/// the range) and every declaration kind (above it). +pub(super) fn is_statement_range(stmt: &Statement<'_>) -> bool { + use Statement as S; + matches!( + stmt, + S::ExpressionStatement(_) + | S::VariableDeclaration(_) + | S::IfStatement(_) + | S::DoWhileStatement(_) + | S::WhileStatement(_) + | S::ForStatement(_) + | S::ForInStatement(_) + | S::ForOfStatement(_) + | S::ContinueStatement(_) + | S::BreakStatement(_) + | S::ReturnStatement(_) + | S::SwitchStatement(_) + | S::LabeledStatement(_) + | S::ThrowStatement(_) + | S::TryStatement(_) + | S::DebuggerStatement(_) + ) +} + +/// `IsDottedName` (utilities.go:1613). +pub(super) fn is_dotted_name(expr: &Expression<'_>) -> bool { + use Expression as E; + match expr { + E::Identifier(_) | E::ThisExpression(_) | E::Super(_) | E::MetaProperty(_) => true, + E::MemberExpression(m) if !m.computed => is_dotted_name(m.object), + E::ParenthesizedExpression(p) => is_dotted_name(p.expression), + _ => false, + } +} + +/// `isNarrowableReference` (binder.go:2633) — the access flow-write gate. +/// Adapted to tsv's AST (tsc's comma/assignment `BinaryExpression` cases are +/// tsv's `SequenceExpression` / `AssignmentExpression`). +pub(in crate::binder::flow) fn is_narrowable_reference(node: &Expression<'_>) -> bool { + use Expression as E; + match node { + E::Identifier(_) | E::ThisExpression(_) | E::Super(_) | E::MetaProperty(_) => true, + E::MemberExpression(m) if !m.computed => is_narrowable_reference(m.object), + E::ParenthesizedExpression(p) => is_narrowable_reference(p.expression), + E::TSNonNullExpression(t) => is_narrowable_reference(t.expression), + E::MemberExpression(m) => { + // computed element access + is_string_or_numeric_literal_like(m.property) + || (is_entity_name_expression(m.property) && is_narrowable_reference(m.object)) + } + E::AssignmentExpression(a) => is_left_hand_side_expression(a.left), + E::SequenceExpression(s) => s.expressions.last().is_some_and(is_narrowable_reference), + _ => false, + } +} + +fn is_string_or_numeric_literal_like(node: &Expression<'_>) -> bool { + matches!( + node, + Expression::Literal(l) if matches!(l.value, LiteralValue::String(_) | LiteralValue::Number(_)) + ) +} + +/// `IsEntityNameExpression` (utilities.go:1595) — an identifier or a dotted +/// property-access chain bottoming in one. +fn is_entity_name_expression(node: &Expression<'_>) -> bool { + use Expression as E; + match node { + E::Identifier(_) => true, + E::MemberExpression(m) if !m.computed => { + matches!(m.property, E::Identifier(_)) && is_entity_name_expression(m.object) + } + _ => false, + } +} + +/// `isLeftHandSideExpressionKind` (utilities.go:396) — the postfix/primary +/// expression forms. Reached only via the rare `(x = y).z` narrowable case. +fn is_left_hand_side_expression(node: &Expression<'_>) -> bool { + use Expression as E; + matches!( + node, + E::MemberExpression(_) + | E::NewExpression(_) + | E::CallExpression(_) + | E::TaggedTemplateExpression(_) + | E::ArrayExpression(_) + | E::ParenthesizedExpression(_) + | E::ObjectExpression(_) + | E::ClassExpression(_) + | E::FunctionExpression(_) + | E::Identifier(_) + | E::PrivateIdentifier(_) + | E::RegexLiteral(_) + | E::Literal(_) + | E::TemplateLiteral(_) + | E::ThisExpression(_) + | E::Super(_) + | E::TSNonNullExpression(_) + | E::MetaProperty(_) + | E::ImportExpression(_) + ) +} + +pub(super) fn is_true_keyword(expr: &Expression<'_>) -> bool { + matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(true))) +} + +pub(super) fn is_false_keyword(expr: &Expression<'_>) -> bool { + matches!(expr, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(false))) +} + +/// Whether a condition node is a logical `&&`/`||`/`??` or a logical +/// compound-assignment `&&=`/`||=`/`??=` — the `bindCondition` non-atomic test +/// (binder.go:1801, combining `IsLogicalExpression` + `isLogicalAssignment`). +/// Such a node's sub-binder already wired the true/false targets, so +/// `bindCondition` must NOT re-add the atomic true/false conditions. +pub(super) fn is_logical_condition(e: &Expression<'_>) -> bool { + match e { + Expression::BinaryExpression(b) => b.operator.is_logical(), + Expression::AssignmentExpression(a) => is_logical_assign_op(a.operator), + _ => false, + } +} + +/// Whether an expression **threads** the enclosing condition targets into its +/// operands (vs being a value boundary that resets them). Mirrors the four +/// threading arms of `visit_expression`: `!`, `&&`/`||`/`??`, logical-assignment, +/// and parentheses — the same set tsgo's `isTopLevelLogicalExpression` +/// (binder.go:2782) ascends through. Every other expression is a value +/// sub-position (see the reset in `visit_expression`). +pub(super) fn is_condition_threading(e: &Expression<'_>) -> bool { + match e { + Expression::UnaryExpression(u) => u.operator == UnaryOperator::Bang, + Expression::ParenthesizedExpression(_) => true, + _ => is_logical_condition(e), + } +} + +/// Whether an assignment operator is a logical compound-assignment +/// (`||=`/`&&=`/`??=`) — `IsLogicalOrCoalescingAssignmentOperator`. +pub(super) fn is_logical_assign_op(op: AssignmentOperator) -> bool { + matches!( + op, + AssignmentOperator::LogicalOrAssign + | AssignmentOperator::LogicalAndAssign + | AssignmentOperator::NullishAssign + ) +} + +/// `isNarrowingExpression` (binder.go:2602) — the `createFlowCondition` gate. +/// Adapted to tsv's AST: comma / assignment are their own `SequenceExpression` / +/// `AssignmentExpression` nodes (tsc folds them into `BinaryExpression`), so their +/// `isNarrowingBinaryExpression` cases move here. +pub(super) fn is_narrowing_expression(expr: &Expression<'_>) -> bool { + use Expression as E; + match expr { + E::Identifier(_) | E::ThisExpression(_) => true, + E::MemberExpression(_) => contains_narrowable_reference(expr), + E::CallExpression(c) => { + c.arguments.iter().any(contains_narrowable_reference) + || matches!(c.callee, E::MemberExpression(m) + if !m.computed && contains_narrowable_reference(m.object)) + } + E::ParenthesizedExpression(p) => is_narrowing_expression(p.expression), + E::TSNonNullExpression(t) => is_narrowing_expression(t.expression), + E::UnaryExpression(u) + if u.operator == UnaryOperator::Typeof || u.operator == UnaryOperator::Bang => + { + is_narrowing_expression(u.argument) + } + E::BinaryExpression(b) => is_narrowing_binary_expression(b), + // The `isNarrowingBinaryExpression` assignment cases (`=`/`||=`/`&&=`/`??=` + // → containsNarrowableReference(left)); other compound assignments are not + // narrowing. + E::AssignmentExpression(a) => { + matches!( + a.operator, + AssignmentOperator::Assign + | AssignmentOperator::LogicalOrAssign + | AssignmentOperator::LogicalAndAssign + | AssignmentOperator::NullishAssign + ) && contains_narrowable_reference(a.left) + } + // The `isNarrowingBinaryExpression` comma case (`isNarrowingExpression` + // of the last operand). + E::SequenceExpression(s) => s.expressions.last().is_some_and(is_narrowing_expression), + _ => false, + } +} + +/// `containsNarrowableReference` (binder.go:2620) — a narrowable reference, or an +/// optional-chain node whose object/callee contains one. +fn contains_narrowable_reference(expr: &Expression<'_>) -> bool { + if is_narrowable_reference(expr) { + return true; + } + match expr { + Expression::MemberExpression(m) if expr.has_optional_in_chain() => { + contains_narrowable_reference(m.object) + } + Expression::CallExpression(c) if expr.has_optional_in_chain() => { + contains_narrowable_reference(c.callee) + } + Expression::TSNonNullExpression(n) if expr.has_optional_in_chain() => { + contains_narrowable_reference(n.expression) + } + _ => false, + } +} + +/// `isNarrowingBinaryExpression` (binder.go:2666) for tsv's `BinaryExpression` +/// (which never carries the comma / assignment operators — those are separate +/// nodes, handled in `is_narrowing_expression`). +fn is_narrowing_binary_expression(b: &BinaryExpression<'_>) -> bool { + use BinaryOperator as Op; + match b.operator { + Op::EqualsEquals | Op::BangEquals | Op::EqualsEqualsEquals | Op::BangEqualsEquals => { + let left = skip_parens(b.left); + let right = skip_parens(b.right); + is_narrowable_operand(left) + || is_narrowable_operand(right) + || is_narrowing_typeof_operands(right, left) + || is_narrowing_typeof_operands(left, right) + || (is_boolean_literal(right) && is_narrowing_expression(left)) + || (is_boolean_literal(left) && is_narrowing_expression(right)) + } + Op::Instanceof => is_narrowable_operand(b.left), + Op::In => is_narrowing_expression(b.right), + _ => false, + } +} + +/// `isNarrowableOperand` (binder.go:2686). +fn is_narrowable_operand(expr: &Expression<'_>) -> bool { + match expr { + Expression::ParenthesizedExpression(p) => is_narrowable_operand(p.expression), + Expression::AssignmentExpression(a) if a.operator == AssignmentOperator::Assign => { + is_narrowable_operand(a.left) + } + Expression::SequenceExpression(s) => { + s.expressions.last().is_some_and(is_narrowable_operand) + } + _ => contains_narrowable_reference(expr), + } +} + +/// `isNarrowingTypeOfOperands` (binder.go:2702) — `typeof === `. +fn is_narrowing_typeof_operands(expr1: &Expression<'_>, expr2: &Expression<'_>) -> bool { + matches!(expr1, Expression::UnaryExpression(u) + if u.operator == UnaryOperator::Typeof && is_narrowable_operand(u.argument)) + && is_string_literal_like(expr2) +} + +/// `IsStringLiteralLike` — a string literal or a no-substitution template. +fn is_string_literal_like(e: &Expression<'_>) -> bool { + match e { + Expression::Literal(l) => matches!(l.value, LiteralValue::String(_)), + Expression::TemplateLiteral(t) => t.expressions.is_empty(), + _ => false, + } +} + +/// `IsBooleanLiteral` — a `true` / `false` keyword literal. +fn is_boolean_literal(e: &Expression<'_>) -> bool { + matches!(e, Expression::Literal(l) if matches!(l.value, LiteralValue::Boolean(_))) +} + +/// `SkipParentheses` — strip grouping `ParenthesizedExpression` wrappers (rare in +/// tsv, which discards grouping parens except under `preserve_parens`). +fn skip_parens<'a, 'arena>(e: &'a Expression<'arena>) -> &'a Expression<'arena> { + let mut e = e; + while let Expression::ParenthesizedExpression(p) = e { + e = p.expression; + } + e +} diff --git a/crates/tsv_check/src/binder/flow/build/statements.rs b/crates/tsv_check/src/binder/flow/build/statements.rs new file mode 100644 index 000000000..5982f22e6 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/build/statements.rs @@ -0,0 +1,933 @@ +//! The statement-shaped flow visitors — `visit_statement` and the per-statement +//! flow shapers (conditions, loops, switch, try/finally, labeled statements), +//! plus the declaration-container descents. Contributes its own +//! `impl FlowBuilder` block; the struct and traversal driver live in the parent +//! module. Purely a locality split — no behavior distinction. + +use super::super::*; +use super::predicates::*; +use super::{ActiveLabelEntry, FlowBuilder}; +use crate::binder::{NodeKind, addr_of, statement_kind}; +use smallvec::SmallVec; +use tsv_ts::ast::internal::{ + BreakStatement, ClassDeclaration, ClassMember, ContinueStatement, Decorator, DoWhileStatement, + Expression, ForInOfLeft, ForInit, ForStatement, FunctionDeclaration, Identifier, IfStatement, + LabeledStatement, MethodDefinition, MethodKind, ObjectPatternProperty, Statement, SwitchCase, + SwitchStatement, TSModuleDeclarationBody, TryStatement, VariableDeclarator, WhileStatement, +}; + +impl<'a> FlowBuilder<'a> { + // --- statements ------------------------------------------------------- + + pub(super) fn visit_statement(&mut self, stmt: &Statement<'_>) { + let id = self.require(addr_of(stmt), statement_kind(stmt)); + if self.current_unreachable() { + // bindChildren dead path (binder.go:1651): the non-leaf statement's + // flow attachment is nil (already `None`); mark potentially- + // executable nodes; then descend generically (no flow shaping). + if is_potentially_executable(stmt) { + self.node_flags[id.index()] |= crate::binder::NODE_FLAGS_UNREACHABLE; + } + self.descend_children_generic(stmt); + return; + } + // Reachable: statement-range nodes capture the entry flow before the + // construct dispatches (binder.go:1663). + if is_statement_range(stmt) { + self.flow_of_node[id.index()] = Some(self.current_flow); + } + match stmt { + Statement::ExpressionStatement(s) => { + self.visit_expression(&s.expression); + self.maybe_bind_expression_flow_if_call(&s.expression); + } + Statement::VariableDeclaration(d) => { + for decl in d.declarations { + self.bind_variable_declaration_flow(decl); + } + } + Statement::ReturnStatement(s) => { + // `bindReturnStatement` (binder.go:1939). + if let Some(a) = &s.argument { + self.visit_expression(a); + } + if let Some(rt) = self.current_return_target { + self.add_antecedent(rt, self.current_flow); + } + self.current_flow = self.unreachable_flow; + self.has_explicit_return = true; + self.has_flow_effects = true; + } + Statement::ThrowStatement(s) => { + // `bindThrowStatement` (binder.go:1949). + self.visit_expression(&s.argument); + self.current_flow = self.unreachable_flow; + self.has_flow_effects = true; + } + // --- F1b: branching control-flow topology --------------------- + Statement::IfStatement(s) => self.bind_if_statement(s), + Statement::WhileStatement(s) => self.bind_while_statement(id, s), + Statement::DoWhileStatement(s) => self.bind_do_statement(id, s), + Statement::ForStatement(s) => self.bind_for_statement(id, s), + Statement::ForInStatement(s) => { + self.bind_for_in_or_of(id, &s.left, &s.right, s.body); + } + Statement::ForOfStatement(s) => { + self.bind_for_in_or_of(id, &s.left, &s.right, s.body); + } + Statement::BreakStatement(s) => self.bind_break_statement(s), + Statement::ContinueStatement(s) => self.bind_continue_statement(s), + Statement::SwitchStatement(s) => self.bind_switch_statement(id, s), + Statement::TryStatement(s) => self.bind_try_statement(s), + Statement::LabeledStatement(s) => self.bind_labeled_statement(s), + // Everything else (declarations, blocks, exports, modules) threads + // flow linearly through its children. + _ => self.descend_children_generic(stmt), + } + } + + /// Descend a statement's value children threading `current_flow` linearly, + /// with **no** flow shaping — the `bindEachChild` analog. Used by the + /// **dead-code path** (where linear descent is correct — nothing is + /// reachable) for every statement kind, and by the reachable `_` arm for the + /// kinds without their own shaper (declarations, blocks, and the F2 + /// sequential placeholders — labeled / try / exports / modules). The + /// branching arms below (`if` / the loops / `switch` / `break` / `continue`) + /// are therefore reached **only in dead code**; the reachable topology lives + /// in `visit_statement`. Containers nested here still open their own `Start` + /// regions, so a function body stays reachable even in dead code. + fn descend_children_generic(&mut self, stmt: &Statement<'_>) { + match stmt { + Statement::ExpressionStatement(s) => self.visit_expression(&s.expression), + Statement::VariableDeclaration(d) => { + for decl in d.declarations { + self.visit_expression(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + } + } + Statement::FunctionDeclaration(f) => { + let id = self.require(addr_of(stmt), statement_kind(stmt)); + self.visit_function_declaration(f, id); + } + Statement::ClassDeclaration(c) => self.visit_class_decl(c), + Statement::ReturnStatement(s) => { + if let Some(a) = &s.argument { + self.visit_expression(a); + } + } + Statement::ThrowStatement(s) => self.visit_expression(&s.argument), + Statement::BlockStatement(b) => self.visit_statement_list(b.body), + // --- dead-path linear descent for the branching kinds (their real + // topology lives in `visit_statement`; reached only when dead) --- + Statement::IfStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.consequent); + if let Some(alt) = s.alternate { + self.visit_statement(alt); + } + } + Statement::ForStatement(s) => { + match &s.init { + Some(ForInit::VariableDeclaration(d)) => { + for decl in d.declarations { + self.visit_expression(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + } + } + Some(ForInit::Expression(e)) => self.visit_expression(e), + None => {} + } + if let Some(t) = &s.test { + self.visit_expression(t); + } + if let Some(u) = &s.update { + self.visit_expression(u); + } + self.visit_statement(s.body); + } + Statement::ForInStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); + } + Statement::ForOfStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); + } + Statement::WhileStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.body); + } + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body); + self.visit_expression(&s.test); + } + Statement::SwitchStatement(s) => { + self.visit_expression(&s.discriminant); + for case in s.cases { + if let Some(t) = &case.test { + self.visit_expression(t); + } + self.visit_statement_list(case.consequent); + } + } + Statement::TryStatement(s) => { + self.visit_statement_list(s.block.body); + if let Some(handler) = &s.handler { + if let Some(param) = &handler.param { + self.visit_expression(param); + } + self.visit_statement_list(handler.body.body); + } + if let Some(finalizer) = &s.finalizer { + self.visit_statement_list(finalizer.body); + } + } + Statement::LabeledStatement(s) => { + // Dead-path fallback; the reachable topology lives in + // `bind_labeled_statement`. + self.visit_identifier(&s.label); + self.visit_statement(s.body); + } + Statement::BreakStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label); + } + } + Statement::ContinueStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label); + } + } + Statement::ExportNamedDeclaration(e) => { + if let Some(inner) = e.declaration { + self.visit_statement(inner); + } + // export specifiers / source are non-value (skipped). + } + Statement::ExportDefaultDeclaration(e) => self.visit_export_default(e), + Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression), + Statement::TSModuleDeclaration(m) => self.visit_module(m), + // No value content (types / imports / enum bodies / empty): skipped, + // per the "types are not descended" scope note. See module docs. + Statement::TSTypeAliasDeclaration(_) + | Statement::TSInterfaceDeclaration(_) + | Statement::TSDeclareFunction(_) + | Statement::TSEnumDeclaration(_) + | Statement::ImportDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) + | Statement::ExportAllDeclaration(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) => {} + } + } + + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) { + use ForInOfLeft as L; + match left { + L::VariableDeclaration(d) => { + for decl in d.declarations { + self.visit_expression(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + } + } + L::Pattern(e) => self.visit_expression(e), + } + } + + // --- statement flow shapers ------------------------------------------- + + /// `bindVariableDeclarationFlow` + `bindInitializedVariableFlow` + /// (binder.go:2314) — a `var/let/const x = e` with an initializer emits one + /// unconditional `Assignment`. The name binds as a **binding target** + /// (`bind_binding_target`), so a destructuring default (`let {a = e} = …`) + /// forks per `bindInitializer`. A destructuring pattern emits one + /// `Assignment` per declarator (tsv has no binding-element node — see the + /// module scope note). + fn bind_variable_declaration_flow(&mut self, decl: &VariableDeclarator<'_>) { + self.bind_binding_target(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + if decl.init.is_some() { + let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator); + self.current_flow = + self.create_flow_mutation(FlowFlags::ASSIGNMENT, self.current_flow, decl_id); + } + } + + // --- branching statement flow shapers --------------------------------- + + /// `bindIfStatement` (binder.go:1924) — then/else branch labels merge at + /// `postIf`; each branch binds against the condition-split flow. + fn bind_if_statement(&mut self, s: &IfStatement<'_>) { + let then_label = self.create_branch_label(); + let else_label = self.create_branch_label(); + let post_if = self.create_branch_label(); + self.bind_condition(Some(&s.test), then_label, else_label, false); + self.current_flow = self.finish_flow_label(then_label); + self.visit_statement(s.consequent); + self.add_antecedent(post_if, self.current_flow); + self.current_flow = self.finish_flow_label(else_label); + if let Some(alt) = s.alternate { + self.visit_statement(alt); + } + self.add_antecedent(post_if, self.current_flow); + self.current_flow = self.finish_flow_label(post_if); + } + + /// `bindWhileStatement` (binder.go:1857) — the entry edge is added to the + /// loop label **before** it becomes `current_flow`; the back edge **after** + /// the body. + fn bind_while_statement(&mut self, stmt_id: NodeId, s: &WhileStatement<'_>) { + let loop_label = self.create_loop_label(); + let pre_while = self.set_continue_target(stmt_id, loop_label); + let pre_body = self.create_branch_label(); + let post_while = self.create_branch_label(); + self.add_antecedent(pre_while, self.current_flow); // entry edge (first) + self.current_flow = pre_while; + self.bind_condition(Some(&s.test), pre_body, post_while, false); + self.current_flow = self.finish_flow_label(pre_body); + self.bind_iterative_statement(s.body, post_while, pre_while); + self.add_antecedent(pre_while, self.current_flow); // back edge (after) + self.current_flow = self.finish_flow_label(post_while); + } + + /// `bindDoStatement` (binder.go:1871) — the body runs from the loop label + /// first; the continue target is a **pre-condition** branch label (not the + /// loop label), and the condition loops back to the loop label. + fn bind_do_statement(&mut self, stmt_id: NodeId, s: &DoWhileStatement<'_>) { + let pre_do = self.create_loop_label(); + let condition_label = self.create_branch_label(); + let pre_condition = self.set_continue_target(stmt_id, condition_label); + let post_do = self.create_branch_label(); + self.add_antecedent(pre_do, self.current_flow); + self.current_flow = pre_do; + self.bind_iterative_statement(s.body, post_do, pre_condition); + self.add_antecedent(pre_condition, self.current_flow); + self.current_flow = self.finish_flow_label(pre_condition); + self.bind_condition(Some(&s.test), pre_do, post_do, false); + self.current_flow = self.finish_flow_label(post_do); + } + + /// `bindForStatement` (binder.go:1885) — init → loop label → condition → + /// body (continue = the increment label) → incrementor → back edge. + fn bind_for_statement(&mut self, stmt_id: NodeId, s: &ForStatement<'_>) { + let loop_label = self.create_loop_label(); + let pre_loop = self.set_continue_target(stmt_id, loop_label); + let pre_body = self.create_branch_label(); + let pre_increment = self.create_branch_label(); + let post_loop = self.create_branch_label(); + match &s.init { + Some(ForInit::VariableDeclaration(d)) => { + for decl in d.declarations { + self.bind_variable_declaration_flow(decl); + } + } + Some(ForInit::Expression(e)) => self.visit_expression(e), + None => {} + } + self.add_antecedent(pre_loop, self.current_flow); + self.current_flow = pre_loop; + // A nil condition is a true passthrough / false-unreachable, handled by + // `create_flow_condition`'s nil-expression arm. + self.bind_condition(s.test.as_ref(), pre_body, post_loop, false); + self.current_flow = self.finish_flow_label(pre_body); + self.bind_iterative_statement(s.body, post_loop, pre_increment); + self.add_antecedent(pre_increment, self.current_flow); + self.current_flow = self.finish_flow_label(pre_increment); + if let Some(u) = &s.update { + self.visit_expression(u); + } + self.add_antecedent(pre_loop, self.current_flow); // back edge + self.current_flow = self.finish_flow_label(post_loop); + } + + /// `bindForInOrOfStatement` (binder.go:1904). The exit edge is + /// **unconditional** (a for-in/of can exit after zero iterations); continue + /// targets the loop label. Shared by `for-in` and `for-of` (the for-of + /// `await` modifier is a `bool` in tsv — no node to bind, no fork). + fn bind_for_in_or_of( + &mut self, + stmt_id: NodeId, + left: &ForInOfLeft<'_>, + right: &Expression<'_>, + body: &Statement<'_>, + ) { + let loop_label = self.create_loop_label(); + let pre_loop = self.set_continue_target(stmt_id, loop_label); + let post_loop = self.create_branch_label(); + self.visit_expression(right); + self.add_antecedent(pre_loop, self.current_flow); + self.current_flow = pre_loop; + self.add_antecedent(post_loop, self.current_flow); // unconditional exit + // Bind the initializer (binder.go:1915-1918). A declaration-list variable + // is assigned each iteration (`bindVariableDeclarationFlow`'s for-in/of + // guard, binder.go:2316 — the `Assignment` mutation even with no + // initializer); a pattern initializer runs `bindAssignmentTargetFlow`. + match left { + ForInOfLeft::VariableDeclaration(d) => { + for decl in d.declarations { + self.bind_binding_target(&decl.id); + if let Some(init) = &decl.init { + self.visit_expression(init); + } + let decl_id = self.require(addr_of(decl), NodeKind::VariableDeclarator); + self.current_flow = self.create_flow_mutation( + FlowFlags::ASSIGNMENT, + self.current_flow, + decl_id, + ); + } + } + ForInOfLeft::Pattern(p) => { + self.visit_expression(p); + self.bind_assignment_target_flow(p); + } + } + self.bind_iterative_statement(body, post_loop, pre_loop); + self.add_antecedent(pre_loop, self.current_flow); // back edge + self.current_flow = self.finish_flow_label(post_loop); + } + + /// `setContinueTarget` (binder.go:1779) — walk the parent chain up from a + /// loop while each parent is a `LabeledStatement`, assigning that label's + /// continue target (so `continue L` on a labeled loop lands on the loop's + /// continue point), in lockstep with the active-label stack from its top. No + /// enclosing labeled statements → a no-op returning `target` unchanged. + fn set_continue_target(&mut self, loop_node: NodeId, target: FlowNodeId) -> FlowNodeId { + let mut node = loop_node; + let mut i = self.active_label_list.len(); + loop { + let Some(parent) = self.bound.parents[node.index()] else { + break; + }; + if self.bound.kinds[parent.index()] != NodeKind::LabeledStatement || i == 0 { + break; + } + i -= 1; + self.active_label_list[i].continue_target = Some(target); + node = parent; + } + target + } + + /// `bindIterativeStatement` (binder.go:1807) — bind a loop body with its + /// break/continue targets installed, restored on exit. + fn bind_iterative_statement( + &mut self, + body: &Statement<'_>, + break_target: FlowNodeId, + continue_target: FlowNodeId, + ) { + let save_break = self.current_break_target; + let save_continue = self.current_continue_target; + self.current_break_target = Some(break_target); + self.current_continue_target = Some(continue_target); + self.visit_statement(body); + self.current_break_target = save_break; + self.current_continue_target = save_continue; + } + + /// `bindBreakStatement` (binder.go:1955) — a labeled `break L` resolves to + /// `L`'s **break** target (`findActiveLabel`, marking it referenced); an + /// unlabeled `break` uses `current_break_target`. An unresolved label is a + /// no-op (deferred diagnostic). + fn bind_break_statement(&mut self, s: &BreakStatement<'_>) { + match &s.label { + None => { + let target = self.current_break_target; + self.bind_break_or_continue_flow(target); + } + Some(label) => { + self.visit_identifier(label); + let name = self.label_text(label); + if let Some(i) = self.find_active_label(name) { + self.active_label_list[i].referenced = true; + let target = Some(self.active_label_list[i].break_target); + self.bind_break_or_continue_flow(target); + } + } + } + } + + /// `bindContinueStatement` (binder.go:1959) — a labeled `continue L` resolves + /// to `L`'s **continue** target; an unlabeled `continue` uses + /// `current_continue_target`. A missing/`None` target is a no-op. + fn bind_continue_statement(&mut self, s: &ContinueStatement<'_>) { + match &s.label { + None => { + let target = self.current_continue_target; + self.bind_break_or_continue_flow(target); + } + Some(label) => { + self.visit_identifier(label); + let name = self.label_text(label); + if let Some(i) = self.find_active_label(name) { + self.active_label_list[i].referenced = true; + let target = self.active_label_list[i].continue_target; + self.bind_break_or_continue_flow(target); + } + } + } + } + + /// `bindBreakOrContinueFlow` (binder.go:1985) — route to the target and go + /// unreachable; a `None` target (break/continue outside any loop/switch) is a + /// no-op (the parser accepts it; the illegal-jump diagnostic is F3+). + fn bind_break_or_continue_flow(&mut self, target: Option) { + if let Some(t) = target { + self.add_antecedent(t, self.current_flow); + self.current_flow = self.unreachable_flow; + self.has_flow_effects = true; + } + } + + /// `bindSwitchStatement` (binder.go:2074) — a `switch` with a **local** + /// post-switch break target (so a contained `break` resolves here, not at an + /// enclosing loop) and the real clause topology (`bind_case_block`). When no + /// clause is a `default`, an **unconditional** `(0, 0)` `SwitchClause` + /// exhaustiveness sentinel — "no clause matched" — feeds the post-switch + /// label alongside the case-block exit. `preSwitchCaseFlow` is captured + /// **after** the discriminant is bound (the flow every clause forks from) and + /// saved/restored here, as in tsgo (it is not in the container save set). + fn bind_switch_statement(&mut self, switch_id: NodeId, s: &SwitchStatement<'_>) { + let post_switch = self.create_branch_label(); + self.visit_expression(&s.discriminant); + let save_break = self.current_break_target; + let save_pre_switch = self.pre_switch_case_flow; + self.current_break_target = Some(post_switch); + self.pre_switch_case_flow = Some(self.current_flow); + self.bind_case_block(switch_id, s); + self.add_antecedent(post_switch, self.current_flow); + let has_default = s.cases.iter().any(|c| c.test.is_none()); + if !has_default { + // The "no clause matched" fall-off: reachable from the switch head + // regardless of narrowing (an empty `(0, 0)` range is the sentinel). + let pre_switch = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow); + let sentinel = self.create_flow_switch_clause(pre_switch, switch_id, 0, 0); + self.add_antecedent(post_switch, sentinel); + } + self.current_break_target = save_break; + self.pre_switch_case_flow = save_pre_switch; + self.current_flow = self.finish_flow_label(post_switch); + } + + /// `bindCaseBlock` (binder.go:2095) — thread the clauses. Each clause's + /// `preCase` label is fed **from the switch head** (`preSwitchCaseFlow`, + /// unconditionally — a narrowing switch wraps it in a per-clause + /// `SwitchClause` node) plus the prior clause's fallthrough edge, so a clause + /// reached only after a prior `break`/`return` stays reachable (the F2a + /// reachability fix). An empty-clause run (`case a: case b:` with no + /// statements) re-points to the head only when nothing live falls into it. + fn bind_case_block(&mut self, switch_id: NodeId, s: &SwitchStatement<'_>) { + let clauses = s.cases; + let is_narrowing_switch = + is_true_keyword(&s.discriminant) || is_narrowing_expression(&s.discriminant); + let last = clauses.len().wrapping_sub(1); + let mut fallthrough_flow = self.unreachable_flow; + let mut i = 0; + while i < clauses.len() { + let clause_start = i as u32; + // Empty-clause run: advance past clauses with no statements (bar the + // last), re-pointing to the head only when nothing live falls in. + while clauses[i].consequent.is_empty() && i + 1 < clauses.len() { + if fallthrough_flow == self.unreachable_flow { + self.current_flow = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow); + } + self.bind_case_or_default_clause(&clauses[i]); + i += 1; + } + let pre_case = self.create_branch_label(); + let pre_switch = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow); + let pre_case_flow = if is_narrowing_switch { + self.create_flow_switch_clause(pre_switch, switch_id, clause_start, i as u32 + 1) + } else { + pre_switch + }; + self.add_antecedent(pre_case, pre_case_flow); // head edge (reachability fix) + self.add_antecedent(pre_case, fallthrough_flow); // fallthrough (unreachable = no-op) + self.current_flow = self.finish_flow_label(pre_case); + self.bind_case_or_default_clause(&clauses[i]); + fallthrough_flow = self.current_flow; + if !self.current_unreachable() && i != last { + let clause_id = self.require(addr_of(&clauses[i]), NodeKind::SwitchCase); + self.fallthrough_flow.push((clause_id, self.current_flow)); + } + i += 1; + } + } + + /// `bindCaseOrDefaultClause` (binder.go:2126) — the clause's test expression + /// binds under the switch head (`preSwitchCaseFlow`, saved/restored), its + /// statements under the current (post-`preCase`) flow. + fn bind_case_or_default_clause(&mut self, case: &SwitchCase<'_>) { + if let Some(test) = &case.test { + let saved = self.current_flow; + self.current_flow = self.pre_switch_case_flow.unwrap_or(self.unreachable_flow); + self.visit_expression(test); + self.current_flow = saved; + } + self.visit_statement_list(case.consequent); + } + + // --- try / catch / finally -------------------------------------------- + + /// A snapshot of a label's pending antecedent list (`label.Antecedents`) — + /// the try/finally combine reads three of these directly (the pointer-free + /// `combineFlowLists` analog). + fn scratch_snapshot(&self, label: FlowNodeId) -> SmallVec<[FlowNodeId; 4]> { + self.label_scratch.get(&label).cloned().unwrap_or_default() + } + + /// `bindTryStatement` (binder.go:1993). Three fresh labels — `normalExit`, + /// `returnLabel`, `exceptionLabel` — thread the "any instruction can throw" + /// edge (`exceptionLabel` seeded from `current_flow` **before** the try + /// block, `current_exception_target` repointed so `create_flow_mutation`'s + /// fan-out comes alive). A catch is bound as a **second try** (a fresh + /// `exceptionLabel` seeded from the first one's finish). With a finally, the + /// finally label's antecedents = `normal ++ exception ++ return` + /// (`combineFlowLists`), it becomes `current_flow`, and up to three + /// `ReduceLabel`s route the finally's completion back through the return / + /// outer-exception / normal-exit subsets (binder.go:2052-2067). + fn bind_try_statement(&mut self, s: &TryStatement<'_>) { + let save_return_target = self.current_return_target; + let save_exception_target = self.current_exception_target; + let normal_exit = self.create_branch_label(); + let return_label = self.create_branch_label(); + let mut exception_label = self.create_branch_label(); + if s.finalizer.is_some() { + self.current_return_target = Some(return_label); + } + // The exception edge for exceptions before any mutation. + self.add_antecedent(exception_label, self.current_flow); + self.current_exception_target = Some(exception_label); + self.visit_statement_list(s.block.body); + self.add_antecedent(normal_exit, self.current_flow); + if let Some(handler) = &s.handler { + // The catch is the target of exceptions from the try block; its own + // exceptions flow to a fresh label (catch = a second try). + self.current_flow = self.finish_flow_label(exception_label); + exception_label = self.create_branch_label(); + self.add_antecedent(exception_label, self.current_flow); + self.current_exception_target = Some(exception_label); + if let Some(param) = &handler.param { + // The catch variable is a binding position (tsgo reaches it via + // bindBindingElementFlow → bindInitializer), so a flow-changing + // destructuring default forks — bind_binding_target, not the plain + // value walk. Equivalent for a non-defaulted param. + self.bind_binding_target(param); + } + self.visit_statement_list(handler.body.body); + self.add_antecedent(normal_exit, self.current_flow); + } + // Restore BEFORE the finally — the finally isn't inside its own try. + self.current_return_target = save_return_target; + self.current_exception_target = save_exception_target; + if let Some(finalizer) = &s.finalizer { + let normal_list = self.scratch_snapshot(normal_exit); + let exception_list = self.scratch_snapshot(exception_label); + let return_list = self.scratch_snapshot(return_label); + let finally_label = self.create_branch_label(); + // finallyLabel.Antecedents = normal ++ exception ++ return + // (combineFlowLists, no dedup — faithful to binder.go:2043). + let mut combined: SmallVec<[FlowNodeId; 4]> = SmallVec::new(); + combined.extend(normal_list.iter().copied()); + combined.extend(exception_list.iter().copied()); + combined.extend(return_list.iter().copied()); + self.label_scratch.insert(finally_label, combined); + self.current_flow = finally_label; + self.visit_statement_list(finalizer.body); + if self.current_unreachable() { + // An unreachable end-of-finally makes the whole try unreachable. + self.current_flow = self.unreachable_flow; + } else { + // Route the finally's completion back through the return-only + // subset (IIFE/constructor return target), then the outer + // exception-only subset, then continue via the normal subset. + if let Some(rt) = self.current_return_target + && !return_list.is_empty() + { + let rl = + self.create_reduce_label(finally_label, &return_list, self.current_flow); + self.add_antecedent(rt, rl); + } + if let Some(et) = self.current_exception_target + && !exception_list.is_empty() + { + let el = + self.create_reduce_label(finally_label, &exception_list, self.current_flow); + self.add_antecedent(et, el); + } + if normal_list.is_empty() { + self.current_flow = self.unreachable_flow; + } else { + self.current_flow = + self.create_reduce_label(finally_label, &normal_list, self.current_flow); + } + } + } else { + self.current_flow = self.finish_flow_label(normal_exit); + } + } + + // --- labeled statements ----------------------------------------------- + + /// `bindLabeledStatement` (binder.go:2153). Push an active-label entry + /// (break target = `postStatementLabel`, continue target set later by a + /// directly-enclosed loop's `set_continue_target`), bind the label + body, + /// then pop; an **unreferenced** label gets the `Unreachable` stamp on its + /// identifier (the TS7028 signal, binder.go:2167). The post label merges the + /// body's exit. + fn bind_labeled_statement(&mut self, s: &LabeledStatement<'_>) { + let post = self.create_branch_label(); + let label_id = self.require(addr_of(&s.label), NodeKind::Identifier); + self.active_label_list.push(ActiveLabelEntry { + break_target: post, + continue_target: None, + referenced: false, + label_node_id: label_id, + }); + self.visit_identifier(&s.label); + self.visit_statement(s.body); + // Balanced with the push above (pop is always `Some`). An unreferenced + // label's identifier gets the `Unreachable` stamp (the TS7028 signal). + if let Some(entry) = self.active_label_list.pop() + && !entry.referenced + { + self.node_flags[entry.label_node_id.index()] |= crate::binder::NODE_FLAGS_UNREACHABLE; + } + self.add_antecedent(post, self.current_flow); + self.current_flow = self.finish_flow_label(post); + } + + /// `findActiveLabel` (binder.go:1976) — innermost-first (the stack top is the + /// last element, so scan from the end). Returns the stack index. + fn find_active_label(&self, name: &str) -> Option { + self.active_label_list + .iter() + .rposition(|e| self.bound.spans[e.label_node_id.index()].extract(self.source) == name) + } + + /// The source text of a label identifier (the break/continue label name). + fn label_text(&self, ident: &Identifier<'_>) -> &'a str { + let id = self.require(addr_of(ident), NodeKind::Identifier); + self.bound.spans[id.index()].extract(self.source) + } + + // --- containers ------------------------------------------------------- + + fn visit_function_declaration(&mut self, f: &FunctionDeclaration<'_>, anchor: NodeId) { + let saved = self.enter_container(None, false, false); + self.bind_params(f.params); + self.visit_statement_list(f.body.body); + self.exit_container(saved, false, true, true, anchor, false); + } + + pub(super) fn bind_params(&mut self, params: &[Expression<'_>]) { + for param in params { + self.bind_binding_target(param); + } + } + + /// `bindInitializer` (binder.go:2474) — bind a parameter / binding-element + /// **default** and fork `current_flow` around it, but **only** when binding + /// the default actually changed the flow (a `BindingElement`/`Parameter` has + /// no side effects when its initializer isn't evaluated — GH#49759). The + /// entry/exit pointer-equality guard is exact: a literal default (`= 1`) + /// leaves `current_flow` untouched and mints no label. + fn bind_initializer(&mut self, initializer: &Expression<'_>) { + let entry = self.current_flow; + self.visit_expression(initializer); + if entry == self.unreachable_flow || entry == self.current_flow { + return; + } + let exit = self.create_branch_label(); + self.add_antecedent(exit, entry); + self.add_antecedent(exit, self.current_flow); + self.current_flow = self.finish_flow_label(exit); + } + + /// Bind a **binding target** (declaration / parameter position): + /// `bindParameterFlow` / `bindBindingElementFlow` (binder.go:2463/2450). A + /// defaulted element's initializer is bound **before** the name (TC39 order, + /// via `bind_initializer`, which forks only when the default changed the + /// flow). Distinct from the value traversal (`visit_expression`) so the + /// assignment-target destructuring recursion — a separate deferred item — + /// stays untouched; for a non-defaulted target the two are equivalent. + fn bind_binding_target(&mut self, node: &Expression<'_>) { + use Expression as E; + match node { + E::AssignmentPattern(a) => { + self.visit_decorators(a.decorators); + self.bind_initializer(a.right); + self.bind_binding_target(a.left); + } + E::ObjectPattern(op) => { + self.visit_decorators(op.decorators); + for prop in op.properties { + match prop { + ObjectPatternProperty::Property(pr) => { + self.visit_expression(&pr.key); + self.bind_binding_target(&pr.value); + } + ObjectPatternProperty::RestElement(r) => { + self.bind_binding_target(r.argument); + } + } + } + } + E::ArrayPattern(ap) => { + self.visit_decorators(ap.decorators); + for el in ap.elements.iter().flatten() { + self.bind_binding_target(el); + } + } + E::RestElement(r) => self.bind_binding_target(r.argument), + E::TSParameterProperty(pp) => self.bind_binding_target(pp.parameter), + // A plain identifier / other leaf binding: the ordinary traversal. + _ => self.visit_expression(node), + } + } + + fn visit_class_decl(&mut self, c: &ClassDeclaration<'_>) { + self.visit_class_common( + c.id.as_ref(), + c.decorators, + c.super_class, + c.body.body, + false, + ); + } + + /// The value-flow class descent shared by the declaration and expression forms + /// (distinct types with the same field shape): the name binding, decorators, and + /// the `extends` expression, then each member. Type positions (type params / + /// super type args / `implements`) are skipped. `is_class_expression` threads + /// the parent-kind half of tsgo's + /// `IsObjectLiteralOrClassExpressionMethodOrAccessor` gate (utilities.go:566) + /// down to `visit_method` — tsv expressions carry no parent pointer. + pub(super) fn visit_class_common( + &mut self, + name: Option<&Identifier<'_>>, + decorators: Option<&[Decorator<'_>]>, + super_class: Option<&Expression<'_>>, + members: &[ClassMember<'_>], + is_class_expression: bool, + ) { + if let Some(name) = name { + self.visit_identifier(name); + } + self.visit_decorators(decorators); + if let Some(sc) = super_class { + self.visit_expression(sc); + } + for member in members { + self.visit_class_member(member, is_class_expression); + } + } + + fn visit_class_member(&mut self, member: &ClassMember<'_>, is_class_expression: bool) { + match member { + ClassMember::MethodDefinition(m) => self.visit_method(m, is_class_expression), + ClassMember::PropertyDefinition(p) => { + self.visit_decorators(p.decorators); + self.visit_expression(&p.key); + // property type annotation is a type position (skip). + if let Some(value) = &p.value { + // A property-with-initializer is a control-flow container + // (binder.go:2584): fresh Start around the initializer. + let p_id = self.require(addr_of(p), NodeKind::PropertyDefinition); + let saved = self.enter_container(None, false, false); + self.visit_expression(value); + self.exit_container(saved, false, false, false, p_id, false); + } + } + ClassMember::StaticBlock(s) => { + // A class static block is flow-transparent (binder.go:1525-1528) + // with its own return target; `return_flow` anchors on it. + let s_id = self.require(addr_of(s), NodeKind::StaticBlock); + let saved = self.enter_container(None, true, true); + self.visit_statement_list(s.body); + self.exit_container(saved, true, true, true, s_id, true); + } + // index signatures are type-only (skip). + ClassMember::IndexSignature(_) => {} + } + } + + fn visit_method(&mut self, m: &MethodDefinition<'_>, is_class_expression: bool) { + self.visit_decorators(m.decorators); + let is_ctor = m.kind == MethodKind::Constructor; + self.visit_expression(&m.key); + // The method body lives in `value` (a FunctionExpression); the method is + // a control-flow container anchored on that FunctionExpression — the + // body-bearing node (tsv wraps a method body in a FunctionExpression, + // where tsc's method node holds the body directly). The `MethodDefinition` + // and its inline `value` share an address (a repr reorder puts `value` at + // offset 0), so the address map keys on `(address, NodeKind)`; anchoring + // here resolves the FunctionExpression id via its kind, and the method + // itself resolves separately by `NodeKind::MethodDefinition`. + let anchor = self.require(addr_of(&m.value), NodeKind::FunctionExpression); + // A **class-expression** method/accessor (never a constructor, never a + // class-declaration member) gets the outer-flow write on the METHOD node + // (bindPropertyOrMethodOrAccessor, binder.go:981) and becomes the body + // Start's subject (binder.go:1534) — the P3 narrowing hint + // (`IsObjectLiteralOrClassExpressionMethodOrAccessor`, utilities.go:566; + // the object-literal half lives in `visit_object_expr_property`). + let start_subject = if is_class_expression && !is_ctor { + let method_id = self.require(addr_of(m), NodeKind::MethodDefinition); + self.set_flow_leaf(method_id); + Some(method_id) + } else { + None + }; + let saved = self.enter_container(start_subject, false, is_ctor); + self.bind_params(m.value.params); + self.visit_statement_list(m.value.body.body); + self.exit_container(saved, false, true, true, anchor, is_ctor); + } + + fn visit_module(&mut self, m: &tsv_ts::ast::internal::TSModuleDeclaration<'_>) { + use tsv_ts::ast::internal::TSModuleName; + if let TSModuleName::Identifier(name) = &m.id { + self.visit_identifier(name); + } + match &m.body { + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => { + // A ModuleBlock is a control-flow container (binder.go:2582) — + // fresh Start, no return target, not function-like. + let block_id = self.require(addr_of(block), NodeKind::TSModuleBlock); + let saved = self.enter_container(None, false, false); + self.visit_statement_list(block.body); + self.exit_container(saved, false, false, false, block_id, false); + } + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => { + self.visit_module(nested); + } + None => {} + } + } + + fn visit_export_default(&mut self, e: &tsv_ts::ast::internal::ExportDefaultDeclaration<'_>) { + use tsv_ts::ast::internal::ExportDefaultValue as V; + match &e.declaration { + V::Expression(expr) => self.visit_expression(expr), + V::FunctionDeclaration(f) => { + let id = self.require(addr_of(f), NodeKind::FunctionDeclaration); + self.visit_function_declaration(f, id); + } + V::ClassDeclaration(c) => self.visit_class_decl(c), + // A declare function / interface has no value body (skip). + V::TSDeclareFunction(_) | V::TSInterfaceDeclaration(_) => {} + } + } +} diff --git a/crates/tsv_check/src/binder/flow/flags.rs b/crates/tsv_check/src/binder/flow/flags.rs new file mode 100644 index 000000000..41f446806 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/flags.rs @@ -0,0 +1,73 @@ +// --- FlowFlags ------------------------------------------------------------- + +/// The flow-node flag bits — a `u16` newtype over tsgo's 13 `FlowFlags` +/// (flow.go:5-23; the max bit is `Shared`, `1 << 12`, so a `u16` fits). All 13 +/// bits are defined for shape; `SwitchClause` (F2a) and `ReduceLabel` (F2b) are +/// set by the flow builder, while `ArrayMutation` is never *set* (its two ordinary +/// mutation sites are deliberately skipped per the F2 census — a narrowing hint). +/// +/// # tsgo +/// `internal/ast/flow.go` `FlowFlags`. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct FlowFlags(u16); + +impl FlowFlags { + /// Unreachable code. + pub const UNREACHABLE: FlowFlags = FlowFlags(1 << 0); + /// Start of the flow graph. + pub const START: FlowFlags = FlowFlags(1 << 1); + /// Non-looping junction. + pub const BRANCH_LABEL: FlowFlags = FlowFlags(1 << 2); + /// Looping junction. + pub const LOOP_LABEL: FlowFlags = FlowFlags(1 << 3); + /// Assignment. + pub const ASSIGNMENT: FlowFlags = FlowFlags(1 << 4); + /// Condition known to be true. + pub const TRUE_CONDITION: FlowFlags = FlowFlags(1 << 5); + /// Condition known to be false. + pub const FALSE_CONDITION: FlowFlags = FlowFlags(1 << 6); + /// Switch-statement clause (set by the switch flow builder, F2a). + pub const SWITCH_CLAUSE: FlowFlags = FlowFlags(1 << 7); + /// Potential array mutation — never set (its two ordinary mutation sites are + /// deliberately skipped per the F2 census; a narrowing-only hint). + pub const ARRAY_MUTATION: FlowFlags = FlowFlags(1 << 8); + /// Potential assertion call. + pub const CALL: FlowFlags = FlowFlags(1 << 9); + /// Temporarily reduce antecedents of a label (set by try/finally, F2b). + pub const REDUCE_LABEL: FlowFlags = FlowFlags(1 << 10); + /// Referenced as an antecedent once. + pub const REFERENCED: FlowFlags = FlowFlags(1 << 11); + /// Referenced as an antecedent more than once. + pub const SHARED: FlowFlags = FlowFlags(1 << 12); + /// `BranchLabel | LoopLabel`. + pub const LABEL: FlowFlags = FlowFlags((1 << 2) | (1 << 3)); + /// `TrueCondition | FalseCondition`. + pub const CONDITION: FlowFlags = FlowFlags((1 << 5) | (1 << 6)); + + /// Whether every bit of `other` is set. + #[inline] + #[must_use] + pub const fn contains(self, other: FlowFlags) -> bool { + self.0 & other.0 == other.0 + } + + /// Whether any bit of `other` is set. + #[inline] + #[must_use] + pub const fn intersects(self, other: FlowFlags) -> bool { + self.0 & other.0 != 0 + } + + /// Set `other`'s bits. + #[inline] + pub(super) fn insert(&mut self, other: FlowFlags) { + self.0 |= other.0; + } + + /// Whether this is a label node (`BranchLabel` or `LoopLabel`). + #[inline] + #[must_use] + pub const fn is_label(self) -> bool { + self.intersects(FlowFlags::LABEL) + } +} diff --git a/crates/tsv_check/src/binder/flow/graph.rs b/crates/tsv_check/src/binder/flow/graph.rs new file mode 100644 index 000000000..3c8500c19 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/graph.rs @@ -0,0 +1,187 @@ +use super::*; + +// --- F2 payload shapes (defined for the SoA shape; not populated in F1a) ---- + +/// A switch-clause payload: the switch it belongs to and the half-open +/// `[clause_start, clause_end)` clause range it matched. Written by the switch +/// flow builder (binder.go:2087-2108) and read back through +/// [`FlowGraph::switch_clause_data`]. +#[derive(Clone, Copy, Debug)] +pub struct FlowSwitchClause { + /// The switch statement node. + pub switch: NodeId, + /// Inclusive clause-range start index. + pub clause_start: u32, + /// Exclusive clause-range end index. + pub clause_end: u32, +} + +/// A reduce-label payload — the try/finally "temporarily reduce a label's +/// antecedents" node (`createReduceLabel`, binder.go:475/2042-2045). Written by +/// the try/finally flow builder and read back through +/// [`FlowGraph::reduce_label_data`]. +#[derive(Clone, Copy, Debug)] +pub struct FlowReduceLabel { + /// The label whose antecedent set is temporarily reduced. + pub target: FlowNodeId, + /// **1-based** pool-run index of the reduced antecedent list (the same + /// length-prefixed pool convention the label pool uses). + pub antecedents: u32, +} + +// --- FlowGraph ------------------------------------------------------------- + +/// A per-file control-flow graph in struct-of-arrays form (per +/// `TODO_TYPECHECKER_INTERNALS.md` §Flow graph). Backward edges only (the +/// checker walks use→def). +/// +/// Columns are indexed by `FlowNodeId::index()`. Antecedents are +/// kind-discriminated via `flags`: a non-label node's `antecedent` slot is the +/// single antecedent's raw id (0 = none); a label's slot is a **1-based +/// pool-run index** (0 = the label collapsed / was never finalized). The pool +/// stores length-prefixed runs (`[len, edge0, edge1, …]`); the entry edge is +/// appended first and order is preserved (load-bearing for P3), never sorted. +pub struct FlowGraph { + pub(super) flags: Vec, + /// Kind-discriminated by `flags`: a `NodeId` (raw, 1-based) | payload index + /// | 0 = none. In F1a it is always a `NodeId` or 0. + pub(super) subject: Vec, + /// Non-label: the single antecedent's raw `FlowNodeId` (0 = none). + /// Label: a 1-based pool-run index (0 = collapsed / unfinalized). + pub(super) antecedent: Vec, + /// Length-prefixed antecedent runs for labels (`[len, e0, e1, …]`). + pub(super) pool: Vec, + /// Switch-clause payloads, addressed by a `SwitchClause` node's 1-based + /// `subject` slot (read via [`FlowGraph::switch_clause_data`]). + pub(super) switch_payloads: Vec, + /// Reduce-label payloads (try/finally), addressed by a `ReduceLabel` node's + /// 1-based `subject` slot (read via [`FlowGraph::reduce_label_data`]). + pub(super) reduce_payloads: Vec, +} + +impl FlowGraph { + /// The number of flow nodes in the graph (id 1 is `unreachableFlow`). + #[inline] + #[must_use] + pub fn node_count(&self) -> u32 { + self.flags.len() as u32 + } + + /// The flags of a flow node. + #[inline] + #[must_use] + pub fn flags(&self, id: FlowNodeId) -> FlowFlags { + self.flags[id.index()] + } + + /// The subject `NodeId` of a flow node, if any (labels have none). + /// + /// **Not** valid on a `SwitchClause` node: there the `subject` slot holds a + /// 1-based payload index, not a raw `NodeId` — use + /// [`FlowGraph::switch_clause_data`] instead. + #[inline] + #[must_use] + pub fn subject(&self, id: FlowNodeId) -> Option { + NodeId::from_raw_opt(self.subject[id.index()]) + } + + /// The switch-clause payload of a `SwitchClause` flow node. + /// + /// A `SwitchClause` node's `subject` slot stores a **1-based index** into + /// `switch_payloads` (the same convention the label pool uses), not a + /// [`NodeId`], so [`FlowGraph::subject`] must not be called on it — it would + /// mis-decode the index as a node id. This is the only correct reader; + /// callers gate on `flags(id).contains(SWITCH_CLAUSE)`. + #[must_use] + pub fn switch_clause_data(&self, id: FlowNodeId) -> &FlowSwitchClause { + debug_assert!(self.flags(id).contains(FlowFlags::SWITCH_CLAUSE)); + let index = self.subject[id.index()] as usize; // 1-based + &self.switch_payloads[index - 1] + } + + /// The reduce-label payload of a `ReduceLabel` flow node. + /// + /// Like a `SwitchClause` node, a `ReduceLabel` node's `subject` slot stores a + /// **1-based index** into `reduce_payloads`, not a [`NodeId`], so + /// [`FlowGraph::subject`] must not be called on it. Callers gate on + /// `flags(id).contains(REDUCE_LABEL)`. The payload's `antecedents` field is a + /// 1-based pool-run index of the reduced antecedent list. + #[must_use] + pub fn reduce_label_data(&self, id: FlowNodeId) -> &FlowReduceLabel { + debug_assert!(self.flags(id).contains(FlowFlags::REDUCE_LABEL)); + let index = self.subject[id.index()] as usize; // 1-based + &self.reduce_payloads[index - 1] + } + + /// A length-prefixed pool run as a raw slice (`slot` is 1-based; 0 = empty). + #[inline] + fn pool_run(&self, slot: u32) -> &[u32] { + if slot == 0 { + return &[]; + } + let off = (slot - 1) as usize; + let len = self.pool[off] as usize; + &self.pool[off + 1..off + 1 + len] + } + + /// The single antecedent of a **non-label** flow node (`None` for `Start` / + /// `Unreachable`) — the O(1) slot read the CFA's linear-chain walk follows + /// (tsgo's `flow.Antecedent` chase), no pool touch, no allocation. + /// + /// Not valid on a label node, whose slot holds a pool-run index — decode + /// those via [`FlowGraph::antecedents_iter`]. + #[inline] + #[must_use] + pub fn single_antecedent(&self, id: FlowNodeId) -> Option { + debug_assert!( + !self.flags[id.index()].is_label(), + "a label's antecedent slot is a pool-run index — use antecedents_iter" + ); + FlowNodeId::from_raw(self.antecedent[id.index()]) + } + + /// The antecedents of a flow node, in append order, as a **zero-alloc** + /// borrowing iterator — the hot-path form for the CFA walkers (label + /// recursion iterates this; linear chains take [`FlowGraph::single_antecedent`]). + /// Labels decode their length-prefixed pool run; non-label nodes yield their + /// 0-or-1 slot. + pub fn antecedents_iter(&self, id: FlowNodeId) -> impl Iterator + '_ { + let flags = self.flags[id.index()]; + let slot = self.antecedent[id.index()]; + let (run, single) = if flags.is_label() { + (self.pool_run(slot), None) + } else { + (&[][..], FlowNodeId::from_raw(slot)) + }; + run.iter() + .filter_map(|&raw| FlowNodeId::from_raw(raw)) + .chain(single) + } + + /// The reduced antecedent list of a `ReduceLabel` node, in append order, as + /// a **zero-alloc** borrowing iterator (the temporary antecedent subset the + /// checker substitutes for `target` while it passes this node). + pub fn reduce_label_antecedents_iter( + &self, + id: FlowNodeId, + ) -> impl Iterator + '_ { + let data = self.reduce_label_data(id); + self.pool_run(data.antecedents) + .iter() + .filter_map(|&raw| FlowNodeId::from_raw(raw)) + } + + /// [`FlowGraph::reduce_label_antecedents_iter`], collected (the convenient + /// form for tests and the DOT renderer; hot paths take the iterator). + #[must_use] + pub fn reduce_label_antecedents(&self, id: FlowNodeId) -> Vec { + self.reduce_label_antecedents_iter(id).collect() + } + + /// [`FlowGraph::antecedents_iter`], collected (the convenient form for tests + /// and the DOT renderer; hot paths take the iterator). + #[must_use] + pub fn antecedents(&self, id: FlowNodeId) -> Vec { + self.antecedents_iter(id).collect() + } +} diff --git a/crates/tsv_check/src/binder/flow/mod.rs b/crates/tsv_check/src/binder/flow/mod.rs new file mode 100644 index 000000000..fcefe34b7 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/mod.rs @@ -0,0 +1,114 @@ +//! The flow-graph walk — a per-file control-flow graph in struct-of-arrays form. +//! +//! This is the **third walk** of the binder (after the SoA node-identity walk +//! and the symbol bind). It ports tsgo's binder flow construction (`bind` / +//! `bindContainer` / `bindChildren` + the per-statement flow shapers) onto the +//! tsv AST, resolving each attachment's [`NodeId`] through the F0 address map's +//! **strict** [`BoundFile::require_node_id`] (a miss aborts — a flow graph must +//! never silently splice onto the wrong node). +//! +//! **F1b scope: the branching control-flow constructs.** On top of F1a's linear +//! substrate this slice builds faithful topology for **conditions** (the +//! `bindCondition` machinery — `&&`/`||`/`??`/`?:`/`!`/parenthesized + the +//! `hasFlowEffects` save/restore family), **`if`/`else`**, the five loops +//! (**`while`**, **`do…while`**, **`for`**, **`for-in`**, **`for-of`**), and +//! **unlabeled `break`/`continue`**. +//! +//! **F2a scope: switch-statement flow topology.** On top of F1b's local +//! post-switch break target this slice builds the real clause topology +//! (`bindSwitchStatement` / `bindCaseBlock` / `bindCaseOrDefaultClause`): every +//! clause's `preCase` label is fed **from the switch head unconditionally** +//! (`preSwitchCaseFlow`) in addition to the prior clause's fallthrough edge, so a +//! clause reached only after a prior `break`/`return` stays reachable — F1b's +//! linear stub wrongly marked it `Unreachable`. A narrowing switch +//! (`switch (true)` or a narrowing discriminant) additionally mints a +//! `SwitchClause` flow node per clause carrying the matched half-open +//! `[start, end)` clause range, and a switch with no `default` clause adds a +//! `(0, 0)` "no clause matched" `SwitchClause` exhaustiveness sentinel to the +//! post-switch label. Post-exhaustive-switch reachability (code after an +//! exhaustive no-`default` switch) is type-dependent +//! (`isExhaustiveSwitchStatement`) and stays deferred. +//! +//! **F2b scope: the four remaining flow landmines.** On top of F2a this slice +//! builds **try/catch/finally** topology (`bindTryStatement` — the +//! exception/return/normal-exit labels, the catch-as-second-try re-point, and +//! the `ReduceLabel` finally-completion routing back through the return / +//! outer-exception / normal antecedent subsets), **IIFE inlining** +//! (`GetImmediatelyInvokedFunctionExpression` + the `bindContainer` +//! `!isImmediatelyInvoked` gate — a non-async, non-generator function/arrow +//! callee of a call is bound *transparently* into the containing flow, with its +//! own return target merged at exit but no fresh `Start` and no `current_flow` +//! restore), **initializer forks** (`bindInitializer` — a parameter / +//! binding-element default that actually changes `current_flow` forks around +//! it), and **labeled statements** (`bindLabeledStatement` + the +//! `activeLabelList` — labeled `break`/`continue` resolution, per-label +//! continue-target propagation, and the unreferenced-label `Unreachable` stamp). +//! Flow stays **dark** — nothing consumes it until F3, so this slice emits no +//! diagnostics. +//! +//! **`isTopLevelLogicalExpression` without parent pointers.** tsgo's +//! `bindBinaryExpressionFlow` walks the parent chain to decide whether a logical +//! expression is evaluated for its value (top-level → `hasFlowEffects` post-label +//! wrap) or as a condition (nested → wired to the enclosing true/false targets). +//! tsv's `Expression` has no parent pointer, so the walk is replaced by keeping +//! the true/false targets `Some` **only** while binding an actual sub-condition — +//! they are set by `do_with_conditional_branches` / the `!`-swap, and reset to +//! `None` at three boundaries so they never leak into a non-condition: (1) at every +//! **value sub-position** — `visit_expression` resets them for every non-threading +//! expression, so a logical nested in a call argument / `?:` arm / array element +//! (`if (f(x && y))`) is classified top-level (a value), not a sub-condition; +//! (2) at every **flow container** — one can be entered mid-condition +//! (`if (arr.some(x => x && y))`), which would otherwise leak the outer targets +//! into the callback body; and (3) around the **logical-compound-assignment RHS** — +//! the RHS of `&&=`/`||=`/`??=` is reached through a *threading* node (the +//! compound-assign itself), so the `visit_expression` auto-reset never fires; +//! `bind_logical_like_expression` clears the targets explicitly so a logical RHS +//! (`a &&= x && y`) is classified top-level, matching tsgo's +//! `isTopLevelLogicalExpression` verdict on the RHS's parent (the compound-assign, +//! not a logical binary; see that site for detail). With these resets a logical +//! expression is top-level iff `current_true_target` is `None`. All are deliberate +//! departures from tsgo (which never saves the targets, relying on the parent walk) +//! required by the pointer-free heuristic. +//! +//! **Deliberate scoping deviations (F1a; documented for F1b):** +//! - **Types are not descended.** The walk visits value positions only; pure +//! type nodes (annotations, type arguments, type-parameter constraints, +//! heritage type args, interface/type-alias bodies, enum bodies) are skipped. +//! tsgo stamps `currentFlow` on every identifier *including* type positions +//! (binder.go:602). For **pure** type positions those stamps are inert (the +//! checker runs no CFA there — the same soundness that lets lib files skip +//! flow). The **exception is `typeof` queries**: `typeof x` / `typeof x.y` in a +//! type position *is* flow-narrowed by the checker, which is exactly why tsgo +//! gates the `QualifiedName` stamp on `IsPartOfTypeQuery` (binder.go:611). So +//! the omitted type-position identifier stamp (for `typeof x`) and the +//! `QualifiedName`-inside-`typeof` stamp are **not** dead weight — they are a +//! **P3 prerequisite** for typeof-query narrowing (ledgered as such), not +//! inert. Nothing before P3 reads them, so deferring is safe now. +//! - **No `Start` region for the bodiless signature/type function-likes** +//! (`TSFunctionType` / `TSConstructorType` / method-/call-/construct-signature) +//! — a corollary of not descending types. +//! - **Binding-element flow.** tsv has no distinct binding-element node (patterns +//! are pattern-shaped `Expression`s), so a destructuring `let {a} = e` emits a +//! single `Assignment` per *declarator* (subject = the declarator) rather than +//! one per element (binder.go:2329). Exact for the identifier case; the +//! contained identifiers still get their leaf stamps. Binding-element and +//! parameter **defaults** fork per `bindInitializer` (F2b) when the default +//! changes the flow. +// +// tsgo: internal/binder/binder.go bind / bindContainer / bindChildren +// (+ the newFlowNode* / createFlow* / finishFlowLabel / addAntecedent +// constructor family and the per-statement flow shapers) + +mod build; +mod flags; +mod graph; +mod product; +#[cfg(test)] +mod tests; + +pub use build::build_flow; +pub use flags::FlowFlags; +pub use graph::{FlowGraph, FlowReduceLabel, FlowSwitchClause}; +pub use product::{FlowProduct, FlowStats, render_flow_dot}; + +use crate::ids::{FlowNodeId, NodeId}; diff --git a/crates/tsv_check/src/binder/flow/product.rs b/crates/tsv_check/src/binder/flow/product.rs new file mode 100644 index 000000000..5f3e21561 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/product.rs @@ -0,0 +1,201 @@ +use super::*; +use tsv_lang::Span; + +// --- FlowProduct ----------------------------------------------------------- + +/// Small construction counters, surfaced for the density / dead-label-row +/// perf report (they are not consumed by any checker phase). +#[derive(Clone, Copy, Debug, Default)] +pub struct FlowStats { + /// Branch labels created (`createBranchLabel`). + pub branch_labels: u32, + /// Branch labels that collapsed at `finishFlowLabel` (0 or 1 antecedent), + /// leaving a dead row — the fraction to watch (INTERNALS §Flow graph). + pub dead_labels: u32, +} + +/// The owned, arena-free, file-local flow product carried **dark** in a +/// `BoundUnit` (nothing consumes it until F3; F1a builds it and `--dump-flow` +/// renders it). C15-relocatable by construction. +pub struct FlowProduct { + /// The flow graph. + pub graph: FlowGraph, + /// Per-`NodeId` flow attachment (`None` where tsgo attaches nil — including + /// non-leaf nodes cleared in dead code; a dead *leaf* keeps + /// `Some(unreachable)`). + pub flow_of_node: Vec>, + /// Per-node flag bytes, one per [`NodeId`] (minted zeroed here — the flow + /// walk is the sole writer today), with the `Unreachable` bit set during the + /// dead-code walk (`NODE_FLAGS_UNREACHABLE`). + pub node_flags: Vec, + /// Function-body + `SourceFile` end-of-flow anchors (binder.go:1561,1569), + /// sorted by `NodeId`. + pub end_flow: Vec<(NodeId, FlowNodeId)>, + /// Constructor + class-static-block return-flow anchors ONLY + /// (binder.go:1575), sorted by `NodeId`. Every other tsgo `ReturnFlowNode` + /// write/read is dead plumbing and is not ported. + pub return_flow: Vec<(NodeId, FlowNodeId)>, + /// Case-clause fallthrough anchors: the reachable exit flow of each non-last + /// clause (tsgo's `clause.AsCaseOrDefaultClause().FallthroughFlowNode`, + /// binder.go:2121), sorted by `NodeId`. + pub fallthrough_flow: Vec<(NodeId, FlowNodeId)>, + /// Construction counters. + pub stats: FlowStats, +} + +impl FlowProduct { + /// The `end_flow` anchor for a node, if any (small sorted anchor list). + #[must_use] + pub fn end_flow_of(&self, node: NodeId) -> Option { + self.end_flow + .binary_search_by_key(&node, |&(n, _)| n) + .ok() + .map(|i| self.end_flow[i].1) + } + + /// The `return_flow` anchor for a node, if any (constructor / static block). + #[must_use] + pub fn return_flow_of(&self, node: NodeId) -> Option { + self.return_flow + .binary_search_by_key(&node, |&(n, _)| n) + .ok() + .map(|i| self.return_flow[i].1) + } + + /// The `fallthrough_flow` anchor for a case clause, if any (the reachable + /// exit flow of a non-last clause). + #[must_use] + pub fn fallthrough_flow_of(&self, node: NodeId) -> Option { + self.fallthrough_flow + .binary_search_by_key(&node, |&(n, _)| n) + .ok() + .map(|i| self.fallthrough_flow[i].1) + } +} + +// --- DOT renderer (formatControlFlowGraph reference) ----------------------- + +/// Render one unit's flow graph to Graphviz DOT — the `--dump-flow` product. +/// Backward DFS from the `SourceFile`/function end-of-flow anchors (and return +/// anchors) with cycle detection, after Strada's `formatControlFlowGraph` +/// (flag→header label, subject-node source text, backward edges). `node_spans` +/// is the F0 `BoundFile::spans` column (subject text = `source[span]`). +#[must_use] +pub fn render_flow_dot(product: &FlowProduct, node_spans: &[Span], source: &str) -> String { + use std::fmt::Write as _; + let g = &product.graph; + let mut out = String::new(); + out.push_str("digraph flow {\n"); + out.push_str(" rankdir=BT;\n"); + out.push_str(" node [shape=box, fontname=\"monospace\"];\n"); + + let mut seen = vec![false; g.node_count() as usize + 1]; + let mut stack: Vec = Vec::new(); + // Roots: every end_flow / return_flow anchor (the exits), plus id 1 so a + // fully-unreachable graph still renders the singleton. + for &(_, f) in product.end_flow.iter().chain(product.return_flow.iter()) { + stack.push(f); + } + stack.push(FlowNodeId::UNREACHABLE); + + while let Some(id) = stack.pop() { + if seen[id.index() + 1] { + continue; + } + seen[id.index() + 1] = true; + let label = flow_node_label(g, id, node_spans, source); + let _ = writeln!(out, " N{} [label=\"{}\"];", id.get(), escape_dot(&label)); + for ante in g.antecedents_iter(id) { + let _ = writeln!(out, " N{} -> N{};", id.get(), ante.get()); + stack.push(ante); // cycle-guarded by `seen` + } + } + + // Anchor edges (dashed) so the exits are visible. + for (node, f) in &product.end_flow { + let _ = writeln!( + out, + " END_{n} [shape=doublecircle, label=\"end#{n}\"];\n END_{n} -> N{f} [style=dashed];", + n = node.get(), + f = f.get() + ); + } + out.push_str("}\n"); + out +} + +fn flow_node_label(g: &FlowGraph, id: FlowNodeId, node_spans: &[Span], source: &str) -> String { + let flags = g.flags(id); + let header = flow_flag_header(flags); + if flags.contains(FlowFlags::REDUCE_LABEL) { + // The `subject` slot is a payload index, not a NodeId — read the target + // through the payload, never subject(). + let data = g.reduce_label_data(id); + return format!("#{} {}→N{}", id.get(), header, data.target.get()); + } + if flags.contains(FlowFlags::SWITCH_CLAUSE) { + // A SwitchClause node's `subject` slot is a payload index, not a NodeId — + // read the switch text + clause range through the payload, never subject(). + let data = g.switch_clause_data(id); + let span = node_spans[data.switch.index()]; + let text = span.extract(source); + let text = text.split('\n').next().unwrap_or(text); + let text = match text.char_indices().nth(24) { + Some((idx, _)) => &text[..idx], + None => text, + }; + return format!( + "#{} {}[{},{}): {}", + id.get(), + header, + data.clause_start, + data.clause_end, + text + ); + } + if let Some(node) = g.subject(id) { + let span = node_spans[node.index()]; + let text = span.extract(source); + let text = text.split('\n').next().unwrap_or(text); + // Truncate on a char boundary (byte-slicing `&text[..32]` panics when a + // multibyte char straddles byte 32). + let text = match text.char_indices().nth(32) { + Some((idx, _)) => &text[..idx], + None => text, + }; + format!("#{} {}: {}", id.get(), header, text) + } else { + format!("#{} {}", id.get(), header) + } +} + +/// The most salient flag as a short header label (label/condition/start/…). +fn flow_flag_header(flags: FlowFlags) -> &'static str { + if flags.contains(FlowFlags::UNREACHABLE) { + "unreachable" + } else if flags.contains(FlowFlags::START) { + "start" + } else if flags.contains(FlowFlags::LOOP_LABEL) { + "loop" + } else if flags.contains(FlowFlags::BRANCH_LABEL) { + "branch" + } else if flags.contains(FlowFlags::ASSIGNMENT) { + "assign" + } else if flags.contains(FlowFlags::TRUE_CONDITION) { + "true" + } else if flags.contains(FlowFlags::FALSE_CONDITION) { + "false" + } else if flags.contains(FlowFlags::SWITCH_CLAUSE) { + "switch" + } else if flags.contains(FlowFlags::REDUCE_LABEL) { + "reduce" + } else if flags.contains(FlowFlags::CALL) { + "call" + } else { + "flow" + } +} + +fn escape_dot(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/crates/tsv_check/src/binder/flow/tests/expressions.rs b/crates/tsv_check/src/binder/flow/tests/expressions.rs new file mode 100644 index 000000000..ca276b795 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/tests/expressions.rs @@ -0,0 +1,425 @@ +//! Tests for the expression-shaped flow topology — logical / compound-assign +//! conditions, class-expression / object-literal method start-subjects, and +//! IIFE / function-expression isolation — the counterpart of +//! `build/expressions.rs`. + +use super::super::*; +use super::{build, build_with_bound, condition_of, find_flow, flow_of_node, ident, nodes_of_kind}; +use crate::binder::{BoundFile, NodeKind}; + +/// Count `CALL` flow nodes in the whole graph. +fn call_node_count(product: &FlowProduct) -> usize { + (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .filter(|&id| product.graph.flags(id).contains(FlowFlags::CALL)) + .count() +} + +#[test] +fn comma_operands_each_mint_a_call_flow_node() { + // `bindBinaryExpressionFlow` comma branch applies `maybeBindExpressionFlowIfCall` + // to every operand — each discarded (statement-like) dotted-name call is a + // potential assertion, so a two-operand comma mints one CALL per operand. + let two = build("function f() { m1(), m2(); }"); + assert_eq!( + call_node_count(&two), + 2, + "each comma operand's dotted-name call should mint a CALL flow node" + ); + // Control: a bare expression statement mints exactly one (the established path). + let one = build("function f() { m1(); }"); + assert_eq!(call_node_count(&one), 1); +} + +/// Find the first node of `kind`, with its body-`Start` flow node (the START +/// whose subject is that node), if any. +fn start_subject_of( + product: &FlowProduct, + bound: &BoundFile, + kind: NodeKind, +) -> (NodeId, Option) { + let node = NodeId::from_index( + bound + .kinds + .iter() + .position(|&k| k == kind) + .expect("node of kind"), + ); + let g = &product.graph; + let start = (1..=g.node_count()) + .filter_map(FlowNodeId::from_raw) + .find(|&f| g.flags(f).contains(FlowFlags::START) && g.subject(f) == Some(node)); + (node, start) +} + +#[test] +fn class_expression_method_gets_flow_write_and_start_subject() { + // tsgo binder.go:981 (outer-flow write on the method node) + :1534 + // (Start.Node = the method) — class-EXPRESSION methods only. + let (product, bound) = + build_with_bound("const C = class { m() { return 1; } get g() { return 2; } };"); + let (method, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition); + assert!( + start.is_some(), + "class-expression method Start carries the method subject" + ); + assert!( + product.flow_of_node[method.index()].is_some(), + "class-expression method node gets the outer-flow write" + ); +} + +#[test] +fn class_declaration_method_stays_unstamped() { + // The Parent.Kind gate (utilities.go:566): a class-DECLARATION method gets + // neither the flow write nor a Start subject. + let (product, bound) = build_with_bound("class D { m() { return 1; } }"); + let (method, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition); + assert!( + start.is_none(), + "class-declaration method Start has no subject" + ); + assert!(product.flow_of_node[method.index()].is_none()); +} + +#[test] +fn class_expression_constructor_excluded_from_method_gate() { + // A constructor is not a MethodDeclaration/accessor kind — excluded even + // inside a class expression. + let (product, bound) = build_with_bound("const C = class { constructor() { this.x = 1; } };"); + let (ctor, start) = start_subject_of(&product, &bound, NodeKind::MethodDefinition); + assert!(start.is_none(), "constructor Start has no subject"); + assert!(product.flow_of_node[ctor.index()].is_none()); +} + +#[test] +fn object_literal_method_gets_flow_write_and_start_subject() { + // The object-literal half of the gate: the Property node (tsv's analog of + // tsgo's object-literal MethodDeclaration) is stamped and made the subject. + let (product, bound) = build_with_bound("const o = { m() { return 1; } };"); + let (prop, start) = start_subject_of(&product, &bound, NodeKind::Property); + assert!( + start.is_some(), + "object-literal method Start carries the Property subject" + ); + assert!(product.flow_of_node[prop.index()].is_some()); +} + +#[test] +fn object_literal_plain_property_stays_unstamped() { + // A function-VALUED plain property is not a method: the FunctionExpression + // itself is the Start subject (the fn-expr rule), the Property is not. + let (product, bound) = build_with_bound("const o = { m: function () { return 1; } };"); + let (prop, prop_start) = start_subject_of(&product, &bound, NodeKind::Property); + assert!( + prop_start.is_none(), + "plain property Start has no Property subject" + ); + assert!(product.flow_of_node[prop.index()].is_none()); + let (_f, f_start) = start_subject_of(&product, &bound, NodeKind::FunctionExpression); + assert!( + f_start.is_some(), + "the function expression keeps its own subject" + ); +} + +#[test] +fn logical_in_condition_value_subposition_is_top_level() { + // `if (f(x && y)) a; else b;` — the `x && y` sits in a VALUE sub-position + // (a call argument) of the if condition, so it is top-level (a value with + // its own post-label), NOT a sub-condition of the if. tsgo classifies this + // via a parent walk (`isTopLevelLogicalExpression`); tsv resets the + // condition targets at the value boundary in `visit_expression`. The if's + // actual condition `f(x && y)` is non-narrowing with no flow effects, so + // BOTH arms enter from the function Start — the distinguishing property: + // the bug wired x/y's conditions into the if's then/else, making + // a.flow != b.flow. (`if (c ? x && y : z)` and `if (g([x && y]))` are the + // same class — value sub-positions.) + let src = "function w() { if (f(x && y)) a; else b; }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + let a_flow = flow_of_node(&product, a); + let b_flow = flow_of_node(&product, b); + assert_eq!( + a_flow, b_flow, + "a non-narrowing if-condition merges both arms; x && y must not wire into them" + ); + assert!(product.graph.flags(a_flow).contains(FlowFlags::START)); + // `x && y` is still narrowed as a value — its own condition nodes exist, + // but they feed x && y's post-label, not the if arms. + let x = ident(&bound, src, "x"); + let xc = condition_of(&product, x, true); + assert_ne!(a_flow, xc); +} + +#[test] +fn logical_compound_assign_rhs_is_top_level_value() { + // `a &&= x && y;` as a STATEMENT — the RHS `x && y` binds as a top-level + // VALUE. tsgo classifies it via `isTopLevelLogicalExpression` (binder.go:2782) + // on `right`'s PARENT, which is the `&&=` node (not a logical operator), so + // the RHS is top-level: its own true/false conditions are self-contained in a + // throwaway post-label and discarded (effect-free identifiers), NOT threaded + // into the outer `&&=` post-label. tsgo wires only FALSE(a) + the whole-node + // truthiness — 3 antecedents. The bug (threading the RHS) leaked x/y's four + // conditions, giving 6: [FALSE(a), FALSE(x), TRUE(y), FALSE(y), TRUE(whole), + // FALSE(whole)]. + let src = "function f() { a &&= x && y; }"; + let (product, bound) = build_with_bound(src); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + // The `&&=` has flow effects (the Assignment mutation), so its post-label is + // materialized and becomes the function's end-of-flow. + let post = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL)); + + let a = ident(&bound, src, "a"); + let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0]; + let false_a = condition_of(&product, a, false); + let true_whole = condition_of(&product, whole, true); + let false_whole = condition_of(&product, whole, false); + // Exact shape (and order): FALSE(a), then the whole-node TRUE/FALSE — no x/y. + assert_eq!( + product.graph.antecedents(post), + vec![false_a, true_whole, false_whole], + "the &&= post-label carries FALSE(a) + TRUE/FALSE(whole) only — x/y stay top-level" + ); +} + +#[test] +fn logical_compound_assign_still_threads_whole_node_in_condition() { + // `if (a &&= x && y) d;` — the `&&=` node itself is a CONDITION (its parent + // is the if), so its whole-node truthiness threads into then/else, while its + // RHS `x && y` is still top-level (self-contained, discarded). Post-fix: + // - the then-branch enters from the whole-node TRUE condition ALONE + // (d.flow == TRUE(whole)) — x/y's TRUE(y) does not merge in; + // - the else branch carries exactly FALSE(a) + FALSE(whole) — x/y's + // FALSE(x)/FALSE(y) do not leak in. + // The bug merged TRUE(y) into the then-branch and FALSE(x)/FALSE(y) into the + // else-branch. + let src = "function f() { if (a &&= x && y) d; }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let d = ident(&bound, src, "d"); + let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0]; + let false_a = condition_of(&product, a, false); + let true_whole = condition_of(&product, whole, true); + let false_whole = condition_of(&product, whole, false); + + // then-branch = the whole-node TRUE condition alone (single antecedent + // collapses the then-label to the condition itself). + assert_eq!( + flow_of_node(&product, d), + true_whole, + "the then-branch enters from the &&= whole-node truthiness alone — TRUE(y) must not merge in" + ); + + // postIf merges the then-exit (TRUE(whole)) and the else-branch label. + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let post_if = product.end_flow_of(f).expect("f end_flow"); + let ants = product.graph.antecedents(post_if); + assert_eq!( + ants.len(), + 2, + "postIf merges the then-exit and the else-branch" + ); + assert_eq!( + ants[0], true_whole, + "then-exit is the whole-node TRUE condition" + ); + let else_label = ants[1]; + assert_eq!( + product.graph.antecedents(else_label), + vec![false_a, false_whole], + "the else branch carries only FALSE(a) + FALSE(whole) — x/y stay top-level" + ); +} + +#[test] +fn coalescing_compound_assign_rhs_is_top_level_value() { + // `a ??= x || y;` as a STATEMENT — the shared logical-compound-assign branch + // walked with `is_and=false, is_nullish=true` (the `??=` path, distinct from + // `&&=`). Like `&&=`, the RHS `x || y` is a top-level VALUE: tsgo's + // `isTopLevelLogicalExpression(right)` (binder.go:2782) inspects `right`'s + // PARENT — the `??=` node, which is a compound-assignment operator, not a + // logical binary (`IsLogicalExpression` unwraps parens/`!` then requires a + // `&&`/`||`/`??` *binary*), so `right` is top-level. Its own true/false + // conditions are self-contained in a throwaway post-label and discarded + // (effect-free identifiers), NOT threaded into the outer `??=` post-label. + // The `??=`/`||` mirror of `bindLogicalLikeExpression` (binder.go:2266-2268, + // the non-`&&` branch) wires the LEFT's TRUE condition (not FALSE, as `&&=` + // does) into the post: the outer post carries TRUE(a) + the whole-node + // truthiness — 3 antecedents, no x/y. The bug (threading the RHS) would leak + // x/y's four conditions. + let src = "function f() { a ??= x || y; }"; + let (product, bound) = build_with_bound(src); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + // The `??=` mutates `a` (a flow effect), so its post-label is materialized and + // becomes the function's end-of-flow. + let post = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL)); + + let a = ident(&bound, src, "a"); + let x = ident(&bound, src, "x"); + let whole = nodes_of_kind(&bound, NodeKind::AssignmentExpression)[0]; + let true_a = condition_of(&product, a, true); + let true_whole = condition_of(&product, whole, true); + let false_whole = condition_of(&product, whole, false); + // Exact shape (and order): TRUE(a) (the `??=`/`||` mirror of the `&&=` test's + // FALSE(a)), then the whole-node TRUE/FALSE — no x/y. + assert_eq!( + product.graph.antecedents(post), + vec![true_a, true_whole, false_whole], + "the ??= post-label carries TRUE(a) + TRUE/FALSE(whole) only — x || y stays top-level" + ); + // `x || y` is still narrowed as a value — its TRUE(x) condition exists and + // feeds its OWN (discarded, effect-free) post-label, distinct from the ??= post. + let true_x = condition_of(&product, x, true); + let x_post = find_flow(&product, |g, id| { + g.flags(id).is_label() && g.antecedents(id).contains(&true_x) + }); + assert_ne!( + x_post, post, + "x || y feeds its own post-label, not the ??= post" + ); + assert!(!product.graph.antecedents(post).contains(&true_x)); +} + +#[test] +fn nested_logical_compound_assign_rhs_gets_own_post_label() { + // `a &&= b ||= c;` — the RHS `b ||= c` is ITSELF a logical compound-assignment. + // Its parent is the outer `&&=` node (an assignment operator, not a logical + // binary), so tsgo `isTopLevelLogicalExpression(b ||= c)` is true: it is bound + // top-level with its OWN post-label, NOT threaded into the outer `&&=` targets. + // Because `b ||= c` has a flow effect (it mutates `b`), its post-label is + // materialized and the outer `a`-mutation flows THROUGH it — distinct from the + // effect-free logical-RHS case (`a ??= x || y`) where the RHS post is discarded. + let src = "function f() { a &&= b ||= c; }"; + let (product, bound) = build_with_bound(src); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let post = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(post).contains(FlowFlags::BRANCH_LABEL)); + + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + // Two AssignmentExpressions: the outer `a &&= b ||= c` (whole statement) and + // the inner RHS `b ||= c`. Disambiguate by span length (outer encloses inner). + let assigns = nodes_of_kind(&bound, NodeKind::AssignmentExpression); + assert_eq!(assigns.len(), 2); + let span_len = |id: NodeId| bound.spans[id.index()].end - bound.spans[id.index()].start; + let outer = assigns + .iter() + .copied() + .max_by_key(|&id| span_len(id)) + .unwrap(); + let inner = assigns + .iter() + .copied() + .min_by_key(|&id| span_len(id)) + .unwrap(); + + let false_a = condition_of(&product, a, false); + let true_outer = condition_of(&product, outer, true); + let false_outer = condition_of(&product, outer, false); + // The outer `&&=` post carries FALSE(a) + the outer whole-node TRUE/FALSE only + // (the `&&=` mirror) — the inner `b ||= c`'s conditions do NOT leak in. + assert_eq!( + product.graph.antecedents(post), + vec![false_a, true_outer, false_outer], + "the &&= post carries FALSE(a) + TRUE/FALSE(outer) only — b ||= c stays top-level" + ); + + // The inner `b ||= c` has its OWN materialized post-label (it mutates `b`), + // carrying its own [TRUE(b), TRUE(inner), FALSE(inner)] — the `||=` mirror, + // self-contained exactly as the whole `??=` RHS was, one level down. + let true_b = condition_of(&product, b, true); + let true_inner = condition_of(&product, inner, true); + let false_inner = condition_of(&product, inner, false); + let inner_post = find_flow(&product, |g, id| { + g.flags(id).is_label() && g.antecedents(id).contains(&true_inner) + }); + assert_ne!( + inner_post, post, + "b ||= c feeds its own post-label, not the &&= post" + ); + assert_eq!( + product.graph.antecedents(inner_post), + vec![true_b, true_inner, false_inner], + "b ||= c's own post carries TRUE(b) + its whole-node TRUE/FALSE" + ); + // The outer `a`-mutation's antecedent is that inner post (b ||= c had flow + // effects), so the nested compound-assign threads through as a top-level value. + let a_assign = find_flow(&product, |g, id| { + g.flags(id).contains(FlowFlags::ASSIGNMENT) && g.subject(id) == Some(a) + }); + assert_eq!( + product.graph.antecedents(a_assign), + vec![inner_post], + "the outer a-mutation's antecedent is b ||= c's materialized post" + ); +} + +#[test] +fn iife_body_is_inlined_into_containing_flow() { + // THE IIFE PROOF. `(function(){ g(); })(); h();` — the IIFE body is NOT + // flow-isolated: `h` continues from the IIFE body's exit (the g() call), + // and `g` binds under the ambient flow (no fresh Start). + let src = "function f() { (function(){ g(); })(); h(); }"; + let (product, bound) = build_with_bound(src); + let g = ident(&bound, src, "g"); + let h = ident(&bound, src, "h"); + assert!( + product + .graph + .flags(flow_of_node(&product, g)) + .contains(FlowFlags::START), + "g binds under the ambient (transparent) flow" + ); + assert!( + product + .graph + .flags(flow_of_node(&product, h)) + .contains(FlowFlags::CALL), + "h continues from the IIFE body's g() call, not a restored/fresh flow" + ); +} + +#[test] +fn non_invoked_function_expression_is_flow_isolated() { + // Contrast: a non-invoked function expression IS isolated — `h` is + // unaffected (binds at the `const x = …` mutation), and `g` binds under + // the function's own fresh Start. + let src = "function f() { const x = function(){ g(); }; h(); }"; + let (product, bound) = build_with_bound(src); + let g = ident(&bound, src, "g"); + let h = ident(&bound, src, "h"); + assert!( + product + .graph + .flags(flow_of_node(&product, g)) + .contains(FlowFlags::START) + ); + assert!( + product + .graph + .flags(flow_of_node(&product, h)) + .contains(FlowFlags::ASSIGNMENT), + "h binds at the const-x assignment, not the isolated g() call" + ); +} + +#[test] +fn async_iife_stays_isolated() { + // Guards the `!async` gate: an async IIFE is NOT inlined, so `h` binds + // under the outer function's own flow (Start), not continued from the + // async body's g() call. A regression dropping the async check would make + // `h`'s flow the inlined CALL (as in the sync-IIFE proof). + let src = "function f() { (async function(){ g(); })(); h(); }"; + let (product, bound) = build_with_bound(src); + let h = ident(&bound, src, "h"); + let h_flow = flow_of_node(&product, h); + assert!( + product.graph.flags(h_flow).contains(FlowFlags::START), + "h binds under the outer Start — the async IIFE body is flow-isolated" + ); + assert!(!product.graph.flags(h_flow).contains(FlowFlags::CALL)); +} diff --git a/crates/tsv_check/src/binder/flow/tests/mod.rs b/crates/tsv_check/src/binder/flow/tests/mod.rs new file mode 100644 index 000000000..173764796 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/tests/mod.rs @@ -0,0 +1,305 @@ +//! Shared test fixtures for the flow-graph walk, plus the flow-node minting, +//! container, and label-pool tests — the graph-construction machinery that +//! `build/mod.rs` itself owns, as opposed to any one statement or expression +//! shape. The statement-shaped, expression-shaped, and predicate tests live +//! in the `statements`, `expressions`, and `predicates` submodules, +//! mirroring `build/statements.rs`, `build/expressions.rs`, and +//! `build/predicates.rs`. + +mod expressions; +mod predicates; +mod statements; + +use super::build::FlowBuilder; +use super::*; +use crate::binder::{BoundFile, NodeKind, bind_file}; +use crate::ids::FileId; +use bumpalo::Bump; + +/// Bind + build the flow product for a snippet (a fresh arena per call). +fn flow_of(source: &str) -> (Bump, BoundFile) { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + let bound = bind_file(&program, source, FileId::ROOT); + (arena, bound) +} + +fn build(source: &str) -> FlowProduct { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + let bound = bind_file(&program, source, FileId::ROOT); + build_flow(&program, source, &bound) +} + +/// Build the flow product **and** keep the `BoundFile` (both owned) so a +/// topology test can look up node ids by kind / text. +fn build_with_bound(source: &str) -> (FlowProduct, BoundFile) { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + let bound = bind_file(&program, source, FileId::ROOT); + let product = build_flow(&program, source, &bound); + (product, bound) +} + +/// The flow node stamped on a node (panics if unattached). +fn flow_of_node(product: &FlowProduct, id: NodeId) -> FlowNodeId { + product.flow_of_node[id.index()].expect("flow attachment") +} + +/// The single flow node matching `pred` (panics if none / used where unique). +fn find_flow(product: &FlowProduct, pred: impl Fn(&FlowGraph, FlowNodeId) -> bool) -> FlowNodeId { + (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .find(|&id| pred(&product.graph, id)) + .expect("matching flow node") +} + +/// The condition node (`TrueCondition`/`FalseCondition`) whose subject is +/// `subject`. +fn condition_of(product: &FlowProduct, subject: NodeId, want_true: bool) -> FlowNodeId { + let flag = if want_true { + FlowFlags::TRUE_CONDITION + } else { + FlowFlags::FALSE_CONDITION + }; + find_flow(product, |g, id| { + g.flags(id).contains(flag) && g.subject(id) == Some(subject) + }) +} + +fn nodes_of_kind(bound: &BoundFile, kind: NodeKind) -> Vec { + bound + .kinds + .iter() + .enumerate() + .filter(|(_, k)| **k == kind) + .map(|(i, _)| NodeId::from_index(i)) + .collect() +} + +/// The `NodeId` of the identifier whose source text is exactly `text`. +fn ident(bound: &BoundFile, source: &str, text: &str) -> NodeId { + for (i, k) in bound.kinds.iter().enumerate() { + if *k == NodeKind::Identifier && bound.spans[i].extract(source) == text { + return NodeId::from_index(i); + } + } + panic!("identifier {text:?} not found"); +} + +#[test] +fn unreachable_flow_is_id_1() { + let product = build("const x = 1;"); + let uid = FlowNodeId::UNREACHABLE; + assert_eq!(uid.get(), 1); + assert!(product.graph.flags(uid).contains(FlowFlags::UNREACHABLE)); + // The SourceFile Start is id 2 (minted right after unreachable). + assert!(product.graph.node_count() >= 2); +} + +#[test] +fn antecedent_iter_forms_agree_with_collected_forms() { + // A branching + loop + try/finally program exercises single-slot nodes, + // multi-antecedent labels, and a ReduceLabel; the zero-alloc iterators + // must agree with the collected forms on every node, and the non-label + // single-slot read must agree with the general form. + let product = build( + "function f(a: boolean) { try { while (a) { if (a) break; a = !a; } } finally { g(); } }", + ); + let g = &product.graph; + for raw in 1..=g.node_count() { + let id = FlowNodeId::from_raw(raw).unwrap(); + let collected = g.antecedents(id); + assert_eq!(g.antecedents_iter(id).collect::>(), collected); + if !g.flags(id).is_label() { + assert_eq!(g.single_antecedent(id), collected.first().copied()); + assert!(collected.len() <= 1); + } + if g.flags(id).contains(FlowFlags::REDUCE_LABEL) { + assert_eq!( + g.reduce_label_antecedents_iter(id).collect::>(), + g.reduce_label_antecedents(id) + ); + } + } +} + +#[test] +fn node_flags_column_is_minted_here_zeroed_and_sized() { + // The per-node flag column lives on the flow product (its sole writer); + // reachable-only code leaves every byte zero. + let (product, bound) = build_with_bound("const x = 1; function f(a: T) { return a; }"); + assert_eq!(product.node_flags.len(), bound.node_count as usize); + assert!(product.node_flags.iter().all(|&b| b == 0)); +} + +#[test] +fn linear_two_statements_thread_one_start() { + let src = "function f() { a; b; }"; + let (_arena, bound) = flow_of(src); + let product = { + let arena = Bump::new(); + let program = tsv_ts::parse(src, &arena).expect("parse"); + build_flow(&program, src, &bind_file(&program, src, FileId::ROOT)) + }; + // Both expression statements capture the same entry flow (f's Start), and + // that Start is f's end-of-flow (reachable at exit). + let stmts = nodes_of_kind(&bound, NodeKind::ExpressionStatement); + assert_eq!(stmts.len(), 2); + let flow_a = product.flow_of_node[stmts[0].index()].expect("a entry flow"); + let flow_b = product.flow_of_node[stmts[1].index()].expect("b entry flow"); + assert_eq!(flow_a, flow_b); + assert!(product.graph.flags(flow_a).contains(FlowFlags::START)); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), Some(flow_a)); +} + +#[test] +fn linear_var_init_and_dotted_call() { + let product = build("function f() { let x = 1; g(); }"); + // One Assignment mutation (`x = 1`) and one Call (`g()`). + let has_assignment = (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .any(|id| product.graph.flags(id).contains(FlowFlags::ASSIGNMENT)); + let has_call = (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .any(|id| product.graph.flags(id).contains(FlowFlags::CALL)); + assert!( + has_assignment, + "expected a createFlowMutation(Assignment) node" + ); + assert!(has_call, "expected a createFlowCall node"); +} + +#[test] +fn unreachable_after_return_propagates() { + let src = "function f() { return; a; }"; + let (_arena, bound) = flow_of(src); + let product = { + let arena = Bump::new(); + let program = tsv_ts::parse(src, &arena).expect("parse"); + build_flow(&program, src, &bind_file(&program, src, FileId::ROOT)) + }; + + // The ReturnStatement's entry flow is f's Start. + let ret = nodes_of_kind(&bound, NodeKind::ReturnStatement)[0]; + let ret_flow = product.flow_of_node[ret.index()].expect("return entry flow"); + assert!(product.graph.flags(ret_flow).contains(FlowFlags::START)); + + // The dead `a;` ExpressionStatement: flow nil (None) + Unreachable bit. + let a_stmt = nodes_of_kind(&bound, NodeKind::ExpressionStatement)[0]; + assert_eq!(product.flow_of_node[a_stmt.index()], None); + assert_ne!( + product.node_flags[a_stmt.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0 + ); + + // The dead leaf identifier `a` keeps Some(unreachable = id 1). + let a_id = ident(&bound, src, "a"); + assert_eq!( + product.flow_of_node[a_id.index()], + Some(FlowNodeId::UNREACHABLE) + ); + + // f gets NO end_flow (its exit is unreachable). The only end_flow is the + // SourceFile root. + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), None); + assert_eq!(product.end_flow.len(), 1); // SourceFile only +} + +#[test] +fn constructor_gets_a_return_flow_anchor() { + let src = "class C { constructor() { return; } }"; + let (_arena, bound) = flow_of(src); + let product = { + let arena = Bump::new(); + let program = tsv_ts::parse(src, &arena).expect("parse"); + build_flow(&program, src, &bind_file(&program, src, FileId::ROOT)) + }; + // The constructor container carries exactly one return_flow anchor (keyed + // on the value FunctionExpression — the reliably-addressable body-bearing + // node; see the F0-collision note in `visit_method`). Its single- + // antecedent return label collapsed to the `return`'s Start (a dead row). + assert_eq!(product.return_flow.len(), 1); + let rf = product.return_flow[0].1; + assert!(product.graph.flags(rf).contains(FlowFlags::START)); + // The anchor is a FunctionExpression node (the method body). + let anchor_node = product.return_flow[0].0; + assert_eq!( + bound.kinds[anchor_node.index()], + NodeKind::FunctionExpression + ); + // The symmetric accessor resolves the anchor to the same return flow. + assert_eq!(product.return_flow_of(anchor_node), Some(rf)); + assert!(product.stats.branch_labels >= 1); + assert!(product.stats.dead_labels >= 1); +} + +#[test] +fn finish_flow_label_pool_run_preserves_order_and_dedups() { + let src = "const x = 1;"; + let arena = Bump::new(); + let program = tsv_ts::parse(src, &arena).expect("parse"); + let bound = bind_file(&program, src, FileId::ROOT); + let mut b = FlowBuilder::new(&bound, src); + let a1 = b.new_flow_node(FlowFlags::START); + let a2 = b.new_flow_node(FlowFlags::ASSIGNMENT); + let label = b.create_branch_label(); + b.add_antecedent(label, a1); + b.add_antecedent(label, a2); + b.add_antecedent(label, a1); // id-equality dedup: ignored + let finished = b.finish_flow_label(label); + assert_eq!(finished, label); // 2+ antecedents → the label survives + let product = b.finish(); + // Entry edge first, order preserved, no duplicate. + assert_eq!(product.graph.antecedents(label), vec![a1, a2]); + // Both antecedents were referenced; a1 twice would be Shared, but the dup + // was a no-op, so a1 is Referenced-once here. + assert!(product.graph.flags(a1).contains(FlowFlags::REFERENCED)); +} + +#[test] +fn referenced_shared_recompute_parity() { + // Recompute the live-graph in-degree and check it against the Referenced / + // Shared bits. `setFlowNodeReferenced` marks a node on EVERY antecedent + // add at construction (matching tsgo), including adds into a branch label + // that later COLLAPSES to a dead row — and tsv's SoA drops a collapsed + // label's edges (slot 0, no pool run). So the live in-degree is a **lower + // bound** on the referenced-count, and the sound, one-directional + // invariant is: every live antecedent edge is reflected in the bits (they + // never under-mark). The fn Start (shared by both condition nodes) gives a + // genuine live in-degree ≥ 2 → Shared. + let src = "function f() { if (x) a; else b; }"; + let product = build(src); + let g = &product.graph; + let n = g.node_count(); + let mut indeg = vec![0u32; (n + 1) as usize]; + for id in (1..=n).filter_map(FlowNodeId::from_raw) { + for ante in g.antecedents(id) { + indeg[ante.get() as usize] += 1; + } + } + let mut saw_shared = false; + for id in (1..=n).filter_map(FlowNodeId::from_raw) { + let d = indeg[id.get() as usize]; + let flags = g.flags(id); + if d >= 1 { + assert!( + flags.contains(FlowFlags::REFERENCED), + "in-degree ≥ 1 ⟹ Referenced at node {}", + id.get() + ); + } + if d >= 2 { + assert!( + flags.contains(FlowFlags::SHARED), + "in-degree ≥ 2 ⟹ Shared at node {}", + id.get() + ); + saw_shared = true; + } + } + assert!(saw_shared, "the fn Start is shared by both condition nodes"); +} diff --git a/crates/tsv_check/src/binder/flow/tests/predicates.rs b/crates/tsv_check/src/binder/flow/tests/predicates.rs new file mode 100644 index 000000000..d46da25a6 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/tests/predicates.rs @@ -0,0 +1,109 @@ +//! Tests for the pure AST predicates the flow walk dispatches on — the +//! counterpart of `build/predicates.rs`. + +use super::super::build::{FlowBuilder, is_narrowable_reference}; +use super::super::*; +use crate::binder::{NodeKind, addr_of, bind_file}; +use crate::ids::FileId; +use bumpalo::Bump; +use tsv_ts::ast::internal::{Expression, Statement}; + +#[test] +fn create_flow_condition_ports_verbatim() { + let src = "true; false; y;"; + let arena = Bump::new(); + let program = tsv_ts::parse(src, &arena).expect("parse"); + let bound = bind_file(&program, src, FileId::ROOT); + + // Extract the top-level expressions + their node ids. + let expr_at = |i: usize| -> (&Expression<'_>, NodeId) { + let Statement::ExpressionStatement(s) = &program.body[i] else { + panic!("expression statement"); + }; + let id = match &s.expression { + Expression::Literal(l) => bound.require_node_id(addr_of(l), NodeKind::Literal), + Expression::Identifier(idn) => { + bound.require_node_id(addr_of(idn), NodeKind::Identifier) + } + _ => panic!("unexpected expression"), + }; + (&s.expression, id) + }; + let true_lit = expr_at(0); + let false_lit = expr_at(1); + let y = expr_at(2); + + let mut b = FlowBuilder::new(&bound, src); + let ante = b.new_flow_node(FlowFlags::START); + + // nil-expr True → passthrough; nil-expr False → unreachable. + assert_eq!( + b.create_flow_condition(FlowFlags::TRUE_CONDITION, ante, None, false, false, false), + ante + ); + assert_eq!( + b.create_flow_condition(FlowFlags::FALSE_CONDITION, ante, None, false, false, false), + b.unreachable_flow + ); + + // literal `true` under a FalseCondition (not in an optional-chain / + // nullish context) short-circuits to unreachable; `false` under a + // TrueCondition likewise. + assert_eq!( + b.create_flow_condition( + FlowFlags::FALSE_CONDITION, + ante, + Some(true_lit), + false, + false, + false + ), + b.unreachable_flow + ); + assert_eq!( + b.create_flow_condition( + FlowFlags::TRUE_CONDITION, + ante, + Some(false_lit), + false, + false, + false + ), + b.unreachable_flow + ); + + // A non-narrowing expression leaves the antecedent unchanged. + assert_eq!( + b.create_flow_condition( + FlowFlags::TRUE_CONDITION, + ante, + Some(y), + false, + false, + false + ), + ante + ); + + // A narrowing expression mints a new condition node carrying the flag. + let cond = + b.create_flow_condition(FlowFlags::TRUE_CONDITION, ante, Some(y), true, false, false); + assert_ne!(cond, ante); + assert!(b.flags[cond.index()].contains(FlowFlags::TRUE_CONDITION)); +} + +#[test] +fn is_narrowable_reference_matches_tsgo_shape() { + // Sanity for the live access-gate helper. + let arena = Bump::new(); + let src = "a.b; a[0]; a?.b;"; + let program = tsv_ts::parse(src, &arena).expect("parse"); + for stmt in program.body { + if let Statement::ExpressionStatement(s) = stmt { + assert!( + is_narrowable_reference(&s.expression), + "member/element access should be narrowable" + ); + } + } +} diff --git a/crates/tsv_check/src/binder/flow/tests/statements.rs b/crates/tsv_check/src/binder/flow/tests/statements.rs new file mode 100644 index 000000000..b16770734 --- /dev/null +++ b/crates/tsv_check/src/binder/flow/tests/statements.rs @@ -0,0 +1,612 @@ +//! Tests for the statement-shaped flow topology — conditions, loops, switch, +//! try/catch/finally, initializer forks, and labeled statements — the +//! counterpart of `build/statements.rs`. + +use super::super::*; +use super::{build, build_with_bound, condition_of, flow_of_node, ident, nodes_of_kind}; +use crate::binder::NodeKind; + +// --- F1b branching topology (hand-traced graphs) ---------------------- + +#[test] +fn if_else_two_arm_merge() { + // `if (x) a; else b;` — C1=TrueCond(x,F0), C2=FalseCond(x,F0); a.flow=C1, + // b.flow=C2; both merge at a materialized BranchLabel [C1,C2]; F0 Shared. + let src = "function f() { if (x) a; else b; }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + + let f0 = flow_of_node(&product, x); + assert!(product.graph.flags(f0).contains(FlowFlags::START)); + + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + assert_eq!(product.graph.antecedents(c1), vec![f0]); + assert_eq!(product.graph.antecedents(c2), vec![f0]); + assert_eq!(flow_of_node(&product, a), c1); + assert_eq!(flow_of_node(&product, b), c2); + + // F0 is referenced by both conditions → Shared. + assert!(product.graph.flags(f0).contains(FlowFlags::SHARED)); + + // The if merges at postIf (a materialized 2-antecedent BranchLabel) — the + // function's end-of-flow. + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(exit).contains(FlowFlags::BRANCH_LABEL)); + assert_eq!(product.graph.antecedents(exit), vec![c1, c2]); +} + +#[test] +fn reachable_after_if_merge() { + // `if (x) a; b;` — with no else, `b` (the statement after the if) binds at + // the postIf merge label. + let src = "function f() { if (x) a; b; }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let b = ident(&bound, src, "b"); + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + let b_flow = flow_of_node(&product, b); + // b's entry flow is the postIf label carrying the then-branch (C1) and the + // empty-else branch (C2). + assert!( + product + .graph + .flags(b_flow) + .contains(FlowFlags::BRANCH_LABEL) + ); + assert_eq!(product.graph.antecedents(b_flow), vec![c1, c2]); +} + +#[test] +fn while_loop_topology() { + // `while (x) a;` — L1=LoopLabel; entry F0 added first, back edge (C1) + // after the body → L1.antecedents=[F0,C1]; x.flow=L1; a.flow=C1; exit=C2. + let src = "function f() { while (x) a; }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let a = ident(&bound, src, "a"); + let while_stmt = nodes_of_kind(&bound, NodeKind::WhileStatement)[0]; + let f0 = flow_of_node(&product, while_stmt); // the while's entry flow (f's Start) + + let l1 = flow_of_node(&product, x); + assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL)); + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + assert_eq!(product.graph.antecedents(l1), vec![f0, c1]); + assert_eq!(flow_of_node(&product, a), c1); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), Some(c2)); +} + +#[test] +fn do_while_loop_topology() { + // `do a; while (x);` — L1=LoopLabel[F0]; a.flow=L1; x.flow=L1; the + // true-condition loops back → L1.antecedents=[F0,C1]; exit=C2. + let src = "function f() { do a; while (x); }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let a = ident(&bound, src, "a"); + let do_stmt = nodes_of_kind(&bound, NodeKind::DoWhileStatement)[0]; + let f0 = flow_of_node(&product, do_stmt); + + let l1 = flow_of_node(&product, a); + assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL)); + assert_eq!(flow_of_node(&product, x), l1); // condition binds from the loop label + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + assert_eq!(product.graph.antecedents(l1), vec![f0, c1]); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), Some(c2)); +} + +#[test] +fn for_infinite_self_loop() { + // `for (;;) a;` — nil condition: True→L1 passthrough, False→unreachable + // (dropped). a.flow=L1; the back edge self-loops → L1.antecedents=[F0,L1]; + // postLoop stays empty so the function exits unreachable (no end_flow). + let src = "function f() { for (;;) a; }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let for_stmt = nodes_of_kind(&bound, NodeKind::ForStatement)[0]; + let f0 = flow_of_node(&product, for_stmt); + + let l1 = flow_of_node(&product, a); + assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL)); + // Self-loop: L1 is its own back-edge antecedent (guarded by vec equality). + assert_eq!(product.graph.antecedents(l1), vec![f0, l1]); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), None); // unreachable exit +} + +#[test] +fn unlabeled_continue_targets_loop_label() { + // `while (x) continue;` — the continue routes back to the loop label, + // so L1.antecedents=[F0, C1]; the normal exit is the false condition. + let src = "function f() { while (x) continue; }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let l1 = flow_of_node(&product, x); + let c1 = condition_of(&product, x, true); + let antes = product.graph.antecedents(l1); + assert!( + antes.contains(&c1), + "continue back-edge lands on the loop label" + ); + assert_eq!(antes.len(), 2); // [entry F0, continue C1] + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let c2 = condition_of(&product, x, false); + assert_eq!(product.end_flow_of(f), Some(c2)); +} + +#[test] +fn unlabeled_break_targets_post_loop() { + // `while (x) break;` — the break routes to the post-loop label (the + // function exit), which also carries the false-condition edge; the break + // makes the back edge unreachable, so the loop label keeps only its entry. + let src = "function f() { while (x) break; }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + let antes = product.graph.antecedents(exit); + assert!(antes.contains(&c1), "break edge to the post-loop label"); + assert!(antes.contains(&c2), "false-condition exit edge"); + + // The loop label kept only the entry edge (the back edge was unreachable). + let l1 = flow_of_node(&product, x); + assert_eq!(product.graph.antecedents(l1).len(), 1); +} + +// --- F2a switch topology (hand-traced graphs) ------------------------- + +/// Every `SwitchClause` flow node, in id order. +fn switch_clauses(product: &FlowProduct) -> Vec { + (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .filter(|&id| product.graph.flags(id).contains(FlowFlags::SWITCH_CLAUSE)) + .collect() +} + +#[test] +fn switch_no_default_has_exhaustive_sentinel() { + // `switch (x) { case 1: a; }` — no default clause, so postSwitch gets the + // clause-1 exit AND a `(0, 0)` "no clause matched" SwitchClause sentinel. + let src = "function f() { switch (x) { case 1: a; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + // The clause body is reachable (fed from the switch head). + assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE); + + // The `(0, 0)` sentinel exists and feeds postSwitch (the function exit). + let sentinel = switch_clauses(&product) + .into_iter() + .find(|&id| { + let d = product.graph.switch_clause_data(id); + d.clause_start == 0 && d.clause_end == 0 + }) + .expect("no-default (0,0) sentinel"); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + assert!( + product.graph.antecedents(exit).contains(&sentinel), + "the (0,0) sentinel feeds postSwitch" + ); +} + +#[test] +fn switch_break_then_clause_stays_reachable() { + // THE F2a PROOF. `switch (x) { case 1: break; case 2: a; }` — case 1 + // breaks, so nothing falls through into case 2; but case 2 is reachable + // FROM THE SWITCH HEAD, so `a` must be reachable. F1b's linear stub + // threaded current_flow (= unreachable after the break) into case 2 and + // wrongly marked it Unreachable — this test fails on that stub. + let src = "function f() { switch (x) { case 1: break; case 2: a; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let a_flow = flow_of_node(&product, a); + assert_ne!( + a_flow, + FlowNodeId::UNREACHABLE, + "case 2 is reachable from the switch head despite case 1's break" + ); + // `a`'s entry is the clause's SwitchClause node covering range [1, 2). + assert!( + product + .graph + .flags(a_flow) + .contains(FlowFlags::SWITCH_CLAUSE) + ); + assert_eq!( + { + let d = product.graph.switch_clause_data(a_flow); + (d.clause_start, d.clause_end) + }, + (1, 2) + ); + // The `a;` statement is reachable: Some entry flow, no Unreachable flag. + let a_stmt = nodes_of_kind(&bound, NodeKind::ExpressionStatement)[0]; + assert!(product.flow_of_node[a_stmt.index()].is_some()); + assert_eq!( + product.node_flags[a_stmt.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0 + ); +} + +#[test] +fn switch_fallthrough_feeds_next_clause() { + // `switch (x) { case 1: a; case 2: b; }` — case 1 falls through to case 2, + // so case 2's preCase merges its switch-head edge (a SwitchClause[1,2)) and + // case 1's fallthrough edge; case 1 records a fallthrough anchor. + let src = "function f() { switch (x) { case 1: a; case 2: b; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + let a_flow = flow_of_node(&product, a); + let b_flow = flow_of_node(&product, b); + + // case 2 binds at a materialized 2-antecedent branch label. + assert!( + product + .graph + .flags(b_flow) + .contains(FlowFlags::BRANCH_LABEL) + ); + let antes = product.graph.antecedents(b_flow); + assert_eq!(antes.len(), 2); + // One antecedent is case 1's exit (the fallthrough). + assert!(antes.contains(&a_flow), "fallthrough edge from case 1"); + // The other is case 2's switch-head SwitchClause with range [1, 2). + let head = antes + .iter() + .copied() + .find(|&x| x != a_flow) + .expect("head edge"); + assert!(product.graph.flags(head).contains(FlowFlags::SWITCH_CLAUSE)); + assert_eq!( + { + let d = product.graph.switch_clause_data(head); + (d.clause_start, d.clause_end) + }, + (1, 2) + ); + // case 1 (the first SwitchCase node) recorded its reachable exit anchor. + let case1 = nodes_of_kind(&bound, NodeKind::SwitchCase)[0]; + assert_eq!(product.fallthrough_flow_of(case1), Some(a_flow)); +} + +#[test] +fn switch_empty_clause_run_reachable() { + // `switch (x) { case 1: case 2: a; }` — the empty `case 1` shares the run + // with `case 2`; `a` is reachable, fed from the head via one SwitchClause + // whose range spans the merged run [0, 2). + let src = "function f() { switch (x) { case 1: case 2: a; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let a_flow = flow_of_node(&product, a); + assert_ne!(a_flow, FlowNodeId::UNREACHABLE); + assert!( + product + .graph + .flags(a_flow) + .contains(FlowFlags::SWITCH_CLAUSE) + ); + assert_eq!( + { + let d = product.graph.switch_clause_data(a_flow); + (d.clause_start, d.clause_end) + }, + (0, 2) + ); +} + +#[test] +fn switch_true_narrows_with_real_range() { + // `switch (true) { case y: a; }` — a narrowing switch, so the clause gets + // a real SwitchClause node carrying its [0, 1) range, fed from the head. + let src = "function f() { switch (true) { case y: a; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let a_flow = flow_of_node(&product, a); + assert!( + product + .graph + .flags(a_flow) + .contains(FlowFlags::SWITCH_CLAUSE) + ); + assert_eq!( + { + let d = product.graph.switch_clause_data(a_flow); + (d.clause_start, d.clause_end) + }, + (0, 1) + ); + // The SwitchClause node's single antecedent is the switch head (fn Start). + let head = product.graph.antecedents(a_flow); + assert_eq!(head.len(), 1); + assert!(product.graph.flags(head[0]).contains(FlowFlags::START)); +} + +#[test] +fn switch_non_narrowing_clauses_have_no_payload() { + // `switch (f()) { case 1: a; case 2: b; }` — a call discriminant is NOT + // narrowing, so each clause is fed from the bare switch head (no per-clause + // `SwitchClause` payload node). Clauses stay reachable; the only SwitchClause + // in the graph is the no-default `(0,0)` sentinel. (Guards the `is_narrowing_switch` + // false branch — a regression that always minted SwitchClause nodes would + // pass every narrowing test.) + let src = "function f() { switch (f()) { case 1: a; case 2: b; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE); + assert_ne!(flow_of_node(&product, b), FlowNodeId::UNREACHABLE); + // Neither clause body's entry flow is a SwitchClause node. + assert!( + !product + .graph + .flags(flow_of_node(&product, a)) + .contains(FlowFlags::SWITCH_CLAUSE) + ); + assert!( + !product + .graph + .flags(flow_of_node(&product, b)) + .contains(FlowFlags::SWITCH_CLAUSE) + ); + // The only SwitchClause node is the `(0,0)` sentinel (no default clause). + let clauses = switch_clauses(&product); + assert_eq!(clauses.len(), 1); + let d = product.graph.switch_clause_data(clauses[0]); + assert_eq!((d.clause_start, d.clause_end), (0, 0)); +} + +#[test] +fn switch_with_default_has_no_sentinel() { + // `switch (x) { case 1: a; default: b; }` — a `default` clause makes the + // switch exhaustive, so NO `(0,0)` sentinel is emitted. (Narrowing, so the + // clauses still get real SwitchClause payloads.) Guards the `has_default` + // path — a regression that always emitted the sentinel would pass every + // no-default test. + let src = "function f() { switch (x) { case 1: a; default: b; } }"; + let (product, bound) = build_with_bound(src); + let a = ident(&bound, src, "a"); + let b = ident(&bound, src, "b"); + assert_ne!(flow_of_node(&product, a), FlowNodeId::UNREACHABLE); + assert_ne!(flow_of_node(&product, b), FlowNodeId::UNREACHABLE); + // No SwitchClause node carries the `(0,0)` sentinel range. + assert!( + switch_clauses(&product).into_iter().all(|id| { + let d = product.graph.switch_clause_data(id); + (d.clause_start, d.clause_end) != (0, 0) + }), + "a default-present switch emits no (0,0) exhaustiveness sentinel" + ); +} + +// --- F2b: the four remaining flow landmines (hand-traced graphs) ------- + +/// Every `ReduceLabel` flow node, in id order. +fn reduce_labels(product: &FlowProduct) -> Vec { + (1..=product.graph.node_count()) + .filter_map(FlowNodeId::from_raw) + .filter(|&id| product.graph.flags(id).contains(FlowFlags::REDUCE_LABEL)) + .collect() +} + +#[test] +fn try_finally_reduce_label_and_merge() { + // `try { a; } finally { b; }` — b binds at the finally label (a branch + // label merging the try-normal and exception antecedents); the try exits + // through a REDUCE_LABEL (the finally's normal-completion routing) whose + // target is that finally label. + let src = "function f() { try { a; } finally { b; } }"; + let (product, bound) = build_with_bound(src); + let b = ident(&bound, src, "b"); + let b_flow = flow_of_node(&product, b); + assert!( + product + .graph + .flags(b_flow) + .contains(FlowFlags::BRANCH_LABEL) + ); + + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(exit).contains(FlowFlags::REDUCE_LABEL)); + assert_eq!(product.graph.reduce_label_data(exit).target, b_flow); + // The reduced antecedent list is the try block's normal exit (f's Start). + let reduced = product.graph.reduce_label_antecedents(exit); + assert_eq!(reduced.len(), 1); + assert!(product.graph.flags(reduced[0]).contains(FlowFlags::START)); +} + +#[test] +fn try_catch_finally_exception_edges() { + // Catch = a second try. `try { x = 1; } catch { b; } finally { c; }` — + // the catch binds at the try's exception label, fed by BOTH the + // "any instruction can throw" edge (the entry Start) AND the mutation's + // exception fan-out (createFlowMutation → currentExceptionTarget). + let src = "function f() { try { x = 1; } catch { b; } finally { c; } }"; + let (product, bound) = build_with_bound(src); + let b = ident(&bound, src, "b"); + let b_flow = flow_of_node(&product, b); + assert!( + product + .graph + .flags(b_flow) + .contains(FlowFlags::BRANCH_LABEL) + ); + let antes = product.graph.antecedents(b_flow); + assert!( + antes + .iter() + .any(|&a| product.graph.flags(a).contains(FlowFlags::START)), + "the pre-mutation throw edge" + ); + assert!( + antes + .iter() + .any(|&a| product.graph.flags(a).contains(FlowFlags::ASSIGNMENT)), + "the mutation's exception fan-out" + ); + // The finally still routes normal completion through a REDUCE_LABEL. + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(exit).contains(FlowFlags::REDUCE_LABEL)); +} + +#[test] +fn try_finally_return_routes_through_reduce_label() { + // An IIFE gives the try a real (non-None) return target, so a `return` + // inside a try-with-finally materializes a return-only ReduceLabel that + // feeds that target (and collapses onto it as the function exit). + let src = "function f() { (function() { try { return 1; } finally { g(); } })(); }"; + let (product, bound) = build_with_bound(src); + let reduces = reduce_labels(&product); + assert_eq!( + reduces.len(), + 1, + "one ReduceLabel: the return-only finally routing" + ); + let rl = reduces[0]; + let reduced = product.graph.reduce_label_antecedents(rl); + assert_eq!(reduced.len(), 1, "the single return path"); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + assert_eq!(product.end_flow_of(f), Some(rl)); +} + +#[test] +fn try_return_finally_leaves_post_try_unreachable_in_plain_function() { + // Guards the normal-list-empty → unreachable branch: in a PLAIN function + // (no return target), `try { return; } finally {}` leaves the code after + // the try unreachable — the try's only exit was via `return` (to the + // return label), so the finally's normal-exit list is empty. The existing + // return-reduce test uses an IIFE (non-None return target), so this + // plain-function branch was uncovered. + let src = "function f() { try { return; } finally {} g(); }"; + let (product, bound) = build_with_bound(src); + let g = ident(&bound, src, "g"); + // `g` (a leaf in dead code) keeps `Some(unreachable)`; the `g();` statement + // is unreachable. + assert_eq!(flow_of_node(&product, g), FlowNodeId::UNREACHABLE); +} + +#[test] +fn parameter_default_that_changes_flow_forks() { + // A parameter default containing a flow-changing expression (an + // assignment mutation) forks current_flow around the initializer + // (bindInitializer). The only branch label is the fork's exit. + let src = "function f(a = (b = c)) {}"; + let (product, bound) = build_with_bound(src); + assert_eq!(product.stats.branch_labels, 1); + let a = ident(&bound, src, "a"); + let a_flow = flow_of_node(&product, a); + assert!( + product + .graph + .flags(a_flow) + .contains(FlowFlags::BRANCH_LABEL) + ); + assert_eq!( + product.graph.antecedents(a_flow).len(), + 2, + "the no-default entry + the post-initializer flow merge" + ); +} + +#[test] +fn parameter_default_without_flow_change_does_not_fork() { + // A literal default doesn't change current_flow → no fork, no label. + let src = "function f(a = 1) {}"; + let product = build(src); + assert_eq!(product.stats.branch_labels, 0); +} + +#[test] +fn labeled_continue_resolves_to_loop_continue_target() { + // `outer: while (x) { continue outer; }` — continue outer routes to the + // while's continue target (the loop label), and `outer` is referenced so + // its label identifier carries NO Unreachable bit. + let src = "function f() { outer: while (x) { continue outer; } }"; + let (product, bound) = build_with_bound(src); + let x = ident(&bound, src, "x"); + let l1 = flow_of_node(&product, x); + assert!(product.graph.flags(l1).contains(FlowFlags::LOOP_LABEL)); + let c1 = condition_of(&product, x, true); + let antes = product.graph.antecedents(l1); + assert!( + antes.contains(&c1), + "continue outer lands on the loop label (like an unlabeled continue)" + ); + assert_eq!(antes.len(), 2); // [entry, continue-outer back edge] + + let outer = ident(&bound, src, "outer"); + assert_eq!( + product.node_flags[outer.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0, + "outer is referenced → no Unreachable stamp" + ); +} + +#[test] +fn unreferenced_label_gets_unreachable_stamp() { + // `unused: a;` — the label is never targeted, so its identifier gets the + // Unreachable bit (the TS7028 signal). + let src = "function f() { unused: a; }"; + let (product, bound) = build_with_bound(src); + let unused = ident(&bound, src, "unused"); + assert_ne!( + product.node_flags[unused.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0, + "an unreferenced label identifier carries the Unreachable bit" + ); +} + +#[test] +fn labeled_break_targets_outer_post_label() { + // `outer: inner: while (x) { break outer; }` — break outer targets + // outer's post-statement label (the function exit, merging the break edge + // and the loop's normal false-condition exit). `outer` is referenced, + // `inner` is not. + let src = "function f() { outer: inner: while (x) { break outer; } }"; + let (product, bound) = build_with_bound(src); + let outer = ident(&bound, src, "outer"); + let inner = ident(&bound, src, "inner"); + assert_eq!( + product.node_flags[outer.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0, + "outer is referenced by break outer" + ); + assert_ne!( + product.node_flags[inner.index()] & crate::binder::NODE_FLAGS_UNREACHABLE, + 0, + "inner is unused" + ); + + let x = ident(&bound, src, "x"); + let c1 = condition_of(&product, x, true); + let c2 = condition_of(&product, x, false); + let f = nodes_of_kind(&bound, NodeKind::FunctionDeclaration)[0]; + let exit = product.end_flow_of(f).expect("f end_flow"); + assert!(product.graph.flags(exit).contains(FlowFlags::BRANCH_LABEL)); + let antes = product.graph.antecedents(exit); + assert!( + antes.contains(&c1), + "the break-outer edge (from inside the loop body)" + ); + assert!( + antes.contains(&c2), + "the loop's normal false-condition exit" + ); +} diff --git a/crates/tsv_check/src/binder/lower/expression.rs b/crates/tsv_check/src/binder/lower/expression.rs new file mode 100644 index 000000000..f3200d21b --- /dev/null +++ b/crates/tsv_check/src/binder/lower/expression.rs @@ -0,0 +1,523 @@ +//! The expression-shaped visitor methods — `visit_expression` (the full +//! pattern-aware descent, since expression slots also host `Object`/`Array`/ +//! `Assignment` patterns, `RestElement`, and `TSParameterProperty`) and its +//! supporting visitors (object/array literal members, templates, decorators, +//! identifiers). + +use super::super::*; +use tsv_ts::ast::internal::{ + ArrayExpression, ArrayPattern, ArrowFunctionBody, ArrowFunctionExpression, + AssignmentExpression, AssignmentPattern, AwaitExpression, BinaryExpression, CallExpression, + ClassExpression, ConditionalExpression, Decorator, Expression, FunctionExpression, Identifier, + ImportExpression, JsdocCast, MemberExpression, MetaProperty, NewExpression, ObjectExpression, + ObjectPattern, ObjectPatternProperty, ObjectProperty, ParenthesizedExpression, Property, + RestElement, SequenceExpression, SpreadElement, TSAsExpression, TSInstantiationExpression, + TSNonNullExpression, TSParameterProperty, TSSatisfiesExpression, TSTypeAssertion, + TaggedTemplateExpression, TemplateElement, TemplateLiteral, UnaryExpression, UpdateExpression, + YieldExpression, +}; + +impl SoaWalk { + pub(super) fn visit_params(&mut self, params: &[Expression<'_>], parent: NodeId) { + for param in params { + self.visit_expression(param, parent); + } + } + + /// Visit any expression position, including the pattern-shaped ones + /// (`Object`/`Array`/`Assignment` pattern, `RestElement`, `TSParameterProperty`) + /// that occupy parameter, declarator, assignment-target, and for-left slots. A + /// binding identifier / pattern also carries an optional type annotation and + /// parameter decorators — `None` outside those positions, so descending them + /// unconditionally lets this one method serve every expression slot. + pub(super) fn visit_expression(&mut self, expr: &Expression<'_>, parent: NodeId) { + use Expression as E; + match expr { + E::Identifier(idn) => self.visit_identifier(idn, parent), + E::Literal(lit) => self.leaf(NodeKind::Literal, lit.span, addr_of(lit), parent), + E::PrivateIdentifier(pid) => { + self.leaf(NodeKind::PrivateIdentifier, pid.span, addr_of(pid), parent); + } + E::RegexLiteral(r) => self.leaf(NodeKind::RegexLiteral, r.span, addr_of(r), parent), + E::ThisExpression(t) => self.leaf(NodeKind::ThisExpression, t.span, addr_of(t), parent), + E::Super(s) => self.leaf(NodeKind::Super, s.span, addr_of(s), parent), + E::ObjectExpression(o) => self.visit_object_expression(o, parent), + E::ArrayExpression(a) => self.visit_array_expression(a, parent), + E::UnaryExpression(u) => self.visit_unary_expression(u, parent), + E::UpdateExpression(u) => self.visit_update_expression(u, parent), + E::BinaryExpression(b) => self.visit_binary_expression(b, parent), + E::CallExpression(c) => self.visit_call_expression(c, parent), + E::NewExpression(n) => self.visit_new_expression(n, parent), + E::MemberExpression(m) => self.visit_member_expression(m, parent), + E::ConditionalExpression(c) => self.visit_conditional_expression(c, parent), + E::ArrowFunctionExpression(a) => self.visit_arrow_function_expression(a, parent), + E::FunctionExpression(f) => self.visit_function_expression(f, parent), + E::ClassExpression(c) => self.visit_class_expression(c, parent), + E::SpreadElement(s) => self.visit_spread(s, parent), + E::TemplateLiteral(t) => self.visit_template_literal(t, parent), + E::TaggedTemplateExpression(t) => self.visit_tagged_template_expression(t, parent), + E::AwaitExpression(a) => self.visit_await_expression(a, parent), + E::YieldExpression(y) => self.visit_yield_expression(y, parent), + E::SequenceExpression(s) => self.visit_sequence_expression(s, parent), + E::AssignmentExpression(a) => self.visit_assignment_expression(a, parent), + E::ObjectPattern(op) => self.visit_object_pattern(op, parent), + E::ArrayPattern(ap) => self.visit_array_pattern(ap, parent), + E::AssignmentPattern(a) => self.visit_assignment_pattern(a, parent), + E::RestElement(r) => self.visit_rest_element(r, parent), + E::TSTypeAssertion(t) => self.visit_ts_type_assertion(t, parent), + E::TSAsExpression(t) => self.visit_ts_as_expression(t, parent), + E::TSSatisfiesExpression(t) => self.visit_ts_satisfies_expression(t, parent), + E::TSInstantiationExpression(t) => self.visit_ts_instantiation_expression(t, parent), + E::TSNonNullExpression(t) => self.visit_ts_non_null_expression(t, parent), + E::TSParameterProperty(pp) => self.visit_ts_parameter_property(pp, parent), + E::ImportExpression(i) => self.visit_import_expression(i, parent), + E::MetaProperty(m) => self.visit_meta_property(m, parent), + E::JsdocCast(c) => self.visit_jsdoc_cast(c, parent), + E::ParenthesizedExpression(p) => self.visit_parenthesized_expression(p, parent), + } + // Lockstep guard: the arm above must have registered this expression + // under the `(address, kind)` key the shared `expression_addr_kind` + // mapping (the flow walk's resolver) predicts — drift between the two + // is caught here per lowered expression in debug builds (which the + // conformance gate runs), before the strict resolver would hard-fail. + debug_assert!( + self.address_map.contains_key(&expression_addr_kind(expr)), + "visit_expression and expression_addr_kind disagree on an expression's (address, kind) key" + ); + } + + #[inline] + fn visit_object_expression(&mut self, o: &ObjectExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::ObjectExpression, o.span, Some(parent), addr_of(o)); + for prop in o.properties { + self.visit_object_property(prop, id); + } + self.close(id); + } + + #[inline] + fn visit_array_expression(&mut self, a: &ArrayExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::ArrayExpression, a.span, Some(parent), addr_of(a)); + for el in a.elements.iter().flatten() { + self.visit_expression(el, id); + } + self.close(id); + } + + #[inline] + fn visit_unary_expression(&mut self, u: &UnaryExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + + #[inline] + fn visit_update_expression(&mut self, u: &UpdateExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::UpdateExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + + #[inline] + fn visit_binary_expression(&mut self, b: &BinaryExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::BinaryExpression, b.span, Some(parent), addr_of(b)); + self.visit_expression(b.left, id); + self.visit_expression(b.right, id); + self.close(id); + } + + #[inline] + fn visit_call_expression(&mut self, c: &CallExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::CallExpression, c.span, Some(parent), addr_of(c)); + self.visit_expression(c.callee, id); + if let Some(ta) = &c.type_arguments { + self.visit_type_args(ta, id); + } + for a in c.arguments { + self.visit_expression(a, id); + } + self.close(id); + } + + #[inline] + fn visit_new_expression(&mut self, n: &NewExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::NewExpression, n.span, Some(parent), addr_of(n)); + self.visit_expression(n.callee, id); + if let Some(ta) = &n.type_arguments { + self.visit_type_args(ta, id); + } + for a in n.arguments { + self.visit_expression(a, id); + } + self.close(id); + } + + #[inline] + fn visit_member_expression(&mut self, m: &MemberExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::MemberExpression, m.span, Some(parent), addr_of(m)); + self.visit_expression(m.object, id); + self.visit_expression(m.property, id); + self.close(id); + } + + #[inline] + fn visit_conditional_expression(&mut self, c: &ConditionalExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::ConditionalExpression, + c.span, + Some(parent), + addr_of(c), + ); + self.visit_expression(c.test, id); + self.visit_expression(c.consequent, id); + self.visit_expression(c.alternate, id); + self.close(id); + } + + #[inline] + fn visit_arrow_function_expression(&mut self, a: &ArrowFunctionExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::ArrowFunctionExpression, + a.span, + Some(parent), + addr_of(a), + ); + self.visit_type_params(a.type_parameters.as_ref(), id); + self.visit_params(a.params, id); + self.visit_type_annotation_opt(a.return_type.as_ref(), id); + match &a.body { + ArrowFunctionBody::Expression(e) => self.visit_expression(e, id), + ArrowFunctionBody::BlockStatement(b) => self.visit_statements(b.body, id), + } + self.close(id); + } + + #[inline] + fn visit_class_expression(&mut self, c: &ClassExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::ClassExpression, c.span, Some(parent), addr_of(c)); + if let Some(name) = &c.id { + self.visit_identifier(name, id); + } + // Kept in sync with `descend_class` (see the coverage test). + self.visit_type_params(c.type_parameters.as_ref(), id); + self.visit_class_heritage( + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + id, + ); + self.visit_class_body(&c.body, id); + self.close(id); + } + + #[inline] + fn visit_tagged_template_expression( + &mut self, + t: &TaggedTemplateExpression<'_>, + parent: NodeId, + ) { + let id = self.add( + NodeKind::TaggedTemplateExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.tag, id); + if let Some(ta) = &t.type_arguments { + self.visit_type_args(ta, id); + } + self.visit_template_literal(&t.quasi, id); + self.close(id); + } + + #[inline] + fn visit_await_expression(&mut self, a: &AwaitExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::AwaitExpression, a.span, Some(parent), addr_of(a)); + self.visit_expression(a.argument, id); + self.close(id); + } + + #[inline] + fn visit_yield_expression(&mut self, y: &YieldExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::YieldExpression, y.span, Some(parent), addr_of(y)); + if let Some(a) = y.argument { + self.visit_expression(a, id); + } + self.close(id); + } + + #[inline] + fn visit_sequence_expression(&mut self, s: &SequenceExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::SequenceExpression, + s.span, + Some(parent), + addr_of(s), + ); + for e in s.expressions { + self.visit_expression(e, id); + } + self.close(id); + } + + #[inline] + fn visit_assignment_expression(&mut self, a: &AssignmentExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::AssignmentExpression, + a.span, + Some(parent), + addr_of(a), + ); + // `a.left` may be an Object/Array pattern (destructuring assignment) + // — pattern-aware descent, never swallowed by a wildcard. + self.visit_expression(a.left, id); + self.visit_expression(a.right, id); + self.close(id); + } + + #[inline] + fn visit_object_pattern(&mut self, op: &ObjectPattern<'_>, parent: NodeId) { + let id = self.add(NodeKind::ObjectPattern, op.span, Some(parent), addr_of(op)); + if let Some(decs) = op.decorators { + self.visit_decorators(decs, id); + } + self.visit_type_annotation_opt(op.type_annotation.as_ref(), id); + for prop in op.properties { + self.visit_object_pattern_property(prop, id); + } + self.close(id); + } + + #[inline] + fn visit_array_pattern(&mut self, ap: &ArrayPattern<'_>, parent: NodeId) { + let id = self.add(NodeKind::ArrayPattern, ap.span, Some(parent), addr_of(ap)); + if let Some(decs) = ap.decorators { + self.visit_decorators(decs, id); + } + self.visit_type_annotation_opt(ap.type_annotation.as_ref(), id); + for el in ap.elements.iter().flatten() { + self.visit_expression(el, id); + } + self.close(id); + } + + #[inline] + fn visit_assignment_pattern(&mut self, a: &AssignmentPattern<'_>, parent: NodeId) { + let id = self.add( + NodeKind::AssignmentPattern, + a.span, + Some(parent), + addr_of(a), + ); + if let Some(decs) = a.decorators { + self.visit_decorators(decs, id); + } + self.visit_expression(a.left, id); + self.visit_expression(a.right, id); + self.close(id); + } + + #[inline] + fn visit_ts_type_assertion(&mut self, t: &TSTypeAssertion<'_>, parent: NodeId) { + let id = self.add(NodeKind::TSTypeAssertion, t.span, Some(parent), addr_of(t)); + self.visit_type(t.type_annotation, id); + self.visit_expression(t.expression, id); + self.close(id); + } + + #[inline] + fn visit_ts_as_expression(&mut self, t: &TSAsExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::TSAsExpression, t.span, Some(parent), addr_of(t)); + self.visit_expression(t.expression, id); + self.visit_type(t.type_annotation, id); + self.close(id); + } + + #[inline] + fn visit_ts_satisfies_expression(&mut self, t: &TSSatisfiesExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::TSSatisfiesExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.expression, id); + self.visit_type(t.type_annotation, id); + self.close(id); + } + + #[inline] + fn visit_ts_instantiation_expression( + &mut self, + t: &TSInstantiationExpression<'_>, + parent: NodeId, + ) { + let id = self.add( + NodeKind::TSInstantiationExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.expression, id); + self.visit_type_args(&t.type_arguments, id); + self.close(id); + } + + #[inline] + fn visit_ts_non_null_expression(&mut self, t: &TSNonNullExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::TSNonNullExpression, + t.span, + Some(parent), + addr_of(t), + ); + self.visit_expression(t.expression, id); + self.close(id); + } + + #[inline] + fn visit_ts_parameter_property(&mut self, pp: &TSParameterProperty<'_>, parent: NodeId) { + let id = self.add( + NodeKind::TSParameterProperty, + pp.span, + Some(parent), + addr_of(pp), + ); + self.visit_expression(pp.parameter, id); + self.close(id); + } + + #[inline] + fn visit_import_expression(&mut self, i: &ImportExpression<'_>, parent: NodeId) { + let id = self.add(NodeKind::ImportExpression, i.span, Some(parent), addr_of(i)); + self.visit_expression(i.source, id); + if let Some(o) = i.options { + self.visit_expression(o, id); + } + self.close(id); + } + + #[inline] + fn visit_meta_property(&mut self, m: &MetaProperty<'_>, parent: NodeId) { + let id = self.add(NodeKind::MetaProperty, m.span, Some(parent), addr_of(m)); + self.visit_identifier(&m.meta, id); + self.visit_identifier(&m.property, id); + self.close(id); + } + + #[inline] + fn visit_jsdoc_cast(&mut self, c: &JsdocCast<'_>, parent: NodeId) { + let id = self.add(NodeKind::JsdocCast, c.span, Some(parent), addr_of(c)); + self.visit_expression(c.inner, id); + self.close(id); + } + + #[inline] + fn visit_parenthesized_expression(&mut self, p: &ParenthesizedExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::ParenthesizedExpression, + p.span, + Some(parent), + addr_of(p), + ); + self.visit_expression(p.expression, id); + self.close(id); + } + + pub(super) fn visit_function_expression(&mut self, f: &FunctionExpression<'_>, parent: NodeId) { + let id = self.add( + NodeKind::FunctionExpression, + f.span, + Some(parent), + addr_of(f), + ); + self.descend_function_common( + id, + f.id.as_ref(), + f.type_parameters.as_ref(), + f.params, + f.return_type.as_ref(), + f.body.body, + ); + self.close(id); + } + + fn visit_object_property(&mut self, prop: &ObjectProperty<'_>, parent: NodeId) { + match prop { + ObjectProperty::Property(pr) => self.visit_property(pr, parent), + ObjectProperty::SpreadElement(s) => self.visit_spread(s, parent), + } + } + + fn visit_object_pattern_property(&mut self, prop: &ObjectPatternProperty<'_>, parent: NodeId) { + match prop { + ObjectPatternProperty::Property(pr) => self.visit_property(pr, parent), + ObjectPatternProperty::RestElement(r) => self.visit_rest_element(r, parent), + } + } + + fn visit_property(&mut self, pr: &Property<'_>, parent: NodeId) { + let id = self.add(NodeKind::Property, pr.span, Some(parent), addr_of(pr)); + self.visit_expression(&pr.key, id); + self.visit_expression(&pr.value, id); + self.close(id); + } + + fn visit_spread(&mut self, s: &SpreadElement<'_>, parent: NodeId) { + let id = self.add(NodeKind::SpreadElement, s.span, Some(parent), addr_of(s)); + self.visit_expression(s.argument, id); + self.close(id); + } + + fn visit_rest_element(&mut self, r: &RestElement<'_>, parent: NodeId) { + let id = self.add(NodeKind::RestElement, r.span, Some(parent), addr_of(r)); + self.visit_type_annotation_opt(r.type_annotation.as_ref(), id); + self.visit_expression(r.argument, id); + self.close(id); + } + + fn visit_template_literal(&mut self, t: &TemplateLiteral<'_>, parent: NodeId) { + let id = self.add(NodeKind::TemplateLiteral, t.span, Some(parent), addr_of(t)); + for q in t.quasis { + self.visit_template_element(q, id); + } + for e in t.expressions { + self.visit_expression(e, id); + } + self.close(id); + } + + pub(super) fn visit_template_element(&mut self, q: &TemplateElement<'_>, parent: NodeId) { + self.leaf(NodeKind::TemplateElement, q.span, addr_of(q), parent); + } + + pub(super) fn visit_decorators(&mut self, decorators: &[Decorator<'_>], parent: NodeId) { + for d in decorators { + let id = self.add(NodeKind::Decorator, d.span, Some(parent), addr_of(d)); + self.visit_expression(&d.expression, id); + self.close(id); + } + } + + // --- identifiers --------------------------------------------------------- + + /// Id an identifier, then descend the binding-only extras (parameter + /// decorators + type annotation) it carries — both `None` for a reference, so + /// this serves reference and binding positions alike. + pub(super) fn visit_identifier(&mut self, ident: &Identifier<'_>, parent: NodeId) { + let id = self.add( + NodeKind::Identifier, + ident.span, + Some(parent), + addr_of(ident), + ); + if let Some(decs) = ident.decorators() { + self.visit_decorators(decs, id); + } + if let Some(ann) = ident.type_annotation() { + self.visit_type_annotation(ann, id); + } + self.close(id); + } +} diff --git a/crates/tsv_check/src/binder/lower/mod.rs b/crates/tsv_check/src/binder/lower/mod.rs new file mode 100644 index 000000000..2648de88f --- /dev/null +++ b/crates/tsv_check/src/binder/lower/mod.rs @@ -0,0 +1,11 @@ +//! The lowering walk — `SoaWalk`'s per-node visitor methods, split by the AST +//! shape they descend (statements, expressions, types). Each submodule +//! contributes its own `impl SoaWalk { ... }` block; multiple `impl` blocks for +//! the same type are ordinary Rust, so the `SoaWalk` struct itself (its fields +//! and the `add`/`close`/`leaf` id-recording primitives) stays defined once in +//! the parent `binder` module. Purely a locality split — no behavior +//! distinction between the three files. + +mod expression; +mod statement; +mod types; diff --git a/crates/tsv_check/src/binder/lower/statement.rs b/crates/tsv_check/src/binder/lower/statement.rs new file mode 100644 index 000000000..b507cbacc --- /dev/null +++ b/crates/tsv_check/src/binder/lower/statement.rs @@ -0,0 +1,592 @@ +//! The statement-shaped visitor methods — `visit_statement` and everything a +//! statement position (declarations, class/module bodies, import/export +//! specifiers) descends into. + +use super::super::*; +use tsv_ts::ast::internal::{ + CatchClause, ClassBody, ClassDeclaration, ClassMember, Decorator, ExportDefaultValue, + ExportSpecifier, Expression, ForInOfLeft, ForInit, FunctionDeclaration, Identifier, + ImportAttribute, ImportAttributeKey, ImportSpecifier, ModuleExportName, Statement, SwitchCase, + TSDeclareFunction, TSEnumMember, TSEnumMemberId, TSInterfaceDeclaration, TSInterfaceHeritage, + TSModuleDeclaration, TSModuleDeclarationBody, TSModuleName, TSModuleReference, + TSTypeAnnotation, TSTypeParameterDeclaration, TSTypeParameterInstantiation, + VariableDeclaration, VariableDeclarator, +}; + +impl SoaWalk { + pub(super) fn visit_statements(&mut self, stmts: &[Statement<'_>], parent: NodeId) { + for stmt in stmts { + self.visit_statement(stmt, parent); + } + } + + /// Visit a statement: assign its id (keyed on the `&Statement` address, the key + /// the symbol bind and the address-map tests use), descend, then close. + pub(in crate::binder) fn visit_statement(&mut self, stmt: &Statement<'_>, parent: NodeId) { + let id = self.add( + statement_kind(stmt), + stmt.span(), + Some(parent), + addr_of(stmt), + ); + match stmt { + Statement::ExpressionStatement(s) => self.visit_expression(&s.expression, id), + Statement::VariableDeclaration(decl) => self.visit_declarators(decl, id), + Statement::FunctionDeclaration(f) => self.descend_function(f, id), + Statement::ClassDeclaration(c) => self.descend_class(c, id), + Statement::TSDeclareFunction(f) => self.descend_declare_function(f, id), + Statement::TSTypeAliasDeclaration(t) => { + self.visit_identifier(&t.id, id); + self.visit_type_params(t.type_parameters.as_ref(), id); + self.visit_type(&t.type_annotation, id); + } + Statement::TSInterfaceDeclaration(i) => self.descend_interface(i, id), + Statement::TSEnumDeclaration(e) => { + self.visit_identifier(&e.id, id); + for member in e.members { + self.visit_enum_member(member, id); + } + } + Statement::TSModuleDeclaration(m) => self.descend_module(m, id), + Statement::ImportDeclaration(imp) => { + for spec in imp.specifiers { + self.visit_import_specifier(spec, id); + } + self.leaf(NodeKind::Literal, imp.source.span, addr_of(&imp.source), id); + if let Some(attrs) = imp.attributes { + for a in attrs { + self.visit_import_attribute(a, id); + } + } + } + Statement::TSImportEqualsDeclaration(ie) => { + self.visit_identifier(&ie.id, id); + self.visit_module_reference(&ie.module_reference, id); + } + Statement::ExportNamedDeclaration(e) => { + if let Some(inner) = e.declaration { + self.visit_statement(inner, id); + } else { + for spec in e.specifiers { + self.visit_export_specifier(spec, id); + } + } + if let Some(src) = &e.source { + self.leaf(NodeKind::Literal, src.span, addr_of(src), id); + } + if let Some(attrs) = e.attributes { + for a in attrs { + self.visit_import_attribute(a, id); + } + } + } + Statement::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration, id), + Statement::ExportAllDeclaration(e) => { + if let Some(exp) = &e.exported { + self.visit_module_export_name(exp, id); + } + self.leaf(NodeKind::Literal, e.source.span, addr_of(&e.source), id); + if let Some(attrs) = e.attributes { + for a in attrs { + self.visit_import_attribute(a, id); + } + } + } + Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression, id), + Statement::TSNamespaceExportDeclaration(n) => self.visit_identifier(&n.id, id), + Statement::ReturnStatement(s) => { + if let Some(a) = &s.argument { + self.visit_expression(a, id); + } + } + // A function/try/catch/finally body `BlockStatement` is flattened by + // its owner (a list-wrapper, per today's shape); a *standalone* block + // statement is its own node whose body follows here. + Statement::BlockStatement(block) => self.visit_statements(block.body, id), + Statement::IfStatement(s) => { + self.visit_expression(&s.test, id); + self.visit_statement(s.consequent, id); + if let Some(alt) = s.alternate { + self.visit_statement(alt, id); + } + } + Statement::ForStatement(s) => { + match &s.init { + Some(ForInit::VariableDeclaration(decl)) => { + self.visit_variable_declaration(decl, id); + } + Some(ForInit::Expression(e)) => self.visit_expression(e, id), + None => {} + } + if let Some(t) = &s.test { + self.visit_expression(t, id); + } + if let Some(u) = &s.update { + self.visit_expression(u, id); + } + self.visit_statement(s.body, id); + } + Statement::ForInStatement(s) => { + self.visit_for_left(&s.left, id); + self.visit_expression(&s.right, id); + self.visit_statement(s.body, id); + } + Statement::ForOfStatement(s) => { + self.visit_for_left(&s.left, id); + self.visit_expression(&s.right, id); + self.visit_statement(s.body, id); + } + Statement::WhileStatement(s) => { + self.visit_expression(&s.test, id); + self.visit_statement(s.body, id); + } + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body, id); + self.visit_expression(&s.test, id); + } + Statement::SwitchStatement(s) => { + self.visit_expression(&s.discriminant, id); + for case in s.cases { + self.visit_switch_case(case, id); + } + } + Statement::TryStatement(s) => { + self.visit_statements(s.block.body, id); + if let Some(handler) = &s.handler { + self.visit_catch_clause(handler, id); + } + if let Some(finalizer) = &s.finalizer { + self.visit_statements(finalizer.body, id); + } + } + Statement::ThrowStatement(s) => self.visit_expression(&s.argument, id), + Statement::BreakStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label, id); + } + } + Statement::ContinueStatement(s) => { + if let Some(label) = &s.label { + self.visit_identifier(label, id); + } + } + Statement::LabeledStatement(s) => { + self.visit_identifier(&s.label, id); + self.visit_statement(s.body, id); + } + Statement::EmptyStatement(_) | Statement::DebuggerStatement(_) => {} + } + self.close(id); + } + + // --- declaration descents (shared between statement + export-default) ----- + + fn descend_function(&mut self, f: &FunctionDeclaration<'_>, id: NodeId) { + self.descend_function_common( + id, + f.id.as_ref(), + f.type_parameters.as_ref(), + f.params, + f.return_type.as_ref(), + f.body.body, + ); + } + + /// The body-bearing function descent shared by the declaration form + /// ([`Self::descend_function`]) and the method-value / function-expression form + /// (`SoaWalk::visit_function_expression`), keyed on the already-minted `id`. Kept + /// as one helper so `FunctionDeclaration` and `FunctionExpression` — distinct + /// types with the same field shape — never drift in what the walk descends. + pub(super) fn descend_function_common( + &mut self, + id: NodeId, + name: Option<&Identifier<'_>>, + type_parameters: Option<&TSTypeParameterDeclaration<'_>>, + params: &[Expression<'_>], + return_type: Option<&TSTypeAnnotation<'_>>, + body: &[Statement<'_>], + ) { + if let Some(name) = name { + self.visit_identifier(name, id); + } + self.visit_type_params(type_parameters, id); + self.visit_params(params, id); + self.visit_type_annotation_opt(return_type, id); + self.visit_statements(body, id); + } + + fn descend_declare_function(&mut self, f: &TSDeclareFunction<'_>, id: NodeId) { + self.visit_identifier(&f.id, id); + self.visit_type_params(f.type_parameters.as_ref(), id); + self.visit_params(f.params, id); + self.visit_type_annotation_opt(f.return_type.as_ref(), id); + } + + fn descend_class(&mut self, c: &ClassDeclaration<'_>, id: NodeId) { + if let Some(name) = &c.id { + self.visit_identifier(name, id); + } + // The class's own `` — kept in sync with the `ClassExpression` arm in + // `visit_expression` (guarded by the `require_node_id` coverage test). + self.visit_type_params(c.type_parameters.as_ref(), id); + self.visit_class_heritage( + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + id, + ); + self.visit_class_body(&c.body, id); + } + + fn descend_interface(&mut self, i: &TSInterfaceDeclaration<'_>, id: NodeId) { + self.visit_identifier(&i.id, id); + self.visit_type_params(i.type_parameters.as_ref(), id); + self.visit_heritages(i.extends, id); + // `TSInterfaceBody` is a list-wrapper: its members stay flat under the + // interface (no separate node), matching today's shape. + self.visit_type_elements(i.body.body, id); + } + + fn visit_export_default(&mut self, value: &ExportDefaultValue<'_>, parent: NodeId) { + match value { + ExportDefaultValue::Expression(e) => self.visit_expression(e, parent), + ExportDefaultValue::FunctionDeclaration(f) => { + let id = self.add( + NodeKind::FunctionDeclaration, + f.span, + Some(parent), + addr_of(f), + ); + self.descend_function(f, id); + self.close(id); + } + ExportDefaultValue::TSDeclareFunction(f) => { + let id = self.add( + NodeKind::TSDeclareFunction, + f.span, + Some(parent), + addr_of(f), + ); + self.descend_declare_function(f, id); + self.close(id); + } + ExportDefaultValue::ClassDeclaration(c) => { + let id = self.add(NodeKind::ClassDeclaration, c.span, Some(parent), addr_of(c)); + self.descend_class(c, id); + self.close(id); + } + ExportDefaultValue::TSInterfaceDeclaration(i) => { + let id = self.add( + NodeKind::TSInterfaceDeclaration, + i.span, + Some(parent), + addr_of(i), + ); + self.descend_interface(i, id); + self.close(id); + } + } + } + + // --- variable declarations / for headers --------------------------------- + + fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) { + let id = self.add( + NodeKind::VariableDeclaration, + decl.span, + Some(parent), + addr_of(decl), + ); + self.visit_declarators(decl, id); + self.close(id); + } + + fn visit_declarators(&mut self, decl: &VariableDeclaration<'_>, parent: NodeId) { + for declarator in decl.declarations { + self.visit_declarator(declarator, parent); + } + } + + fn visit_declarator(&mut self, declarator: &VariableDeclarator<'_>, parent: NodeId) { + let id = self.add( + NodeKind::VariableDeclarator, + declarator.span, + Some(parent), + addr_of(declarator), + ); + // The binding target — an identifier (with its type annotation) or a + // destructuring pattern — is an `Expression`, routed through the + // pattern-aware `visit_expression`. + self.visit_expression(&declarator.id, id); + if let Some(init) = &declarator.init { + self.visit_expression(init, id); + } + self.close(id); + } + + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>, parent: NodeId) { + match left { + ForInOfLeft::VariableDeclaration(decl) => self.visit_variable_declaration(decl, parent), + // A pattern here may be an Object/ArrayPattern — pattern-aware descent. + ForInOfLeft::Pattern(e) => self.visit_expression(e, parent), + } + } + + // --- modules / enums / cases / catch ------------------------------------- + + /// Descend a module's name and body (the module's own node is `module_id`). + fn descend_module(&mut self, m: &TSModuleDeclaration<'_>, module_id: NodeId) { + match &m.id { + TSModuleName::Identifier(id) => self.visit_identifier(id, module_id), + TSModuleName::Literal(lit) => { + self.leaf(NodeKind::Literal, lit.span, addr_of(lit), module_id); + } + } + match &m.body { + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => { + let id = self.add( + NodeKind::TSModuleBlock, + block.span, + Some(module_id), + addr_of(block), + ); + self.visit_statements(block.body, id); + self.close(id); + } + // The dotted-namespace continuation (`namespace A.B {}`) — a nested + // `TSModuleDeclaration` node (reused kind), recursed. + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => { + let id = self.add( + NodeKind::TSModuleDeclaration, + nested.span, + Some(module_id), + addr_of(nested), + ); + self.descend_module(nested, id); + self.close(id); + } + None => {} + } + } + + fn visit_enum_member(&mut self, member: &TSEnumMember<'_>, parent: NodeId) { + let id = self.add( + NodeKind::TSEnumMember, + member.span, + Some(parent), + addr_of(member), + ); + match &member.id { + TSEnumMemberId::Identifier(idn) => self.visit_identifier(idn, id), + TSEnumMemberId::String(lit) => self.leaf(NodeKind::Literal, lit.span, addr_of(lit), id), + } + if let Some(init) = &member.initializer { + self.visit_expression(init, id); + } + self.close(id); + } + + fn visit_switch_case(&mut self, case: &SwitchCase<'_>, parent: NodeId) { + let id = self.add(NodeKind::SwitchCase, case.span, Some(parent), addr_of(case)); + if let Some(t) = &case.test { + self.visit_expression(t, id); + } + self.visit_statements(case.consequent, id); + self.close(id); + } + + fn visit_catch_clause(&mut self, h: &CatchClause<'_>, parent: NodeId) { + let id = self.add(NodeKind::CatchClause, h.span, Some(parent), addr_of(h)); + if let Some(param) = &h.param { + self.visit_expression(param, id); + } + // The catch body block is flattened (list-wrapper, today's shape). + self.visit_statements(h.body.body, id); + self.close(id); + } + + // --- classes ------------------------------------------------------------- + + /// Descend class heritage: decorators, the `extends` expression + its type + /// arguments, and each `implements`/`extends` heritage clause. + pub(super) fn visit_class_heritage( + &mut self, + decorators: Option<&[Decorator<'_>]>, + super_class: Option<&Expression<'_>>, + super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>, + heritages: &[TSInterfaceHeritage<'_>], + parent: NodeId, + ) { + if let Some(decs) = decorators { + self.visit_decorators(decs, parent); + } + if let Some(sc) = super_class { + self.visit_expression(sc, parent); + } + if let Some(tp) = super_type_parameters { + self.visit_type_args(tp, parent); + } + self.visit_heritages(heritages, parent); + } + + fn visit_heritages(&mut self, heritages: &[TSInterfaceHeritage<'_>], parent: NodeId) { + for h in heritages { + let id = self.add( + NodeKind::TSInterfaceHeritage, + h.span, + Some(parent), + addr_of(h), + ); + // The heritage target (`extends Base` / `implements Base`) — an entity + // name — plus its type arguments. + self.visit_entity_name(&h.expression, id); + if let Some(ta) = &h.type_arguments { + self.visit_type_args(ta, id); + } + self.close(id); + } + } + + /// `ClassBody` is a list-wrapper: its members stay flat under the class (no + /// separate node), matching today's shape. + pub(super) fn visit_class_body(&mut self, body: &ClassBody<'_>, parent: NodeId) { + for member in body.body { + self.visit_class_member(member, parent); + } + } + + fn visit_class_member(&mut self, member: &ClassMember<'_>, parent: NodeId) { + match member { + ClassMember::MethodDefinition(m) => { + let id = self.add(NodeKind::MethodDefinition, m.span, Some(parent), addr_of(m)); + if let Some(decs) = m.decorators { + self.visit_decorators(decs, id); + } + self.visit_expression(&m.key, id); + self.visit_function_expression(&m.value, id); + self.close(id); + } + ClassMember::PropertyDefinition(p) => { + let id = self.add( + NodeKind::PropertyDefinition, + p.span, + Some(parent), + addr_of(p), + ); + if let Some(decs) = p.decorators { + self.visit_decorators(decs, id); + } + self.visit_expression(&p.key, id); + self.visit_type_annotation_opt(p.type_annotation.as_ref(), id); + if let Some(v) = &p.value { + self.visit_expression(v, id); + } + self.close(id); + } + ClassMember::StaticBlock(s) => { + let id = self.add(NodeKind::StaticBlock, s.span, Some(parent), addr_of(s)); + self.visit_statements(s.body, id); + self.close(id); + } + ClassMember::IndexSignature(i) => self.visit_index_signature(i, parent), + } + } + + // --- imports / exports ---------------------------------------------------- + + fn visit_import_specifier(&mut self, spec: &ImportSpecifier<'_>, parent: NodeId) { + match spec { + ImportSpecifier::Default(d) => { + let id = self.add( + NodeKind::ImportDefaultSpecifier, + d.span, + Some(parent), + addr_of(d), + ); + self.visit_identifier(&d.local, id); + self.close(id); + } + ImportSpecifier::Named(n) => { + let id = self.add( + NodeKind::ImportNamedSpecifier, + n.span, + Some(parent), + addr_of(n), + ); + self.visit_module_export_name(&n.imported, id); + self.visit_identifier(&n.local, id); + self.close(id); + } + ImportSpecifier::Namespace(n) => { + let id = self.add( + NodeKind::ImportNamespaceSpecifier, + n.span, + Some(parent), + addr_of(n), + ); + self.visit_identifier(&n.local, id); + self.close(id); + } + } + } + + fn visit_export_specifier(&mut self, spec: &ExportSpecifier<'_>, parent: NodeId) { + let id = self.add( + NodeKind::ExportSpecifier, + spec.span, + Some(parent), + addr_of(spec), + ); + self.visit_module_export_name(&spec.local, id); + self.visit_module_export_name(&spec.exported, id); + self.close(id); + } + + fn visit_module_export_name(&mut self, name: &ModuleExportName<'_>, parent: NodeId) { + match name { + ModuleExportName::Identifier(id) => self.visit_identifier(id, parent), + ModuleExportName::Literal(lit) => { + self.leaf(NodeKind::Literal, lit.span, addr_of(lit), parent); + } + } + } + + fn visit_import_attribute(&mut self, attr: &ImportAttribute<'_>, parent: NodeId) { + let id = self.add( + NodeKind::ImportAttribute, + attr.span, + Some(parent), + addr_of(attr), + ); + match &attr.key { + ImportAttributeKey::Identifier(idn) => self.visit_identifier(idn, id), + ImportAttributeKey::Literal(lit) => { + self.leaf(NodeKind::Literal, lit.span, addr_of(lit), id); + } + } + self.leaf(NodeKind::Literal, attr.value.span, addr_of(&attr.value), id); + self.close(id); + } + + fn visit_module_reference(&mut self, mr: &TSModuleReference<'_>, parent: NodeId) { + match mr { + TSModuleReference::ExternalModuleReference(ext) => { + let id = self.add( + NodeKind::TSExternalModuleReference, + ext.span, + Some(parent), + addr_of(ext), + ); + self.leaf( + NodeKind::Literal, + ext.expression.span, + addr_of(&ext.expression), + id, + ); + self.close(id); + } + TSModuleReference::EntityName(en) => self.visit_entity_name(en, parent), + } + } +} diff --git a/crates/tsv_check/src/binder/lower/types.rs b/crates/tsv_check/src/binder/lower/types.rs new file mode 100644 index 000000000..6dc89ce5e --- /dev/null +++ b/crates/tsv_check/src/binder/lower/types.rs @@ -0,0 +1,393 @@ +//! The type-shaped visitor methods — `visit_type` and everything a type +//! position (annotations, type arguments/parameters, entity names, interface / +//! type-literal members) descends into. `visit_index_signature` lives here too: +//! one `TSIndexSignature` node serves both a class member and a type-element +//! position, and its shape (parameters + an optional type annotation) is purely +//! type-flavored. + +use super::super::*; +use tsv_ts::ast::internal::{ + TSEntityName, TSImportType, TSIndexSignature, TSLiteralType, TSMappedTypeParameter, + TSQualifiedName, TSType, TSTypeAnnotation, TSTypeElement, TSTypeParameter, + TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypeQueryExprName, +}; + +impl SoaWalk { + pub(super) fn visit_index_signature(&mut self, i: &TSIndexSignature<'_>, parent: NodeId) { + let id = self.add(NodeKind::TSIndexSignature, i.span, Some(parent), addr_of(i)); + for p in i.parameters { + self.visit_identifier(p, id); + } + self.visit_type_annotation_opt(i.type_annotation.as_ref(), id); + self.close(id); + } + + /// A `TSTypeAnnotation` (`: T`) is a transparent wrapper — not idd; the walk + /// descends straight into the inner `TSType`, which is the node. + pub(super) fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>, parent: NodeId) { + self.visit_type(ann.type_annotation, parent); + } + + pub(super) fn visit_type_annotation_opt( + &mut self, + ann: Option<&TSTypeAnnotation<'_>>, + parent: NodeId, + ) { + if let Some(a) = ann { + self.visit_type_annotation(a, parent); + } + } + + pub(super) fn visit_type_args( + &mut self, + args: &TSTypeParameterInstantiation<'_>, + parent: NodeId, + ) { + let id = self.add( + NodeKind::TSTypeParameterInstantiation, + args.span, + Some(parent), + addr_of(args), + ); + for t in args.params { + self.visit_type(t, id); + } + self.close(id); + } + + pub(super) fn visit_type_params( + &mut self, + params: Option<&TSTypeParameterDeclaration<'_>>, + parent: NodeId, + ) { + if let Some(decl) = params { + let id = self.add( + NodeKind::TSTypeParameterDeclaration, + decl.span, + Some(parent), + addr_of(decl), + ); + for p in decl.params { + self.visit_type_parameter(p, id); + } + self.close(id); + } + } + + fn visit_type_parameter(&mut self, p: &TSTypeParameter<'_>, parent: NodeId) { + let id = self.add(NodeKind::TSTypeParameter, p.span, Some(parent), addr_of(p)); + self.visit_identifier(&p.name, id); + if let Some(c) = p.constraint { + self.visit_type(c, id); + } + if let Some(d) = p.default { + self.visit_type(d, id); + } + self.close(id); + } + + fn visit_mapped_type_parameter(&mut self, mtp: &TSMappedTypeParameter<'_>, parent: NodeId) { + // The `name` is a bare `IdentName` (no child identifier node); the mapped + // type parameter's own span covers the name token. + let id = self.add( + NodeKind::TSMappedTypeParameter, + mtp.span, + Some(parent), + addr_of(mtp), + ); + self.visit_type(mtp.constraint, id); + self.close(id); + } + + pub(super) fn visit_entity_name(&mut self, name: &TSEntityName<'_>, parent: NodeId) { + match name { + TSEntityName::Identifier(id) => self.visit_identifier(id, parent), + TSEntityName::QualifiedName(qn) => self.visit_qualified_name(qn, parent), + } + } + + fn visit_qualified_name(&mut self, qn: &TSQualifiedName<'_>, parent: NodeId) { + let id = self.add( + NodeKind::TSQualifiedName, + qn.span, + Some(parent), + addr_of(qn), + ); + self.visit_entity_name(&qn.left, id); + self.visit_identifier(&qn.right, id); + self.close(id); + } + + fn visit_import_type(&mut self, i: &TSImportType<'_>, parent: NodeId) { + let id = self.add(NodeKind::TSImportType, i.span, Some(parent), addr_of(i)); + self.leaf(NodeKind::Literal, i.argument.span, addr_of(&i.argument), id); + if let Some(o) = i.options { + self.visit_expression(o, id); + } + if let Some(q) = &i.qualifier { + self.visit_entity_name(q, id); + } + if let Some(ta) = &i.type_arguments { + self.visit_type_args(ta, id); + } + self.close(id); + } + + pub(super) fn visit_type_elements(&mut self, members: &[TSTypeElement<'_>], parent: NodeId) { + for member in members { + self.visit_type_element(member, parent); + } + } + + fn visit_type_element(&mut self, member: &TSTypeElement<'_>, parent: NodeId) { + match member { + TSTypeElement::PropertySignature(p) => { + let id = self.add( + NodeKind::TSPropertySignature, + p.span, + Some(parent), + addr_of(p), + ); + self.visit_expression(&p.key, id); + self.visit_type_annotation_opt(p.type_annotation.as_ref(), id); + self.close(id); + } + TSTypeElement::MethodSignature(m) => { + let id = self.add( + NodeKind::TSMethodSignature, + m.span, + Some(parent), + addr_of(m), + ); + self.visit_expression(&m.key, id); + self.visit_type_params(m.type_parameters.as_ref(), id); + self.visit_params(m.params, id); + self.visit_type_annotation_opt(m.return_type.as_ref(), id); + self.close(id); + } + TSTypeElement::CallSignature(c) => { + let id = self.add( + NodeKind::TSCallSignatureDeclaration, + c.span, + Some(parent), + addr_of(c), + ); + self.visit_type_params(c.type_parameters.as_ref(), id); + self.visit_params(c.params, id); + self.visit_type_annotation_opt(c.return_type.as_ref(), id); + self.close(id); + } + TSTypeElement::ConstructSignature(c) => { + let id = self.add( + NodeKind::TSConstructSignatureDeclaration, + c.span, + Some(parent), + addr_of(c), + ); + self.visit_type_params(c.type_parameters.as_ref(), id); + self.visit_params(c.params, id); + self.visit_type_annotation_opt(c.return_type.as_ref(), id); + self.close(id); + } + TSTypeElement::IndexSignature(i) => self.visit_index_signature(i, parent), + } + } + + pub(super) fn visit_type(&mut self, ty: &TSType<'_>, parent: NodeId) { + match ty { + TSType::Keyword(kw) => self.leaf(NodeKind::TSKeywordType, kw.span, addr_of(kw), parent), + TSType::ThisType(t) => self.leaf(NodeKind::TSThisType, t.span, addr_of(t), parent), + TSType::Literal(lit) => self.visit_literal_type(lit, parent), + TSType::Array(a) => { + let id = self.add(NodeKind::TSArrayType, a.span, Some(parent), addr_of(a)); + self.visit_type(a.element_type, id); + self.close(id); + } + TSType::Union(u) => { + let id = self.add(NodeKind::TSUnionType, u.span, Some(parent), addr_of(u)); + for t in u.types { + self.visit_type(t, id); + } + self.close(id); + } + TSType::Intersection(i) => { + let id = self.add( + NodeKind::TSIntersectionType, + i.span, + Some(parent), + addr_of(i), + ); + for t in i.types { + self.visit_type(t, id); + } + self.close(id); + } + TSType::TypeReference(r) => { + let id = self.add(NodeKind::TSTypeReference, r.span, Some(parent), addr_of(r)); + self.visit_entity_name(&r.type_name, id); + if let Some(ta) = &r.type_arguments { + self.visit_type_args(ta, id); + } + self.close(id); + } + TSType::TypeLiteral(tl) => { + let id = self.add(NodeKind::TSTypeLiteral, tl.span, Some(parent), addr_of(tl)); + self.visit_type_elements(tl.members, id); + self.close(id); + } + TSType::Function(f) => { + let id = self.add(NodeKind::TSFunctionType, f.span, Some(parent), addr_of(f)); + self.visit_type_params(f.type_parameters.as_ref(), id); + self.visit_params(f.params, id); + self.visit_type_annotation(&f.return_type, id); + self.close(id); + } + TSType::Constructor(c) => { + let id = self.add( + NodeKind::TSConstructorType, + c.span, + Some(parent), + addr_of(c), + ); + self.visit_type_params(c.type_parameters.as_ref(), id); + self.visit_params(c.params, id); + self.visit_type_annotation(&c.return_type, id); + self.close(id); + } + TSType::Tuple(t) => { + let id = self.add(NodeKind::TSTupleType, t.span, Some(parent), addr_of(t)); + for e in t.element_types { + self.visit_type(e, id); + } + self.close(id); + } + TSType::Parenthesized(p) => { + let id = self.add( + NodeKind::TSParenthesizedType, + p.span, + Some(parent), + addr_of(p), + ); + self.visit_type(p.type_annotation, id); + self.close(id); + } + TSType::TypePredicate(p) => { + let id = self.add(NodeKind::TSTypePredicate, p.span, Some(parent), addr_of(p)); + self.visit_identifier(&p.parameter_name, id); + if let Some(t) = p.type_annotation { + self.visit_type(t, id); + } + self.close(id); + } + TSType::Conditional(c) => { + let id = self.add( + NodeKind::TSConditionalType, + c.span, + Some(parent), + addr_of(c), + ); + self.visit_type(c.check_type, id); + self.visit_type(c.extends_type, id); + self.visit_type(c.true_type, id); + self.visit_type(c.false_type, id); + self.close(id); + } + TSType::Mapped(m) => { + let id = self.add(NodeKind::TSMappedType, m.span, Some(parent), addr_of(m)); + self.visit_mapped_type_parameter(&m.type_parameter, id); + if let Some(nt) = m.name_type { + self.visit_type(nt, id); + } + if let Some(ta) = m.type_annotation { + self.visit_type(ta, id); + } + self.close(id); + } + TSType::TypeOperator(o) => { + let id = self.add(NodeKind::TSTypeOperator, o.span, Some(parent), addr_of(o)); + self.visit_type(o.type_annotation, id); + self.close(id); + } + TSType::Import(i) => self.visit_import_type(i, parent), + TSType::TypeQuery(q) => { + let id = self.add(NodeKind::TSTypeQuery, q.span, Some(parent), addr_of(q)); + match &q.expr_name { + TSTypeQueryExprName::EntityName(en) => self.visit_entity_name(en, id), + TSTypeQueryExprName::Import(imp) => self.visit_import_type(imp, id), + } + if let Some(ta) = &q.type_arguments { + self.visit_type_args(ta, id); + } + self.close(id); + } + TSType::IndexedAccess(i) => { + let id = self.add( + NodeKind::TSIndexedAccessType, + i.span, + Some(parent), + addr_of(i), + ); + self.visit_type(i.object_type, id); + self.visit_type(i.index_type, id); + self.close(id); + } + TSType::Rest(r) => { + let id = self.add(NodeKind::TSRestType, r.span, Some(parent), addr_of(r)); + self.visit_type(r.type_annotation, id); + self.close(id); + } + TSType::Optional(o) => { + let id = self.add(NodeKind::TSOptionalType, o.span, Some(parent), addr_of(o)); + self.visit_type(o.type_annotation, id); + self.close(id); + } + TSType::NamedTupleMember(n) => { + let id = self.add( + NodeKind::TSNamedTupleMember, + n.span, + Some(parent), + addr_of(n), + ); + self.visit_identifier(&n.label, id); + self.visit_type(n.element_type, id); + self.close(id); + } + TSType::Infer(inf) => { + let id = self.add(NodeKind::TSInferType, inf.span, Some(parent), addr_of(inf)); + self.visit_type_parameter(&inf.type_parameter, id); + self.close(id); + } + } + } + + /// The nested `TSLiteralType` dispatcher: a template-literal type is its own + /// node (`TSTemplateLiteralType`); a string/number/bigint literal type reuses + /// `Literal`; a negative-number literal type reuses `UnaryExpression`. + fn visit_literal_type(&mut self, lit: &TSLiteralType<'_>, parent: NodeId) { + match lit { + TSLiteralType::TemplateLiteral(t) => { + let id = self.add( + NodeKind::TSTemplateLiteralType, + t.span, + Some(parent), + addr_of(t), + ); + for q in t.quasis { + self.visit_template_element(q, id); + } + for ty in t.types { + self.visit_type(ty, id); + } + self.close(id); + } + TSLiteralType::String(l) | TSLiteralType::Number(l) | TSLiteralType::BigInt(l) => { + self.leaf(NodeKind::Literal, l.span, addr_of(l), parent); + } + TSLiteralType::UnaryExpression(u) => { + let id = self.add(NodeKind::UnaryExpression, u.span, Some(parent), addr_of(u)); + self.visit_expression(u.argument, id); + self.close(id); + } + } + } +} diff --git a/crates/tsv_check/src/binder/mod.rs b/crates/tsv_check/src/binder/mod.rs new file mode 100644 index 000000000..f0a11c7c8 --- /dev/null +++ b/crates/tsv_check/src/binder/mod.rs @@ -0,0 +1,1135 @@ +//! The lower+bind pass: node identity (SoA columns) plus the symbol bind. +//! +//! Two cooperating walks run per file, kept in one module: +//! +//! - **the SoA walk** ([`SoaWalk`]) — one **full pre-order descent** assigning a +//! dense pre-order [`NodeId`] to every AST node the checker addresses: +//! statements, expressions (including the pattern-shaped ones at +//! assignment-target / for-left positions), types, and their sub-nodes +//! (heritage clauses, type parameters, member signatures, decorators, import/ +//! export specifiers, …). It fills the parent/kind/span/`subtree_end` side +//! columns and the address→id map. +//! Pre-order — each parent precedes its contiguous subtree, so the +//! `subtree_end` interval test (`is X a descendant of Y`) stays valid. Sibling +//! order follows the traversal, not always source order (an annotated binding +//! descends its `: T` before the binding), which the interval test does not rely on. The one deliberate carve-out is the pure list-wrapper nodes +//! (`ClassBody` / `TSInterfaceBody` / a function/try/catch/finally/static body +//! `BlockStatement` / the `Program.body` slice / the transparent +//! `TSTypeAnnotation` `: T` wrapper): their members/inner stay flat children of +//! the owning node, matching today's shape (documented at the sites). +//! - **the symbol bind** ([`sym::SymbolBinder`]) — a container-threaded walk that +//! ports tsgo's binder: `declareSymbolEx` conflict cascade (TS2300/2451/2567/ +//! 2528), the module-member routing, class/enum/interface member tables, and +//! the **functions-first** statement-list ordering (`bindEachStatementFunctionsFirst`). +//! It reaches every binding-introducing position and emits the bind-time +//! duplicate/conflict family. +//! +//! The two are separate passes rather than one fused walk because functions-first +//! symbol binding reorders symbol *creation* within a statement list, which would +//! break the SoA walk's strict pre-order id intervals. The symbol bind resolves a +//! declaration's [`NodeId`] through the SoA walk's address map. Statement-level +//! inner structs it keys on (`TSExportAssignment`, `ExportDefaultDeclaration`, +//! `TSModuleDeclaration`) resolve against the enclosing `&Statement` address, so +//! those `node_id_of` lookups fall back to the root id — the id is not consumed by +//! the family cascade, which keys on name spans, so the fallback is inert. The +//! **strict** resolver flow consumers use is [`BoundFile::require_node_id`], which +//! hard-fails on a miss (a flow graph must never silently drop an attachment). +//! +//! **Borrow-only discipline (load-bearing).** Every visitor takes `&'arena` +//! references and NEVER clones an AST node. The address map keys on +//! `std::ptr::from_ref(node) as usize` (a safe reference-to-integer cast — the +//! crate keeps `unsafe_code = "forbid"`), and arena addresses are stable for the +//! program's lifetime. Every tsv AST type derives `Clone`, so one accidental +//! `.clone()` in a visitor would mint a differently-addressed copy and silently +//! break the map. Nothing type-level enforces this — the discipline is the contract. +//! +//! **No behavior change (F0 invariant).** The SoA columns feed only the symbol +//! bind (which reads the address map) and this module's tests; the graded +//! diagnostic stream carries no `NodeId` (`Diagnostic` has none; the symbol bind's +//! `Decl.node` is dead), and every graded span derives from an AST `.span` / +//! `.name_span()`, never a `spans[node_id]` lookup. So growing the walk — and thus +//! renumbering the ids — cannot perturb graded output. +// +// tsgo: internal/binder/binder.go bindSourceFile / bindChildren / bindContainer +// (single-walk parent stamping; tsgo stamps in the parser, we stamp here — +// a recorded deviation with identical results) + +mod atoms; +pub mod flow; +mod lower; +mod sym; +pub mod symbols; + +use crate::diag::Diagnostic; +use crate::ids::{FileId, NodeId}; +use crate::merge::FileMerge; +use tsv_lang::{FxHashMap, Span}; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{Expression, Statement, TSModuleReference}; + +/// The pre-order node kinds the SoA walk assigns — one variant per tsv_ts AST enum +/// variant the walk ids (the program root, then statements, expressions, types, and +/// their sub-nodes). Several kinds are **reused** across positions: `Identifier` +/// tags every identifier — a binding *or* a reference (labels, member/property +/// names, type-param names, entity-name segments, …); `Literal` tags a value +/// literal and a string/number/bigint literal type; `UnaryExpression` a value unary +/// and a negative-number literal type; `TSIndexSignature` both the class-member and +/// type-element index-signature forms; `FunctionExpression` a value function and a +/// method's `value`. The set is not graded or serialized, so its ordering is free. +/// +/// `Hash` is derived so a `(usize, NodeKind)` pair can key the address map — the +/// compound key that disambiguates the one offset-0 collision pair +/// (`MethodDefinition` / its inline `value: FunctionExpression`). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +#[repr(u16)] +pub enum NodeKind { + /// The source file root. + Program, + /// An identifier — a binding (declaration name, parameter, catch/type-param + /// name) or a reference (variable use, label, member/property/entity-name + /// segment). The scope is every identifier the walk reaches, not only bindings. + Identifier, + // --- Statements --- + ExpressionStatement, + VariableDeclaration, + VariableDeclarator, + FunctionDeclaration, + ClassDeclaration, + TSTypeAliasDeclaration, + TSInterfaceDeclaration, + TSDeclareFunction, + TSEnumDeclaration, + TSModuleDeclaration, + ImportDeclaration, + TSImportEqualsDeclaration, + ExportNamedDeclaration, + ExportDefaultDeclaration, + ExportAllDeclaration, + TSExportAssignment, + TSNamespaceExportDeclaration, + ReturnStatement, + BlockStatement, + IfStatement, + ForStatement, + ForInStatement, + ForOfStatement, + WhileStatement, + DoWhileStatement, + SwitchStatement, + TryStatement, + ThrowStatement, + BreakStatement, + ContinueStatement, + LabeledStatement, + EmptyStatement, + DebuggerStatement, + // --- Expressions --- + Literal, + PrivateIdentifier, + ObjectExpression, + ArrayExpression, + UnaryExpression, + UpdateExpression, + BinaryExpression, + CallExpression, + NewExpression, + MemberExpression, + ConditionalExpression, + ArrowFunctionExpression, + FunctionExpression, + ClassExpression, + SpreadElement, + TemplateLiteral, + TaggedTemplateExpression, + AwaitExpression, + YieldExpression, + SequenceExpression, + RegexLiteral, + ThisExpression, + Super, + AssignmentExpression, + ObjectPattern, + ArrayPattern, + AssignmentPattern, + RestElement, + TSTypeAssertion, + TSAsExpression, + TSSatisfiesExpression, + TSInstantiationExpression, + TSNonNullExpression, + TSParameterProperty, + ImportExpression, + MetaProperty, + JsdocCast, + ParenthesizedExpression, + // --- Types --- + TSKeywordType, + TSArrayType, + TSUnionType, + TSIntersectionType, + TSTypeReference, + TSTypeLiteral, + TSFunctionType, + TSConstructorType, + TSTupleType, + TSParenthesizedType, + TSTypePredicate, + TSConditionalType, + TSMappedType, + TSTypeOperator, + TSImportType, + TSTypeQuery, + TSIndexedAccessType, + TSRestType, + TSOptionalType, + TSNamedTupleMember, + TSInferType, + TSThisType, + TSTemplateLiteralType, + // --- Type elements (interface / type-literal members) --- + TSPropertySignature, + TSMethodSignature, + TSCallSignatureDeclaration, + TSConstructSignatureDeclaration, + TSIndexSignature, + // --- Class members --- + MethodDefinition, + PropertyDefinition, + StaticBlock, + // --- Entity / property / specifier sub-nodes --- + TSQualifiedName, + Property, + ImportDefaultSpecifier, + ImportNamedSpecifier, + ImportNamespaceSpecifier, + ExportSpecifier, + TSExternalModuleReference, + TSModuleBlock, + // --- Plain structs at distinct addressable positions --- + Decorator, + TSInterfaceHeritage, + TSTypeParameterDeclaration, + TSTypeParameter, + TSTypeParameterInstantiation, + CatchClause, + SwitchCase, + TemplateElement, + ImportAttribute, + TSEnumMember, + TSMappedTypeParameter, +} + +/// Per-node flag bits in the [`flow::FlowProduct::node_flags`] column (one `u8` +/// per [`NodeId`], minted zeroed by the flow builder — the sole writer today, +/// setting [`NODE_FLAGS_UNREACHABLE`] during unreachable tagging). A bind-side +/// column returns to [`BoundFile`] when a bind-time writer lands (the planned +/// ambient/context node-identity bits). +#[allow(clippy::identity_op)] // bit 0 — kept in the `1 << N` idiom for the bits F1 adds +pub const NODE_FLAGS_UNREACHABLE: u8 = 1 << 0; + +/// Whether a file is an external module — tsgo's `externalModuleIndicator`, +/// derived post-parse (`getExternalModuleIndicator`). Recorded so the binder's +/// module-vs-script member routing has the fact ready. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ModuleNess { + /// Has a top-level module indicator (`import`/`export`/`export =`/an + /// `import =` with an external-module reference/`import.meta`). + Module, + /// No module indicator. + Script, +} + +/// Per-file facts filled at lower+bind (reached O(1) from any node in the file). +#[derive(Clone, Copy, Debug)] +pub struct FileFacts { + /// Module-vs-script indicator (see [`ModuleNess`]). + pub module_ness: ModuleNess, +} + +/// The product of binding one file: the pre-order node columns, the address->id +/// map, per-file facts, and the bind diagnostics. +pub struct BoundFile { + /// The file these nodes belong to. + pub file: FileId, + /// Total nodes assigned (the program root plus every visited node). + pub node_count: u32, + /// Parent id per node (`None` for the root), indexed by `NodeId::index`. + pub parents: Vec>, + /// Node kind per node. + pub kinds: Vec, + /// Node span per node. + pub spans: Vec, + /// The last id in each node's pre-order subtree (self for a leaf) — makes + /// descendant tests an O(1) id-range check. + pub subtree_end: Vec, + /// Node `(arena address, kind)` -> id (the random-access escape hatch). The + /// kind is part of the key so the one offset-0 collision pair + /// (`MethodDefinition` and its inline `value: FunctionExpression`) stays + /// distinctly resolvable (see [`BoundFile::require_node_id`]). + /// + /// These keys assume a node's address is stable, which is why the borrow-only + /// discipline exists. `tests/clone_discipline.rs` guards the clone half of it, but + /// it cannot see the other half: a `Copy` AST type (`TSKeywordType`) is + /// re-addressed by a plain `*deref` copy, with no clone syntax to flag. Keep those + /// borrowed too. + pub address_map: FxHashMap<(usize, NodeKind), NodeId>, + /// Bind diagnostics — the duplicate/conflict family, in emission order (the + /// caller sorts + dedups across the whole program). + pub diagnostics: Vec, + /// Per-file facts. + pub facts: FileFacts, + /// The program-independent merge product ([`crate::merge`] folds these across + /// files into the global scope). + pub merge: FileMerge, +} + +impl BoundFile { + /// Whether node `descendant` lies in node `ancestor`'s pre-order subtree — + /// an O(1) id-interval test enabled by pre-order ids + `subtree_end`. + #[must_use] + pub fn is_descendant_of(&self, descendant: NodeId, ancestor: NodeId) -> bool { + ancestor < descendant && descendant <= self.subtree_end[ancestor.index()] + } + + /// Strict `(address, kind)` -> [`NodeId`] resolution for flow consumers. + /// Unlike the symbol bind's lenient `node_id_of` (whose `Decl.node` result is + /// dead), a flow graph attaches to the node at `address`, so a **miss is a + /// bug** — the SoA walk failed to cover a node the flow pass reached, and a + /// silent `NodeId::FIRST` fallback would splice the graph onto the wrong node. + /// So this hard-fails rather than returning a sentinel. + /// + /// `address` is `std::ptr::from_ref(node) as usize` for the same arena node + /// reference the walk keyed on, and `kind` is the [`NodeKind`] the walk + /// assigned it. + /// + /// **The offset-0 collision, resolved by compound keying.** Pointer-identity + /// alone cannot distinguish a node from an offset-0 inline struct-typed child + /// at the same address. The AST has exactly one such pair: `MethodDefinition` + /// and its inline `value: FunctionExpression` (value at struct offset 0). The + /// kind component of the key disambiguates them — no same-kind collisions + /// exist (verified via `-Zprint-type-sizes`), so `(address, kind)` is a total + /// key. `require_node_id(addr_of(&method), NodeKind::MethodDefinition)` and + /// `require_node_id(addr_of(&method.value), NodeKind::FunctionExpression)` now + /// resolve to their respective distinct ids (pinned by + /// `method_and_value_resolve_distinctly` below). + #[must_use] + pub fn require_node_id(&self, address: usize, kind: NodeKind) -> NodeId { + match self.address_map.get(&(address, kind)) { + Some(&id) => id, + None => node_id_miss(address, kind), + } + } +} + +/// The `require_node_id` miss path, isolated so its deliberate panic carries the +/// one `#[allow(clippy::panic)]` the crate's restriction-lint posture requires +/// (panic points need an explicit allow + justification). A miss means the SoA +/// walk did not id a node a flow consumer reached — an internal invariant break +/// that must abort, not a recoverable data error. +#[cold] +#[inline(never)] +#[allow(clippy::panic)] +fn node_id_miss(address: usize, kind: NodeKind) -> ! { + panic!( + "require_node_id: ({address:#x}, {kind:?}) not covered by the SoA walk — a flow \ + consumer reached a node the lowering pass did not id (would corrupt the flow graph)" + ); +} + +/// Derive a file's [`ModuleNess`] — a faithful port of tsgo's +/// `getExternalModuleIndicator` / `isAnExternalModuleIndicatorNode`: a top-level +/// statement is an indicator when it carries an `export` modifier, is an +/// `import`/`export`/`export =` declaration, or is an `import =` with an external +/// (`require(...)`) module reference; failing that, an `import.meta` anywhere in +/// the file counts. Notably `export as namespace` (a UMD export) does **not** +/// count, and an `import =` with an entity-name reference (`import x = A.B`) does +/// not. +/// +/// The module indicators are all structural — no name resolution (and so no +/// `source`) is needed here. +#[must_use] +pub fn module_ness(program: &Program<'_>) -> ModuleNess { + for stmt in program.body { + if is_external_module_indicator(stmt) { + return ModuleNess::Module; + } + } + if program.body.iter().any(stmt_contains_import_meta) { + return ModuleNess::Module; + } + ModuleNess::Script +} + +/// tsgo's `isAnExternalModuleIndicatorNode` over one top-level statement. +fn is_external_module_indicator(stmt: &Statement<'_>) -> bool { + match stmt { + // `import ...` / `export ... from` / `export {}` / `export *`. + Statement::ImportDeclaration(_) + | Statement::ExportNamedDeclaration(_) + | Statement::ExportAllDeclaration(_) + // `export = x` and `export default ...` are both `ExportAssignment` in + // tsgo, both indicators. + | Statement::TSExportAssignment(_) + | Statement::ExportDefaultDeclaration(_) => true, + // `import x = require('y')` counts only with an external-module reference; + // `import x = A.B` (an entity name) does not. + Statement::TSImportEqualsDeclaration(decl) => matches!( + decl.module_reference, + TSModuleReference::ExternalModuleReference(_) + ), + _ => false, + } +} + +/// Whether a statement subtree contains an `import.meta` meta-property (tsgo's +/// `getImportMetaIfNecessary`). A bounded structural walk over the statement and +/// its nested expressions/blocks — `import.meta` is inert for the bind cascade, +/// so this only refines the recorded [`ModuleNess`] fact. +fn stmt_contains_import_meta(stmt: &Statement<'_>) -> bool { + use Statement as S; + match stmt { + S::ExpressionStatement(s) => expr_contains_import_meta(&s.expression), + S::VariableDeclaration(d) => d + .declarations + .iter() + .any(|decl| decl.init.as_ref().is_some_and(expr_contains_import_meta)), + S::ReturnStatement(s) => s.argument.as_ref().is_some_and(expr_contains_import_meta), + S::ThrowStatement(s) => expr_contains_import_meta(&s.argument), + S::BlockStatement(b) => b.body.iter().any(stmt_contains_import_meta), + S::IfStatement(s) => { + expr_contains_import_meta(&s.test) + || stmt_contains_import_meta(s.consequent) + || s.alternate.is_some_and(stmt_contains_import_meta) + } + S::ForStatement(s) => { + s.test.as_ref().is_some_and(expr_contains_import_meta) + || stmt_contains_import_meta(s.body) + } + S::ForInStatement(s) => { + expr_contains_import_meta(&s.right) || stmt_contains_import_meta(s.body) + } + S::ForOfStatement(s) => { + expr_contains_import_meta(&s.right) || stmt_contains_import_meta(s.body) + } + S::WhileStatement(s) => { + expr_contains_import_meta(&s.test) || stmt_contains_import_meta(s.body) + } + S::DoWhileStatement(s) => { + expr_contains_import_meta(&s.test) || stmt_contains_import_meta(s.body) + } + S::SwitchStatement(s) => { + expr_contains_import_meta(&s.discriminant) + || s.cases.iter().any(|c| { + c.test.as_ref().is_some_and(expr_contains_import_meta) + || c.consequent.iter().any(stmt_contains_import_meta) + }) + } + S::TryStatement(s) => { + s.block.body.iter().any(stmt_contains_import_meta) + || s.handler + .as_ref() + .is_some_and(|h| h.body.body.iter().any(stmt_contains_import_meta)) + || s.finalizer + .as_ref() + .is_some_and(|f| f.body.iter().any(stmt_contains_import_meta)) + } + S::LabeledStatement(s) => stmt_contains_import_meta(s.body), + _ => false, + } +} + +/// Whether an expression subtree contains an `import.meta` meta-property. Covers +/// the common expression positions; deliberately not exhaustive over every type +/// node (types never carry `import.meta`). +fn expr_contains_import_meta(expr: &Expression<'_>) -> bool { + use Expression as E; + match expr { + // `import.meta` vs `new.target`: the only two meta-properties, told apart + // by the meta keyword's name length (`import` = 6, `new` = 3). + E::MetaProperty(m) => m.meta.name_len == 6, + E::ParenthesizedExpression(p) => expr_contains_import_meta(p.expression), + E::UnaryExpression(u) => expr_contains_import_meta(u.argument), + E::UpdateExpression(u) => expr_contains_import_meta(u.argument), + E::AwaitExpression(a) => expr_contains_import_meta(a.argument), + E::YieldExpression(y) => y.argument.is_some_and(expr_contains_import_meta), + E::BinaryExpression(b) => { + expr_contains_import_meta(b.left) || expr_contains_import_meta(b.right) + } + E::AssignmentExpression(a) => { + expr_contains_import_meta(a.left) || expr_contains_import_meta(a.right) + } + E::ConditionalExpression(c) => { + expr_contains_import_meta(c.test) + || expr_contains_import_meta(c.consequent) + || expr_contains_import_meta(c.alternate) + } + E::SequenceExpression(s) => s.expressions.iter().any(expr_contains_import_meta), + E::CallExpression(c) => { + expr_contains_import_meta(c.callee) || c.arguments.iter().any(expr_contains_import_meta) + } + E::NewExpression(n) => { + expr_contains_import_meta(n.callee) || n.arguments.iter().any(expr_contains_import_meta) + } + E::MemberExpression(m) => { + expr_contains_import_meta(m.object) || expr_contains_import_meta(m.property) + } + E::TSNonNullExpression(t) => expr_contains_import_meta(t.expression), + E::TSAsExpression(t) => expr_contains_import_meta(t.expression), + E::TSSatisfiesExpression(t) => expr_contains_import_meta(t.expression), + E::ArrayExpression(a) => a.elements.iter().flatten().any(expr_contains_import_meta), + _ => false, + } +} + +/// Bind one file: run the SoA walk and the symbol bind, returning the [`BoundFile`]. +/// +/// `source` is the host document the AST spans index into (the binder resolves +/// declared names by slicing it, matching the parser's span-identity names). +#[must_use] +pub fn bind_file<'arena>( + program: &'arena Program<'arena>, + source: &str, + file: FileId, +) -> BoundFile { + // Pass 1: the SoA node-identity walk (source pre-order). + let mut walk = SoaWalk::default(); + let root = walk.add(NodeKind::Program, program.span, None, addr_of(program)); + // The `Program.body` slice is a pure list-wrapper: its statements stay flat + // children of the root (no separate node), matching today's shape. + for stmt in program.body { + walk.visit_statement(stmt, root); + } + walk.close(root); + + let facts = FileFacts { + module_ness: module_ness(program), + }; + + // Pass 2: the symbol bind (functions-first, container-threaded). + let (diagnostics, merge) = { + let mut binder = sym::SymbolBinder::new(source, &walk.address_map, file, facts); + binder.bind_program(program); + binder.finish() + }; + + BoundFile { + file, + node_count: walk.kinds.len() as u32, + parents: walk.parents, + kinds: walk.kinds, + spans: walk.spans, + subtree_end: walk.subtree_end, + address_map: walk.address_map, + diagnostics, + facts, + merge, + } +} + +/// The address key of an arena node: a reference-to-integer cast (safe — no +/// dereference, so `unsafe_code = "forbid"` holds). Stable for the arena's life. +#[inline] +pub(crate) fn addr_of(node: &T) -> usize { + std::ptr::from_ref(node) as usize +} + +/// The mutable SoA columns being filled by pass 1. +#[derive(Default)] +struct SoaWalk { + parents: Vec>, + kinds: Vec, + spans: Vec, + subtree_end: Vec, + address_map: FxHashMap<(usize, NodeKind), NodeId>, +} + +impl SoaWalk { + /// Assign the next pre-order id to a node, recording its columns and address. + fn add( + &mut self, + kind: NodeKind, + span: Span, + parent: Option, + address: usize, + ) -> NodeId { + let id = NodeId::from_index(self.kinds.len()); + self.parents.push(parent); + self.kinds.push(kind); + self.spans.push(span); + self.subtree_end.push(id); // provisional (a leaf owns only itself) + // The insert must run in ALL profiles — the flow walk's strict + // `require_node_id` reads this map at runtime. Each node is added exactly + // once by the pre-order walk, so a prior entry for this `(address, kind)` + // key is a genuine same-kind offset-0 collision (the "no same-kind + // collisions exist" claim in `require_node_id`, made a corpus-checked + // invariant — `tsc_conformance run` is a debug build). Only that + // collision *assertion* compiles out of release; the insert side effect + // is hoisted out of the assert condition so it always happens. + let prev = self.address_map.insert((address, kind), id); + debug_assert!( + prev.is_none(), + "same-kind address collision at {address:#x} / {kind:?}" + ); + id + } + + /// Close a node after its children are visited: its subtree spans every id + /// assigned since (the current maximum). + fn close(&mut self, id: NodeId) { + let last = NodeId::from_index(self.kinds.len() - 1); + self.subtree_end[id.index()] = last; + } + + /// Add a leaf node (no children): one `add` immediately followed by `close`. + fn leaf(&mut self, kind: NodeKind, span: Span, address: usize, parent: NodeId) { + let id = self.add(kind, span, Some(parent), address); + self.close(id); + } +} + +/// The [`NodeKind`] for a statement variant. Shared with the flow walk and the +/// unreachable-code shim, which resolve statements through the compound-keyed +/// address map (`require_node_id` / a lenient `address_map` lookup). +pub(crate) fn statement_kind(stmt: &Statement<'_>) -> NodeKind { + match stmt { + Statement::ExpressionStatement(_) => NodeKind::ExpressionStatement, + Statement::VariableDeclaration(_) => NodeKind::VariableDeclaration, + Statement::TSTypeAliasDeclaration(_) => NodeKind::TSTypeAliasDeclaration, + Statement::TSInterfaceDeclaration(_) => NodeKind::TSInterfaceDeclaration, + Statement::TSDeclareFunction(_) => NodeKind::TSDeclareFunction, + Statement::TSEnumDeclaration(_) => NodeKind::TSEnumDeclaration, + Statement::TSModuleDeclaration(_) => NodeKind::TSModuleDeclaration, + Statement::ReturnStatement(_) => NodeKind::ReturnStatement, + Statement::BlockStatement(_) => NodeKind::BlockStatement, + Statement::FunctionDeclaration(_) => NodeKind::FunctionDeclaration, + Statement::ClassDeclaration(_) => NodeKind::ClassDeclaration, + Statement::ExportNamedDeclaration(_) => NodeKind::ExportNamedDeclaration, + Statement::ExportDefaultDeclaration(_) => NodeKind::ExportDefaultDeclaration, + Statement::ExportAllDeclaration(_) => NodeKind::ExportAllDeclaration, + Statement::TSExportAssignment(_) => NodeKind::TSExportAssignment, + Statement::TSNamespaceExportDeclaration(_) => NodeKind::TSNamespaceExportDeclaration, + Statement::ImportDeclaration(_) => NodeKind::ImportDeclaration, + Statement::TSImportEqualsDeclaration(_) => NodeKind::TSImportEqualsDeclaration, + Statement::IfStatement(_) => NodeKind::IfStatement, + Statement::ForStatement(_) => NodeKind::ForStatement, + Statement::ForInStatement(_) => NodeKind::ForInStatement, + Statement::ForOfStatement(_) => NodeKind::ForOfStatement, + Statement::WhileStatement(_) => NodeKind::WhileStatement, + Statement::DoWhileStatement(_) => NodeKind::DoWhileStatement, + Statement::SwitchStatement(_) => NodeKind::SwitchStatement, + Statement::TryStatement(_) => NodeKind::TryStatement, + Statement::ThrowStatement(_) => NodeKind::ThrowStatement, + Statement::BreakStatement(_) => NodeKind::BreakStatement, + Statement::ContinueStatement(_) => NodeKind::ContinueStatement, + Statement::LabeledStatement(_) => NodeKind::LabeledStatement, + Statement::EmptyStatement(_) => NodeKind::EmptyStatement, + Statement::DebuggerStatement(_) => NodeKind::DebuggerStatement, + } +} + +/// The `(arena address, NodeKind)` compound key for an expression variant — the +/// key `SoaWalk::visit_expression` registers it under in the address map. +/// Shared with the flow walk's `expr_id`, so the two mappings cannot drift: a +/// `debug_assert` at the end of `visit_expression` pins the agreement on every +/// lowered expression (corpus-exercised — the conformance gate runs debug +/// builds), and the flow walk's strict resolver hard-fails on any residual +/// mismatch. A `JsdocCast` resolves to its **inner** expression's key: the flow +/// walk treats the cast wrapper as transparent (matching tsgo, where the +/// reparsed cast is not a flow subject), and the SoA walk lowers both the +/// wrapper and the inner, so the inner's key is always present. +pub(crate) fn expression_addr_kind(e: &Expression<'_>) -> (usize, NodeKind) { + use Expression as E; + match e { + E::JsdocCast(c) => expression_addr_kind(c.inner), + E::Literal(x) => (addr_of(x), NodeKind::Literal), + E::Identifier(x) => (addr_of(x), NodeKind::Identifier), + E::PrivateIdentifier(x) => (addr_of(x), NodeKind::PrivateIdentifier), + E::ObjectExpression(x) => (addr_of(x), NodeKind::ObjectExpression), + E::ArrayExpression(x) => (addr_of(x), NodeKind::ArrayExpression), + E::UnaryExpression(x) => (addr_of(x), NodeKind::UnaryExpression), + E::UpdateExpression(x) => (addr_of(x), NodeKind::UpdateExpression), + E::BinaryExpression(x) => (addr_of(x), NodeKind::BinaryExpression), + E::CallExpression(x) => (addr_of(x), NodeKind::CallExpression), + E::NewExpression(x) => (addr_of(x), NodeKind::NewExpression), + E::MemberExpression(x) => (addr_of(x), NodeKind::MemberExpression), + E::ConditionalExpression(x) => (addr_of(x), NodeKind::ConditionalExpression), + E::ArrowFunctionExpression(x) => (addr_of(x), NodeKind::ArrowFunctionExpression), + E::FunctionExpression(x) => (addr_of(x), NodeKind::FunctionExpression), + E::ClassExpression(x) => (addr_of(x), NodeKind::ClassExpression), + E::SpreadElement(x) => (addr_of(x), NodeKind::SpreadElement), + E::TemplateLiteral(x) => (addr_of(x), NodeKind::TemplateLiteral), + E::TaggedTemplateExpression(x) => (addr_of(x), NodeKind::TaggedTemplateExpression), + E::AwaitExpression(x) => (addr_of(x), NodeKind::AwaitExpression), + E::YieldExpression(x) => (addr_of(x), NodeKind::YieldExpression), + E::SequenceExpression(x) => (addr_of(x), NodeKind::SequenceExpression), + E::RegexLiteral(x) => (addr_of(x), NodeKind::RegexLiteral), + E::ThisExpression(x) => (addr_of(x), NodeKind::ThisExpression), + E::Super(x) => (addr_of(x), NodeKind::Super), + E::AssignmentExpression(x) => (addr_of(x), NodeKind::AssignmentExpression), + E::ObjectPattern(x) => (addr_of(x), NodeKind::ObjectPattern), + E::ArrayPattern(x) => (addr_of(x), NodeKind::ArrayPattern), + E::AssignmentPattern(x) => (addr_of(x), NodeKind::AssignmentPattern), + E::RestElement(x) => (addr_of(x), NodeKind::RestElement), + E::TSTypeAssertion(x) => (addr_of(x), NodeKind::TSTypeAssertion), + E::TSAsExpression(x) => (addr_of(x), NodeKind::TSAsExpression), + E::TSSatisfiesExpression(x) => (addr_of(x), NodeKind::TSSatisfiesExpression), + E::TSInstantiationExpression(x) => (addr_of(x), NodeKind::TSInstantiationExpression), + E::TSNonNullExpression(x) => (addr_of(x), NodeKind::TSNonNullExpression), + E::TSParameterProperty(x) => (addr_of(x), NodeKind::TSParameterProperty), + E::ImportExpression(x) => (addr_of(x), NodeKind::ImportExpression), + E::MetaProperty(x) => (addr_of(x), NodeKind::MetaProperty), + E::ParenthesizedExpression(x) => (addr_of(x), NodeKind::ParenthesizedExpression), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bumpalo::Bump; + + fn bind(source: &str) -> BoundFile { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + bind_file(&program, source, FileId::ROOT) + } + + #[test] + fn preorder_ids_parents_and_kinds() { + // Program(1) -> VariableDeclaration(2) -> VariableDeclarator(3) + // -> Identifier(4) (the `x`, now with the init idd too) + // -> Literal(5) (the `1`) + let bound = bind("const x = 1;"); + assert_eq!(bound.node_count, 5); + assert_eq!(bound.kinds[0], NodeKind::Program); + assert_eq!(bound.kinds[1], NodeKind::VariableDeclaration); + assert_eq!(bound.kinds[2], NodeKind::VariableDeclarator); + assert_eq!(bound.kinds[3], NodeKind::Identifier); + assert_eq!(bound.kinds[4], NodeKind::Literal); + assert_eq!(bound.parents[0], None); + assert_eq!(bound.parents[1], Some(NodeId::FIRST)); + assert_eq!(bound.parents[3], Some(NodeId::from_index(2))); + assert_eq!(bound.parents[4], Some(NodeId::from_index(2))); + } + + #[test] + fn subtree_end_enables_descendant_test() { + // Program(1) .. Literal(5); the root's subtree ends at the last id (5). + let bound = bind("const x = 1;"); + let root = NodeId::FIRST; + let ident = NodeId::from_index(3); // the `x` + let decl = NodeId::from_index(1); // VariableDeclaration + assert_eq!(bound.subtree_end[root.index()], NodeId::from_index(4)); + assert!(bound.is_descendant_of(ident, root)); + assert!(bound.is_descendant_of(ident, decl)); + assert!(!bound.is_descendant_of(root, ident)); + assert!(!bound.is_descendant_of(decl, ident)); + } + + #[test] + fn address_map_resolves_a_statement() { + let arena = Bump::new(); + let program = tsv_ts::parse("let a = 1; let b = 2;", &arena).expect("parse"); + let bound = bind_file(&program, "let a = 1; let b = 2;", FileId::ROOT); + let second = &program.body[1]; + let addr = std::ptr::from_ref(second) as usize; + let id = bound + .address_map + .get(&(addr, NodeKind::VariableDeclaration)) + .copied() + .expect("mapped"); + assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration); + } + + #[test] + fn nested_statements_are_walked() { + let bound = bind("function f() { return; }"); + assert!(bound.kinds.contains(&NodeKind::FunctionDeclaration)); + assert!(bound.kinds.contains(&NodeKind::ReturnStatement)); + let func = NodeId::from_index(1); + let ret = bound + .kinds + .iter() + .position(|k| *k == NodeKind::ReturnStatement) + .map(NodeId::from_index) + .expect("return present"); + assert!(bound.is_descendant_of(ret, func)); + } + + #[test] + fn expressions_and_types_are_idd() { + // The full descent reaches into initializers and type annotations. + let bound = bind("const x: number = f(1);"); + assert!(bound.kinds.contains(&NodeKind::TSKeywordType)); // `number` + assert!(bound.kinds.contains(&NodeKind::CallExpression)); // `f(1)` + assert!(bound.kinds.contains(&NodeKind::Literal)); // `1` + } + + #[test] + fn require_node_id_resolves_a_known_node() { + let arena = Bump::new(); + let program = tsv_ts::parse("let a = 1;", &arena).expect("parse"); + let bound = bind_file(&program, "let a = 1;", FileId::ROOT); + let addr = std::ptr::from_ref(&program.body[0]) as usize; + let id = bound.require_node_id(addr, NodeKind::VariableDeclaration); + assert_eq!(bound.kinds[id.index()], NodeKind::VariableDeclaration); + } + + #[test] + #[should_panic(expected = "not covered by the SoA walk")] + fn require_node_id_hard_fails_on_a_miss() { + // Address 0 is never a real arena node — the strict resolver must abort + // rather than return a corrupting `NodeId::FIRST` sentinel. + let bound = bind("const x = 1;"); + let _ = bound.require_node_id(0, NodeKind::Program); + } + + #[test] + fn method_and_value_resolve_distinctly() { + // The compound key disambiguates the one offset-0 pair: a + // `MethodDefinition` and its inline `value: FunctionExpression` share an + // address (value at struct offset 0), yet each resolves to its own id + // through the `(address, NodeKind)` key. Both look-ups hit the same + // address; only the kind tells them apart. + use tsv_ts::ast::internal::ClassMember; + let arena = Bump::new(); + let src = "class C { m() {} }"; + let program = tsv_ts::parse(src, &arena).expect("parse"); + let bound = bind_file(&program, src, FileId::ROOT); + let Statement::ClassDeclaration(class) = &program.body[0] else { + panic!("expected a class declaration"); + }; + let ClassMember::MethodDefinition(method) = &class.body.body[0] else { + panic!("expected a method definition"); + }; + // The two share one address (the collision the compound key resolves). + let method_addr = std::ptr::from_ref(method) as usize; + let value_addr = std::ptr::from_ref(&method.value) as usize; + assert_eq!(method_addr, value_addr); + // The method resolves to its own id … + let method_id = bound.require_node_id(method_addr, NodeKind::MethodDefinition); + assert_eq!(bound.kinds[method_id.index()], NodeKind::MethodDefinition); + // … and the value resolves to a distinct id. + let value_id = bound.require_node_id(value_addr, NodeKind::FunctionExpression); + assert_eq!(bound.kinds[value_id.index()], NodeKind::FunctionExpression); + assert_ne!(method_id, value_id); + } + + #[test] + fn class_type_parameters_are_descended() { + // Regression guard (F0 review): a class's own `` was dropped — no + // NodeId — so F1's strict `require_node_id` would panic on a class type + // parameter. `class C {}` mints Program, ClassDeclaration, Identifier(C), + // TSTypeParameterDeclaration, TSTypeParameter, Identifier(T) = 6 nodes, and + // `require_node_id` resolves the type-parameter node. + let arena = Bump::new(); + let program = tsv_ts::parse("class C {}", &arena).expect("parse"); + let bound = bind_file(&program, "class C {}", FileId::ROOT); + assert_eq!(bound.node_count, 6); + let tp = bound + .kinds + .iter() + .position(|k| *k == NodeKind::TSTypeParameter) + .map(NodeId::from_index) + .expect("class type parameter is idd"); + // The `` decl is the type-param's parent; the class owns both. + assert!(bound.is_descendant_of(tp, NodeId::from_index(1))); + // The class-expression path mirrors the declaration path (kept in sync). + assert!( + bind("const C = class {};") + .kinds + .contains(&NodeKind::TSTypeParameter) + ); + // A class type param's constraint + default are reached (both `TSTypeReference`s). + assert!( + bind("class C {}") + .kinds + .contains(&NodeKind::TSTypeReference) + ); + } + + /// The sorted family diagnostic codes a source produces — via the full + /// program pipeline, so the canonical sort + dedup applies (a conflict emits + /// one diagnostic per position after collapsing the repeated prior-decl ones). + fn diag_codes(source: &str) -> Vec { + let arena = Bump::new(); + let result = crate::program::check_program( + &[crate::program::SourceUnit::new("t.ts", source)], + &arena, + &crate::options::CheckOptions::default(), + ); + result.diagnostics.iter().map(|d| d.code).collect() + } + + #[test] + fn cascade_block_scoped_redeclare_is_2451() { + assert_eq!(diag_codes("let x; let x;"), vec![2451, 2451]); + assert_eq!(diag_codes("const y = 1; const y = 2;"), vec![2451, 2451]); + } + + #[test] + fn cascade_functions_first_picks_2300_over_2451() { + // The function hoists first, so the table symbol is the function (not + // block-scoped) -> TS2300 for the whole `x` run. + assert_eq!( + diag_codes("let x; var x; function x() {}"), + vec![2300, 2300, 2300] + ); + // No same-scope function: `let` is first -> TS2451. + assert_eq!( + diag_codes("function f() { let y; { var y; } }"), + vec![2451, 2451] + ); + } + + #[test] + fn cascade_class_and_method_conflicts_are_2300() { + assert_eq!(diag_codes("class C {} class C {}"), vec![2300, 2300]); + // A method vs a same-named property conflicts (Method in PropertyExcludes). + assert_eq!( + diag_codes("class C { m() {} m: number; }"), + vec![2300, 2300] + ); + // Duplicate parameters conflict via ParameterExcludes. + assert_eq!(diag_codes("function f(a, a) {}"), vec![2300, 2300]); + } + + #[test] + fn cascade_enum_merge_is_2567() { + // A regular enum and a const enum cannot merge -> the enum-merge message. + assert_eq!(diag_codes("enum E {} const enum E {}"), vec![2567, 2567]); + // Two regular enums merge cleanly (no diagnostic). + assert!(diag_codes("enum F {} enum F {}").is_empty()); + } + + #[test] + fn cascade_multiple_default_exports_is_2528() { + assert_eq!( + diag_codes("export default 0; export default 1;"), + vec![2528, 2528] + ); + } + + #[test] + fn uninstantiated_namespace_does_not_conflict_with_var() { + // A types-only namespace binds as the inert NamespaceModule, so a same-named + // `var` merges without a diagnostic. + assert!(diag_codes("namespace N { interface I {} } declare var N: any;").is_empty()); + // A value-content namespace is a ValueModule and conflicts with a `let` + // (TS2300 — the namespace, first in the table, is not block-scoped). + assert_eq!( + diag_codes("namespace M { const v = 1; } let M;"), + vec![2300, 2300] + ); + } + + #[test] + fn signature_duplicate_params_conflict() { + // A method / call / construct signature is its own function scope, so its + // duplicate params conflict (TS2300) — the anonymous signature declaration + // itself never conflicts. + assert_eq!(diag_codes("interface I { foo(x, x); }"), vec![2300, 2300]); + assert_eq!(diag_codes("interface I { (x, x); }"), vec![2300, 2300]); + assert_eq!(diag_codes("interface I { new (x, x); }"), vec![2300, 2300]); + // A generic method signature still conflicts on the params (the type param + // binds in the same scope without colliding). + assert_eq!( + diag_codes("interface I { foo(x: T, x: T); }"), + vec![2300, 2300] + ); + // Distinct param names in one signature and a lone param never conflict. + assert!(diag_codes("interface I { foo(x, y); bar(z); }").is_empty()); + } + + #[test] + fn type_annotation_type_literal_members_bind() { + // A typed binding descends its annotation: a type-literal method signature's + // duplicate params conflict. + assert_eq!(diag_codes("var a: { foo(x, x); };"), vec![2300, 2300]); + // Duplicate *members* of a type literal silent-merge at bind, but the + // check pass emits them (a check-time TS2300 per declaration). + assert_eq!( + diag_codes("var a: { x: number; x: string; };"), + vec![2300, 2300] + ); + } + + #[test] + fn object_literal_duplicate_methods_conflict() { + // Two same-named object-literal methods conflict (the method exclude is the + // whole Value mask for an object-literal method). + assert_eq!( + diag_codes("var b = { foo() {}, foo() {} };"), + vec![2300, 2300] + ); + // Duplicate plain properties silent-merge (Property is not in PropertyExcludes). + assert!(diag_codes("var b = { x: 1, x: 2 };").is_empty()); + // A getter/setter pair of the same name merges without a diagnostic. + assert!(diag_codes("var b = { get x() { return 1; }, set x(v) {} };").is_empty()); + } + + #[test] + fn dotted_namespace_merges_with_explicit_nested() { + // The dotted form's intermediate segments route to the enclosing namespace's + // exports, so they merge with the explicit-nested form — and their conflicting + // members surface (two classes named `P` in the merged inner namespace). + assert_eq!( + diag_codes( + "namespace M.X { export class P {} } \ + namespace M { export namespace X { export class P {} } }" + ), + vec![2300, 2300] + ); + // A lone dotted namespace introduces no spurious conflict. + assert!(diag_codes("namespace A.B.C { export const x = 1; }").is_empty()); + } + + #[test] + fn private_name_mangling_collides_at_hash() { + // A private method vs a same-named private field is a bind-time conflict + // (Method in PropertyExcludes); the mangling (class symbol id + name) makes + // the two `#x` share a table key, and the squiggle covers the `#`. (Two + // private *fields* would be property-vs-property — a check-time TS2300.) + let src = "class C { #x() {} #x = 1; }"; + let bound = bind(src); + let mut diags: Vec<(u32, u32)> = bound + .diagnostics + .iter() + .map(|d| (d.code, d.span.start)) + .collect(); + diags.sort_unstable(); + assert_eq!( + diags.iter().map(|d| d.0).collect::>(), + vec![2300, 2300] + ); + for (_, start) in &diags { + assert_eq!(&src[*start as usize..=*start as usize], "#"); + } + } + + #[test] + fn param_position_type_literal_method_params_conflict() { + // A method signature inside a parameter's type annotation is its own + // function scope, so its duplicate params conflict (the param-position + // type-annotation hook reaches the type literal). + assert_eq!( + diag_codes("function f(p: { foo(x, x); }) {}"), + vec![2300, 2300] + ); + } + + #[test] + fn object_literal_getter_getter_conflicts() { + // Two same-named object-literal getters conflict (GET_ACCESSOR_EXCLUDES); + // the accessor bump keeps the run reporting. + assert_eq!( + diag_codes("var b = { get x() {}, get x() {} };"), + vec![2300, 2300] + ); + } + + #[test] + fn object_literal_computed_key_method_conflicts() { + // A computed string-literal key names an object-literal method, so two + // `['foo']()` methods conflict (the object-literal method exclude is Value). + assert_eq!( + diag_codes("var b = { ['foo']() {}, ['foo']() {} };"), + vec![2300, 2300] + ); + } + + #[test] + fn check_pass_duplicate_type_parameters() { + // The check pass emits TS2300 for a duplicate type parameter (the binder + // silent-merges same-name type params, so this is check-only). One duplicate + // → one diagnostic after sort/dedup. + assert_eq!(diag_codes("function f() {}"), vec![2300]); + assert_eq!(diag_codes("class C {}"), vec![2300]); + assert_eq!(diag_codes("interface I {}"), vec![2300]); + // Distinct names never fire. + assert!(diag_codes("function g() {}").is_empty()); + // Declaration-merged interfaces are scoped per-declaration — only the second + // (its own `C, C`) fires; the two decls never cross-compare (that would be + // TS2428, deliberately not ported). + assert_eq!( + diag_codes("interface J {} interface J {}"), + vec![2300] + ); + } + + #[test] + fn check_pass_type_parameters_three_way_dedup() { + // `` fires 1 at T₂ + 2 at T₃ raw; the program-wide sort/dedup + // collapses the T₃ pair → 2 final diagnostics. + assert_eq!(diag_codes("function f() {}"), vec![2300, 2300]); + } + + #[test] + fn check_pass_non_decimal_numeric_keys_stay_distinct() { + // `0x0` / `0xff` (and octal / binary / numeric-separator forms) decode to + // NaN upstream; keyed on their verbatim source they stay distinct, so no + // false TS2300 (a `NaN`-keyed collision would flag them all). + assert!(diag_codes("type T = { 0x0: number; 0xff: string };").is_empty()); + assert!(diag_codes("type T = { 0o7: number; 1_0: string };").is_empty()); + // The identical form still collides (both key `"0x1"`). + assert_eq!( + diag_codes("type T = { 0x1: number; 0x1: string };"), + vec![2300, 2300] + ); + } + + #[test] + fn dotted_namespace_three_deep_merges_with_explicit_nested() { + // A 3-deep dotted namespace's intermediate segments route to their + // enclosing namespace's exports, so `M.X.Y` merges with the explicit 3-deep + // nested form and the inner `P` conflict surfaces. + assert_eq!( + diag_codes( + "namespace M.X.Y { export class P {} } \ + namespace M { export namespace X { export namespace Y { export class P {} } } }" + ), + vec![2300, 2300] + ); + } + + #[test] + fn export_default_identifier_is_alias_no_2528() { + // `export default ` binds as an inert alias, so a following + // default declaration does not conflict (matches tsgo; the redeclare is a + // check-time TS2323, not a bind-time TS2528). + assert!( + diag_codes("const foo = 1; export default foo; export default class Foo {}").is_empty() + ); + } + + #[test] + fn module_ness_detects_indicators() { + assert_eq!( + bind("export const x = 1;").facts.module_ness, + ModuleNess::Module + ); + assert_eq!( + bind("import x from 'y';").facts.module_ness, + ModuleNess::Module + ); + assert_eq!(bind("const x = 1;").facts.module_ness, ModuleNess::Script); + // `import x = require('y')` counts; `import x = A.B` and `export as + // namespace N` do not. + assert_eq!( + bind("import x = require('y');").facts.module_ness, + ModuleNess::Module + ); + assert_eq!( + bind("import x = A.B;").facts.module_ness, + ModuleNess::Script + ); + assert_eq!( + bind("export as namespace N;").facts.module_ness, + ModuleNess::Script + ); + // `import.meta` anywhere counts. + assert_eq!( + bind("const u = import.meta.url;").facts.module_ness, + ModuleNess::Module + ); + } +} diff --git a/crates/tsv_check/src/binder/sym/declare.rs b/crates/tsv_check/src/binder/sym/declare.rs new file mode 100644 index 000000000..44289c571 --- /dev/null +++ b/crates/tsv_check/src/binder/sym/declare.rs @@ -0,0 +1,372 @@ +//! The declare/conflict cascade — tsgo's `declareSymbolEx` port: declare a +//! symbol into the right table (locals/members/exports) via the container-kind +//! routing (`declareSymbolAndAddToSymbolTable`/`declareModuleMember`/ +//! `declareClassMember`/`bindBlockScopedDeclaration`), running the conflict +//! cascade (TS2300/2451/2567/2528) at each, plus alias declarations +//! (`declare_alias`, for import/export specifiers and `import =`). +// +// tsgo: internal/binder/binder.go declareSymbolEx (the cascade), +// declareSymbolAndAddToSymbolTable / declareModuleMember / declareClassMember +// (the routing) + +// The routing methods `.expect()`/`.unwrap()` on `Scope::symbol`/`Scope::locals` +// that the scope *kind* guarantees is `Some` — a class/enum/interface/module +// scope always carries its container symbol, a locals scope always carries its +// table. These are structural invariants of the container stack; a violation is a +// binder bug (contained by the harness's per-test `catch_unwind`), not a +// recoverable data error, so the panic points are the honest expression. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use super::super::symbols::{Decl, SymbolFlags, SymbolId, TableId}; +use crate::diag::{Category, Diagnostic}; +use tsv_lang::Span; + +use super::{ContainerKind, DeclInput, SymbolBinder}; + +impl<'a> SymbolBinder<'a> { + // --- the cascade --------------------------------------------------------- + + /// tsgo `declareSymbolEx` — declare `decl` into `table`, running the conflict + /// cascade, and return the symbol the declaration attached to (a fresh orphan + /// on conflict, so the table's original symbol keeps accumulating priors). + pub(super) fn declare_symbol( + &mut self, + table: TableId, + parent: Option, + decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + ) -> SymbolId { + let existing = self.tables[table.index()].get(&decl.name).copied(); + let symbol = match existing { + None => { + let sid = self.new_symbol(SymbolFlags::NONE, decl.name); + self.tables[table.index()].insert(decl.name, sid); + sid + } + Some(sid) => { + let flags = self.symbols[sid.index()].flags; + if flags.intersects(excludes) { + self.report_conflict(sid, &decl, includes); + // Accessor bump: mark the (kept) table symbol a full accessor + // so a get/non-accessor/set run all conflict. + let sflags = self.symbols[sid.index()].flags; + if sflags.intersects(SymbolFlags::ACCESSOR) + && (sflags.0 & SymbolFlags::ACCESSOR.0) + != (includes.0 & SymbolFlags::ACCESSOR.0) + { + self.symbols[sid.index()] + .flags + .insert(SymbolFlags::ACCESSOR); + } + // A fresh orphan (NOT inserted into the table): this + // declaration does not merge into the original, so the + // original's declaration list — the priors the cascade points + // at — stays fixed. + self.new_symbol(SymbolFlags::NONE, decl.name) + } else { + sid + } + } + }; + self.add_declaration(symbol, &decl, includes); + if self.symbols[symbol.index()].parent.is_none() { + self.symbols[symbol.index()].parent = parent; + } + symbol + } + + fn add_declaration(&mut self, symbol: SymbolId, decl: &DeclInput, includes: SymbolFlags) { + let is_type_decl = is_type_declaration(includes); + let s = &mut self.symbols[symbol.index()]; + s.flags.insert(includes); + s.decls.push(Decl { + node: decl.node, + error_span: decl.error_span, + display: decl.display, + is_type_decl, + }); + } + + /// Emit the duplicate/conflict diagnostics for `decl` against `existing`. + fn report_conflict(&mut self, existing: SymbolId, decl: &DeclInput, includes: SymbolFlags) { + let sym_flags = self.symbols[existing.index()].flags; + let mut code: u32 = if sym_flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE) { + 2451 + } else { + 2300 + }; + let mut needs_name = true; + if sym_flags.intersects(SymbolFlags::ENUM) || includes.intersects(SymbolFlags::ENUM) { + code = 2567; + needs_name = false; + } + let mut multiple_default = false; + if !self.symbols[existing.index()].decls.is_empty() + && (decl.is_default_export || decl.is_export_assignment_default) + { + code = 2528; + needs_name = false; + multiple_default = true; + } + + let new_span = decl.error_span; + let new_name = if needs_name { + Some(self.atoms.resolve(decl.display).to_string()) + } else { + None + }; + let mut new_diag = self.make_diag(new_span, code, new_name.as_deref()); + + let priors: Vec = self.symbols[existing.index()].decls.to_vec(); + for (index, pdecl) in priors.iter().enumerate() { + let pname = if needs_name { + Some(self.atoms.resolve(pdecl.display).to_string()) + } else { + None + }; + let mut d = self.make_diag(pdecl.error_span, code, pname.as_deref()); + if multiple_default { + let rcode = if index == 0 { 2753 } else { 6204 }; + let r_new = self.make_related(new_span, rcode); + d.related.push(r_new); + let r_first = self.make_related(pdecl.error_span, 2752); + new_diag.related.push(r_first); + } + self.diagnostics.push(d); + } + self.diagnostics.push(new_diag); + } + + pub(super) fn make_diag(&self, span: Span, code: u32, name: Option<&str>) -> Diagnostic { + let message = message_for(code, name); + let args = name.map(|n| vec![n.to_string()]).unwrap_or_default(); + Diagnostic { + file: Some(self.file), + span, + code, + category: Category::Error, + message, + args, + chain: Vec::new(), + related: Vec::new(), + } + } + + fn make_related(&self, span: Span, code: u32) -> Diagnostic { + Diagnostic { + file: Some(self.file), + span, + code, + // The two `export default` related codes are `Error` category in tsgo's + // diagnosticMessages; `and here.` (6204) and the `Did you mean` hint + // (1369) are `Message`. (Category is unobservable in code+span grading; + // this stays faithful to the oracle.) + category: match code { + 2752 | 2753 => Category::Error, + _ => Category::Message, + }, + message: message_for(code, None), + args: Vec::new(), + chain: Vec::new(), + related: Vec::new(), + } + } + + // --- routing ------------------------------------------------------------- + + /// tsgo `declareSymbolAndAddToSymbolTable` — route by the current container. + pub(super) fn declare_in_container( + &mut self, + decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + ) -> SymbolId { + match self.container.kind { + ContainerKind::Module => self.declare_module_member(decl, includes, excludes), + ContainerKind::SourceFile => self.declare_source_file_member(decl, includes, excludes), + ContainerKind::Class => self.declare_class_member(decl, includes, excludes, false), + ContainerKind::Enum => { + let sym = self.container.symbol.expect("enum has a symbol"); + let table = self.exports_of(sym); + self.declare_symbol(table, Some(sym), decl, includes, excludes) + } + ContainerKind::Interface => { + let sym = self + .container + .symbol + .expect("members container has a symbol"); + let table = self.members_of(sym); + self.declare_symbol(table, Some(sym), decl, includes, excludes) + } + ContainerKind::Locals => { + let table = self.container.locals.expect("locals container has a table"); + self.declare_symbol(table, None, decl, includes, excludes) + } + } + } + + /// tsgo `bindBlockScopedDeclaration` — route by the current block scope. + pub(super) fn declare_block_scoped( + &mut self, + decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + ) -> SymbolId { + match self.block_scope.kind { + ContainerKind::Module => self.declare_module_member(decl, includes, excludes), + ContainerKind::SourceFile => { + if self.block_scope.is_external_module { + self.declare_module_member(decl, includes, excludes) + } else { + let table = self.block_scope.locals.expect("source file has locals"); + self.declare_symbol(table, None, decl, includes, excludes) + } + } + _ => { + let table = self.block_scope.locals.expect("block scope has locals"); + self.declare_symbol(table, None, decl, includes, excludes) + } + } + } + + /// tsgo `declareSourceFileMember`. + fn declare_source_file_member( + &mut self, + decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + ) -> SymbolId { + if self.container.is_external_module { + self.declare_module_member(decl, includes, excludes) + } else { + let table = self.container.locals.expect("source file has locals"); + self.declare_symbol(table, None, decl, includes, excludes) + } + } + + /// tsgo `declareModuleMember` — the exported-member routing (dual split + /// collapsed to the export symbol; see the module doc). Aliases route through + /// [`Self::declare_alias`] instead, so this handles only value/type members. + fn declare_module_member( + &mut self, + mut decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + ) -> SymbolId { + let to_exports = + decl.exported || decl.is_default_export || self.container.is_export_context; + if to_exports { + let sym = self + .container + .symbol + .expect("module member exports needs a container symbol"); + if decl.is_default_export { + // A default export forces the `"default"` table key. + decl.name = self.atoms.default_export(); + } + let table = self.exports_of(sym); + self.declare_symbol(table, Some(sym), decl, includes, excludes) + } else { + let table = self.container.locals.expect("module member locals"); + self.declare_symbol(table, None, decl, includes, excludes) + } + } + + /// tsgo `declareClassMember` — static members to `exports`, else `members`. + pub(super) fn declare_class_member( + &mut self, + decl: DeclInput, + includes: SymbolFlags, + excludes: SymbolFlags, + is_static: bool, + ) -> SymbolId { + let sym = self.container.symbol.expect("class has a symbol"); + let table = if is_static { + self.exports_of(sym) + } else { + self.members_of(sym) + }; + self.declare_symbol(table, Some(sym), decl, includes, excludes) + } + + // --- imports / exports (aliases) ----------------------------------------- + + /// Declare an alias symbol (import/export specifier, `import =`). Exported + /// aliases route to `exports`, others to `locals`. + pub(super) fn declare_alias(&mut self, decl: DeclInput, to_exports: bool) { + match self.container.kind { + ContainerKind::Module | ContainerKind::SourceFile + if self.container.symbol.is_some() => + { + if to_exports { + let sym = self.container.symbol.unwrap(); + let mut d = decl; + if d.is_default_export { + d.name = self.atoms.default_export(); + } + let table = self.exports_of(sym); + self.declare_symbol( + table, + Some(sym), + d, + SymbolFlags::ALIAS, + SymbolFlags::ALIAS_EXCLUDES, + ); + } else { + let table = self.container.locals.expect("locals for alias"); + self.declare_symbol( + table, + None, + decl, + SymbolFlags::ALIAS, + SymbolFlags::ALIAS_EXCLUDES, + ); + } + } + _ => { + if let Some(table) = self.container.locals { + self.declare_symbol( + table, + None, + decl, + SymbolFlags::ALIAS, + SymbolFlags::ALIAS_EXCLUDES, + ); + } + } + } + } +} + +/// Whether a declaration's `includes` flags mark it a *type* declaration (tsgo +/// `IsTypeDeclaration`: class / interface / enum / type-alias / type-parameter) — +/// each of those flag families corresponds one-to-one to a type-declaration node +/// kind. The merge's `undefined`-redeclaration check (TS2397) skips these. +fn is_type_declaration(includes: SymbolFlags) -> bool { + includes.intersects(SymbolFlags( + SymbolFlags::CLASS.0 + | SymbolFlags::INTERFACE.0 + | SymbolFlags::ENUM.0 + | SymbolFlags::TYPE_ALIAS.0 + | SymbolFlags::TYPE_PARAMETER.0, + )) +} + +/// The `.errors.txt` message text for a family / related-info code. +fn message_for(code: u32, name: Option<&str>) -> String { + match code { + 2300 => format!("Duplicate identifier '{}'.", name.unwrap_or("")), + 2451 => format!( + "Cannot redeclare block-scoped variable '{}'.", + name.unwrap_or("") + ), + 2567 => "Enum declarations can only merge with namespace or other enum declarations." + .to_string(), + 2528 => "A module cannot have multiple default exports.".to_string(), + 2752 => "The first export default is here.".to_string(), + 2753 => "Another export default is here.".to_string(), + 6204 => "and here.".to_string(), + _ => String::new(), + } +} diff --git a/crates/tsv_check/src/binder/sym/expression.rs b/crates/tsv_check/src/binder/sym/expression.rs new file mode 100644 index 000000000..1f9eaae0a --- /dev/null +++ b/crates/tsv_check/src/binder/sym/expression.rs @@ -0,0 +1,169 @@ +//! The expression-shaped bind-descent — `visit_expression` (function/arrow/ +//! class-expression scopes, the pattern-aware nested descent) and +//! `bind_object_expression` (the object-literal member table). Contributes its +//! own `impl SymbolBinder` block; the struct and the scope helpers live in the +//! parent module. Purely a locality split — no behavior distinction. + +use super::super::symbols::SymbolFlags; +use super::{DeclInput, SymbolBinder}; +use crate::ids::NodeId; +use tsv_ts::ast::internal::{Expression, ObjectExpression, ObjectProperty, PropertyKind}; + +impl<'a> SymbolBinder<'a> { + // --- expressions (nested scopes) ----------------------------------------- + + pub(super) fn visit_expression(&mut self, expr: &Expression<'a>) { + use Expression as E; + match expr { + E::FunctionExpression(f) => { + self.with_function_scope(f.type_parameters.as_ref(), |b| { + b.bind_params(f.params); + b.bind_statement_list(f.body.body, true); + }); + } + E::ArrowFunctionExpression(a) => { + self.with_function_scope(a.type_parameters.as_ref(), |b| { + b.bind_params(a.params); + match &a.body { + tsv_ts::ast::internal::ArrowFunctionBody::Expression(e) => { + b.visit_expression(e); + } + tsv_ts::ast::internal::ArrowFunctionBody::BlockStatement(block) => { + b.bind_statement_list(block.body, true); + } + } + }); + } + E::ClassExpression(c) => { + let sym = c.id.as_ref().map(|_| { + let name = self.atoms.intern("__class"); + self.new_symbol(SymbolFlags::CLASS, name) + }); + self.bind_class_body(&c.body, sym, c.type_parameters.as_ref()); + } + E::ParenthesizedExpression(p) => self.visit_expression(p.expression), + E::UnaryExpression(u) => self.visit_expression(u.argument), + E::UpdateExpression(u) => self.visit_expression(u.argument), + E::AwaitExpression(a) => self.visit_expression(a.argument), + E::YieldExpression(y) => { + if let Some(a) = y.argument { + self.visit_expression(a); + } + } + E::BinaryExpression(b) => { + self.visit_expression(b.left); + self.visit_expression(b.right); + } + E::AssignmentExpression(a) => { + self.visit_expression(a.left); + self.visit_expression(a.right); + } + E::ConditionalExpression(c) => { + self.visit_expression(c.test); + self.visit_expression(c.consequent); + self.visit_expression(c.alternate); + } + E::SequenceExpression(s) => { + for e in s.expressions { + self.visit_expression(e); + } + } + E::CallExpression(c) => { + self.visit_expression(c.callee); + for a in c.arguments { + self.visit_expression(a); + } + } + E::NewExpression(n) => { + self.visit_expression(n.callee); + for a in n.arguments { + self.visit_expression(a); + } + } + E::MemberExpression(m) => { + self.visit_expression(m.object); + self.visit_expression(m.property); + } + E::TSNonNullExpression(t) => self.visit_expression(t.expression), + E::TSAsExpression(t) => self.visit_expression(t.expression), + E::TSSatisfiesExpression(t) => self.visit_expression(t.expression), + E::TSInstantiationExpression(t) => self.visit_expression(t.expression), + E::SpreadElement(s) => self.visit_expression(s.argument), + E::ArrayExpression(a) => { + for e in a.elements.iter().flatten() { + self.visit_expression(e); + } + } + E::ObjectExpression(o) => self.bind_object_expression(o), + E::TemplateLiteral(t) => { + for e in t.expressions { + self.visit_expression(e); + } + } + E::TaggedTemplateExpression(t) => { + self.visit_expression(t.tag); + for e in t.quasi.expressions { + self.visit_expression(e); + } + } + _ => {} + } + } + + // --- object literals ----------------------------------------------------- + + /// Bind an object literal's members into a fresh member table so duplicate + /// members conflict. tsgo binds the literal an anonymous `ObjectLiteral` + /// container; tsv builds the member table locally and swaps no scope — an + /// object literal is not a `HasLocals` container, and nothing consumes the + /// literal's symbol, so nested function/arrow *values* still open their own + /// scope through the per-value [`Self::visit_expression`] recursion. + /// + /// The load-bearing choice is the object-literal-method exclude: it is the + /// whole `Value` mask (tsgo `IsObjectLiteralMethod ? SymbolFlagsValue : + /// SymbolFlagsMethodExcludes`), and `Value ⊇ Method`, so two same-named + /// object-literal methods conflict — while class/interface methods + /// (`METHOD_EXCLUDES`) keep their silent-merge untouched. + /// + /// tsgo: internal/binder/binder.go bindPropertyOrMethodOrAccessor + /// (KindObjectLiteralExpression member cases) + fn bind_object_expression(&mut self, obj: &ObjectExpression<'a>) { + let table = self.new_table(); + for prop in obj.properties { + match prop { + ObjectProperty::Property(pr) => { + if let Some(key) = self.resolve_member_key(&pr.key, pr.computed, None) { + let (inc, exc) = match pr.kind { + PropertyKind::Get => ( + SymbolFlags::GET_ACCESSOR, + SymbolFlags::GET_ACCESSOR_EXCLUDES, + ), + PropertyKind::Set => ( + SymbolFlags::SET_ACCESSOR, + SymbolFlags::SET_ACCESSOR_EXCLUDES, + ), + PropertyKind::Init if pr.method => { + (SymbolFlags::METHOD, SymbolFlags::VALUE) + } + PropertyKind::Init => { + (SymbolFlags::PROPERTY, SymbolFlags::PROPERTY_EXCLUDES) + } + }; + let d = DeclInput { + name: key.key, + display: key.display, + error_span: key.span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_symbol(table, None, d, inc, exc); + } + self.visit_expression(&pr.value); + } + ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument), + } + } + } +} diff --git a/crates/tsv_check/src/binder/sym/members.rs b/crates/tsv_check/src/binder/sym/members.rs new file mode 100644 index 000000000..14c062b7c --- /dev/null +++ b/crates/tsv_check/src/binder/sym/members.rs @@ -0,0 +1,468 @@ +//! The class/interface/type-literal/enum member and type descents — +//! `bind_class_body`/`bind_class_member`/`bind_constructor_params`, the +//! interface/type-literal member bind (`bind_interface_body`/ +//! `bind_type_element`/`declare_type_member`), enum members, and type +//! parameters. Contributes its own `impl SymbolBinder` block; the struct and +//! the scope helpers live in the parent module. Purely a locality split — no +//! behavior distinction. + +use super::super::symbols::{SymbolFlags, SymbolId}; +use super::{ContainerKind, DeclInput, DeclMods, Scope, SymbolBinder}; +use crate::ids::NodeId; +use tsv_ts::ast::internal::{ + ClassBody, ClassMember, Expression, MethodKind, Statement, TSEnumMemberId, TSInterfaceBody, + TSType, TSTypeAnnotation, TSTypeElement, TSTypeLiteral, TSTypeParameterDeclaration, +}; + +impl<'a> SymbolBinder<'a> { + // --- classes ------------------------------------------------------------- + + // tsgo: internal/binder/binder.go bindClassLikeDeclaration (the static-`prototype` clash, :971) + pub(super) fn bind_class_body( + &mut self, + body: &ClassBody<'a>, + class_symbol: Option, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { + let Some(class_symbol) = class_symbol else { + // Anonymous / skipped class: still descend member values for nested + // bindings, but no member tables to conflict in. + self.descend_class_values(body); + return; + }; + // The static-`prototype` clash (checker.go:971): a pre-seeded export. + let proto = self.atoms.intern("prototype"); + let exports = self.exports_of(class_symbol); + if let Some(existing) = self.tables[exports.index()].get(&proto).copied() + && let Some(pdecl) = self.symbols[existing.index()].decls.first().copied() + { + let name = self.atoms.resolve(pdecl.display).to_string(); + let diag = self.make_diag(pdecl.error_span, 2300, Some(&name)); + self.diagnostics.push(diag); + } + let proto_sym = self.new_symbol(SymbolFlags::PROPERTY.union(SymbolFlags::PROTOTYPE), proto); + self.symbols[proto_sym.index()].parent = Some(class_symbol); + self.tables[exports.index()].insert(proto, proto_sym); + + let saved = (self.container, self.block_scope); + let scope = Scope { + kind: ContainerKind::Class, + symbol: Some(class_symbol), + locals: None, + is_external_module: false, + is_export_context: false, + }; + self.container = scope; + self.block_scope = scope; + self.bind_type_params(type_params); + for member in body.body { + self.bind_class_member(member, class_symbol); + } + self.container = saved.0; + self.block_scope = saved.1; + } + + fn bind_class_member(&mut self, member: &ClassMember<'a>, class_symbol: SymbolId) { + match member { + ClassMember::MethodDefinition(m) => { + let is_static = m.is_static; + let (inc, exc) = match m.kind { + MethodKind::Constructor => (SymbolFlags::CONSTRUCTOR, SymbolFlags::NONE), + MethodKind::Get => ( + SymbolFlags::GET_ACCESSOR, + SymbolFlags::GET_ACCESSOR_EXCLUDES, + ), + MethodKind::Set => ( + SymbolFlags::SET_ACCESSOR, + SymbolFlags::SET_ACCESSOR_EXCLUDES, + ), + MethodKind::Method => { + let opt = if m.optional { + SymbolFlags::OPTIONAL + } else { + SymbolFlags::NONE + }; + (SymbolFlags::METHOD.union(opt), SymbolFlags::METHOD_EXCLUDES) + } + }; + if let MethodKind::Constructor = m.kind { + let d = DeclInput { + name: self.atoms.intern("__constructor"), + display: self.atoms.intern("__constructor"), + error_span: m.span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_class_member(d, inc, exc, is_static); + // Bind constructor params (incl. parameter properties -> class members). + self.with_function_scope(m.value.type_parameters.as_ref(), |b| { + b.bind_constructor_params(m.value.params, class_symbol); + b.bind_statement_list(method_body(&m.value), true); + }); + } else if let Some(key) = + self.resolve_member_key(&m.key, m.computed, Some(class_symbol)) + { + let d = DeclInput { + name: key.key, + display: key.display, + error_span: key.span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_class_member(d, inc, exc, is_static); + self.with_function_scope(m.value.type_parameters.as_ref(), |b| { + b.bind_params(m.value.params); + b.bind_statement_list(method_body(&m.value), true); + }); + } else { + // Dynamic computed key: anonymous member, no conflict; still + // descend the value for nested bindings. + self.with_function_scope(m.value.type_parameters.as_ref(), |b| { + b.bind_params(m.value.params); + b.bind_statement_list(method_body(&m.value), true); + }); + } + } + ClassMember::PropertyDefinition(p) => { + let (inc, exc) = if p.accessor { + (SymbolFlags::ACCESSOR, SymbolFlags::ACCESSOR_EXCLUDES) + } else { + let opt = if p.modifier == tsv_ts::ast::internal::PropertyModifier::Optional { + SymbolFlags::OPTIONAL + } else { + SymbolFlags::NONE + }; + ( + SymbolFlags::PROPERTY.union(opt), + SymbolFlags::PROPERTY_EXCLUDES, + ) + }; + if let Some(key) = self.resolve_member_key(&p.key, p.computed, Some(class_symbol)) { + let d = DeclInput { + name: key.key, + display: key.display, + error_span: key.span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_class_member(d, inc, exc, p.is_static); + } + if let Some(v) = &p.value { + self.visit_expression(v); + } + } + ClassMember::StaticBlock(s) => { + self.with_block_scope(|b| b.bind_statement_list(s.body, true)); + } + ClassMember::IndexSignature(_) => {} + } + } + + fn bind_constructor_params(&mut self, params: &[Expression<'a>], class_symbol: SymbolId) { + for param in params { + match param { + Expression::TSParameterProperty(pp) => { + // Bind as a parameter (in the constructor scope)... + self.bind_param(pp.parameter); + // ...and as a class instance member (tsgo bindParameter). + if let Expression::Identifier(id) = ident_of_param(pp.parameter) { + let opt = if id.optional { + SymbolFlags::OPTIONAL + } else { + SymbolFlags::NONE + }; + let d = self.decl_from_ident(id, pp.span, DeclMods::default()); + let table = self.members_of(class_symbol); + self.declare_symbol( + table, + Some(class_symbol), + d, + SymbolFlags::PROPERTY.union(opt), + SymbolFlags::PROPERTY_EXCLUDES, + ); + } + } + _ => self.bind_param(param), + } + } + } + + fn descend_class_values(&mut self, body: &ClassBody<'a>) { + for member in body.body { + match member { + ClassMember::MethodDefinition(m) => { + self.with_function_scope(m.value.type_parameters.as_ref(), |b| { + b.bind_params(m.value.params); + b.bind_statement_list(method_body(&m.value), true); + }); + } + ClassMember::PropertyDefinition(p) => { + if let Some(v) = &p.value { + self.visit_expression(v); + } + } + ClassMember::StaticBlock(s) => { + self.with_block_scope(|b| b.bind_statement_list(s.body, true)); + } + ClassMember::IndexSignature(_) => {} + } + } + } + + // --- interfaces / enums / modules --------------------------------------- + + pub(super) fn bind_interface_body( + &mut self, + body: &TSInterfaceBody<'a>, + interface_symbol: SymbolId, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { + let saved = (self.container, self.block_scope); + let scope = Scope { + kind: ContainerKind::Interface, + symbol: Some(interface_symbol), + locals: None, + is_external_module: false, + is_export_context: false, + }; + self.container = scope; + self.block_scope = scope; + self.bind_type_params(type_params); + for member in body.body { + self.bind_type_element(member); + } + self.container = saved.0; + self.block_scope = saved.1; + } + + pub(super) fn bind_interface_body_symbol_less( + &self, + _body: &TSInterfaceBody<'a>, + _type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { + // `export default interface` with no container symbol: nothing to bind. + } + + // --- type annotations ---------------------------------------------------- + + /// Descend a binding's type annotation. + pub(super) fn bind_type_annotation(&mut self, ann: &TSTypeAnnotation<'a>) { + self.bind_type(ann.type_annotation); + } + + /// Bind the only type shape whose members reach the family cascade — a type + /// literal. Every other variant is a deliberate no-op: a narrower-than-tsgo + /// traversal can only leave things missing, never fabricate an extra. + // + // TODO: this descent is both shallow (direct `TypeLiteral` only) and reached + // from only a few sites — it never runs on a type-alias RHS, heritage type + // arguments, a nested class expression, or a union/array-wrapped nested literal, + // so a method-vs-property conflict in a type literal there is missed at bind + // (miss-only; extra=0 holds; unexercised by the corpus). The coherent fix is one + // general bind-side type descent mirroring the check pass's `CheckWalk::visit_type`, + // wired into those sites together — not patched per-position. + fn bind_type(&mut self, ty: &TSType<'a>) { + if let TSType::TypeLiteral(tl) = ty { + self.bind_type_literal_body(tl); + } + } + + /// Bind a type literal's members under an anonymous `TypeLiteral` symbol — + /// mirrors [`Self::bind_interface_body`]'s member scope, so a method + /// signature's duplicate params conflict and its duplicate members + /// silent-merge (the property/member family is check-time, out of this bind). + /// + /// tsgo: internal/binder/binder.go bindAnonymousDeclaration + /// (SymbolFlagsTypeLiteral, InternalSymbolNameType) + fn bind_type_literal_body(&mut self, tl: &TSTypeLiteral<'a>) { + let name = self.atoms.intern("__type"); + let sym = self.new_symbol(SymbolFlags::TYPE_LITERAL, name); + let saved = (self.container, self.block_scope); + let scope = Scope { + kind: ContainerKind::Interface, + symbol: Some(sym), + locals: None, + is_external_module: false, + is_export_context: false, + }; + self.container = scope; + self.block_scope = scope; + for member in tl.members { + self.bind_type_element(member); + } + self.container = saved.0; + self.block_scope = saved.1; + } + + fn bind_type_element(&mut self, element: &TSTypeElement<'a>) { + match element { + TSTypeElement::PropertySignature(p) => { + self.declare_type_member( + &p.key, + p.computed, + SymbolFlags::PROPERTY, + SymbolFlags::PROPERTY_EXCLUDES, + ); + // Descend the member's own type — a nested type literal's members bind + // (its method-vs-property conflict is bind-time, so it is missed unless + // this recurses). tsgo binds nested type-literal members; a + // property/property nested dup is caught separately by the check pass at + // any depth, so this closes only the bind-time family gap. + if let Some(ann) = &p.type_annotation { + self.bind_type_annotation(ann); + } + } + TSTypeElement::MethodSignature(m) => { + self.declare_type_member( + &m.key, + m.computed, + SymbolFlags::METHOD, + SymbolFlags::METHOD_EXCLUDES, + ); + // A method signature is itself a `HasLocals` function-like container + // (tsgo `GetContainerFlags` KindMethodSignature), so its parameters + // bind into a fresh function scope — duplicate params within one + // signature conflict (TS2300) independently of the enclosing member + // table. + self.with_function_scope(m.type_parameters.as_ref(), |b| b.bind_params(m.params)); + // The return type descends for the same nested-type-literal reason as a + // property signature (param type literals already descend via + // `bind_binding`). + if let Some(ann) = &m.return_type { + self.bind_type_annotation(ann); + } + } + // Call/construct signatures are anonymous in the member table: tsgo binds + // them `SymbolFlagsSignature` with no excludes, so they never conflict — + // tsv skips that inert declaration and binds only their parameters, into + // their own function scope. Index signatures have a single parameter that + // cannot self-conflict, so nothing binds. + // tsgo: internal/binder/binder.go GetContainerFlags (Kind{Call,Construct}Signature) + TSTypeElement::CallSignature(c) => { + self.with_function_scope(c.type_parameters.as_ref(), |b| b.bind_params(c.params)); + if let Some(ann) = &c.return_type { + self.bind_type_annotation(ann); + } + } + TSTypeElement::ConstructSignature(c) => { + self.with_function_scope(c.type_parameters.as_ref(), |b| b.bind_params(c.params)); + if let Some(ann) = &c.return_type { + self.bind_type_annotation(ann); + } + } + TSTypeElement::IndexSignature(_) => {} + } + } + + /// Declare a type-literal / interface member (property or method signature) + /// keyed by its name into the current member container. + fn declare_type_member( + &mut self, + key_expr: &Expression<'a>, + computed: bool, + inc: SymbolFlags, + exc: SymbolFlags, + ) { + if let Some(key) = self.resolve_member_key(key_expr, computed, None) { + let d = DeclInput { + name: key.key, + display: key.display, + error_span: key.span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_in_container(d, inc, exc); + } + } + + pub(super) fn bind_enum_members( + &mut self, + members: &[tsv_ts::ast::internal::TSEnumMember<'a>], + enum_symbol: SymbolId, + ) { + let saved = (self.container, self.block_scope); + let scope = Scope { + kind: ContainerKind::Enum, + symbol: Some(enum_symbol), + locals: None, + is_external_module: false, + is_export_context: false, + }; + self.container = scope; + self.block_scope = scope; + for member in members { + let (key, span) = match &member.id { + TSEnumMemberId::Identifier(id) => (self.ident_atom(id), id.name_span()), + TSEnumMemberId::String(lit) => (self.string_atom(lit), lit.span), + }; + let d = DeclInput { + name: key, + display: key, + error_span: span, + is_default_export: false, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_in_container( + d, + SymbolFlags::ENUM_MEMBER, + SymbolFlags::ENUM_MEMBER_EXCLUDES, + ); + if let Some(init) = &member.initializer { + self.visit_expression(init); + } + } + self.container = saved.0; + self.block_scope = saved.1; + } + + // --- type parameters ----------------------------------------------------- + + pub(super) fn bind_type_params( + &mut self, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { + if let Some(tp) = type_params { + for p in tp.params { + let d = self.decl_from_ident(&p.name, p.span, DeclMods::default()); + self.declare_in_container( + d, + SymbolFlags::TYPE_PARAMETER, + SymbolFlags::TYPE_PARAMETER_EXCLUDES, + ); + } + } + } + + pub(super) fn bind_type_params_in_new_locals( + &mut self, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + ) { + if type_params.is_none() { + return; + } + self.with_function_scope(type_params, |_| {}); + } +} + +/// The binding identifier of a parameter, unwrapping a default (`AssignmentPattern`). +fn ident_of_param<'b, 'a>(param: &'b Expression<'a>) -> &'b Expression<'a> { + match param { + Expression::AssignmentPattern(a) => a.left, + other => other, + } +} + +/// A method's body statements (a `FunctionExpression`'s block body). +fn method_body<'a>(f: &tsv_ts::ast::internal::FunctionExpression<'a>) -> &'a [Statement<'a>] { + f.body.body +} diff --git a/crates/tsv_check/src/binder/sym/mod.rs b/crates/tsv_check/src/binder/sym/mod.rs new file mode 100644 index 000000000..47f042db7 --- /dev/null +++ b/crates/tsv_check/src/binder/sym/mod.rs @@ -0,0 +1,543 @@ +//! The symbol bind — tsgo's binder ported for the duplicate/conflict family. +//! +//! A container-threaded walk that declares a symbol for every binding-introducing +//! node into the right table (locals / members / exports), running the +//! `declareSymbolEx` conflict cascade at each — the source of TS2300 (duplicate +//! identifier), TS2451 (block-scoped redeclare), TS2567 (enum-merge), and TS2528 +//! (multiple default exports). Statement lists bind **functions-first** +//! (`bindEachStatementFunctionsFirst`), so a hoisted function's symbol is the +//! table's first entry — the reason `let x; var x; function x(){}` reports TS2300 +//! (function is first) where `let x; { var x; }` reports TS2451 (the `let` is +//! first). +//! +//! Deliberate simplifications from the full binder, each sound for the family: +//! - the exported-member **dual local+export split** is collapsed to a single +//! export symbol. tsgo gives an exported module member two symbols +//! (`declareModuleMember`, binder.go:387-414): an export symbol with the full +//! flags, and a **local** symbol declared into the container's `locals` with +//! only `ExportValue` as its *includes* but the **full `symbolExcludes`** mask +//! (binder.go:409-411). That local half exists **precisely to conflict** — when +//! an exported member follows a same-name **non-exported** local, the export's +//! local half (full excludes) collides with the prior plain local in the +//! `locals` table and issues a duplicate-identifier error. Collapsing to +//! export-only drops that one collision, but it is sound for the **P1 family** +//! because the local↔export mixing it would catch surfaces instead as the +//! check-time **TS2395** ("individual declarations … must be all exported or all +//! local"), which is out of the bind/merge family; and the functions-first pass +//! defuses the common function-overload cases before they reach the locals +//! table. It is sound for the **S4 merge** for a separate reason: the global +//! merge folds a *script's* `file.Locals` (no dual split — scripts declare +//! straight into locals with full flags) and `declare global` / augmentation +//! *exports* (the export halves, full flags), and **never** an external module's +//! locals (they don't reach global scope), so the merge never reads a +//! dual-split local half at all. +//! - module instantiation state (`getModuleInstanceState`) is approximated: +//! specifier-only named exports are treated as non-instantiated rather than +//! resolving each alias target, and const-enum-only propagation is folded into +//! the instantiated verdict (a const enum makes a namespace `ValueModule`). +//! - the JS-only `declareSymbolEx` branches (`isReplaceableByMethod` +//! constructor-vs-prototype discard, and the assignment-merge escape that lets +//! `SymbolFlagsAssignment` declarations coexist with variables) are deliberately +//! unported: the tsc conformance corpus this grades is TS-only, so those +//! JS-expando paths are unreachable. Revisit if a `.js`-flavored suite enters scope. +//! +//! The same-table cascade lands here; the **cross-declaration-space merge** (a +//! script's `file.Locals` folded into global scope, `declare global` and +//! `declare module "X"` augmentations) runs in [`crate::merge`] over the +//! [`FileMerge`] product this bind returns. +//! +//! Split for locality across four files: this module (`mod.rs`) keeps the +//! `SymbolBinder` struct, its lifecycle (`new`/`bind_program`/`finish`), the +//! table/symbol/atom primitives every descendant shares, the member-key +//! resolver, the functions-first statement-list driver (`bind_statement_list`), +//! and the scope helpers (`with_function_scope`/`with_block_scope`) every +//! descendant calls into; `statement.rs` holds the statement-shaped +//! bind-descent (`visit_statement` and every `bind_*_statement`, the +//! param/binding helpers, and the module/import/export-specifier binds); +//! `expression.rs` holds `visit_expression` and `bind_object_expression`; +//! `members.rs` holds the class/interface/type-literal/enum member and type +//! descents; `declare.rs` holds the `declareSymbolEx` cascade and the container +//! routing. No behavior distinction between the four. + +mod declare; +mod expression; +mod members; +mod statement; + +use super::atoms::{Atom, Atoms}; +use super::symbols::{Symbol, SymbolFlags, SymbolId, TableId}; +use super::{FileFacts, NodeKind, addr_of}; +use crate::diag::Diagnostic; +use crate::ids::{FileId, NodeId}; +use crate::merge::{FileMerge, MergeDecl, MergeSymbol, ModuleAug}; +use tsv_lang::{FxHashMap, Span}; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ExportDefaultValue, Expression, Identifier, Literal, LiteralValue, ModuleExportName, Statement, + TSTypeParameterDeclaration, +}; + +/// The container kinds that route member declarations (a subset of tsgo's node +/// kinds, enough to dispatch `declareSymbolAndAddToSymbolTable`). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ContainerKind { + SourceFile, + Module, + Class, + Enum, + Interface, + /// A function-like scope, a type-alias, or any other `HasLocals` container + /// whose members route to `locals`. + Locals, +} + +/// A live scope in the container stack. +#[derive(Clone, Copy)] +struct Scope { + kind: ContainerKind, + /// The container's symbol (owns `members`/`exports`); `None` for a script + /// source file and for a plain block scope. + symbol: Option, + /// This scope's `locals` table; `None` for a class/enum/interface/members + /// container (they route through the symbol's tables). + locals: Option, + /// The source file is an external module (routes exported members). + is_external_module: bool, + /// Ambient implicit-export context (a `.d.ts` file / ambient module with no + /// export declarations); routes non-`export`ed members to `exports`. + is_export_context: bool, +} + +/// A declaration's routing inputs for the cascade. +struct DeclInput { + /// The final table key (default-forced / mangled by the caller). + name: Atom, + /// The display name for the `{0}` message argument. + display: Atom, + /// The span a diagnostic points at (the declaration name). + error_span: Span, + /// This declaration is a default export (`export default`, forcing the + /// `"default"` name). + is_default_export: bool, + /// This declaration is an `export default ` (tsgo's + /// `ExportAssignment` with `IsExportEquals == false`) — the other TS2528 case. + is_export_assignment_default: bool, + /// This declaration carries an `export` modifier (threaded from the wrapper) — + /// routes it to the container's `exports` table. + exported: bool, + /// The declaration node's best-effort dense id (via the SoA address map). + node: NodeId, +} + +/// Modifiers threaded from an `export` wrapper into the wrapped declaration. +#[derive(Clone, Copy, Default)] +struct DeclMods { + exported: bool, + default: bool, +} + +/// The symbol bind for one file. +pub(super) struct SymbolBinder<'a> { + source: &'a str, + address_map: &'a FxHashMap<(usize, NodeKind), NodeId>, + file: FileId, + is_external: bool, + + atoms: Atoms, + symbols: Vec, + tables: Vec>, + diagnostics: Vec, + + container: Scope, + block_scope: Scope, + + /// The source-file `locals` table — a **script**'s globals-eligible symbols. + source_file_locals: TableId, + /// `declare global {}` augmentation symbols (their exports merge into globals). + global_aug_symbols: Vec, + /// Non-global `declare module "X"` augmentations: `(unquoted-name atom, span)`. + module_augs: Vec<(Atom, Span)>, +} + +impl<'a> SymbolBinder<'a> { + /// Build a binder for one file, seeding the source-file scope. + pub(super) fn new( + source: &'a str, + address_map: &'a FxHashMap<(usize, NodeKind), NodeId>, + file: FileId, + facts: FileFacts, + ) -> SymbolBinder<'a> { + let is_external_module = matches!(facts.module_ness, super::ModuleNess::Module); + let mut binder = SymbolBinder { + source, + address_map, + file, + is_external: is_external_module, + atoms: Atoms::new(), + symbols: Vec::new(), + tables: Vec::new(), + diagnostics: Vec::new(), + container: Scope { + kind: ContainerKind::SourceFile, + symbol: None, + locals: None, + is_external_module, + is_export_context: false, + }, + block_scope: Scope { + kind: ContainerKind::SourceFile, + symbol: None, + locals: None, + is_external_module, + is_export_context: false, + }, + // Provisional; overwritten with the real source-file locals below. + source_file_locals: TableId(0), + global_aug_symbols: Vec::new(), + module_augs: Vec::new(), + }; + let locals = binder.new_table(); + binder.source_file_locals = locals; + let symbol = if is_external_module { + // The file's own module symbol owns the `exports` table. + let name = binder.atoms.intern("\"module\""); + let sid = binder.new_symbol(SymbolFlags::VALUE_MODULE, name); + let exports = binder.new_table(); + binder.symbols[sid.index()].exports = Some(exports); + Some(sid) + } else { + None + }; + binder.container.symbol = symbol; + binder.container.locals = Some(locals); + binder.block_scope = binder.container; + binder + } + + /// Bind the program body, then return. + pub(super) fn bind_program(&mut self, program: &Program<'a>) { + self.bind_statement_list(program.body, true); + } + + // --- statement lists (functions-first) ----------------------------------- + + // tsgo: internal/binder/binder.go bindEachStatementFunctionsFirst (functions-first) + fn bind_statement_list(&mut self, stmts: &[Statement<'a>], functions_first: bool) { + if functions_first { + for stmt in stmts { + if is_function_statement(stmt) { + self.declare_hoisted_function(stmt); + } + } + } + for stmt in stmts { + let skip = functions_first && is_function_statement(stmt); + self.visit_statement(stmt, DeclMods::default(), skip); + } + } + + // --- scopes --------------------------------------------------------------- + + fn with_function_scope( + &mut self, + type_params: Option<&TSTypeParameterDeclaration<'a>>, + f: impl FnOnce(&mut Self), + ) { + let saved = (self.container, self.block_scope); + let locals = self.new_table(); + let scope = Scope { + kind: ContainerKind::Locals, + symbol: None, + locals: Some(locals), + is_external_module: false, + is_export_context: false, + }; + self.container = scope; + self.block_scope = scope; + self.bind_type_params(type_params); + f(self); + self.container = saved.0; + self.block_scope = saved.1; + } + + fn with_block_scope(&mut self, f: impl FnOnce(&mut Self)) { + let saved = self.block_scope; + let locals = self.new_table(); + self.block_scope = Scope { + kind: ContainerKind::Locals, + symbol: None, + locals: Some(locals), + is_external_module: false, + is_export_context: false, + }; + f(self); + self.block_scope = saved; + } + + /// Finish, returning the collected bind diagnostics and the merge product. + pub(super) fn finish(self) -> (Vec, FileMerge) { + // A script's source-file locals reach global scope; an external module's + // do not (its members live in the module's exports). + let source_locals = if self.is_external { + Vec::new() + } else { + self.resolve_table(self.source_file_locals) + }; + let global_augmentations = self + .global_aug_symbols + .iter() + .map(|&sid| match self.symbols[sid.index()].exports { + Some(t) => self.resolve_table(t), + None => Vec::new(), + }) + .collect(); + let module_augmentations = self + .module_augs + .iter() + .map(|&(name, span)| ModuleAug { + file: self.file, + name: self.atoms.resolve(name).to_string(), + name_span: span, + }) + .collect(); + let merge = FileMerge { + file: self.file, + is_external: self.is_external, + source_locals, + global_augmentations, + module_augmentations, + }; + (self.diagnostics, merge) + } + + /// Resolve a symbol table into merge symbols, in **declaration order** (first + /// declaration's span) — deterministic iteration, never the hash-map's. + /// + /// The order is **total**, deliberately: a stable sort on a non-unique key + /// leaves ties in the input's order, which here is the map's iteration order + /// — i.e. the hasher. No tie is known to be reachable, but the name breaks + /// any that is, so the guarantee doesn't rest on that. + fn resolve_table(&self, table: TableId) -> Vec { + let mut symbols: Vec = self.tables[table.index()] + .values() + .map(|&sid| { + let sym = &self.symbols[sid.index()]; + let decls = sym + .decls + .iter() + .map(|d| MergeDecl { + file: self.file, + error_span: d.error_span, + is_type_decl: d.is_type_decl, + }) + .collect(); + MergeSymbol { + name: self.atoms.resolve(sym.name).to_string(), + flags: sym.flags, + decls, + } + }) + .collect(); + // Borrow the name rather than cloning it into a sort key; it is unique + // per table by construction, so it terminates the order. + let span_key = |s: &MergeSymbol| { + s.decls.first().map_or((u32::MAX, u32::MAX), |d| { + (d.error_span.start, d.error_span.end) + }) + }; + symbols.sort_by(|a, b| { + span_key(a) + .cmp(&span_key(b)) + .then_with(|| a.name.cmp(&b.name)) + }); + symbols + } + + // --- table / symbol pool ------------------------------------------------- + + fn new_table(&mut self) -> TableId { + let id = TableId(self.tables.len() as u32); + self.tables.push(FxHashMap::default()); + id + } + + fn new_symbol(&mut self, flags: SymbolFlags, name: Atom) -> SymbolId { + let id = SymbolId(self.symbols.len() as u32); + self.symbols.push(Symbol::new(flags, name)); + id + } + + /// The `exports` table of `symbol`, created on first use. + fn exports_of(&mut self, symbol: SymbolId) -> TableId { + if let Some(t) = self.symbols[symbol.index()].exports { + return t; + } + let t = self.new_table(); + self.symbols[symbol.index()].exports = Some(t); + t + } + + /// The `members` table of `symbol`, created on first use. + fn members_of(&mut self, symbol: SymbolId) -> TableId { + if let Some(t) = self.symbols[symbol.index()].members { + return t; + } + let t = self.new_table(); + self.symbols[symbol.index()].members = Some(t); + t + } + + /// The [`NodeId`] of `node` (of kind `kind`), or [`NodeId::FIRST`] on a miss. + /// Lenient by design — the result feeds `Decl.node`, which is dead in the + /// single-file pipeline (statement-level inner structs the SoA walk keys on + /// the enclosing `&Statement` address fall back to the root id here). + fn node_id_of(&self, node: &T, kind: NodeKind) -> NodeId { + self.address_map + .get(&(addr_of(node), kind)) + .copied() + .unwrap_or(NodeId::FIRST) + } + + // --- name resolution ----------------------------------------------------- + + fn ident_atom(&mut self, id: &Identifier<'_>) -> Atom { + let name = id.name(self.source); + self.atoms.intern(name) + } + + fn string_atom(&mut self, lit: &Literal<'_>) -> Atom { + match &lit.value { + LiteralValue::String(cooked) => { + let s = cooked.resolve(lit.span, self.source); + self.atoms.intern(s) + } + // Non-string literals (numbers etc.) key on their source text. + _ => { + let s = lit.span.extract(self.source); + self.atoms.intern(s) + } + } + } + + fn module_export_name_atom(&mut self, name: &ModuleExportName<'_>) -> (Atom, Span) { + match name { + ModuleExportName::Identifier(id) => (self.ident_atom(id), id.name_span()), + ModuleExportName::Literal(lit) => (self.string_atom(lit), lit.span), + } + } + + // --- member keys --------------------------------------------------------- + + fn resolve_member_key( + &mut self, + key: &Expression<'a>, + computed: bool, + class_symbol: Option, + ) -> Option { + if computed { + // A computed key names a member only for a string/numeric literal. + return match key { + Expression::Literal(lit) + if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => + { + // The grouping key stays the decoded/canonical value (so `[0]` and + // `['0']` collide). The diagnostic points at the whole `[ … ]` name + // node — bracket-inclusive, matching tsgo (`getNameOfDeclaration` -> + // the ComputedPropertyName) and the check-pass span, so a key that + // conflicts at both phases collapses in the sort/dedup. The display + // is that raw bracket-inclusive source (tsgo's `symbolToString`). + let key_atom = self.string_atom(lit); + let source = self.source; + let start = crate::span_scan::bracket_start(source, lit.span.start); + let end = crate::span_scan::bracket_end(source, lit.span.end); + let display = self.atoms.intern(&source[start as usize..end as usize]); + Some(KeyInfo { + key: key_atom, + display, + span: Span::new(start, end), + }) + } + _ => None, + }; + } + match key { + Expression::Identifier(id) => { + let a = self.ident_atom(id); + Some(KeyInfo { + key: a, + display: a, + span: id.name_span(), + }) + } + Expression::Literal(lit) => { + let a = self.string_atom(lit); + Some(KeyInfo { + key: a, + display: a, + span: lit.span, + }) + } + Expression::PrivateIdentifier(pid) => { + let raw = pid.name(self.source); + // The display carries the leading `#`, matching the `#name` span and + // the check-side form (`duplicate_members.rs`'s `member_key`) — a + // duplicate reported by BOTH the bind cascade and the check pass shares + // a code+span but must share this message arg too, or sort/dedup can't + // collapse the pair (a latent span-multiset extra). tsgo prints + // `Duplicate identifier '#foo'.` — the `#` is included. + // TODO: member-key derivation is duplicated across the bind (`sym/`) and + // check (`duplicate_members.rs`) sides with no shared helper — span + // derivation is centralized in `span_scan.rs`, key and display are not. + // The two already disagree on numerics: `string_atom` keys a numeric + // literal on its raw source text while `duplicate_members::literal_key` + // canonicalizes through ECMA `Number::toString`, so `[0]` and `[0.0]` + // collide on the check side but not the bind side. Latent today (the + // check walk covers every member surface that reports, and a raw-text + // key can only under-report relative to a canonical one), but a + // bind-only reporting surface would expose it. tsgo canonicalizes in + // exactly one place. + let display = self.atoms.intern(&format!("#{raw}")); + // Mangle with the class symbol id so same-name privates in one + // class collide (tsgo GetSymbolNameForPrivateIdentifier). The mangled + // key keeps the bare `raw` — only `display` gains the `#`. + let mangled = format!("\u{FE}#{}@{}", class_symbol.map_or(0, |s| s.0), raw); + let key = self.atoms.intern(&mangled); + // The diagnostic points at the whole `#name` node (tsgo's + // `getNameOfDeclaration` -> the PrivateIdentifier), so the squiggle + // covers the `#` — and `display` now matches that span. + Some(KeyInfo { + key, + display, + span: pid.span, + }) + } + _ => None, + } + } +} + +/// A resolved member key. +struct KeyInfo { + key: Atom, + display: Atom, + span: Span, +} + +/// Whether a statement is a function declaration (possibly `export`-wrapped) — +/// the set tsgo's `bindEachStatementFunctionsFirst` binds first. +fn is_function_statement(stmt: &Statement<'_>) -> bool { + match stmt { + Statement::FunctionDeclaration(_) | Statement::TSDeclareFunction(_) => true, + Statement::ExportNamedDeclaration(e) => e.declaration.is_some_and(|inner| { + matches!( + inner, + Statement::FunctionDeclaration(_) | Statement::TSDeclareFunction(_) + ) + }), + Statement::ExportDefaultDeclaration(e) => matches!( + e.declaration, + ExportDefaultValue::FunctionDeclaration(_) | ExportDefaultValue::TSDeclareFunction(_) + ), + _ => false, + } +} diff --git a/crates/tsv_check/src/binder/sym/statement.rs b/crates/tsv_check/src/binder/sym/statement.rs new file mode 100644 index 000000000..5fda6e555 --- /dev/null +++ b/crates/tsv_check/src/binder/sym/statement.rs @@ -0,0 +1,851 @@ +//! The statement-shaped bind-descent — `visit_statement` (the dispatch) and +//! every `bind_*_statement`, the export/default/function-name group, the +//! param/binding helpers, and the module/import/export-specifier binds. +//! Contributes its own `impl SymbolBinder` block; the struct, the +//! functions-first statement-list driver, and the scope helpers live in the +//! parent module. Purely a locality split — no behavior distinction. + +use super::super::symbols::SymbolFlags; +use super::{ContainerKind, DeclInput, DeclMods, NodeKind, Scope, SymbolBinder}; +use crate::ids::NodeId; +use tsv_lang::Span; +use tsv_ts::ast::internal::{ + ExportDefaultValue, ExportSpecifier, Expression, ForInOfLeft, ForInit, Identifier, + ImportSpecifier, ModuleExportName, ObjectPatternProperty, Statement, TSModuleDeclarationBody, + TSModuleName, +}; + +impl<'a> SymbolBinder<'a> { + /// Sub-step A: declare a hoisted function's symbol only (no body descent), + /// unwrapping any `export`/`export default` wrapper for its modifiers. + pub(super) fn declare_hoisted_function(&mut self, stmt: &Statement<'a>) { + match stmt { + Statement::FunctionDeclaration(f) => { + if let Some(id) = &f.id { + self.bind_function_name(id, f.span, DeclMods::default()); + } + } + Statement::TSDeclareFunction(f) => { + self.bind_function_name(&f.id, f.span, DeclMods::default()); + } + Statement::ExportNamedDeclaration(e) => { + if let Some(inner) = e.declaration { + self.declare_hoisted_function_inner( + inner, + DeclMods { + exported: true, + default: false, + }, + ); + } + } + Statement::ExportDefaultDeclaration(e) => { + let mods = DeclMods { + exported: true, + default: true, + }; + match &e.declaration { + ExportDefaultValue::FunctionDeclaration(f) => { + self.bind_default_function(f.id.as_ref(), e.span, mods); + } + ExportDefaultValue::TSDeclareFunction(f) => { + self.bind_default_function(Some(&f.id), e.span, mods); + } + _ => {} + } + } + _ => {} + } + } + + fn declare_hoisted_function_inner(&mut self, inner: &Statement<'a>, mods: DeclMods) { + match inner { + Statement::FunctionDeclaration(f) => { + if let Some(id) = &f.id { + self.bind_function_name(id, f.span, mods); + } + } + Statement::TSDeclareFunction(f) => self.bind_function_name(&f.id, f.span, mods), + _ => {} + } + } + + // --- statements ---------------------------------------------------------- + + pub(super) fn visit_statement( + &mut self, + stmt: &Statement<'a>, + mods: DeclMods, + skip_symbol: bool, + ) { + match stmt { + Statement::VariableDeclaration(decl) => { + let (includes, excludes, block_scoped) = var_flags(decl.kind); + for d in decl.declarations { + self.bind_binding(&d.id, includes, excludes, block_scoped, mods, decl.span); + if let Some(init) = &d.init { + self.visit_expression(init); + } + } + } + Statement::FunctionDeclaration(f) => { + if !skip_symbol && let Some(id) = &f.id { + self.bind_function_name(id, f.span, mods); + } + self.with_function_scope(f.type_parameters.as_ref(), |b| { + b.bind_params(f.params); + b.bind_statement_list(f.body.body, true); + }); + } + Statement::TSDeclareFunction(f) => { + if !skip_symbol { + self.bind_function_name(&f.id, f.span, mods); + } + self.with_function_scope(f.type_parameters.as_ref(), |b| { + b.bind_params(f.params); + }); + } + Statement::ClassDeclaration(c) => self.bind_class_statement(c, mods, skip_symbol), + Statement::TSInterfaceDeclaration(i) => { + let d = self.decl_from_ident(&i.id, i.span, mods); + let sym = self.declare_block_scoped( + d, + SymbolFlags::INTERFACE, + SymbolFlags::INTERFACE_EXCLUDES, + ); + self.bind_interface_body(&i.body, sym, i.type_parameters.as_ref()); + } + Statement::TSEnumDeclaration(e) => self.bind_enum_statement(e, mods), + Statement::TSModuleDeclaration(m) => self.bind_module(m, mods), + Statement::TSTypeAliasDeclaration(t) => self.bind_type_alias_statement(t, mods), + Statement::ImportDeclaration(imp) => { + for spec in imp.specifiers { + self.bind_import_specifier(spec); + } + } + Statement::TSImportEqualsDeclaration(ie) => self.bind_import_equals_statement(ie), + Statement::ExportNamedDeclaration(e) => { + self.bind_export_named_statement(e, skip_symbol); + } + Statement::ExportDefaultDeclaration(e) => self.bind_export_default(e, skip_symbol), + // Control flow: descend for nested bindings + block scopes. + Statement::BlockStatement(b) => { + self.with_block_scope(|bd| bd.bind_statement_list(b.body, true)); + } + Statement::IfStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.consequent, DeclMods::default(), false); + if let Some(alt) = s.alternate { + self.visit_statement(alt, DeclMods::default(), false); + } + } + Statement::ForStatement(s) => self.bind_for_statement(s), + Statement::ForInStatement(s) => self.with_block_scope(|bd| { + bd.bind_for_left(&s.left); + bd.visit_expression(&s.right); + bd.visit_statement(s.body, DeclMods::default(), false); + }), + Statement::ForOfStatement(s) => self.with_block_scope(|bd| { + bd.bind_for_left(&s.left); + bd.visit_expression(&s.right); + bd.visit_statement(s.body, DeclMods::default(), false); + }), + Statement::WhileStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.body, DeclMods::default(), false); + } + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body, DeclMods::default(), false); + self.visit_expression(&s.test); + } + Statement::SwitchStatement(s) => self.bind_switch_statement(s), + Statement::TryStatement(s) => self.bind_try_statement(s), + Statement::LabeledStatement(s) => { + self.visit_statement(s.body, DeclMods::default(), false); + } + Statement::ReturnStatement(s) => { + if let Some(a) = &s.argument { + self.visit_expression(a); + } + } + Statement::ThrowStatement(s) => self.visit_expression(&s.argument), + Statement::ExpressionStatement(s) => self.visit_expression(&s.expression), + Statement::TSExportAssignment(ea) => self.bind_export_assignment_statement(ea), + Statement::ExportAllDeclaration(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) => {} + } + } + + #[inline] + fn bind_class_statement( + &mut self, + c: &tsv_ts::ast::internal::ClassDeclaration<'a>, + mods: DeclMods, + skip_symbol: bool, + ) { + let sym = if skip_symbol { + None + } else { + c.id.as_ref().map(|id| { + let d = self.decl_from_ident(id, c.span, mods); + self.declare_block_scoped(d, SymbolFlags::CLASS, SymbolFlags::CLASS_EXCLUDES) + }) + }; + self.bind_class_body(&c.body, sym, c.type_parameters.as_ref()); + } + + #[inline] + fn bind_enum_statement( + &mut self, + e: &tsv_ts::ast::internal::TSEnumDeclaration<'a>, + mods: DeclMods, + ) { + let (inc, exc) = if e.r#const { + (SymbolFlags::CONST_ENUM, SymbolFlags::CONST_ENUM_EXCLUDES) + } else { + ( + SymbolFlags::REGULAR_ENUM, + SymbolFlags::REGULAR_ENUM_EXCLUDES, + ) + }; + let d = self.decl_from_ident(&e.id, e.span, mods); + let sym = self.declare_block_scoped(d, inc, exc); + self.bind_enum_members(e.members, sym); + } + + #[inline] + fn bind_type_alias_statement( + &mut self, + t: &tsv_ts::ast::internal::TSTypeAliasDeclaration<'a>, + mods: DeclMods, + ) { + // tsgo's `declareSymbolEx` adds a TS1369 "Did you mean + // 'export type { T }'?" related info when a conflicting declaration + // is `export type T;` — a type alias with a *missing* `= type` + // (binder.go:260). That shape is deliberately unported: tsv's parser + // rejects `export type T;` ("Expected '='"), so the declaration never + // reaches this cascade. The sole corpus baseline exercising the hint + // (`exportDeclaration_missingBraces.ts`) is therefore a tsv + // parse-rejection, not a gradeable bind. + let d = self.decl_from_ident(&t.id, t.span, mods); + self.declare_block_scoped(d, SymbolFlags::TYPE_ALIAS, SymbolFlags::TYPE_ALIAS_EXCLUDES); + self.bind_type_params_in_new_locals(t.type_parameters.as_ref()); + } + + #[inline] + fn bind_import_equals_statement( + &mut self, + ie: &tsv_ts::ast::internal::TSImportEqualsDeclaration<'a>, + ) { + let d = self.decl_from_ident( + &ie.id, + ie.span, + DeclMods { + exported: ie.is_export, + default: false, + }, + ); + // An `import =` with an external reference or a plain entity name + // is an alias either way for the family (locals unless exported). + let _ = &ie.module_reference; + self.declare_alias(d, ie.is_export); + } + + #[inline] + fn bind_export_named_statement( + &mut self, + e: &tsv_ts::ast::internal::ExportNamedDeclaration<'a>, + skip_symbol: bool, + ) { + if let Some(inner) = e.declaration { + self.visit_statement( + inner, + DeclMods { + exported: true, + default: false, + }, + skip_symbol, + ); + } else { + for spec in e.specifiers { + self.bind_export_specifier(spec); + } + } + } + + #[inline] + fn bind_for_statement(&mut self, s: &tsv_ts::ast::internal::ForStatement<'a>) { + self.with_block_scope(|bd| { + if let Some(init) = &s.init { + match init { + ForInit::VariableDeclaration(decl) => bd.bind_var_declaration(decl), + ForInit::Expression(e) => bd.visit_expression(e), + } + } + if let Some(t) = &s.test { + bd.visit_expression(t); + } + if let Some(u) = &s.update { + bd.visit_expression(u); + } + bd.visit_statement(s.body, DeclMods::default(), false); + }); + } + + #[inline] + fn bind_switch_statement(&mut self, s: &tsv_ts::ast::internal::SwitchStatement<'a>) { + self.visit_expression(&s.discriminant); + self.with_block_scope(|bd| { + for case in s.cases { + if let Some(t) = &case.test { + bd.visit_expression(t); + } + bd.bind_statement_list(case.consequent, false); + } + }); + } + + #[inline] + fn bind_try_statement(&mut self, s: &tsv_ts::ast::internal::TryStatement<'a>) { + self.with_block_scope(|bd| bd.bind_statement_list(s.block.body, true)); + if let Some(h) = &s.handler { + // The catch clause is a block scope holding the (block-scoped) + // parameter; its body is a *separate* nested block scope, so a + // `const e` shadowing `catch(e)` is a check-time TS2492, not a + // binder conflict (tsgo `bindVariableDeclarationOrBindingElement` + // -> `IsBlockOrCatchScoped`). + self.with_block_scope(|bd| { + if let Some(param) = &h.param { + bd.bind_binding( + param, + SymbolFlags::BLOCK_SCOPED_VARIABLE, + SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES, + true, + DeclMods::default(), + h.span, + ); + } + bd.with_block_scope(|body| body.bind_statement_list(h.body.body, true)); + }); + } + if let Some(f) = &s.finalizer { + self.with_block_scope(|bd| bd.bind_statement_list(f.body, true)); + } + } + + #[inline] + fn bind_export_assignment_statement( + &mut self, + ea: &tsv_ts::ast::internal::TSExportAssignment<'a>, + ) { + // `export = x` — tsgo `bindExportAssignment` with `IsExportEquals`: + // declared into `exports` under the `"export="` name with ALL + // excludes (self-merge-only), so a second `export =` conflicts. + if let Some(sym) = self.container.symbol { + let name = self.atoms.export_equals(); + // The name node is the expression when it is a bare identifier + // (tsgo `getNonAssignedNameOfDeclaration`), else the whole node. + let error_span = match &ea.expression { + Expression::Identifier(id) => id.name_span(), + _ => ea.span, + }; + let d = DeclInput { + name, + display: name, + error_span, + is_default_export: false, + is_export_assignment_default: false, + exported: true, + node: self.node_id_of(ea, NodeKind::TSExportAssignment), + }; + let table = self.exports_of(sym); + self.declare_symbol(table, Some(sym), d, SymbolFlags::PROPERTY, SymbolFlags::ALL); + } + self.visit_expression(&ea.expression); + } + + fn bind_var_declaration(&mut self, decl: &tsv_ts::ast::internal::VariableDeclaration<'a>) { + let (includes, excludes, block_scoped) = var_flags(decl.kind); + for d in decl.declarations { + self.bind_binding( + &d.id, + includes, + excludes, + block_scoped, + DeclMods::default(), + decl.span, + ); + if let Some(init) = &d.init { + self.visit_expression(init); + } + } + } + + fn bind_for_left(&mut self, left: &ForInOfLeft<'a>) { + match left { + ForInOfLeft::VariableDeclaration(decl) => self.bind_var_declaration(decl), + ForInOfLeft::Pattern(_) => {} + } + } + + // --- export default ------------------------------------------------------ + + fn bind_export_default( + &mut self, + e: &tsv_ts::ast::internal::ExportDefaultDeclaration<'a>, + skip_symbol: bool, + ) { + let mods = DeclMods { + exported: true, + default: true, + }; + match &e.declaration { + ExportDefaultValue::Expression(expr) => { + // tsgo `bindExportAssignment` (non-`export =`): excludes = ALL. An + // entity-name expression (`export default foo`) is an **alias** + // (`ExpressionIsAlias`) whose diagnostic points at the name; any + // other expression (`export default 0`) is a `Property` pointing at + // the whole `export default` node. + if let Some(sym) = self.container.symbol { + let name = self.atoms.default_export(); + let is_alias = matches!( + expr, + Expression::Identifier(_) | Expression::MemberExpression(_) + ); + let flags = if is_alias { + SymbolFlags::ALIAS + } else { + SymbolFlags::PROPERTY + }; + // The name node is the expression only when it is a bare + // identifier (tsgo `getNonAssignedNameOfDeclaration`); otherwise + // the whole `export default` node. + let error_span = match expr { + Expression::Identifier(id) => id.name_span(), + _ => e.span, + }; + let d = DeclInput { + name, + display: name, + error_span, + is_default_export: false, + is_export_assignment_default: true, + exported: false, + node: self.node_id_of(e, NodeKind::ExportDefaultDeclaration), + }; + let table = self.exports_of(sym); + self.declare_symbol(table, Some(sym), d, flags, SymbolFlags::ALL); + } + self.visit_expression(expr); + } + ExportDefaultValue::FunctionDeclaration(f) => { + if !skip_symbol { + self.bind_default_function(f.id.as_ref(), e.span, mods); + } + self.with_function_scope(f.type_parameters.as_ref(), |b| { + b.bind_params(f.params); + b.bind_statement_list(f.body.body, true); + }); + } + ExportDefaultValue::TSDeclareFunction(f) => { + if !skip_symbol { + self.bind_default_function(Some(&f.id), e.span, mods); + } + self.with_function_scope(f.type_parameters.as_ref(), |b| b.bind_params(f.params)); + } + ExportDefaultValue::ClassDeclaration(c) => { + let d = self.default_decl(c.id.as_ref(), e.span); + let sym = self.container.symbol.map(|cs| { + let table = self.exports_of(cs); + self.declare_symbol( + table, + Some(cs), + d, + SymbolFlags::CLASS, + SymbolFlags::CLASS_EXCLUDES, + ) + }); + self.bind_class_body(&c.body, sym, c.type_parameters.as_ref()); + } + ExportDefaultValue::TSInterfaceDeclaration(i) => { + let d = self.default_decl(Some(&i.id), e.span); + if let Some(cs) = self.container.symbol { + let table = self.exports_of(cs); + self.declare_symbol( + table, + Some(cs), + d, + SymbolFlags::INTERFACE, + SymbolFlags::INTERFACE_EXCLUDES, + ); + } + self.bind_interface_body_symbol_less(&i.body, i.type_parameters.as_ref()); + } + } + } + + fn default_decl(&mut self, id: Option<&Identifier<'a>>, node_span: Span) -> DeclInput { + let display = match id { + Some(i) => { + let name = i.name(self.source); + self.atoms.intern(name) + } + None => self.atoms.default_export(), + }; + DeclInput { + name: self.atoms.default_export(), + display, + error_span: id.map_or(node_span, Identifier::name_span), + is_default_export: true, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + } + } + + fn bind_default_function( + &mut self, + id: Option<&Identifier<'a>>, + node_span: Span, + _mods: DeclMods, + ) { + if let Some(cs) = self.container.symbol { + let d = self.default_decl(id, node_span); + let table = self.exports_of(cs); + self.declare_symbol( + table, + Some(cs), + d, + SymbolFlags::FUNCTION, + SymbolFlags::FUNCTION_EXCLUDES, + ); + } + } + + // --- function names -------------------------------------------------------- + + fn bind_function_name(&mut self, id: &Identifier<'a>, node_span: Span, mods: DeclMods) { + let d = self.decl_from_ident(id, node_span, mods); + self.declare_block_scoped(d, SymbolFlags::FUNCTION, SymbolFlags::FUNCTION_EXCLUDES); + } + + // --- params + bindings --------------------------------------------------- + + pub(super) fn bind_params(&mut self, params: &[Expression<'a>]) { + for param in params { + self.bind_param(param); + } + } + + pub(super) fn bind_param(&mut self, param: &Expression<'a>) { + match param { + Expression::TSParameterProperty(pp) => { + // The inner parameter binds as a parameter; a property-parameter + // also declares a class member (handled where the constructor's + // owning class scope is live — the constructor scope's parent). + self.bind_param(pp.parameter); + } + _ => self.bind_binding( + param, + SymbolFlags::FUNCTION_SCOPED_VARIABLE, + SymbolFlags::PARAMETER_EXCLUDES, + false, + DeclMods::default(), + param_span(param), + ), + } + } + + /// Bind a binding target: an identifier leaf routes through the given flags; + /// object/array patterns recurse; assignment patterns and rest unwrap. + fn bind_binding( + &mut self, + target: &Expression<'a>, + includes: SymbolFlags, + excludes: SymbolFlags, + block_scoped: bool, + mods: DeclMods, + node_span: Span, + ) { + match target { + Expression::Identifier(id) => { + let d = self.decl_from_ident(id, node_span, mods); + if block_scoped { + self.declare_block_scoped(d, includes, excludes); + } else { + self.declare_in_container(d, includes, excludes); + } + // The binder's one type-annotation entry point: a typed binding + // (`var a: { … }`) descends into its annotation so a type literal's + // members bind (its method-signature params conflict, its duplicate + // members silent-merge). Narrow by design — an incomplete traversal + // only leaves family instances missing, never fabricates a conflict. + if let Some(ann) = id.type_annotation() { + self.bind_type_annotation(ann); + } + } + Expression::ObjectPattern(p) => { + for prop in p.properties { + match prop { + ObjectPatternProperty::Property(pr) => { + self.bind_binding( + &pr.value, + includes, + excludes, + block_scoped, + mods, + pr.span, + ); + } + ObjectPatternProperty::RestElement(r) => { + self.bind_binding( + r.argument, + includes, + excludes, + block_scoped, + mods, + r.span, + ); + } + } + } + } + Expression::ArrayPattern(p) => { + for el in p.elements.iter().flatten() { + self.bind_binding(el, includes, excludes, block_scoped, mods, el_span(el)); + } + } + Expression::AssignmentPattern(a) => { + self.bind_binding(a.left, includes, excludes, block_scoped, mods, node_span); + self.visit_expression(a.right); + } + Expression::RestElement(r) => { + self.bind_binding(r.argument, includes, excludes, block_scoped, mods, r.span); + } + _ => {} + } + } + + pub(super) fn decl_from_ident( + &mut self, + id: &Identifier<'a>, + _node_span: Span, + mods: DeclMods, + ) -> DeclInput { + let name = self.ident_atom(id); + DeclInput { + name, + display: name, + error_span: id.name_span(), + is_default_export: mods.default, + is_export_assignment_default: false, + exported: mods.exported, + node: self.node_id_of(id, NodeKind::Identifier), + } + } + + // --- modules --------------------------------------------------------------- + + fn bind_module(&mut self, m: &tsv_ts::ast::internal::TSModuleDeclaration<'a>, mods: DeclMods) { + // The module's own symbol (name = identifier, or `"name"` for ambient). + let (name, display, span) = match &m.id { + TSModuleName::Identifier(id) => { + let a = self.ident_atom(id); + (a, a, id.name_span()) + } + TSModuleName::Literal(lit) => { + let raw = lit.span.extract(self.source); + let key = self.atoms.intern(raw); + (key, key, lit.span) + } + }; + let d = DeclInput { + name, + display, + error_span: span, + is_default_export: mods.default, + is_export_assignment_default: false, + exported: mods.exported, + node: self.node_id_of(m, NodeKind::TSModuleDeclaration), + }; + // Instantiation state (tsgo `GetModuleInstanceState`): a namespace of only + // types binds as the inert `NamespaceModule`, so it never conflicts with a + // `var`/`let`/`type` of the same name; one with value content is `ValueModule`. + let (inc, exc) = if module_instantiated(m) { + ( + SymbolFlags::VALUE_MODULE, + SymbolFlags::VALUE_MODULE_EXCLUDES, + ) + } else { + ( + SymbolFlags::NAMESPACE_MODULE, + SymbolFlags::NAMESPACE_MODULE_EXCLUDES, + ) + }; + let sym = self.declare_block_scoped(d, inc, exc); + + // Record cross-declaration-space augmentations for the merge phase — only + // top-level ones (container still the source file). `declare global {}` is + // a global-scope augmentation (its exports merge into globals); + // `declare module "X"` in an external module is a module augmentation + // (tsgo `IsModuleAugmentationExternal`, the `KindSourceFile` arm). + if self.container.kind == ContainerKind::SourceFile { + if m.global { + self.global_aug_symbols.push(sym); + } else if self.is_external + && let TSModuleName::Literal(lit) = &m.id + { + let unquoted = self.string_atom(lit); + self.module_augs.push((unquoted, lit.span)); + } + } + + let saved = (self.container, self.block_scope); + let locals = self.new_table(); + let scope = Scope { + kind: ContainerKind::Module, + symbol: Some(sym), + locals: Some(locals), + is_external_module: false, + is_export_context: m.declare, + }; + self.container = scope; + self.block_scope = scope; + self.exports_of(sym); + match &m.body { + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => { + self.bind_statement_list(block.body, true); + } + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => { + // A dotted-namespace continuation: `namespace X.Y.Z {}` parses to a + // nested `TSModuleDeclaration` chain, and this body variant is + // constructed only by that dot path. tsgo's parser synthesizes an + // implicit `export` modifier (`NodeFlagsReparsed`) on every + // dot-continuation segment, so the intermediate segments land in the + // enclosing namespace's persistent *exports* table — the same table an + // explicit `export namespace Y {}` routes to — letting the dotted and + // explicit-nested forms merge (and their members conflict) instead of + // splitting into fresh per-instance locals that never meet. + // + // tsgo: internal/parser/parser.go parseModuleOrNamespaceDeclaration + self.bind_module( + nested, + DeclMods { + exported: true, + default: false, + }, + ); + } + None => {} + } + self.container = saved.0; + self.block_scope = saved.1; + } + + // --- imports / exports (aliases) ----------------------------------------- + + fn bind_import_specifier(&mut self, spec: &ImportSpecifier<'a>) { + let id = match spec { + ImportSpecifier::Default(d) => &d.local, + ImportSpecifier::Named(n) => &n.local, + ImportSpecifier::Namespace(n) => &n.local, + }; + let d = self.decl_from_ident(id, id.span, DeclMods::default()); + self.declare_alias(d, false); + } + + fn bind_export_specifier(&mut self, spec: &ExportSpecifier<'a>) { + // An export specifier's *exported* name is the table key in `exports`. + let (name, span) = self.module_export_name_atom(&spec.exported); + let is_default = matches!(&spec.exported, ModuleExportName::Identifier(id) + if id.name(self.source) == "default"); + let d = DeclInput { + name, + display: name, + error_span: span, + is_default_export: is_default, + is_export_assignment_default: false, + exported: false, + node: NodeId::FIRST, + }; + self.declare_alias(d, true); + } +} + +/// A [`SymbolFlags`] triple for a variable declaration kind: `(includes, +/// excludes, block_scoped)`. `block_scoped` selects `bindBlockScopedDeclaration` +/// (block-scope routing) over `declareSymbolAndAddToSymbolTable` (container). +fn var_flags( + kind: tsv_ts::ast::internal::VariableDeclarationKind, +) -> (SymbolFlags, SymbolFlags, bool) { + use tsv_ts::ast::internal::VariableDeclarationKind as K; + match kind { + // `var` is function-scoped (routes through the container). + K::Var => ( + SymbolFlags::FUNCTION_SCOPED_VARIABLE, + SymbolFlags::FUNCTION_SCOPED_VARIABLE_EXCLUDES, + false, + ), + // `let` / `const` / `using` / `await using` are block-scoped. + K::Let | K::Const | K::Using | K::AwaitUsing => ( + SymbolFlags::BLOCK_SCOPED_VARIABLE, + SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES, + true, + ), + } +} + +/// The span a bare parameter expression points a diagnostic at. +fn param_span(param: &Expression<'_>) -> Span { + match param { + Expression::Identifier(id) => id.name_span(), + _ => param.span(), + } +} + +/// The span an array-pattern element points a diagnostic at. +fn el_span(el: &Expression<'_>) -> Span { + match el { + Expression::Identifier(id) => id.name_span(), + _ => el.span(), + } +} + +/// Whether a namespace/module is instantiated (a `ValueModule`) — a faithful- +/// enough port of tsgo's `getModuleInstanceState`. A module is *non*-instantiated +/// (an inert `NamespaceModule`) only when its whole body is types: interfaces, +/// type aliases, non-exported imports, uninstantiated nested namespaces, and +/// specifier-only named exports (approximated as non-instantiated). Any value +/// content — a var/function/class/enum, an `export =`/`export default`, an +/// expression — makes it instantiated. +fn module_instantiated(m: &tsv_ts::ast::internal::TSModuleDeclaration<'_>) -> bool { + match &m.body { + None => true, + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => module_instantiated(nested), + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => { + !block.body.iter().all(statement_is_non_instantiated) + } + } +} + +/// Whether a module-body statement contributes no value (tsgo +/// `getModuleInstanceStateWorker`). +fn statement_is_non_instantiated(stmt: &Statement<'_>) -> bool { + match stmt { + Statement::TSInterfaceDeclaration(_) | Statement::TSTypeAliasDeclaration(_) => true, + Statement::ImportDeclaration(_) => true, + Statement::TSImportEqualsDeclaration(ie) => !ie.is_export, + Statement::TSModuleDeclaration(nested) => !module_instantiated(nested), + // `export interface`/`export type` wrap a non-instantiated declaration; + // specifier-only named exports are approximated non-instantiated. + Statement::ExportNamedDeclaration(e) => match e.declaration { + Some(inner) => statement_is_non_instantiated(inner), + None => true, + }, + _ => false, + } +} diff --git a/crates/tsv_check/src/binder/symbols.rs b/crates/tsv_check/src/binder/symbols.rs new file mode 100644 index 000000000..ab374306f --- /dev/null +++ b/crates/tsv_check/src/binder/symbols.rs @@ -0,0 +1,243 @@ +//! Symbols, symbol flags, and symbol tables — the binder's substrate. +//! +//! Ported from tsgo's `internal/ast`: [`SymbolFlags`] is the bit table plus the +//! `*Excludes` conflict masks (a construct's flags cannot coexist in one table +//! with any flag in its excludes mask — the whole basis of the duplicate-identifier +//! cascade), reproduced so the merge-vs-conflict verdict matches tsgo by +//! construction. The port covers every flag the binder + merge classify with and +//! every `*Excludes` mask; it deliberately omits the flags no ported path reads — +//! `ConstEnumOnlyModule` (`1 << 28`, only a `getModuleInstanceState` refinement the +//! `module_instantiated` approximation folds away), `GlobalLookup` (`1 << 30`, the +//! name-resolver's global-scope marker), and the convenience composites +//! (`Module`, `ExportHasLocal`, `BlockScoped`, `PropertyOrAccessor`, `ClassMember`, +//! …) whose members are all present. A [`Symbol`] carries its accumulated flags, name [`Atom`], the +//! declaration list the cascade points errors at, and the `members`/`exports` +//! child tables containers own. Tables ([`TableId`] into the binder's pool) are +//! `Atom → SymbolId` maps. +// +// tsgo: internal/ast/symbolflags.go (the bit table + *Excludes masks), +// internal/ast/symbol.go (Symbol shape) + +use crate::binder::atoms::Atom; +use crate::ids::NodeId; +use smallvec::SmallVec; +use tsv_lang::Span; + +/// A dense symbol identity into the binder's `symbols` vector. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct SymbolId(pub u32); + +impl SymbolId { + /// The 0-based index this id addresses. + #[inline] + #[must_use] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +/// A dense symbol-table identity into the binder's `tables` vector. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct TableId(pub u32); + +impl TableId { + /// The 0-based index this id addresses. + #[inline] + #[must_use] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +/// tsgo's `SymbolFlags` — a `u32` bitset whose bits classify a declaration and +/// whose `*Excludes` masks (below) decide same-table conflicts. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct SymbolFlags(pub u32); + +// The full flag + `*Excludes` table is ported verbatim from tsgo; some masks are +// not yet referenced by the family cascade but are kept so the port stays a +// faithful, auditable mirror of `symbolflags.go`. +#[allow(clippy::unreadable_literal, dead_code)] +impl SymbolFlags { + pub const NONE: SymbolFlags = SymbolFlags(0); + pub const FUNCTION_SCOPED_VARIABLE: SymbolFlags = SymbolFlags(1 << 0); + pub const BLOCK_SCOPED_VARIABLE: SymbolFlags = SymbolFlags(1 << 1); + pub const PROPERTY: SymbolFlags = SymbolFlags(1 << 2); + pub const ENUM_MEMBER: SymbolFlags = SymbolFlags(1 << 3); + pub const FUNCTION: SymbolFlags = SymbolFlags(1 << 4); + pub const CLASS: SymbolFlags = SymbolFlags(1 << 5); + pub const INTERFACE: SymbolFlags = SymbolFlags(1 << 6); + pub const CONST_ENUM: SymbolFlags = SymbolFlags(1 << 7); + pub const REGULAR_ENUM: SymbolFlags = SymbolFlags(1 << 8); + pub const VALUE_MODULE: SymbolFlags = SymbolFlags(1 << 9); + pub const NAMESPACE_MODULE: SymbolFlags = SymbolFlags(1 << 10); + pub const TYPE_LITERAL: SymbolFlags = SymbolFlags(1 << 11); + pub const OBJECT_LITERAL: SymbolFlags = SymbolFlags(1 << 12); + pub const METHOD: SymbolFlags = SymbolFlags(1 << 13); + pub const CONSTRUCTOR: SymbolFlags = SymbolFlags(1 << 14); + pub const GET_ACCESSOR: SymbolFlags = SymbolFlags(1 << 15); + pub const SET_ACCESSOR: SymbolFlags = SymbolFlags(1 << 16); + pub const SIGNATURE: SymbolFlags = SymbolFlags(1 << 17); + pub const TYPE_PARAMETER: SymbolFlags = SymbolFlags(1 << 18); + pub const TYPE_ALIAS: SymbolFlags = SymbolFlags(1 << 19); + pub const EXPORT_VALUE: SymbolFlags = SymbolFlags(1 << 20); + pub const ALIAS: SymbolFlags = SymbolFlags(1 << 21); + pub const PROTOTYPE: SymbolFlags = SymbolFlags(1 << 22); + pub const EXPORT_STAR: SymbolFlags = SymbolFlags(1 << 23); + pub const OPTIONAL: SymbolFlags = SymbolFlags(1 << 24); + pub const TRANSIENT: SymbolFlags = SymbolFlags(1 << 25); + pub const ASSIGNMENT: SymbolFlags = SymbolFlags(1 << 26); + pub const MODULE_EXPORTS: SymbolFlags = SymbolFlags(1 << 27); + pub const REPLACEABLE_BY_METHOD: SymbolFlags = SymbolFlags(1 << 29); + + pub const ENUM: SymbolFlags = SymbolFlags(Self::REGULAR_ENUM.0 | Self::CONST_ENUM.0); + pub const VARIABLE: SymbolFlags = + SymbolFlags(Self::FUNCTION_SCOPED_VARIABLE.0 | Self::BLOCK_SCOPED_VARIABLE.0); + pub const VALUE: SymbolFlags = SymbolFlags( + Self::VARIABLE.0 + | Self::PROPERTY.0 + | Self::ENUM_MEMBER.0 + | Self::OBJECT_LITERAL.0 + | Self::FUNCTION.0 + | Self::CLASS.0 + | Self::ENUM.0 + | Self::VALUE_MODULE.0 + | Self::METHOD.0 + | Self::GET_ACCESSOR.0 + | Self::SET_ACCESSOR.0, + ); + pub const TYPE: SymbolFlags = SymbolFlags( + Self::CLASS.0 + | Self::INTERFACE.0 + | Self::ENUM.0 + | Self::ENUM_MEMBER.0 + | Self::TYPE_LITERAL.0 + | Self::TYPE_PARAMETER.0 + | Self::TYPE_ALIAS.0, + ); + pub const ACCESSOR: SymbolFlags = SymbolFlags(Self::GET_ACCESSOR.0 | Self::SET_ACCESSOR.0); + /// All flags except the `GlobalLookup` sentinel — the `export =` excludes. + pub const ALL: SymbolFlags = SymbolFlags((1 << 30) - 1); + + // --- *Excludes masks (verbatim from symbolflags.go) --- + pub const FUNCTION_SCOPED_VARIABLE_EXCLUDES: SymbolFlags = + SymbolFlags(Self::VALUE.0 & !Self::FUNCTION_SCOPED_VARIABLE.0); + pub const BLOCK_SCOPED_VARIABLE_EXCLUDES: SymbolFlags = Self::VALUE; + pub const PARAMETER_EXCLUDES: SymbolFlags = Self::VALUE; + pub const PROPERTY_EXCLUDES: SymbolFlags = + SymbolFlags(Self::VALUE.0 & !(Self::PROPERTY.0 | Self::ACCESSOR.0)); + pub const ENUM_MEMBER_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 | Self::TYPE.0); + pub const FUNCTION_EXCLUDES: SymbolFlags = + SymbolFlags(Self::VALUE.0 & !(Self::FUNCTION.0 | Self::VALUE_MODULE.0 | Self::CLASS.0)); + pub const CLASS_EXCLUDES: SymbolFlags = SymbolFlags( + (Self::VALUE.0 | Self::TYPE.0) + & !(Self::VALUE_MODULE.0 | Self::INTERFACE.0 | Self::FUNCTION.0), + ); + pub const INTERFACE_EXCLUDES: SymbolFlags = + SymbolFlags(Self::TYPE.0 & !(Self::INTERFACE.0 | Self::CLASS.0)); + pub const REGULAR_ENUM_EXCLUDES: SymbolFlags = SymbolFlags( + (Self::VALUE.0 | Self::TYPE.0) & !(Self::REGULAR_ENUM.0 | Self::VALUE_MODULE.0), + ); + pub const CONST_ENUM_EXCLUDES: SymbolFlags = + SymbolFlags((Self::VALUE.0 | Self::TYPE.0) & !Self::CONST_ENUM.0); + pub const VALUE_MODULE_EXCLUDES: SymbolFlags = SymbolFlags( + Self::VALUE.0 + & !(Self::FUNCTION.0 | Self::CLASS.0 | Self::REGULAR_ENUM.0 | Self::VALUE_MODULE.0), + ); + pub const NAMESPACE_MODULE_EXCLUDES: SymbolFlags = Self::NONE; + pub const METHOD_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 & !Self::METHOD.0); + pub const GET_ACCESSOR_EXCLUDES: SymbolFlags = + SymbolFlags(Self::VALUE.0 & !(Self::SET_ACCESSOR.0 | Self::PROPERTY.0)); + pub const SET_ACCESSOR_EXCLUDES: SymbolFlags = + SymbolFlags(Self::VALUE.0 & !(Self::GET_ACCESSOR.0 | Self::PROPERTY.0)); + pub const ACCESSOR_EXCLUDES: SymbolFlags = SymbolFlags(Self::VALUE.0 & !Self::PROPERTY.0); + pub const TYPE_PARAMETER_EXCLUDES: SymbolFlags = + SymbolFlags(Self::TYPE.0 & !Self::TYPE_PARAMETER.0); + pub const TYPE_ALIAS_EXCLUDES: SymbolFlags = Self::TYPE; + pub const ALIAS_EXCLUDES: SymbolFlags = Self::ALIAS; + + /// Whether any bit in `other` is set. + #[inline] + #[must_use] + pub const fn intersects(self, other: SymbolFlags) -> bool { + self.0 & other.0 != 0 + } + + /// Whether every bit in `other` is set. + #[inline] + #[must_use] + pub const fn contains(self, other: SymbolFlags) -> bool { + self.0 & other.0 == other.0 + } + + /// Set the bits in `other`. + #[inline] + pub fn insert(&mut self, other: SymbolFlags) { + self.0 |= other.0; + } + + /// The union of two flag sets. + #[inline] + #[must_use] + pub const fn union(self, other: SymbolFlags) -> SymbolFlags { + SymbolFlags(self.0 | other.0) + } +} + +/// One declaration attached to a symbol: the node's dense id and the source span +/// the cascade points a diagnostic at (the declaration's *name* node, so the +/// squiggle sits on the identifier, matching tsgo's `getNameOfDeclaration`). +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // `node` is the future checker's declaration identity; the family cascade keys on `error_span`. +pub struct Decl { + /// The declaration node's dense id (best-effort via the address map; not yet + /// consumed by the cascade, which keys on `error_span`). + pub node: NodeId, + /// The span the diagnostic points at (the declaration name, or the node when + /// it has no name). + pub error_span: Span, + /// The display name for the `{0}` message argument (the declaration's text). + pub display: Atom, + /// Whether this declaration is a *type* declaration (tsgo `IsTypeDeclaration`: + /// class / interface / enum / type-alias / type-parameter). The merge phase's + /// `undefined`-redeclaration check (TS2397) skips type declarations. + pub is_type_decl: bool, +} + +/// A bound symbol: accumulated flags, its table key, its declarations, and the +/// child tables a container owns. +#[derive(Clone, Debug)] +// `parent` mirrors tsgo's `Symbol` shape and is set by the bind but read by nothing +// yet (hence the allow); the cascade + merge resolution read +// `flags`/`name`/`decls`/`members`/`exports`. +#[allow(dead_code)] +pub struct Symbol { + /// The accumulated classification flags. + pub flags: SymbolFlags, + /// The table key (interned name). + pub name: Atom, + /// The declarations that formed this symbol (most have one). + pub decls: SmallVec<[Decl; 1]>, + /// The `members` table (instance members of a class/interface/type-literal). + pub members: Option, + /// The `exports` table (static members / module + enum exports). + pub exports: Option, + /// The parent symbol (the container whose table this symbol lives in); + /// recorded as tsgo does, unused by the cascade. + pub parent: Option, +} + +impl Symbol { + /// A fresh symbol with the given flags and name and no declarations. + #[must_use] + pub fn new(flags: SymbolFlags, name: Atom) -> Symbol { + Symbol { + flags, + name, + decls: SmallVec::new(), + members: None, + exports: None, + parent: None, + } + } +} diff --git a/crates/tsv_check/src/check/duplicate_members.rs b/crates/tsv_check/src/check/duplicate_members.rs new file mode 100644 index 000000000..eb8ea0c57 --- /dev/null +++ b/crates/tsv_check/src/check/duplicate_members.rs @@ -0,0 +1,507 @@ +//! The duplicate-member check — a syntactic port of tsgo's +//! `checkObjectTypeForDuplicateDeclarations`. +//! +//! For one class body / interface-declaration body / type literal, this runs the +//! two-map (instance / static) state machine tsgo runs, and on the transition into +//! the `Reported` state re-scans the whole body (the `reportDuplicateMemberErrors` +//! batch), emitting one **TS2300** per declaration whose (key, is_static) matches +//! the offending bucket. It is deliberately **purely syntactic** — it never +//! consults the binder's symbol tables (walking the shared interface member table +//! would break declaration-merging), so it works off the AST members directly. +//! +//! Which members participate: +//! - **classified** (feed the state machine): a class field / property signature +//! (kind [`MemberKind::Property`]), a get/set accessor or an auto-`accessor` +//! field (kind [`MemberKind::Accessor`]), and a constructor **parameter +//! property** (kind `Property`, always instance). +//! - **methods, call/construct/index signatures, static blocks** are *not* +//! classified — they never drive a transition. A method still carries a name, so +//! it participates in the batch (its symbol name can match a bucket), matching +//! tsgo's `reportDuplicateMemberErrors` (which emits for any same-named member). +//! +//! Disjointness with the bind cascade is by construction: the binder reports on a +//! same-table *flag* conflict (any pair touching a method, or a same-kind +//! accessor/accessor), so those pairs never reach a classified→`Reported` +//! transition here; this pass fires only on property/property and +//! property/accessor pairs, which silent-merge in the binder. Where both do emit +//! (e.g. `x; get x; m()`), the identical (span, code, args) diagnostics collapse in +//! the program-wide sort/dedup — exactly as tsgo's binder + checker outputs do. +// +// tsgo: internal/checker/checker.go checkObjectTypeForDuplicateDeclarations (:3128) +// + reportDuplicateMemberErrors + +use crate::diag::{Category, Diagnostic}; +use crate::ids::FileId; +use crate::span_scan::{bracket_end, bracket_start}; +use tsv_lang::{FxHashMap, Span}; +use tsv_ts::ast::internal::{ + ClassMember, Expression, Literal, LiteralValue, MethodKind, TSTypeElement, + TSTypeParameterDeclaration, +}; + +/// The per-body derivation context (source for names, file for spans). +pub(super) struct MemberCtx<'a> { + pub source: &'a str, + pub file: FileId, +} + +/// The two member classes tsgo's check distinguishes: `1` (property/property +/// signature) and `2` (accessor). Methods and signatures are neither. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MemberKind { + Property, + Accessor, +} + +/// A body member reduced to what the check needs: its canonical grouping key, the +/// display string its diagnostic message prints (`key` for a plain name, the raw +/// bracket-inclusive `[ … ]` source for a computed key — matching tsgo's +/// `symbolToString`), the span its diagnostic points at (the name node), its +/// static-ness, whether it feeds the state machine (`classify`), and whether it is +/// a constructor parameter property (batched irrespective of the bucket's +/// static-ness, per tsgo). +struct Entry { + key: String, + display: String, + span: Span, + is_static: bool, + classify: Option, + ctor_param: bool, +} + +/// The state machine state for one (key, is_static) bucket — tsgo's `0/1/2/3`. +#[derive(Clone, Copy, PartialEq, Eq)] +enum State { + Unseen, + SeenProperty, + SeenAccessor, + Reported, +} + +/// Check a class body for duplicate property/accessor declarations, appending +/// TS2300 diagnostics to `out`. +pub(super) fn check_class_members( + ctx: &MemberCtx<'_>, + members: &[ClassMember<'_>], + out: &mut Vec, +) { + let entries = class_entries(ctx, members); + run(ctx, &entries, out); +} + +/// Check an interface / type-literal body (a `TSTypeElement` list) for duplicate +/// property/accessor signatures, appending TS2300 diagnostics to `out`. +pub(super) fn check_type_elements( + ctx: &MemberCtx<'_>, + members: &[TSTypeElement<'_>], + out: &mut Vec, +) { + let entries = type_element_entries(ctx, members); + run(ctx, &entries, out); +} + +/// Build the entry list for a class body (constructor param-properties expanded in +/// place; methods kept for the batch but unclassified). +fn class_entries(ctx: &MemberCtx<'_>, members: &[ClassMember<'_>]) -> Vec { + let mut entries = Vec::new(); + for member in members { + match member { + ClassMember::MethodDefinition(m) => match m.kind { + MethodKind::Constructor => { + for param in m.value.params { + if let Expression::TSParameterProperty(pp) = param + && let Some((key, span)) = param_property_key(ctx, pp.parameter) + { + entries.push(Entry { + display: key.clone(), + key, + span, + is_static: false, + classify: Some(MemberKind::Property), + ctor_param: true, + }); + } + } + } + MethodKind::Method => { + if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) { + entries.push(Entry { + key, + display, + span, + is_static: m.is_static, + classify: None, + ctor_param: false, + }); + } + } + MethodKind::Get | MethodKind::Set => { + if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) { + entries.push(Entry { + key, + display, + span, + is_static: m.is_static, + classify: Some(MemberKind::Accessor), + ctor_param: false, + }); + } + } + }, + ClassMember::PropertyDefinition(p) => { + if let Some((key, display, span)) = member_key(ctx, &p.key, p.computed) { + let classify = if p.accessor { + MemberKind::Accessor + } else { + MemberKind::Property + }; + entries.push(Entry { + key, + display, + span, + is_static: p.is_static, + classify: Some(classify), + ctor_param: false, + }); + } + } + ClassMember::StaticBlock(_) | ClassMember::IndexSignature(_) => {} + } + } + entries +} + +/// Build the entry list for an interface / type-literal body. Every member is +/// instance (no static); call/construct/index signatures carry no name. +fn type_element_entries(ctx: &MemberCtx<'_>, members: &[TSTypeElement<'_>]) -> Vec { + let mut entries = Vec::new(); + for member in members { + match member { + TSTypeElement::PropertySignature(p) => { + if let Some((key, display, span)) = member_key(ctx, &p.key, p.computed) { + entries.push(Entry { + key, + display, + span, + is_static: false, + classify: Some(MemberKind::Property), + ctor_param: false, + }); + } + } + TSTypeElement::MethodSignature(m) => { + if let Some((key, display, span)) = member_key(ctx, &m.key, m.computed) { + let classify = match m.kind { + MethodKind::Get | MethodKind::Set => Some(MemberKind::Accessor), + // A plain method signature is unclassified but still batched. + _ => None, + }; + entries.push(Entry { + key, + display, + span, + is_static: false, + classify, + ctor_param: false, + }); + } + } + TSTypeElement::CallSignature(_) + | TSTypeElement::ConstructSignature(_) + | TSTypeElement::IndexSignature(_) => {} + } + } + entries +} + +/// Run the state machine over `entries` (source order), firing the batch on each +/// transition into `Reported`. +fn run(ctx: &MemberCtx<'_>, entries: &[Entry], out: &mut Vec) { + let mut states: FxHashMap<(&str, bool), State> = FxHashMap::default(); + for entry in entries { + let Some(kind) = entry.classify else { continue }; + let bucket = (entry.key.as_str(), entry.is_static); + // Scope the mutable borrow so `fire_batch` (which reads `entries`, not + // `states`) can run after the transition is decided. + let transition = { + let state = states.entry(bucket).or_insert(State::Unseen); + match (*state, kind) { + (State::Unseen, MemberKind::Property) => { + *state = State::SeenProperty; + false + } + (State::Unseen, MemberKind::Accessor) => { + *state = State::SeenAccessor; + false + } + // A second property, or a property after an accessor — always an error. + (State::SeenProperty, _) | (State::SeenAccessor, MemberKind::Property) => { + *state = State::Reported; + true + } + // An accessor after an accessor is a legal get/set pair (the coarse + // kind can't tell get from set) — leave it to the binder's cascade. + (State::SeenAccessor, MemberKind::Accessor) => false, + (State::Reported, _) => false, + } + }; + if transition { + fire_batch(ctx, entries, &entry.key, entry.is_static, out); + } + } +} + +/// tsgo `reportDuplicateMemberErrors`: emit one TS2300 per declaration whose +/// (key, is_static) matches the offending bucket. A constructor parameter property +/// matches on key alone (tsgo's constructor branch ignores `checkStatic`). +fn fire_batch( + ctx: &MemberCtx<'_>, + entries: &[Entry], + key: &str, + is_static: bool, + out: &mut Vec, +) { + for entry in entries { + let matches = if entry.ctor_param { + entry.key == key + } else { + entry.key == key && entry.is_static == is_static + }; + if matches { + out.push(make_2300(ctx.file, entry.span, &entry.display)); + } + } +} + +/// Build one `Duplicate identifier '{0}'.` diagnostic. +fn make_2300(file: FileId, span: Span, display: &str) -> Diagnostic { + Diagnostic { + file: Some(file), + span, + code: 2300, + category: Category::Error, + message: format!("Duplicate identifier '{display}'."), + args: vec![display.to_string()], + chain: Vec::new(), + related: Vec::new(), + } +} + +/// Check one type-parameter declaration's own parameters for duplicate names, +/// appending a TS2300 at each later duplicate's name identifier. +/// +/// A faithful port of tsgo's `checkTypeParameters` identity loop: for each param +/// `i`, for each prior `j < i`, if they share a symbol — equivalently a name, since +/// the binder silent-merges same-name type params (`TypeParameterExcludes` excludes +/// `Type` but not `TypeParameter`, so no bind diagnostic fires) — emit `Duplicate +/// identifier` at param `i`'s name. Scoped strictly to ONE declaration's own params: +/// declaration-merged interfaces never cross-compare (that is the separate TS2428 +/// check, deliberately not ported here). The raw per-`(i, j)` push is intentional — +/// the program-wide sort/dedup collapses the repeats a longer run produces +/// (`` fires 1 at T₂ + 2 at T₃, deduping to 2). +// tsgo: internal/checker/checker.go checkTypeParameters (:6979) +// tsgo: internal/binder/binder.go bindTypeParameter (:1259) — TypeParameterExcludes +// merges same-name type params silently (no bind diagnostic) +pub(super) fn check_type_parameters( + ctx: &MemberCtx<'_>, + decl: &TSTypeParameterDeclaration<'_>, + out: &mut Vec, +) { + for (i, param) in decl.params.iter().enumerate() { + let name = param.name.name(ctx.source); + for prior in &decl.params[..i] { + if prior.name.name(ctx.source) == name { + out.push(make_2300(ctx.file, param.name.name_span(), name)); + } + } + } +} + +/// Derive a member's `(grouping key, message display, name-node span)`. Returns +/// `None` for a member with no stable key — a dynamic (non-literal) computed name, +/// or a non-name key. +/// +/// The **grouping key** is the canonical/decoded value (so `[0]` and `['0']` +/// collide); the **display** is what tsgo's `symbolToString` prints — equal to the +/// key for a plain name, but the raw bracket-inclusive `[ … ]` source for a +/// computed key (`'["a"]'`, not `'a'`). +fn member_key( + ctx: &MemberCtx<'_>, + key: &Expression<'_>, + computed: bool, +) -> Option<(String, String, Span)> { + if computed { + // A computed name is a stable key only for a string/number literal; the + // diagnostic points at the whole `[ … ]` name node, so the span runs from + // the `[` to just past the `]` and the display is that raw source. + return match key { + Expression::Literal(lit) + if matches!(lit.value, LiteralValue::String(_) | LiteralValue::Number(_)) => + { + let k = literal_key(ctx, lit)?; + let start = bracket_start(ctx.source, lit.span.start); + let end = bracket_end(ctx.source, lit.span.end); + let span = Span::new(start, end); + Some((k, span.extract(ctx.source).to_string(), span)) + } + // A dynamic computed name is late-bound (bucket G, deferred) — skip. + _ => None, + }; + } + match key { + Expression::Identifier(id) => { + let name = id.name(ctx.source).to_string(); + Some((name.clone(), name, id.name_span())) + } + Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (k.clone(), k, lit.span)), + Expression::PrivateIdentifier(pid) => { + // A `#name` — key it with the `#` so it never collides with the public + // `name`; the diagnostic covers the whole `#name` node. + let name = pid.name(ctx.source); + let keyed = format!("#{name}"); + Some((keyed.clone(), keyed, pid.span)) + } + _ => None, + } +} + +/// The key of a constructor parameter property: the parameter identifier's name +/// (unwrapping a default `= …`). `None` when the name is a binding pattern (tsgo's +/// `!ast.IsBindingPattern(param.Name())` guard) — those contribute no member. +fn param_property_key(ctx: &MemberCtx<'_>, parameter: &Expression<'_>) -> Option<(String, Span)> { + let inner = match parameter { + Expression::AssignmentPattern(a) => a.left, + other => other, + }; + match inner { + Expression::Identifier(id) => Some((id.name(ctx.source).to_string(), id.name_span())), + _ => None, + } +} + +/// The canonical key string of a literal property name: a string's decoded value, +/// a decimal number's ECMA-262 `Number::toString` form (so `0`, `0.0`, `0e0` all key +/// `0` and collide with the string `'0'`), a bigint's verbatim source (conservative — +/// never over-collides). A non-decimal numeric literal (`0x0`, `0o7`, `1_0`) the +/// parser currently decodes to `NaN` falls back to its verbatim source too, so those +/// forms stay distinct rather than all keying `"NaN"`. +fn literal_key(ctx: &MemberCtx<'_>, lit: &Literal<'_>) -> Option { + match &lit.value { + LiteralValue::String(cooked) => Some(cooked.resolve(lit.span, ctx.source).to_string()), + // A non-decimal numeric literal decodes to NaN upstream; key it on its + // verbatim source (like the bigint arm) so distinct forms stay distinct — + // `ecma_number_to_string(NaN)` would collapse them all to `"NaN"` and collide. + LiteralValue::Number(n) if n.is_nan() => Some(lit.span.extract(ctx.source).to_string()), + LiteralValue::Number(n) => Some(ecma_number_to_string(*n)), + LiteralValue::BigInt => Some(lit.span.extract(ctx.source).to_string()), + LiteralValue::Boolean(_) | LiteralValue::Null => None, + } +} + +/// ECMA-262 `Number::toString` for a finite property-name value — the string tsgo +/// keys a numeric member on (`jsnum.FromString(text).String()` via the scanner's +/// `tokenValue`). Faithful to the spec's digit/exponent rules: `100` → `"100"`, +/// `0.5` → `"0.5"`, `1e21` → `"1e+21"`, `1e-7` → `"1e-7"`. The shortest +/// round-tripping significand comes from Rust's `{:e}` (Grisu), matching the spec's +/// "s as small as possible". +/// +/// Reusable free fn (the number→string helper the check family needs). +// tsgo: internal/scanner/scanner.go scanNumber (tokenValue = jsnum.FromString(...).String()) +pub(crate) fn ecma_number_to_string(value: f64) -> String { + if value.is_nan() { + return "NaN".to_string(); + } + if value == 0.0 { + // Covers +0 and -0 (both are `"0"`). + return "0".to_string(); + } + if value.is_infinite() { + return if value < 0.0 { "-Infinity" } else { "Infinity" }.to_string(); + } + let negative = value < 0.0; + let abs = value.abs(); + // Rust's lower-exp form yields the shortest significand plus a base-10 + // exponent, e.g. `2.55e2`. For any finite non-zero `f64` it is always + // `e`; the `else` fallbacks below never fire, but avoid a + // panic on the impossible. + let formatted = format!("{abs:e}"); + let Some((mantissa, exp_str)) = formatted.split_once('e') else { + return formatted; + }; + let Ok(exp) = exp_str.parse::() else { + return formatted; + }; + let digits: String = mantissa.chars().filter(|c| *c != '.').collect(); + let k = i32::try_from(digits.len()).unwrap_or(i32::MAX); // significant-digit count + let n = exp + 1; // ECMA-262 `n`: the value is digits × 10^(n-k) + // `usize` views of the split points (each branch establishes `n > 0` first). + let n_split = usize::try_from(n).unwrap_or(0); + + let mut out = String::new(); + if negative { + out.push('-'); + } + if k <= n && n <= 21 { + // Integer with trailing zeros. + out.push_str(&digits); + for _ in 0..(n - k) { + out.push('0'); + } + } else if 0 < n && n <= 21 { + // Decimal point inside the digits. + out.push_str(&digits[..n_split]); + out.push('.'); + out.push_str(&digits[n_split..]); + } else if -6 < n && n <= 0 { + // Leading `0.` and `-n` zeros. + out.push_str("0."); + for _ in 0..(-n) { + out.push('0'); + } + out.push_str(&digits); + } else { + // Exponential form. + out.push_str(&digits[..1]); + if k > 1 { + out.push('.'); + out.push_str(&digits[1..]); + } + out.push('e'); + if n > 0 { + out.push('+'); + } else { + out.push('-'); + } + out.push_str(&(n - 1).abs().to_string()); + } + out +} + +#[cfg(test)] +mod tests { + use super::ecma_number_to_string; + + #[test] + fn ecma_number_to_string_integers_and_decimals() { + assert_eq!(ecma_number_to_string(0.0), "0"); + assert_eq!(ecma_number_to_string(-0.0), "0"); + // `0` and `0.0` and `0e0` all parse to the same f64 → same key (they collide). + // (A non-decimal `0x0` decodes to NaN upstream and is keyed on verbatim + // source instead — see `literal_key` — so it does NOT reach this helper.) + assert_eq!(ecma_number_to_string(1.0), "1"); + assert_eq!(ecma_number_to_string(100.0), "100"); + assert_eq!(ecma_number_to_string(255.0), "255"); + assert_eq!(ecma_number_to_string(0.5), "0.5"); + assert_eq!(ecma_number_to_string(2.5), "2.5"); + assert_eq!(ecma_number_to_string(1.25), "1.25"); + } + + #[test] + fn ecma_number_to_string_exponent_thresholds() { + assert_eq!(ecma_number_to_string(1e21), "1e+21"); + assert_eq!(ecma_number_to_string(1e-7), "1e-7"); + assert_eq!(ecma_number_to_string(1e-6), "0.000001"); + assert_eq!(ecma_number_to_string(1e20), "100000000000000000000"); + assert_eq!(ecma_number_to_string(1.5e22), "1.5e+22"); + } +} diff --git a/crates/tsv_check/src/check/mod.rs b/crates/tsv_check/src/check/mod.rs new file mode 100644 index 000000000..f64705dd3 --- /dev/null +++ b/crates/tsv_check/src/check/mod.rs @@ -0,0 +1,869 @@ +//! The syntactic check pass — a standalone walk over `&Program` that emits the +//! check-time diagnostics the binder's symbol cascade cannot (they are not +//! same-table flag conflicts). +//! +//! It is deliberately **not** the binder: it never consults the symbol tables +//! (walking the shared interface member table would break declaration-merging). +//! It descends every syntactic position — class / interface / type-literal bodies, +//! every type-annotation site (variable / parameter / return-type / predicate / +//! function-type / union / intersection / assertion target / …), class and +//! interface heritage type arguments, decorators (class / member / parameter), +//! template-literal-type interpolations, and every type-parameter declaration — and +//! runs a set of per-node checks. Those are the duplicate-member check and the +//! type-parameter-identity check (both in [`duplicate_members`]), sharing the one +//! descent. +//! +//! The output folds into each file's diagnostics in [`crate::program`], alongside +//! the bind product, then the whole program is canonically sorted + deduped — so a +//! diagnostic this pass and the binder both emit (identical span/code/args) +//! collapses to one, exactly as tsgo's binder + checker outputs do. +// +// tsgo: internal/checker/checker.go checkSourceElement dispatch (the per-node +// checks this walk ports piecemeal) + +mod duplicate_members; +pub(crate) mod unreachable; + +use crate::diag::Diagnostic; +use crate::ids::FileId; +use duplicate_members::MemberCtx; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, ClassBody, ClassMember, Decorator, ExportDefaultValue, Expression, + ForInOfLeft, ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSInterfaceDeclaration, + TSInterfaceHeritage, TSLiteralType, TSModuleDeclaration, TSModuleDeclarationBody, TSType, + TSTypeAnnotation, TSTypeElement, TSTypeParameterDeclaration, TSTypeParameterInstantiation, + VariableDeclaration, +}; + +/// Run the syntactic check pass over one parsed file, returning its check-time +/// diagnostics (unsorted — the program-wide sort/dedup canonicalizes order). +#[must_use] +pub fn check_file_members(program: &Program<'_>, source: &str, file: FileId) -> Vec { + let mut walk = CheckWalk { + source, + file, + diagnostics: Vec::new(), + }; + for stmt in program.body { + walk.visit_statement(stmt); + } + walk.diagnostics +} + +/// The check walk's per-file state. +struct CheckWalk<'a> { + source: &'a str, + file: FileId, + diagnostics: Vec, +} + +impl<'a> CheckWalk<'a> { + /// The derivation context the per-node checks need (source + file). + /// Built from disjoint field copies (the references are `Copy`), so it does not + /// borrow `self` — leaving `self.diagnostics` free to borrow mutably alongside. + fn member_ctx(&self) -> MemberCtx<'a> { + MemberCtx { + source: self.source, + file: self.file, + } + } + + // --- statements ---------------------------------------------------------- + + fn visit_statement(&mut self, stmt: &Statement<'_>) { + match stmt { + Statement::ExpressionStatement(s) => self.visit_expression(&s.expression), + Statement::VariableDeclaration(d) => self.visit_variable_declaration(d), + Statement::FunctionDeclaration(f) => self.check_function_common( + f.type_parameters.as_ref(), + f.params, + f.return_type.as_ref(), + f.body.body, + ), + Statement::TSDeclareFunction(f) => { + self.visit_type_params(f.type_parameters.as_ref()); + self.visit_params(f.params); + self.visit_type_annotation_opt(f.return_type.as_ref()); + } + Statement::ClassDeclaration(c) => self.check_class_common( + c.type_parameters.as_ref(), + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + &c.body, + ), + Statement::TSInterfaceDeclaration(i) => self.check_interface_common(i), + Statement::TSTypeAliasDeclaration(t) => { + self.visit_type_params(t.type_parameters.as_ref()); + self.visit_type(&t.type_annotation); + } + Statement::TSEnumDeclaration(e) => { + for member in e.members { + if let Some(init) = &member.initializer { + self.visit_expression(init); + } + } + } + Statement::TSModuleDeclaration(m) => self.visit_module_declaration(m), + Statement::ReturnStatement(s) => { + if let Some(a) = &s.argument { + self.visit_expression(a); + } + } + Statement::BlockStatement(b) => { + for s in b.body { + self.visit_statement(s); + } + } + Statement::IfStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.consequent); + if let Some(alt) = s.alternate { + self.visit_statement(alt); + } + } + Statement::ForStatement(s) => { + if let Some(init) = &s.init { + match init { + ForInit::VariableDeclaration(d) => self.visit_variable_declaration(d), + ForInit::Expression(e) => self.visit_expression(e), + } + } + if let Some(t) = &s.test { + self.visit_expression(t); + } + if let Some(u) = &s.update { + self.visit_expression(u); + } + self.visit_statement(s.body); + } + Statement::ForInStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); + } + Statement::ForOfStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expression(&s.right); + self.visit_statement(s.body); + } + Statement::WhileStatement(s) => { + self.visit_expression(&s.test); + self.visit_statement(s.body); + } + Statement::DoWhileStatement(s) => { + self.visit_statement(s.body); + self.visit_expression(&s.test); + } + Statement::SwitchStatement(s) => { + self.visit_expression(&s.discriminant); + for case in s.cases { + if let Some(t) = &case.test { + self.visit_expression(t); + } + for stmt in case.consequent { + self.visit_statement(stmt); + } + } + } + Statement::TryStatement(s) => { + for stmt in s.block.body { + self.visit_statement(stmt); + } + if let Some(h) = &s.handler { + if let Some(param) = &h.param { + self.visit_param(param); + } + for stmt in h.body.body { + self.visit_statement(stmt); + } + } + if let Some(f) = &s.finalizer { + for stmt in f.body { + self.visit_statement(stmt); + } + } + } + Statement::ThrowStatement(s) => self.visit_expression(&s.argument), + Statement::LabeledStatement(s) => self.visit_statement(s.body), + Statement::ExportNamedDeclaration(e) => { + if let Some(inner) = e.declaration { + self.visit_statement(inner); + } + } + Statement::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration), + Statement::TSExportAssignment(ea) => self.visit_expression(&ea.expression), + Statement::ExportAllDeclaration(_) + | Statement::TSNamespaceExportDeclaration(_) + | Statement::ImportDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) + | Statement::BreakStatement(_) + | Statement::ContinueStatement(_) + | Statement::EmptyStatement(_) + | Statement::DebuggerStatement(_) => {} + } + } + + fn visit_variable_declaration(&mut self, decl: &VariableDeclaration<'_>) { + for d in decl.declarations { + self.visit_param(&d.id); + if let Some(init) = &d.init { + self.visit_expression(init); + } + } + } + + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) { + match left { + ForInOfLeft::VariableDeclaration(d) => self.visit_variable_declaration(d), + ForInOfLeft::Pattern(p) => self.visit_expression(p), + } + } + + /// Descend a `namespace`/`module` body — a block, or the nested declaration a + /// dotted `namespace X.Y {}` parses to (recursed without cloning the node). + fn visit_module_declaration(&mut self, m: &TSModuleDeclaration<'_>) { + match &m.body { + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => { + for s in block.body { + self.visit_statement(s); + } + } + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => { + self.visit_module_declaration(nested); + } + None => {} + } + } + + fn visit_export_default(&mut self, value: &ExportDefaultValue<'_>) { + match value { + ExportDefaultValue::Expression(e) => self.visit_expression(e), + ExportDefaultValue::FunctionDeclaration(f) => self.check_function_common( + f.type_parameters.as_ref(), + f.params, + f.return_type.as_ref(), + f.body.body, + ), + ExportDefaultValue::TSDeclareFunction(f) => { + self.visit_type_params(f.type_parameters.as_ref()); + self.visit_params(f.params); + self.visit_type_annotation_opt(f.return_type.as_ref()); + } + ExportDefaultValue::ClassDeclaration(c) => self.check_class_common( + c.type_parameters.as_ref(), + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + &c.body, + ), + ExportDefaultValue::TSInterfaceDeclaration(i) => self.check_interface_common(i), + } + } + + // --- shared declaration descents ----------------------------------------- + + /// The class descent shared by the declaration, export-default, and expression + /// forms: type parameters, heritage (decorators / `extends` / `implements`), and + /// the member body. The three sites are byte-identical across `ClassDeclaration` + /// and `ClassExpression` (distinct types with the same field shape). + fn check_class_common( + &mut self, + type_parameters: Option<&TSTypeParameterDeclaration<'_>>, + decorators: Option<&[Decorator<'_>]>, + super_class: Option<&Expression<'_>>, + super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>, + implements: &[TSInterfaceHeritage<'_>], + body: &ClassBody<'_>, + ) { + self.visit_type_params(type_parameters); + self.visit_class_heritage(decorators, super_class, super_type_parameters, implements); + self.visit_class_body(body); + } + + /// The interface descent shared by the declaration and export-default forms. + fn check_interface_common(&mut self, interface: &TSInterfaceDeclaration<'_>) { + self.visit_type_params(interface.type_parameters.as_ref()); + self.visit_heritage_type_args(interface.extends); + self.visit_type_elements(interface.body.body); + } + + /// The body-bearing function descent shared by the declaration, export-default, + /// and expression forms: type parameters, parameters, return type, then the body + /// statements. (The bodyless `TSDeclareFunction` arms share only the header, so + /// they stay inline.) + fn check_function_common( + &mut self, + type_parameters: Option<&TSTypeParameterDeclaration<'_>>, + params: &[Expression<'_>], + return_type: Option<&TSTypeAnnotation<'_>>, + body: &[Statement<'_>], + ) { + self.visit_type_params(type_parameters); + self.visit_params(params); + self.visit_type_annotation_opt(return_type); + for s in body { + self.visit_statement(s); + } + } + + // --- expressions --------------------------------------------------------- + + fn visit_expression(&mut self, expr: &Expression<'_>) { + use Expression as E; + match expr { + E::FunctionExpression(f) => self.check_function_common( + f.type_parameters.as_ref(), + f.params, + f.return_type.as_ref(), + f.body.body, + ), + E::ArrowFunctionExpression(a) => { + self.visit_type_params(a.type_parameters.as_ref()); + self.visit_params(a.params); + self.visit_type_annotation_opt(a.return_type.as_ref()); + match &a.body { + ArrowFunctionBody::Expression(e) => self.visit_expression(e), + ArrowFunctionBody::BlockStatement(b) => { + for s in b.body { + self.visit_statement(s); + } + } + } + } + E::ClassExpression(c) => self.check_class_common( + c.type_parameters.as_ref(), + c.decorators, + c.super_class, + c.super_type_parameters.as_ref(), + c.implements, + &c.body, + ), + E::TSAsExpression(t) => { + self.visit_expression(t.expression); + self.visit_type(t.type_annotation); + } + E::TSSatisfiesExpression(t) => { + self.visit_expression(t.expression); + self.visit_type(t.type_annotation); + } + E::TSTypeAssertion(t) => { + self.visit_type(t.type_annotation); + self.visit_expression(t.expression); + } + E::TSInstantiationExpression(t) => { + self.visit_expression(t.expression); + self.visit_type_args(&t.type_arguments); + } + E::TSNonNullExpression(t) => self.visit_expression(t.expression), + E::ParenthesizedExpression(p) => self.visit_expression(p.expression), + E::JsdocCast(c) => self.visit_expression(c.inner), + E::UnaryExpression(u) => self.visit_expression(u.argument), + E::UpdateExpression(u) => self.visit_expression(u.argument), + E::AwaitExpression(a) => self.visit_expression(a.argument), + E::YieldExpression(y) => { + if let Some(a) = y.argument { + self.visit_expression(a); + } + } + E::BinaryExpression(b) => { + self.visit_expression(b.left); + self.visit_expression(b.right); + } + E::AssignmentExpression(a) => { + self.visit_expression(a.left); + self.visit_expression(a.right); + } + E::ConditionalExpression(c) => { + self.visit_expression(c.test); + self.visit_expression(c.consequent); + self.visit_expression(c.alternate); + } + E::SequenceExpression(s) => { + for e in s.expressions { + self.visit_expression(e); + } + } + E::CallExpression(c) => { + self.visit_expression(c.callee); + if let Some(ta) = &c.type_arguments { + self.visit_type_args(ta); + } + for a in c.arguments { + self.visit_expression(a); + } + } + E::NewExpression(n) => { + self.visit_expression(n.callee); + if let Some(ta) = &n.type_arguments { + self.visit_type_args(ta); + } + for a in n.arguments { + self.visit_expression(a); + } + } + E::MemberExpression(m) => { + self.visit_expression(m.object); + self.visit_expression(m.property); + } + E::SpreadElement(s) => self.visit_expression(s.argument), + E::ArrayExpression(a) => { + for e in a.elements.iter().flatten() { + self.visit_expression(e); + } + } + E::ObjectExpression(o) => { + for prop in o.properties { + match prop { + ObjectProperty::Property(pr) => self.visit_expression(&pr.value), + ObjectProperty::SpreadElement(s) => self.visit_expression(s.argument), + } + } + } + E::TemplateLiteral(t) => { + for e in t.expressions { + self.visit_expression(e); + } + } + E::TaggedTemplateExpression(t) => { + self.visit_expression(t.tag); + if let Some(ta) = &t.type_arguments { + self.visit_type_args(ta); + } + for e in t.quasi.expressions { + self.visit_expression(e); + } + } + E::ImportExpression(i) => { + self.visit_expression(i.source); + if let Some(o) = i.options { + self.visit_expression(o); + } + } + _ => {} + } + } + + // --- classes / interfaces / type literals -------------------------------- + + /// Descend a decorator list — each decorator's expression can host a type + /// literal (`@dec({} as {x; x})`). tsgo's `checkSourceElement` checks decorators. + fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) { + if let Some(decs) = decorators { + for d in decs { + self.visit_expression(&d.expression); + } + } + } + + /// Descend a class's decorators + heritage: the `extends` expression and its + /// type arguments, and each `implements` clause's type arguments — every site a + /// type literal can hide (`extends Base<{x; x}>`). tsgo's `checkSourceElement` + /// descends the heritage type arguments and decorators. + fn visit_class_heritage( + &mut self, + decorators: Option<&[Decorator<'_>]>, + super_class: Option<&Expression<'_>>, + super_type_parameters: Option<&TSTypeParameterInstantiation<'_>>, + implements: &[TSInterfaceHeritage<'_>], + ) { + self.visit_decorators(decorators); + if let Some(sc) = super_class { + self.visit_expression(sc); + } + if let Some(tp) = super_type_parameters { + self.visit_type_args(tp); + } + self.visit_heritage_type_args(implements); + } + + /// Descend each heritage clause's type arguments (shared by a class's + /// `implements` and an interface's `extends` — both are `TSInterfaceHeritage`). + fn visit_heritage_type_args(&mut self, heritages: &[TSInterfaceHeritage<'_>]) { + for h in heritages { + if let Some(ta) = &h.type_arguments { + self.visit_type_args(ta); + } + } + } + + fn visit_class_body(&mut self, body: &ClassBody<'_>) { + let ctx = self.member_ctx(); + duplicate_members::check_class_members(&ctx, body.body, &mut self.diagnostics); + for member in body.body { + match member { + ClassMember::MethodDefinition(m) => { + self.visit_decorators(m.decorators); + self.visit_type_params(m.value.type_parameters.as_ref()); + self.visit_params(m.value.params); + self.visit_type_annotation_opt(m.value.return_type.as_ref()); + for s in m.value.body.body { + self.visit_statement(s); + } + } + ClassMember::PropertyDefinition(p) => { + self.visit_decorators(p.decorators); + self.visit_type_annotation_opt(p.type_annotation.as_ref()); + if let Some(v) = &p.value { + self.visit_expression(v); + } + } + ClassMember::StaticBlock(s) => { + for stmt in s.body { + self.visit_statement(stmt); + } + } + ClassMember::IndexSignature(i) => { + self.visit_type_annotation_opt(i.type_annotation.as_ref()); + } + } + } + } + + fn visit_type_elements(&mut self, members: &[TSTypeElement<'_>]) { + let ctx = self.member_ctx(); + duplicate_members::check_type_elements(&ctx, members, &mut self.diagnostics); + for member in members { + match member { + TSTypeElement::PropertySignature(p) => { + self.visit_type_annotation_opt(p.type_annotation.as_ref()); + } + TSTypeElement::MethodSignature(m) => { + self.visit_type_params(m.type_parameters.as_ref()); + self.visit_params(m.params); + self.visit_type_annotation_opt(m.return_type.as_ref()); + } + TSTypeElement::CallSignature(c) => { + self.visit_type_params(c.type_parameters.as_ref()); + self.visit_params(c.params); + self.visit_type_annotation_opt(c.return_type.as_ref()); + } + TSTypeElement::ConstructSignature(c) => { + self.visit_type_params(c.type_parameters.as_ref()); + self.visit_params(c.params); + self.visit_type_annotation_opt(c.return_type.as_ref()); + } + TSTypeElement::IndexSignature(i) => { + self.visit_type_annotation_opt(i.type_annotation.as_ref()); + } + } + } + } + + // --- parameters ---------------------------------------------------------- + + fn visit_params(&mut self, params: &[Expression<'_>]) { + for param in params { + self.visit_param(param); + } + } + + fn visit_param(&mut self, param: &Expression<'_>) { + match param { + Expression::Identifier(id) => { + self.visit_decorators(id.decorators()); + if let Some(ann) = id.type_annotation() { + self.visit_type_annotation(ann); + } + } + Expression::ObjectPattern(op) => { + self.visit_decorators(op.decorators); + if let Some(ann) = &op.type_annotation { + self.visit_type_annotation(ann); + } + for prop in op.properties { + match prop { + ObjectPatternProperty::Property(pr) => self.visit_param(&pr.value), + ObjectPatternProperty::RestElement(r) => self.visit_param(r.argument), + } + } + } + Expression::ArrayPattern(ap) => { + self.visit_decorators(ap.decorators); + if let Some(ann) = &ap.type_annotation { + self.visit_type_annotation(ann); + } + for el in ap.elements.iter().flatten() { + self.visit_param(el); + } + } + Expression::AssignmentPattern(a) => { + self.visit_decorators(a.decorators); + self.visit_param(a.left); + self.visit_expression(a.right); + } + Expression::RestElement(r) => { + if let Some(ann) = &r.type_annotation { + self.visit_type_annotation(ann); + } + self.visit_param(r.argument); + } + Expression::TSParameterProperty(pp) => self.visit_param(pp.parameter), + _ => {} + } + } + + // --- types (general recursion) ------------------------------------------- + + fn visit_type_annotation_opt(&mut self, ann: Option<&TSTypeAnnotation<'_>>) { + if let Some(a) = ann { + self.visit_type_annotation(a); + } + } + + fn visit_type_annotation(&mut self, ann: &TSTypeAnnotation<'_>) { + self.visit_type(ann.type_annotation); + } + + fn visit_type_args(&mut self, args: &TSTypeParameterInstantiation<'_>) { + for t in args.params { + self.visit_type(t); + } + } + + /// The per-type-parameter-declaration hook: the duplicate-name identity check + /// (`check_type_parameters`) plus the descent into each parameter's constraint + /// and default (both are types). + fn visit_type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) { + if let Some(decl) = params { + let ctx = self.member_ctx(); + duplicate_members::check_type_parameters(&ctx, decl, &mut self.diagnostics); + for p in decl.params { + if let Some(c) = p.constraint { + self.visit_type(c); + } + if let Some(d) = p.default { + self.visit_type(d); + } + } + } + } + + fn visit_type(&mut self, ty: &TSType<'_>) { + match ty { + TSType::TypeLiteral(tl) => self.visit_type_elements(tl.members), + TSType::Array(a) => self.visit_type(a.element_type), + TSType::Union(u) => { + for t in u.types { + self.visit_type(t); + } + } + TSType::Intersection(i) => { + for t in i.types { + self.visit_type(t); + } + } + TSType::Parenthesized(p) => self.visit_type(p.type_annotation), + TSType::Function(f) => { + self.visit_type_params(f.type_parameters.as_ref()); + self.visit_params(f.params); + self.visit_type_annotation(&f.return_type); + } + TSType::Constructor(c) => { + self.visit_type_params(c.type_parameters.as_ref()); + self.visit_params(c.params); + self.visit_type_annotation(&c.return_type); + } + TSType::Tuple(t) => { + for e in t.element_types { + self.visit_type(e); + } + } + TSType::TypePredicate(p) => { + if let Some(t) = p.type_annotation { + self.visit_type(t); + } + } + TSType::Conditional(c) => { + self.visit_type(c.check_type); + self.visit_type(c.extends_type); + self.visit_type(c.true_type); + self.visit_type(c.false_type); + } + TSType::Mapped(m) => { + self.visit_type(m.type_parameter.constraint); + if let Some(nt) = m.name_type { + self.visit_type(nt); + } + if let Some(ta) = m.type_annotation { + self.visit_type(ta); + } + } + TSType::TypeOperator(o) => self.visit_type(o.type_annotation), + TSType::IndexedAccess(i) => { + self.visit_type(i.object_type); + self.visit_type(i.index_type); + } + TSType::Rest(r) => self.visit_type(r.type_annotation), + TSType::Optional(o) => self.visit_type(o.type_annotation), + TSType::NamedTupleMember(n) => self.visit_type(n.element_type), + TSType::Infer(inf) => { + if let Some(c) = inf.type_parameter.constraint { + self.visit_type(c); + } + if let Some(d) = inf.type_parameter.default { + self.visit_type(d); + } + } + TSType::TypeReference(r) => { + if let Some(args) = &r.type_arguments { + self.visit_type_args(args); + } + } + TSType::TypeQuery(q) => { + if let Some(args) = &q.type_arguments { + self.visit_type_args(args); + } + } + TSType::Import(i) => { + if let Some(args) = &i.type_arguments { + self.visit_type_args(args); + } + } + TSType::Literal(lit) => { + // A template-literal type's interpolations are types, which can be + // type literals (`` `p-${ {x; x} }` ``); every other literal type is a + // leaf. + if let TSLiteralType::TemplateLiteral(t) = lit { + for ty in t.types { + self.visit_type(ty); + } + } + } + TSType::Keyword(_) | TSType::ThisType(_) => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bumpalo::Bump; + + /// Run the check pass in isolation over `source`, returning the raw (unsorted, + /// un-deduped) diagnostic codes — the check pass alone, no binder, no + /// program-wide sort/dedup. + fn check_codes(source: &str) -> Vec { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + check_file_members(&program, source, FileId::ROOT) + .iter() + .map(|d| d.code) + .collect() + } + + #[test] + fn type_parameters_duplicate_fires_at_later_param() { + // The identity check emits one TS2300 (at the second occurrence); no binder. + assert_eq!(check_codes("function f() {}"), vec![2300]); + assert_eq!(check_codes("class C {}"), vec![2300]); + assert_eq!(check_codes("interface I {}"), vec![2300]); + // Distinct names never fire. + assert!(check_codes("function g() {}").is_empty()); + } + + #[test] + fn type_parameters_three_way_pushes_raw_before_dedup() { + // The raw per-(i, j) push: T₂ fires once (j=0), T₃ fires twice (j=0, j=1) — + // three diagnostics at two distinct spans. The program-wide sort/dedup (see + // the binder-side `diag_codes` test) later collapses the T₃ pair to one. + let arena = Bump::new(); + let src = "function f() {}"; + let program = tsv_ts::parse(src, &arena).expect("parse"); + let diags = check_file_members(&program, src, FileId::ROOT); + assert_eq!( + diags.iter().map(|d| d.code).collect::>(), + vec![2300, 2300, 2300] + ); + let distinct: std::collections::BTreeSet = + diags.iter().map(|d| d.span.start).collect(); + assert_eq!(distinct.len(), 2, "two distinct spans (T2 once, T3 twice)"); + } + + #[test] + fn accessor_accessor_is_check_pass_noop() { + // The state machine leaves a same-named accessor/accessor pair to the binder + // (the coarse kind can't tell get from set), so the check pass emits nothing. + assert!(check_codes("class C { get x() {} get x() {} }").is_empty()); + assert!(check_codes("class C { get x() {} set x(v) {} }").is_empty()); + } + + #[test] + fn static_and_instance_members_are_separate_buckets() { + // `static x` and instance `x` live in different (key, is_static) buckets, so + // the check pass never merges them into a duplicate. + assert!(check_codes("class C { static x = 1; x = 2; }").is_empty()); + // A same-static-ness duplicate DOES fire — the separation is by + // (key, is_static), not by ignoring static. + assert_eq!( + check_codes("class C { static x = 1; static x = 2; }"), + vec![2300, 2300] + ); + } + + #[test] + fn heritage_and_decorator_and_template_type_literals_are_checked() { + // The walk descends class/interface heritage type arguments, decorators, and + // template-literal-type interpolations — every syntactic position a type + // literal can hide — so a duplicate member there is caught (two raw TS2300). + assert_eq!( + check_codes("class C extends Base<{x:number;x:string}> {}"), + vec![2300, 2300] + ); + assert_eq!( + check_codes("interface I extends Base<{x;x}> {}"), + vec![2300, 2300] + ); + assert_eq!( + check_codes("class C implements Base<{x;x}> {}"), + vec![2300, 2300] + ); + assert_eq!( + check_codes("@dec({} as {x;x}) class C {}"), + vec![2300, 2300] + ); + assert_eq!( + check_codes("class C { @dec({} as {x;x}) m() {} }"), + vec![2300, 2300] + ); + assert_eq!( + check_codes("class C { @dec({} as {x;x}) p = 1; }"), + vec![2300, 2300] + ); + // A constructor parameter's own decorator. + assert_eq!( + check_codes("class C { m(@dec({} as {x;x}) p) {} }"), + vec![2300, 2300] + ); + assert_eq!(check_codes("type T = `p-${ {x;x} }`;"), vec![2300, 2300]); + } + + #[test] + fn computed_literal_key_display_is_raw_bracket_source() { + // A computed key's message arg is the raw `[ … ]` source (tsgo's + // `symbolToString`), not the decoded value — while its grouping key stays the + // decoded value. Interface computed keys fire property/property in the check + // pass, so this exercises the check-side display in isolation. + let arena = Bump::new(); + let src = "interface I { ['a']: number; ['a']: string; }"; + let program = tsv_ts::parse(src, &arena).expect("parse"); + let diags = check_file_members(&program, src, FileId::ROOT); + assert_eq!(diags.len(), 2); + assert!(diags.iter().all(|d| d.code == 2300)); + assert!(diags.iter().all(|d| d.args == vec!["['a']".to_string()])); + } + + #[test] + fn constructor_parameter_property_participates_in_batch() { + // A constructor parameter property (`public x`) is an instance property that + // matches the batch on key alone; paired with a field `x` the check pass + // fires at both (2 raw diagnostics — property/property silent-merges at bind). + assert_eq!( + check_codes("class C { constructor(public x: number) {} x = 1; }"), + vec![2300, 2300] + ); + } +} diff --git a/crates/tsv_check/src/check/unreachable.rs b/crates/tsv_check/src/check/unreachable.rs new file mode 100644 index 000000000..70d343808 --- /dev/null +++ b/crates/tsv_check/src/check/unreachable.rs @@ -0,0 +1,1077 @@ +//! The unreachable-code (TS7027) + unused-label (TS7028) shim — a **fast-path** +//! reader of the flow product's `NODE_FLAGS_UNREACHABLE` bit. +//! +//! This is the first slice that consumes the flow product ([`crate::binder::flow`]) +//! and emits diagnostics. It ports the binder-set-bit branch of tsgo's +//! `checkSourceElementUnreachable` / `isSourceElementUnreachable` and its +//! `checkLabeledStatement` unused-label check — **only** the branch that reads the +//! binder's `Unreachable` flag. The type-dependent fallback +//! (`isReachableFlowNode` — never-returning signatures, assertion predicates, +//! exhaustive switches) is `deferred_cfa`, out of scope. +//! +//! ## Two phases, split across bind and check +//! +//! The flag bit, the run grouping, and the const-enum / module-instance +//! classification are **all syntactic** (bind-time, variant-independent). So the +//! candidate table is built **once per unit** in `bind_program` while the AST + +//! flow product are alive ([`build_candidates`]) and stored owned in the +//! `BoundUnit` — keeping the `BoundProgram` C15-relocatable. Then per-variant +//! [`UnreachableCandidates::emit`] applies the option filter (which members count +//! as executable) and routes each surviving run — **error** into `diagnostics` +//! (only when the option is explicit-`False`), **suggestion** into a separate +//! `suggestions` sink (the default `Unknown`), which the conformance gate's +//! expect-clean channel never inspects. +//! +//! ## Grouping (tsgo checker.go:2394-2439, forward scan only) +//! +//! Within a statement list, a maximal run of consecutive candidates (each a +//! potentially-executable statement carrying the `Unreachable` bit) is recorded +//! once. At emit time the run is split into sub-runs at members that fail the +//! option filter (a `const enum` at `preserveConstEnums:false`, a non-instantiated +//! module), and one TS7027 is emitted per surviving sub-run spanning +//! `first.start → last.end`. A reported run's children are **not** descended for +//! more runs — tsgo's `withinUnreachableCode` / `reportedUnreachableNodes` +//! suppression, which here falls out of not descending into a candidate statement. +// +// tsgo: internal/checker/checker.go checkSourceElementUnreachable (2380-2439), +// isSourceElementUnreachable (2441-2459), checkLabeledStatement (4190-4206), +// errorOrSuggestion/addErrorOrSuggestion (13937-13957); +// internal/ast/utilities.go GetModuleInstanceState / IsInstantiatedModule +// (2294-2428), IsPotentiallyExecutableNode (4210). + +use crate::binder::flow::FlowProduct; +use crate::binder::{BoundFile, NODE_FLAGS_UNREACHABLE, NodeKind, addr_of, statement_kind}; +use crate::diag::Diagnostic; +use crate::ids::{FileId, NodeId}; +use crate::options::{CheckOptions, Tristate}; +use smallvec::SmallVec; +use tsv_lang::{Comment, Span}; +use tsv_ts::ast::Program; +use tsv_ts::ast::internal::{ + ArrowFunctionBody, ClassMember, Decorator, ExportDefaultValue, Expression, ForInOfLeft, + ForInit, ObjectPatternProperty, ObjectProperty, Statement, TSModuleDeclaration, + TSModuleDeclarationBody, +}; + +/// A namespace body's instantiation classification — tsgo's +/// `ModuleInstanceState`, a **pure syntactic** fold of the body's declarations. +/// +/// # tsgo +/// `internal/ast/utilities.go` `ModuleInstanceState`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ModuleInstanceState { + /// Only interfaces / type aliases / non-exported imports — no value emitted. + NonInstantiated, + /// Produces a value (functions, classes, non-const enums, value exports). + Instantiated, + /// Contains only `const enum`s — instantiated iff `preserveConstEnums`. + ConstEnumOnly, +} + +/// The per-candidate classification that decides whether an unreachable member is +/// reportable under the option filter (tsgo `isSourceElementUnreachable`'s switch). +#[derive(Clone, Copy, Debug)] +enum CandidateKind { + /// Always reportable (class, plain statement, instantiated module, non-const + /// enum). + Plain, + /// A (possibly const) enum: reportable iff `!is_const || preserveConstEnums`. + Enum { + /// Whether this is a `const enum`. + is_const: bool, + }, + /// A namespace/module: reportable iff `IsInstantiatedModule(state, preserve)`. + Module { + /// The syntactic instantiation state. + state: ModuleInstanceState, + }, +} + +/// One member of a contiguous unreachable run: its reportable span + its filter +/// classification. +#[derive(Clone, Copy, Debug)] +struct RunMember { + span: Span, + kind: CandidateKind, +} + +/// A maximal contiguous run of unreachable candidates in one statement list +/// (variant-independent; the option filter splits it at emit time). +#[derive(Clone, Debug)] +struct CandidateRun { + members: SmallVec<[RunMember; 4]>, +} + +/// The owned, arena-free per-unit unreachable/unused-label candidate table, built +/// at bind time and consumed by [`UnreachableCandidates::emit`] per variant. +/// C15-relocatable by construction (spans are `Copy`; nothing borrows the AST). +#[derive(Clone, Debug, Default)] +pub struct UnreachableCandidates { + /// Maximal unreachable-statement runs (TS7027 candidates). + runs: Vec, + /// Unreferenced-label identifier spans (TS7028 candidates), pre-order. + unused_labels: Vec, + /// Byte ranges of source lines suppressed by a preceding + /// `@ts-ignore` / `@ts-expect-error` directive (source-fixed, so + /// option-independent). A reachability diagnostic whose start falls in one is + /// dropped, matching tsc's `getDiagnosticsWithPrecedingDirectives`. + suppressed_ranges: Vec<(u32, u32)>, +} + +impl UnreachableCandidates { + /// Emit the reachability diagnostics for one unit under `options`, routing each + /// to `diagnostics` (error category) or `suggestions` (suggestion category): + /// TS7027 per surviving unreachable sub-run, TS7028 per unreferenced label. + /// A `True` option skips its probe entirely; `False` → error; `Unknown` → + /// suggestion. + // `&CheckOptions` mirrors the `check_bound` threading (uniform + future-proof). + #[allow(clippy::trivially_copy_pass_by_ref)] + pub fn emit( + &self, + file: FileId, + options: &CheckOptions, + diagnostics: &mut Vec, + suggestions: &mut Vec, + ) { + // TODO: tsgo's `getDiagnosticsWithPrecedingDirectives` filters ALL bind+check + // diagnostics; tsv suppresses only this flow family (TS7027/7028), so a + // `@ts-ignore`'d dup-family diagnostic still emits as a family extra. Widening + // is deferred — the clean form is a final per-file pass over the concatenated + // bind+check(+merge) diagnostics, which entangles with multi-file merge + // cross-file attribution (a P3/multi-file concern). + // TS7027 — unreachable code. Skip the probe entirely at `True`. + if options.allow_unreachable_code != Tristate::True { + let is_error = options.allow_unreachable_code == Tristate::False; + let preserve = options.preserve_const_enums; + for run in &self.runs { + let mut i = 0; + while i < run.members.len() { + // Skip a filtered-out member (splits the run — the const-enum / + // non-instantiated-module gap). + if !passes(run.members[i].kind, preserve) { + i += 1; + continue; + } + // Absorb the contiguous filter-passing sub-run. + let start = run.members[i].span.start; + let mut end = run.members[i].span.end; + let mut j = i + 1; + while j < run.members.len() && passes(run.members[j].kind, preserve) { + end = run.members[j].span.end; + j += 1; + } + // A directive (`@ts-ignore` / `@ts-expect-error`) preceding the + // sub-run's start line drops an ERROR-category diagnostic; tsgo's + // suggestion collection bypasses directive filtering + // (`getSuggestionDiagnosticsWithChecker`), so a suggestion is kept. + if !(is_error && self.is_suppressed(start)) { + push( + diagnostics, + suggestions, + is_error, + file, + Span::new(start, end), + 7027, + "Unreachable code detected.", + ); + } + i = j; + } + } + } + // TS7028 — unused label. One per unreferenced label identifier (no run + // grouping); span = the label identifier alone. + if options.allow_unused_labels != Tristate::True { + let is_error = options.allow_unused_labels == Tristate::False; + for &span in &self.unused_labels { + // Error-category only; a suggestion bypasses directive suppression. + if is_error && self.is_suppressed(span.start) { + continue; + } + push( + diagnostics, + suggestions, + is_error, + file, + span, + 7028, + "Unused label.", + ); + } + } + } + + /// Whether `start` (a diagnostic's start offset) falls on a directive-suppressed + /// line. The range list is tiny (usually empty), so a linear scan is fine. + fn is_suppressed(&self, start: u32) -> bool { + self.suppressed_ranges + .iter() + .any(|&(s, e)| start >= s && start < e) + } +} + +/// Route a reachability diagnostic to the error sink or the suggestion sink. +fn push( + diagnostics: &mut Vec, + suggestions: &mut Vec, + is_error: bool, + file: FileId, + span: Span, + code: u32, + message: &str, +) { + if is_error { + diagnostics.push(Diagnostic::error(file, span, code, message)); + } else { + suggestions.push(Diagnostic::suggestion(file, span, code, message)); + } +} + +/// Whether an unreachable member is reportable under `preserve_const_enums` +/// (tsgo `isSourceElementUnreachable`'s enum/module switch). +fn passes(kind: CandidateKind, preserve: bool) -> bool { + match kind { + CandidateKind::Plain => true, + CandidateKind::Enum { is_const } => !is_const || preserve, + CandidateKind::Module { state } => is_instantiated_module(state, preserve), + } +} + +/// `IsInstantiatedModule` (tsgo utilities.go:2422). +fn is_instantiated_module(state: ModuleInstanceState, preserve: bool) -> bool { + matches!(state, ModuleInstanceState::Instantiated) + || (preserve && matches!(state, ModuleInstanceState::ConstEnumOnly)) +} + +/// Build the per-unit candidate table from a parsed file's AST + the flow product +/// (the `Unreachable`-bit source). Called once per parsed unit in `bind_program`. +#[must_use] +pub fn build_candidates( + program: &Program<'_>, + source: &str, + bound: &BoundFile, + flow: &FlowProduct, +) -> UnreachableCandidates { + let mut walk = CandidateWalk { + bound, + flow, + runs: Vec::new(), + }; + walk.visit_list(program.body); + let unused_labels = collect_unused_labels(bound, flow); + let suppressed_ranges = compute_suppressed_ranges(source, program.comments); + UnreachableCandidates { + runs: walk.runs, + unused_labels, + suppressed_ranges, + } +} + +/// Compute the source lines suppressed by an `@ts-ignore` / `@ts-expect-error` +/// directive — tsc's `getDiagnosticsWithPrecedingDirectives`. A directive on line +/// `D` suppresses diagnostics on the following lines, scanning down through +/// blank/comment lines up to and including the first code line it protects. Empty +/// (the fast path) when the file has no directive comments. +fn compute_suppressed_ranges(source: &str, comments: &[Comment]) -> Vec<(u32, u32)> { + // Key each directive off its comment's END offset: tsc attributes a directive + // to its comment's LAST line (`lastLineStart`), so a multi-line + // `/* … @ts-ignore */` protects the line after the `*/`, not after the `/*`. A + // `//` directive can't span lines, so `end` == `start`'s line for those. + let mut directive_ends: Vec = comments + .iter() + .filter(|c| is_directive_comment(c.content(source))) + .map(|c| c.span.end) + .collect(); + if directive_ends.is_empty() { + return Vec::new(); + } + directive_ends.sort_unstable(); + let bytes = source.as_bytes(); + let line_starts = compute_line_starts(source); + let mut ranges: Vec<(u32, u32)> = Vec::new(); + for &directive_end in &directive_ends { + let mut l = line_of(&line_starts, directive_end) + 1; + while l < line_starts.len() { + let line_start = line_starts[l]; + let line_end = line_starts + .get(l + 1) + .copied() + .unwrap_or(source.len() as u32); + ranges.push((line_start, line_end)); + if is_comment_or_blank_line(bytes, line_start as usize) { + l += 1; + } else { + break; // the protected code line — stop + } + } + } + ranges.sort_unstable(); + ranges +} + +/// Byte offsets of each line start (`[0, …after each '\n']`). +fn compute_line_starts(source: &str) -> Vec { + let mut starts = vec![0u32]; + for (i, b) in source.bytes().enumerate() { + if b == b'\n' { + starts.push((i + 1) as u32); + } + } + starts +} + +/// The line index (0-based) containing `offset`. +fn line_of(line_starts: &[u32], offset: u32) -> usize { + match line_starts.binary_search(&offset) { + Ok(i) => i, + // `line_starts[0] == 0 <= offset`, so the insertion point is `>= 1`. + Err(i) => i - 1, + } +} + +/// `isCommentOrBlankLine` (tsgo program.go:1427) — a line that is whitespace-only +/// or begins (after indent) with `//`. +fn is_comment_or_blank_line(bytes: &[u8], line_start: usize) -> bool { + let mut p = line_start; + while p < bytes.len() && (bytes[p] == b' ' || bytes[p] == b'\t') { + p += 1; + } + p == bytes.len() + || bytes[p] == b'\r' + || bytes[p] == b'\n' + || (p + 1 < bytes.len() && bytes[p] == b'/' && bytes[p + 1] == b'/') +} + +/// Whether a comment's (delimiter-stripped) content is an `@ts-ignore` / +/// `@ts-expect-error` directive (tsgo scanner.go `processCommentDirective`): skip +/// leading whitespace + extra `/`/`*`, then require `@ts-ignore` / `@ts-expect-error`. +fn is_directive_comment(content: &str) -> bool { + let t = content + .trim_start() + .trim_start_matches(['/', '*']) + .trim_start(); + t.strip_prefix('@') + .is_some_and(|r| r.starts_with("ts-ignore") || r.starts_with("ts-expect-error")) +} + +/// Scan the SoA columns for unreferenced-label identifiers: a node carrying the +/// `Unreachable` bit whose kind is `Identifier` and whose parent is a +/// `LabeledStatement` (the only `Identifier` child of a labeled statement is its +/// label). The bit is set on such a label only by `bindLabeledStatement` for an +/// unreferenced label, so this is exact and needs no traversal. +fn collect_unused_labels(bound: &BoundFile, flow: &FlowProduct) -> Vec { + let mut out = Vec::new(); + for i in 0..bound.node_count as usize { + if flow.node_flags[i] & NODE_FLAGS_UNREACHABLE == 0 + || bound.kinds[i] != NodeKind::Identifier + { + continue; + } + if let Some(parent) = bound.parents[i] + && bound.kinds[parent.index()] == NodeKind::LabeledStatement + { + out.push(bound.spans[i]); + } + } + out +} + +/// The candidate-collection walk over the value structure of one file. +struct CandidateWalk<'a> { + bound: &'a BoundFile, + flow: &'a FlowProduct, + runs: Vec, +} + +impl CandidateWalk<'_> { + /// The `NodeId` of a statement if it carries the `Unreachable` bit (a + /// candidate). Only potentially-executable statements ever get the bit + /// (`bindChildren`'s dead path gates on `IsPotentiallyExecutableNode`), so a + /// bit-bearing statement *is* a candidate. A safe (non-panicking) address + /// lookup — a miss (never expected) simply treats the statement as + /// non-candidate. + fn candidate_id(&self, stmt: &Statement<'_>) -> Option { + let id = self + .bound + .address_map + .get(&(addr_of(stmt), statement_kind(stmt))) + .copied()?; + (self.flow.node_flags[id.index()] & NODE_FLAGS_UNREACHABLE != 0).then_some(id) + } + + /// Process a statement list: group maximal runs of consecutive candidates + /// (recorded, not descended — the suppression), and descend every + /// non-candidate statement to find dead code in its nested lists / bodies. + fn visit_list(&mut self, stmts: &[Statement<'_>]) { + let mut i = 0; + while i < stmts.len() { + let Some(id) = self.candidate_id(&stmts[i]) else { + self.descend(&stmts[i]); + i += 1; + continue; + }; + // Start a maximal run; a candidate's subtree is suppressed (not + // descended), matching tsgo's `withinUnreachableCode`. + let mut members: SmallVec<[RunMember; 4]> = SmallVec::new(); + members.push(RunMember { + span: self.bound.spans[id.index()], + kind: classify(&stmts[i]), + }); + i += 1; + while i < stmts.len() { + let Some(id) = self.candidate_id(&stmts[i]) else { + break; + }; + members.push(RunMember { + span: self.bound.spans[id.index()], + kind: classify(&stmts[i]), + }); + i += 1; + } + self.runs.push(CandidateRun { members }); + } + } + + /// Descend a **non-candidate** statement's nested statement positions and value + /// expressions (an embedded arrow/function/class body can hide dead code). + fn descend(&mut self, stmt: &Statement<'_>) { + use Statement as S; + match stmt { + S::ExpressionStatement(s) => self.visit_expr(&s.expression), + S::VariableDeclaration(d) => { + for decl in d.declarations { + self.visit_expr(&decl.id); + if let Some(init) = &decl.init { + self.visit_expr(init); + } + } + } + S::FunctionDeclaration(f) => { + self.visit_params(f.params); + self.visit_list(f.body.body); + } + S::ClassDeclaration(c) => { + self.visit_class(c.body.body, c.super_class, c.decorators); + } + S::TSEnumDeclaration(e) => { + for m in e.members { + if let Some(init) = &m.initializer { + self.visit_expr(init); + } + } + } + S::TSModuleDeclaration(m) => self.visit_module(m), + S::ReturnStatement(s) => { + if let Some(a) = &s.argument { + self.visit_expr(a); + } + } + S::ThrowStatement(s) => self.visit_expr(&s.argument), + S::BlockStatement(b) => self.visit_list(b.body), + S::IfStatement(s) => { + self.visit_expr(&s.test); + self.visit_list(std::slice::from_ref(s.consequent)); + if let Some(alt) = s.alternate { + self.visit_list(std::slice::from_ref(alt)); + } + } + S::ForStatement(s) => { + match &s.init { + Some(ForInit::VariableDeclaration(d)) => { + for decl in d.declarations { + self.visit_expr(&decl.id); + if let Some(init) = &decl.init { + self.visit_expr(init); + } + } + } + Some(ForInit::Expression(e)) => self.visit_expr(e), + None => {} + } + if let Some(t) = &s.test { + self.visit_expr(t); + } + if let Some(u) = &s.update { + self.visit_expr(u); + } + self.visit_list(std::slice::from_ref(s.body)); + } + S::ForInStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expr(&s.right); + self.visit_list(std::slice::from_ref(s.body)); + } + S::ForOfStatement(s) => { + self.visit_for_left(&s.left); + self.visit_expr(&s.right); + self.visit_list(std::slice::from_ref(s.body)); + } + S::WhileStatement(s) => { + self.visit_expr(&s.test); + self.visit_list(std::slice::from_ref(s.body)); + } + S::DoWhileStatement(s) => { + self.visit_list(std::slice::from_ref(s.body)); + self.visit_expr(&s.test); + } + S::SwitchStatement(s) => { + self.visit_expr(&s.discriminant); + for case in s.cases { + if let Some(t) = &case.test { + self.visit_expr(t); + } + self.visit_list(case.consequent); + } + } + S::TryStatement(s) => { + self.visit_list(s.block.body); + if let Some(h) = &s.handler { + if let Some(p) = &h.param { + self.visit_expr(p); + } + self.visit_list(h.body.body); + } + if let Some(f) = &s.finalizer { + self.visit_list(f.body); + } + } + S::LabeledStatement(s) => self.visit_list(std::slice::from_ref(s.body)), + S::ExportNamedDeclaration(e) => { + if let Some(inner) = e.declaration { + self.visit_list(std::slice::from_ref(inner)); + } + } + S::ExportDefaultDeclaration(e) => self.visit_export_default(&e.declaration), + S::TSExportAssignment(ea) => self.visit_expr(&ea.expression), + // No value body to descend for dead code. + S::TSDeclareFunction(_) + | S::TSInterfaceDeclaration(_) + | S::TSTypeAliasDeclaration(_) + | S::ExportAllDeclaration(_) + | S::TSNamespaceExportDeclaration(_) + | S::ImportDeclaration(_) + | S::TSImportEqualsDeclaration(_) + | S::BreakStatement(_) + | S::ContinueStatement(_) + | S::EmptyStatement(_) + | S::DebuggerStatement(_) => {} + } + } + + fn visit_for_left(&mut self, left: &ForInOfLeft<'_>) { + match left { + ForInOfLeft::VariableDeclaration(d) => { + for decl in d.declarations { + self.visit_expr(&decl.id); + if let Some(init) = &decl.init { + self.visit_expr(init); + } + } + } + ForInOfLeft::Pattern(e) => self.visit_expr(e), + } + } + + fn visit_export_default(&mut self, v: &ExportDefaultValue<'_>) { + use ExportDefaultValue as V; + match v { + V::Expression(e) => self.visit_expr(e), + V::FunctionDeclaration(f) => { + self.visit_params(f.params); + self.visit_list(f.body.body); + } + V::ClassDeclaration(c) => self.visit_class(c.body.body, c.super_class, c.decorators), + V::TSDeclareFunction(_) | V::TSInterfaceDeclaration(_) => {} + } + } + + fn visit_class( + &mut self, + members: &[ClassMember<'_>], + super_class: Option<&Expression<'_>>, + decorators: Option<&[Decorator<'_>]>, + ) { + self.visit_decorators(decorators); + if let Some(sc) = super_class { + self.visit_expr(sc); + } + for member in members { + match member { + ClassMember::MethodDefinition(m) => { + self.visit_decorators(m.decorators); + self.visit_params(m.value.params); + self.visit_list(m.value.body.body); + } + ClassMember::PropertyDefinition(p) => { + self.visit_decorators(p.decorators); + if let Some(v) = &p.value { + self.visit_expr(v); + } + } + ClassMember::StaticBlock(s) => self.visit_list(s.body), + ClassMember::IndexSignature(_) => {} + } + } + } + + fn visit_module(&mut self, m: &TSModuleDeclaration<'_>) { + match &m.body { + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => self.visit_list(block.body), + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => self.visit_module(nested), + None => {} + } + } + + fn visit_decorators(&mut self, decorators: Option<&[Decorator<'_>]>) { + if let Some(decs) = decorators { + for d in decs { + self.visit_expr(&d.expression); + } + } + } + + fn visit_params(&mut self, params: &[Expression<'_>]) { + for p in params { + self.visit_param(p); + } + } + + fn visit_param(&mut self, param: &Expression<'_>) { + use Expression as E; + match param { + E::AssignmentPattern(a) => { + self.visit_param(a.left); + self.visit_expr(a.right); + } + E::ObjectPattern(op) => { + for prop in op.properties { + match prop { + ObjectPatternProperty::Property(pr) => self.visit_param(&pr.value), + ObjectPatternProperty::RestElement(r) => self.visit_param(r.argument), + } + } + } + E::ArrayPattern(ap) => { + for el in ap.elements.iter().flatten() { + self.visit_param(el); + } + } + E::RestElement(r) => self.visit_param(r.argument), + E::TSParameterProperty(pp) => self.visit_param(pp.parameter), + _ => {} + } + } + + /// Descend a value expression, looking for embedded function/arrow/class + /// bodies (which hold their own statement lists / dead code). + fn visit_expr(&mut self, expr: &Expression<'_>) { + use Expression as E; + match expr { + E::FunctionExpression(f) => { + self.visit_params(f.params); + self.visit_list(f.body.body); + } + E::ArrowFunctionExpression(a) => { + self.visit_params(a.params); + match &a.body { + ArrowFunctionBody::Expression(e) => self.visit_expr(e), + ArrowFunctionBody::BlockStatement(b) => self.visit_list(b.body), + } + } + E::ClassExpression(c) => self.visit_class(c.body.body, c.super_class, c.decorators), + E::TSAsExpression(t) => self.visit_expr(t.expression), + E::TSSatisfiesExpression(t) => self.visit_expr(t.expression), + E::TSTypeAssertion(t) => self.visit_expr(t.expression), + E::TSInstantiationExpression(t) => self.visit_expr(t.expression), + E::TSNonNullExpression(t) => self.visit_expr(t.expression), + E::ParenthesizedExpression(p) => self.visit_expr(p.expression), + E::JsdocCast(c) => self.visit_expr(c.inner), + E::UnaryExpression(u) => self.visit_expr(u.argument), + E::UpdateExpression(u) => self.visit_expr(u.argument), + E::AwaitExpression(a) => self.visit_expr(a.argument), + E::YieldExpression(y) => { + if let Some(a) = y.argument { + self.visit_expr(a); + } + } + E::BinaryExpression(b) => { + self.visit_expr(b.left); + self.visit_expr(b.right); + } + E::AssignmentExpression(a) => { + self.visit_expr(a.left); + self.visit_expr(a.right); + } + E::ConditionalExpression(c) => { + self.visit_expr(c.test); + self.visit_expr(c.consequent); + self.visit_expr(c.alternate); + } + E::SequenceExpression(s) => { + for e in s.expressions { + self.visit_expr(e); + } + } + E::CallExpression(c) => { + self.visit_expr(c.callee); + for a in c.arguments { + self.visit_expr(a); + } + } + E::NewExpression(n) => { + self.visit_expr(n.callee); + for a in n.arguments { + self.visit_expr(a); + } + } + E::MemberExpression(m) => { + self.visit_expr(m.object); + self.visit_expr(m.property); + } + E::SpreadElement(s) => self.visit_expr(s.argument), + E::ArrayExpression(a) => { + for e in a.elements.iter().flatten() { + self.visit_expr(e); + } + } + E::ObjectExpression(o) => { + for prop in o.properties { + match prop { + ObjectProperty::Property(pr) => { + self.visit_expr(&pr.key); + self.visit_expr(&pr.value); + } + ObjectProperty::SpreadElement(s) => self.visit_expr(s.argument), + } + } + } + E::TemplateLiteral(t) => { + for e in t.expressions { + self.visit_expr(e); + } + } + E::TaggedTemplateExpression(t) => { + self.visit_expr(t.tag); + for e in t.quasi.expressions { + self.visit_expr(e); + } + } + E::ImportExpression(i) => { + self.visit_expr(i.source); + if let Some(o) = i.options { + self.visit_expr(o); + } + } + E::AssignmentPattern(a) => { + self.visit_expr(a.left); + self.visit_expr(a.right); + } + E::ObjectPattern(op) => { + for prop in op.properties { + match prop { + ObjectPatternProperty::Property(pr) => { + self.visit_expr(&pr.key); + self.visit_expr(&pr.value); + } + ObjectPatternProperty::RestElement(r) => self.visit_expr(r.argument), + } + } + } + E::ArrayPattern(ap) => { + for el in ap.elements.iter().flatten() { + self.visit_expr(el); + } + } + E::RestElement(r) => self.visit_expr(r.argument), + E::TSParameterProperty(pp) => self.visit_expr(pp.parameter), + // Leaves (identifier / literal / this / super / meta / private / regex). + _ => {} + } + } +} + +/// Classify a candidate statement for the option filter. +fn classify(stmt: &Statement<'_>) -> CandidateKind { + match stmt { + Statement::TSEnumDeclaration(e) => CandidateKind::Enum { + is_const: e.r#const, + }, + Statement::TSModuleDeclaration(m) => CandidateKind::Module { + state: module_instance_state(m), + }, + _ => CandidateKind::Plain, + } +} + +/// `GetModuleInstanceState` (tsgo utilities.go:2302) — a namespace with no body +/// (`declare module 'x';`) is instantiated; otherwise fold its block. +fn module_instance_state(m: &TSModuleDeclaration<'_>) -> ModuleInstanceState { + match &m.body { + None => ModuleInstanceState::Instantiated, + Some(TSModuleDeclarationBody::TSModuleBlock(block)) => block_instance_state(block.body), + Some(TSModuleDeclarationBody::TSModuleDeclaration(nested)) => module_instance_state(nested), + } +} + +/// The `ModuleBlock` fold of `getModuleInstanceStateWorker` (tsgo utilities.go:2363): +/// non-instantiated unless a member is const-enum-only (→ const-enum-only) or +/// instantiated (→ instantiated, short-circuit). +fn block_instance_state(stmts: &[Statement<'_>]) -> ModuleInstanceState { + let mut state = ModuleInstanceState::NonInstantiated; + for stmt in stmts { + match statement_instance_state(stmt) { + ModuleInstanceState::NonInstantiated => {} + ModuleInstanceState::ConstEnumOnly => state = ModuleInstanceState::ConstEnumOnly, + ModuleInstanceState::Instantiated => return ModuleInstanceState::Instantiated, + } + } + state +} + +/// The per-statement classification of `getModuleInstanceStateWorker`'s switch. +fn statement_instance_state(stmt: &Statement<'_>) -> ModuleInstanceState { + use Statement as S; + match stmt { + S::TSInterfaceDeclaration(_) | S::TSTypeAliasDeclaration(_) => { + ModuleInstanceState::NonInstantiated + } + S::TSEnumDeclaration(e) => { + if e.r#const { + ModuleInstanceState::ConstEnumOnly + } else { + ModuleInstanceState::Instantiated + } + } + // A non-exported import/import-equals produces no value. tsv treats a bare + // import as non-instantiated; the exported `export import x = …` form (tsgo: + // instantiated) is a rare-in-dead-code residual, and under-reporting it is + // safe (a missing, never an extra). + S::ImportDeclaration(_) | S::TSImportEqualsDeclaration(_) => { + ModuleInstanceState::NonInstantiated + } + S::TSModuleDeclaration(nested) => module_instance_state(nested), + S::ExportNamedDeclaration(e) => match e.declaration { + // `export interface` / `export const enum` / `export const` — classify + // the wrapped declaration. + Some(inner) => statement_instance_state(inner), + // Bare `export { … }` alias targets: a faithful resolution + // (`getModuleInstanceStateForAliasTarget`) walks the enclosing scope per + // name — a type-only re-export folds to NonInstantiated. tsv does not + // resolve, so it takes the **NonInstantiated** default: under-reporting a + // value re-export is safe (a missing), whereas assuming Instantiated + // would over-report a type-only re-export as a false TS7027 extra (the + // dangerous direction). The residual is a dead namespace whose only + // member is a value re-export. + None => ModuleInstanceState::NonInstantiated, + }, + _ => ModuleInstanceState::Instantiated, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::binder::{bind_file, flow::build_flow}; + use crate::ids::FileId; + use bumpalo::Bump; + + /// Emit both sinks under `options` and return them owned (the arena drops here + /// — the candidate table and diagnostics borrow nothing from it). + #[allow(clippy::trivially_copy_pass_by_ref)] + fn emit_both(source: &str, opts: &CheckOptions) -> (Vec, Vec) { + let arena = Bump::new(); + let program = tsv_ts::parse(source, &arena).expect("parse"); + let bound = bind_file(&program, source, FileId::ROOT); + let flow = build_flow(&program, source, &bound); + let cands = build_candidates(&program, source, &bound, &flow); + let mut diags = Vec::new(); + let mut sugg = Vec::new(); + cands.emit(FileId::ROOT, opts, &mut diags, &mut sugg); + (diags, sugg) + } + + /// Emit TS7027/7028 as `(code, start, end)` (both sinks merged). + fn emit_codes( + source: &str, + allow_unreachable: Tristate, + allow_unused: Tristate, + preserve: bool, + ) -> Vec<(u32, u32, u32)> { + let opts = CheckOptions { + allow_unreachable_code: allow_unreachable, + allow_unused_labels: allow_unused, + preserve_const_enums: preserve, + }; + let (diags, sugg) = emit_both(source, &opts); + diags + .iter() + .chain(sugg.iter()) + .map(|d| (d.code, d.span.start, d.span.end)) + .collect() + } + + #[test] + fn contiguous_enum_run_merges_or_splits_by_preserve() { + // `enum A` then `const enum B` after a return: one merged run when + // preserving const enums, split (only A) otherwise. + let src = "function f() { return; enum A { X } const enum B { Y } }"; + let merged = emit_codes(src, Tristate::False, Tristate::Unknown, true); + assert_eq!(merged.len(), 1, "preserve merges the two into one run"); + let split = emit_codes(src, Tristate::False, Tristate::Unknown, false); + assert_eq!(split.len(), 1, "no-preserve keeps only the non-const enum"); + // The merged span is strictly wider (absorbs `const enum B`). + assert!(merged[0].2 > split[0].2); + } + + #[test] + fn lone_const_enum_reports_only_when_preserving() { + let src = "function f() { return; const enum B { Y } }"; + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, false).len(), + 0 + ); + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, true).len(), + 1 + ); + } + + #[test] + fn non_instantiated_module_never_reports() { + // A dead namespace containing only a type is non-instantiated → no TS7027, + // even preserving const enums. + let src = "function f() { return; namespace N { interface I {} } }"; + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, false).len(), + 0 + ); + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, true).len(), + 0 + ); + // A namespace with a value export is instantiated → reports. + let src2 = "function f() { return; namespace N { export const v = 1; } }"; + assert_eq!( + emit_codes(src2, Tristate::False, Tristate::Unknown, false).len(), + 1 + ); + } + + #[test] + fn midrun_const_enum_splits_run_into_two() { + // `[plain, const enum, plain]` after a return: at preserve=false the const + // enum fails the filter and SPLITS the run into two separate TS7027 (the + // mid-run re-split path — a filtered gap in the middle of a run); + // preserve=true merges all three into one. + let src = "function f() { return; a; const enum B { Y } c; }"; + let split = emit_codes(src, Tristate::False, Tristate::Unknown, false); + assert_eq!( + split.len(), + 2, + "the const enum splits the run into two TS7027" + ); + assert!(split.iter().all(|d| d.0 == 7027)); + let merged = emit_codes(src, Tristate::False, Tristate::Unknown, true); + assert_eq!(merged.len(), 1, "preserve merges all three into one run"); + } + + #[test] + fn bare_export_alias_target_is_non_instantiated() { + // Regression (F3 review): a dead namespace whose only members are a type + // and a bare `export { … }` re-export must NOT over-report. tsv can't + // resolve the alias, so it defaults to NonInstantiated — assuming + // Instantiated would over-report a type-only re-export as a false TS7027 + // extra (the dangerous direction). + let src = "function f() { return; namespace N { interface I {} export { I }; } }"; + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, false).len(), + 0 + ); + assert_eq!( + emit_codes(src, Tristate::False, Tristate::Unknown, true).len(), + 0 + ); + } + + #[test] + fn unused_label_span_is_the_identifier() { + let src = "loop: while (true) { break; }"; + let out = emit_codes(src, Tristate::Unknown, Tristate::False, false); + assert_eq!(out.len(), 1); + assert_eq!(out[0].0, 7028); + // Span covers `loop` (4 bytes at offset 0). + assert_eq!((out[0].1, out[0].2), (0, 4)); + } + + #[test] + fn suggestion_vs_error_routing() { + let src = "function f() { return; foo(); }"; + // Unknown → suggestion sink; False → error sink; True → neither. + for (opt, in_err, in_sugg) in [ + (Tristate::Unknown, 0, 1), + (Tristate::False, 1, 0), + (Tristate::True, 0, 0), + ] { + let opts = CheckOptions { + allow_unreachable_code: opt, + allow_unused_labels: Tristate::Unknown, + preserve_const_enums: false, + }; + let (diags, sugg) = emit_both(src, &opts); + assert_eq!(diags.len(), in_err, "error sink for {opt:?}"); + assert_eq!(sugg.len(), in_sugg, "suggestion sink for {opt:?}"); + } + } + + #[test] + fn ts_ignore_directive_suppresses_unreachable() { + // `@ts-ignore` / `@ts-expect-error` on the preceding line drops the TS7027 + // entirely (neither sink) — tsc's comment-directive suppression. + let ignored = + "function a() {\n\tthrow new Error('');\n\t// @ts-ignore\n\tconsole.log('x');\n}"; + assert_eq!( + emit_codes(ignored, Tristate::False, Tristate::Unknown, false).len(), + 0 + ); + let expect = + "function a() {\n\tthrow new Error('');\n\t// @ts-expect-error\n\tconsole.log('x');\n}"; + assert_eq!( + emit_codes(expect, Tristate::False, Tristate::Unknown, false).len(), + 0 + ); + // A non-directive comment on the preceding line does NOT suppress. + let plain = + "function a() {\n\tthrow new Error('');\n\t// just a note\n\tconsole.log('x');\n}"; + assert_eq!( + emit_codes(plain, Tristate::False, Tristate::Unknown, false).len(), + 1 + ); + } + + #[test] + fn module_instance_state_classification() { + // Interface/type only → non-instantiated; const enum only → const-enum-only; + // a value → instantiated. + let arena = Bump::new(); + let parse = |s: &str| tsv_ts::parse(s, &arena).expect("parse"); + let state_of = |src: &str| -> ModuleInstanceState { + let program = parse(src); + match &program.body[0] { + Statement::TSModuleDeclaration(m) => module_instance_state(m), + _ => panic!("expected a module"), + } + }; + assert_eq!( + state_of("namespace N { interface I {} type T = number; }"), + ModuleInstanceState::NonInstantiated + ); + assert_eq!( + state_of("namespace N { const enum E { A } }"), + ModuleInstanceState::ConstEnumOnly + ); + assert_eq!( + state_of("namespace N { export function f() {} }"), + ModuleInstanceState::Instantiated + ); + } +} diff --git a/crates/tsv_check/src/diag.rs b/crates/tsv_check/src/diag.rs new file mode 100644 index 000000000..de8e0987d --- /dev/null +++ b/crates/tsv_check/src/diag.rs @@ -0,0 +1,504 @@ +//! The checker's `Diagnostic` representation and the sort/dedup kernel. +//! +//! `Diagnostic` is the shape a future checker emits; at this slice the checker +//! emits none, so the value here is the **pure kernel** ports — the canonical +//! sort ([`compare_diagnostics`], tsgo's `CompareDiagnostics`) and the +//! dedup-with-related-info-merge ([`sort_and_deduplicate`], tsgo's +//! `compactAndMergeRelatedInfos`) — unit-tested against every comparator leg. +//! The canonical *sorted* order is a must-match property (baseline order is +//! canonical regardless of production order), so these are semantic-core. +//! +//! A message chain and each related-info entry are modelled as nested +//! `Diagnostic`s exactly as tsgo models them (`messageChain []*Diagnostic`, +//! related information `[]*Diagnostic`) — the comparator reads only the fields +//! each leg touches (chain nodes: code/args/chain; related nodes: the full +//! diagnostic recursively). Message text is kept as an owned string here (the +//! template-catalog codegen is a later phase); the comparator keys on `args`, +//! never the rendered text, so this simplification is faithful. +// +// tsgo: internal/ast/diagnostic.go CompareDiagnostics (:329), +// EqualDiagnosticsNoRelatedInfo (:265), equalMessageChain, +// compareMessageChainSize/Content, compareRelatedInfo +// tsgo: internal/compiler/program.go SortAndDeduplicateDiagnostics (:1436), +// compactAndMergeRelatedInfos (:1444) + +use crate::ids::FileId; +use std::cmp::Ordering; +use tsv_lang::Span; + +/// The diagnostic category (tsc's `DiagnosticCategory`). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Category { + /// A warning (tsc emits these rarely). + Warning, + /// An error — the conformance-visible category. + Error, + /// A suggestion (opt-in, `@captureSuggestions`). + Suggestion, + /// An informational message. + Message, +} + +/// One diagnostic — plus, when it is a message-chain or related-info node, its +/// nested diagnostics. +/// +/// The comparator/dedup read a node differently by role: +/// - as a top-level diagnostic: path, span, code, args, chain, related info; +/// - as a **chain** node: only code, args, and its own chain; +/// - as a **related-info** node: the full diagnostic, recursively. +#[derive(Clone, Debug)] +pub struct Diagnostic { + /// The file this diagnostic points at; `None` for a global (fileless) one, + /// which sorts first (its path resolves to the empty string). + pub file: Option, + /// The source range — `Pos` (start) and `End` in the tsgo comparer. + pub span: Span, + /// The tsc numeric code (the checker emits positive TS codes). + pub code: u32, + /// The diagnostic category. + pub category: Category, + /// The rendered head message (display only — the comparer keys on `args`). + pub message: String, + /// Message-substitution arguments — tsgo's `MessageArgs` compared leg. + pub args: Vec, + /// The elaboration chain, each node a nested `Diagnostic`. + pub chain: Vec, + /// Related information, each entry a full nested `Diagnostic`. + pub related: Vec, +} + +impl Diagnostic { + /// Build a bare error diagnostic (no chain, no related info) — the shape + /// the first family checks will emit. + #[must_use] + pub fn error(file: FileId, span: Span, code: u32, message: impl Into) -> Diagnostic { + Diagnostic { + file: Some(file), + span, + code, + category: Category::Error, + message: message.into(), + args: Vec::new(), + chain: Vec::new(), + related: Vec::new(), + } + } + + /// Build a bare suggestion diagnostic — the same shape as [`Diagnostic::error`] + /// but [`Category::Suggestion`]. Suggestions are routed to a separate sink the + /// conformance gate's error channel never inspects (the default reachability + /// diagnostic when its option is unset). + #[must_use] + pub fn suggestion( + file: FileId, + span: Span, + code: u32, + message: impl Into, + ) -> Diagnostic { + Diagnostic { + file: Some(file), + span, + code, + category: Category::Suggestion, + message: message.into(), + args: Vec::new(), + chain: Vec::new(), + related: Vec::new(), + } + } +} + +/// The diagnostic's sort path: the file's name, or `""` for a global one. +fn diag_path<'a>(d: &Diagnostic, paths: &[&'a str]) -> &'a str { + match d.file { + Some(f) => paths.get(f.index()).copied().unwrap_or(""), + None => "", + } +} + +/// Compare two diagnostics by tsgo's `CompareDiagnostics` total order: +/// path -> start -> end -> code -> args -> chain size -> chain content -> +/// related info. `paths` maps `FileId` -> file name. +/// +/// `args` compares as `slices.Compare` (element-wise then length) — Rust's +/// `Vec` ordering is exactly that. +// tsgo: internal/ast/diagnostic.go CompareDiagnostics (:329) +#[must_use] +pub fn compare_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> Ordering { + diag_path(a, paths) + .cmp(diag_path(b, paths)) + .then_with(|| a.span.start.cmp(&b.span.start)) + .then_with(|| a.span.end.cmp(&b.span.end)) + .then_with(|| a.code.cmp(&b.code)) + .then_with(|| a.args.cmp(&b.args)) + .then_with(|| compare_chain_size(&a.chain, &b.chain)) + .then_with(|| compare_chain_content(&a.chain, &b.chain)) + .then_with(|| compare_related_info(&a.related, &b.related, paths)) +} + +/// Compare message-chain size: **more elaboration sorts first**, then recurse +/// pairwise (tsgo `compareMessageChainSize`, `len(c2) - len(c1)`). +fn compare_chain_size(a: &[Diagnostic], b: &[Diagnostic]) -> Ordering { + b.len().cmp(&a.len()).then_with(|| { + for (ca, cb) in a.iter().zip(b) { + let c = compare_chain_size(&ca.chain, &cb.chain); + if c != Ordering::Equal { + return c; + } + } + Ordering::Equal + }) +} + +/// Compare message-chain content: per element, compare `args`, then recurse +/// (tsgo `compareMessageChainContent`). Sizes are already equal when this runs. +fn compare_chain_content(a: &[Diagnostic], b: &[Diagnostic]) -> Ordering { + for (ca, cb) in a.iter().zip(b) { + let c = ca.args.cmp(&cb.args); + if c != Ordering::Equal { + return c; + } + let c = compare_chain_content(&ca.chain, &cb.chain); + if c != Ordering::Equal { + return c; + } + } + Ordering::Equal +} + +/// Compare related-info lists: **more related info sorts first**, then compare +/// each entry as a full diagnostic (tsgo `compareRelatedInfo`). +fn compare_related_info(a: &[Diagnostic], b: &[Diagnostic], paths: &[&str]) -> Ordering { + b.len().cmp(&a.len()).then_with(|| { + for (ra, rb) in a.iter().zip(b) { + let c = compare_diagnostics(ra, rb, paths); + if c != Ordering::Equal { + return c; + } + } + Ordering::Equal + }) +} + +/// Equality excluding related information (tsgo `EqualDiagnosticsNoRelatedInfo`): +/// path, loc (start+end), code, args, and the full message chain. +#[must_use] +pub fn equal_no_related_info(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> bool { + diag_path(a, paths) == diag_path(b, paths) + && a.span == b.span + && a.code == b.code + && a.args == b.args + && equal_chain(&a.chain, &b.chain) +} + +/// Message-chain equality (tsgo `equalMessageChain`): code, args, chain — +/// **not** path or loc. +fn equal_chain(a: &[Diagnostic], b: &[Diagnostic]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x.code == y.code && x.args == y.args && equal_chain(&x.chain, &y.chain)) +} + +/// Full diagnostic equality (tsgo `EqualDiagnostics`): equal-no-related-info and +/// related info equal recursively. +#[must_use] +pub fn equal_diagnostics(a: &Diagnostic, b: &Diagnostic, paths: &[&str]) -> bool { + equal_no_related_info(a, b, paths) + && a.related.len() == b.related.len() + && a.related + .iter() + .zip(&b.related) + .all(|(x, y)| equal_diagnostics(x, y, paths)) +} + +/// Sort `diags` into canonical order and merge duplicates, faithful to tsgo's +/// `SortAndDeduplicateDiagnostics` -> `compactAndMergeRelatedInfos`: a run of +/// diagnostics equal except for related information collapses to the first, with +/// their related infos concatenated, sorted, and deduped. +// tsgo: internal/compiler/program.go SortAndDeduplicateDiagnostics (:1436) +pub fn sort_and_deduplicate(diags: &mut Vec, paths: &[&str]) { + diags.sort_by(|a, b| compare_diagnostics(a, b, paths)); + compact_and_merge_related_infos(diags, paths); +} + +/// Collapse runs of `equal_no_related_info` diagnostics, merging related info +/// (tsgo `compactAndMergeRelatedInfos`). Keeps the first of each run. +// The inner `while let` can't become a `for`: the iterator is shared with the +// outer run loop (a non-equal candidate becomes the next run's head), so it must +// outlive the inner loop. +#[allow(clippy::while_let_on_iterator)] +fn compact_and_merge_related_infos(diags: &mut Vec, paths: &[&str]) { + if diags.len() < 2 { + return; + } + let mut out: Vec = Vec::with_capacity(diags.len()); + let mut iter = std::mem::take(diags).into_iter(); + let mut current = iter.next(); + while let Some(mut head) = current.take() { + // Related infos across the whole run (the head's included, per tsgo). + let mut run_related: Vec = std::mem::take(&mut head.related); + let mut had_dupes = false; + // `current` stays `None` if the iterator empties — the run ends the list. + while let Some(candidate) = iter.next() { + if equal_no_related_info(&head, &candidate, paths) { + had_dupes = true; + run_related.extend(candidate.related); + } else { + current = Some(candidate); + break; + } + } + if had_dupes { + // tsgo sets merged related only when the run produced any; an + // all-empty run leaves the head's (empty) related untouched. + if !run_related.is_empty() { + run_related.sort_by(|a, b| compare_diagnostics(a, b, paths)); + dedup_by_equal(&mut run_related, paths); + head.related = run_related; + } + } else { + // A singleton run keeps its related info verbatim. + head.related = run_related; + } + out.push(head); + } + *diags = out; +} + +/// Remove adjacent `equal_diagnostics` duplicates, keeping the first (tsgo +/// `slices.CompactFunc(_, EqualDiagnostics)` over the sorted related list). +fn dedup_by_equal(diags: &mut Vec, paths: &[&str]) { + diags.dedup_by(|a, b| equal_diagnostics(a, b, paths)); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths() -> Vec<&'static str> { + vec!["a.ts", "b.ts"] + } + + fn diag(file: Option, start: u32, end: u32, code: u32) -> Diagnostic { + Diagnostic { + file: file.map(FileId), + span: Span::new(start, end), + code, + category: Category::Error, + message: String::new(), + args: Vec::new(), + chain: Vec::new(), + related: Vec::new(), + } + } + + fn with_args(mut d: Diagnostic, args: &[&str]) -> Diagnostic { + d.args = args.iter().map(|s| (*s).to_string()).collect(); + d + } + + fn with_chain(mut d: Diagnostic, chain: Vec) -> Diagnostic { + d.chain = chain; + d + } + + fn with_related(mut d: Diagnostic, related: Vec) -> Diagnostic { + d.related = related; + d + } + + /// Assert `a` sorts strictly before `b` under the two-file `paths()`. + fn assert_lt(a: &Diagnostic, b: &Diagnostic) { + assert_eq!(compare_diagnostics(a, b, &paths()), Ordering::Less); + assert_eq!(compare_diagnostics(b, a, &paths()), Ordering::Greater); + } + + #[test] + fn path_leg() { + // b.ts (FileId 1) sorts after a.ts (FileId 0); a global (None) first. + assert_lt(&diag(Some(0), 0, 0, 1), &diag(Some(1), 0, 0, 1)); + assert_lt(&diag(None, 5, 5, 1), &diag(Some(0), 0, 0, 1)); + } + + #[test] + fn start_then_end_then_code_legs() { + assert_lt(&diag(Some(0), 1, 9, 1), &diag(Some(0), 2, 3, 1)); // start + assert_lt(&diag(Some(0), 2, 3, 1), &diag(Some(0), 2, 5, 1)); // same start, end tiebreak + assert_lt(&diag(Some(0), 2, 3, 1), &diag(Some(0), 2, 3, 9)); // same span, code tiebreak + } + + #[test] + fn args_leg_is_slices_compare() { + let p = paths(); + let a = with_args(diag(Some(0), 0, 0, 1), &["x"]); + let b = with_args(diag(Some(0), 0, 0, 1), &["y"]); + assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less); + // shorter prefix sorts first + let short = with_args(diag(Some(0), 0, 0, 1), &["x"]); + let long = with_args(diag(Some(0), 0, 0, 1), &["x", "y"]); + assert_eq!(compare_diagnostics(&short, &long, &p), Ordering::Less); + } + + #[test] + fn chain_size_leg_more_elaboration_first() { + let p = paths(); + let child = diag(Some(0), 0, 0, 2); + let more = with_chain(diag(Some(0), 0, 0, 1), vec![child]); + let less = diag(Some(0), 0, 0, 1); + // more elaboration (non-empty chain) sorts first + assert_eq!(compare_diagnostics(&more, &less, &p), Ordering::Less); + assert_eq!(compare_diagnostics(&less, &more, &p), Ordering::Greater); + } + + #[test] + fn chain_content_leg() { + let p = paths(); + let a = with_chain( + diag(Some(0), 0, 0, 1), + vec![with_args(diag(Some(0), 0, 0, 2), &["a"])], + ); + let b = with_chain( + diag(Some(0), 0, 0, 1), + vec![with_args(diag(Some(0), 0, 0, 2), &["b"])], + ); + // same size, chain content (args) breaks the tie + assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less); + } + + #[test] + fn related_info_leg_count_then_recursive() { + let p = paths(); + let r = diag(Some(0), 3, 4, 9); + let more = with_related(diag(Some(0), 0, 0, 1), vec![r]); + let less = diag(Some(0), 0, 0, 1); + // more related info sorts first + assert_eq!(compare_diagnostics(&more, &less, &p), Ordering::Less); + // same count, recursive compare of the related entries + let a = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 1, 2, 9)]); + let b = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 5, 6, 9)]); + assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Less); + } + + #[test] + fn equal_ignores_related_but_reads_chain() { + let p = paths(); + let base = diag(Some(0), 0, 0, 1); + let with_r = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 9, 9, 2)]); + assert!(equal_no_related_info(&base, &with_r, &p)); + assert!(!equal_diagnostics(&base, &with_r, &p)); + // a differing chain breaks even no-related equality + let with_c = with_chain(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 0, 0, 2)]); + assert!(!equal_no_related_info(&base, &with_c, &p)); + } + + #[test] + fn dedup_collapses_identical() { + let p = paths(); + let mut v = vec![ + diag(Some(0), 0, 0, 1), + diag(Some(0), 0, 0, 1), + diag(Some(0), 1, 1, 2), + ]; + sort_and_deduplicate(&mut v, &p); + assert_eq!(v.len(), 2); + assert_eq!(v[0].code, 1); + assert_eq!(v[1].code, 2); + } + + #[test] + fn dedup_merges_related_info_sorted_and_deduped() { + let p = paths(); + // Two diagnostics equal-except-related-info; their related infos merge, + // sort, and dedup into the survivor. + let d1 = with_related(diag(Some(0), 0, 0, 1), vec![diag(Some(0), 5, 6, 9)]); + let d2 = with_related( + diag(Some(0), 0, 0, 1), + vec![diag(Some(0), 1, 2, 9), diag(Some(0), 5, 6, 9)], + ); + let mut v = vec![d1, d2]; + sort_and_deduplicate(&mut v, &p); + assert_eq!(v.len(), 1); + // (1,2) and (5,6) survive once each, sorted by position. + assert_eq!(v[0].related.len(), 2); + assert_eq!(v[0].related[0].span, Span::new(1, 2)); + assert_eq!(v[0].related[1].span, Span::new(5, 6)); + } + + #[test] + fn equality_and_order_ignore_message_text() { + // Message text is display-only — the comparer keys on `args`, never the + // rendered string (the template-catalog codegen is a later phase). + let p = paths(); + let mut a = with_args(diag(Some(0), 0, 0, 2300), &["x"]); + a.message = "Duplicate identifier 'x'.".to_string(); + let mut b = with_args(diag(Some(0), 0, 0, 2300), &["x"]); + b.message = "a completely different rendering".to_string(); + assert!(equal_no_related_info(&a, &b, &p)); + assert!(equal_diagnostics(&a, &b, &p)); + assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Equal); + } + + #[test] + fn depth_2_chain_recursion() { + // Two-level chains: outer -> mid -> leaf. Equal chains compare Equal; + // a differing leaf arg two levels down orders by that arg. + let p = paths(); + let mid = with_chain( + diag(Some(0), 0, 0, 2), + vec![with_args(diag(Some(0), 0, 0, 3), &["x"])], + ); + let a = with_chain(diag(Some(0), 0, 0, 1), vec![mid.clone()]); + let b = with_chain(diag(Some(0), 0, 0, 1), vec![mid]); + assert!(equal_no_related_info(&a, &b, &p)); + assert_eq!(compare_diagnostics(&a, &b, &p), Ordering::Equal); + + let mid_y = with_chain( + diag(Some(0), 0, 0, 2), + vec![with_args(diag(Some(0), 0, 0, 3), &["y"])], + ); + let c = with_chain(diag(Some(0), 0, 0, 1), vec![mid_y]); + assert!(!equal_no_related_info(&a, &c, &p)); + // Same chain sizes; the depth-2 content ("x" < "y") breaks the tie. + assert_eq!(compare_diagnostics(&a, &c, &p), Ordering::Less); + } + + #[test] + fn depth_2_related_recursion() { + // Related info compares recursively as full diagnostics — including a + // related entry's own nested related. + let p = paths(); + let outer_r = with_related(diag(Some(0), 3, 4, 5), vec![diag(Some(0), 7, 8, 9)]); + let a = with_related(diag(Some(0), 0, 0, 1), vec![outer_r.clone()]); + let b = with_related(diag(Some(0), 0, 0, 1), vec![outer_r]); + assert!(equal_diagnostics(&a, &b, &p)); + + // A differing nested related span breaks full equality — but related info + // is excluded from `equal_no_related_info`, so that stays true. + let outer_r2 = with_related(diag(Some(0), 3, 4, 5), vec![diag(Some(0), 100, 101, 9)]); + let c = with_related(diag(Some(0), 0, 0, 1), vec![outer_r2]); + assert!(!equal_diagnostics(&a, &c, &p)); + assert!(equal_no_related_info(&a, &c, &p)); + } + + #[test] + fn sort_is_stable_total_order() { + let p = paths(); + let mut v = vec![ + diag(Some(1), 0, 0, 1), + diag(Some(0), 4, 5, 1), + diag(None, 0, 0, 7), + diag(Some(0), 4, 5, 1), + diag(Some(0), 1, 2, 1), + ]; + sort_and_deduplicate(&mut v, &p); + // global first, then a.ts by position, then b.ts; the duplicate collapsed. + assert_eq!(v.len(), 4); + assert_eq!(v[0].file, None); + assert_eq!(v[1].file, Some(FileId(0))); + assert_eq!(v[1].span, Span::new(1, 2)); + assert_eq!(v[2].file, Some(FileId(0))); + assert_eq!(v[2].span, Span::new(4, 5)); + assert_eq!(v[3].file, Some(FileId(1))); + } +} diff --git a/crates/tsv_check/src/ids.rs b/crates/tsv_check/src/ids.rs new file mode 100644 index 000000000..4d0c23146 --- /dev/null +++ b/crates/tsv_check/src/ids.rs @@ -0,0 +1,174 @@ +//! Dense integer identities for the checker's side tables. +//! +//! `NodeId` is a program-dense pre-order index over the AST nodes the binder +//! addresses; `FileId` indexes the per-program file set. Both are `u32`-width +//! newtypes — the checker's struct-of-arrays columns are `Vec`s indexed by +//! these, so an id is a plain array offset. tsgo keys the same facts through +//! global integer ids into flat `nodeLinks`/`symbolLinks` arrays; the deviation +//! is that we assign the ids **eagerly** in the bind walk rather than lazily on +//! first touch (unobservable, and it makes every column dense from the start). +//! +//! Distinct newtypes make cross-index bugs uncompilable (tsgo uses raw +//! `uint32`s/pointers and relies on review) — a `NodeId` can never be used where +//! a `FileId` is expected. +// +// tsgo: internal/ast/ids.go (NodeId/SymbolId are global atomic counters) + +use std::num::NonZeroU32; + +/// A dense, pre-order node identity assigned by the binder walk. +/// +/// Ids start at 1 so `Option` niche-packs into 4 bytes — a `None` parent +/// for the root costs no discriminant, the sentinel idiom without a magic +/// `u32::MAX`. Convert to a 0-based column index with [`NodeId::index`]. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct NodeId(NonZeroU32); + +impl NodeId { + /// The first node id assigned in a pre-order walk (the program root). + pub const FIRST: NodeId = NodeId(NonZeroU32::MIN); + + /// Build a `NodeId` from a 0-based dense index (`index + 1`). + /// + /// Total by construction: real ASTs never approach `u32::MAX` nodes, but a + /// wrap is clamped to [`NodeId::FIRST`] rather than panicking — the crate's + /// `unwrap_used` / `panic` clippy lints are warn-level, so this stays + /// panic-free by construction rather than by an `#[allow]` at a fallible call. + #[inline] + #[must_use] + pub fn from_index(index: usize) -> NodeId { + let raw = (index as u32).wrapping_add(1); + match NonZeroU32::new(raw) { + Some(n) => NodeId(n), + None => NodeId::FIRST, + } + } + + /// The 0-based column index this id addresses (`id - 1`). + #[inline] + #[must_use] + pub const fn index(self) -> usize { + (self.0.get() - 1) as usize + } + + /// The raw 1-based id value. + #[inline] + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } + + /// A `NodeId` from a raw 1-based value, or `None` for 0 (the "no node" + /// sentinel the flow graph's `subject` column uses). + #[inline] + #[must_use] + pub fn from_raw_opt(raw: u32) -> Option { + NonZeroU32::new(raw).map(NodeId) + } +} + +/// A dense, per-file flow-node identity assigned by the flow-graph walk +/// ([`crate::binder`]'s third walk). +/// +/// Ids start at 1 (`Option` niche-packs into 4 bytes — the same +/// idiom as [`NodeId`], the sentinel without a magic `u32::MAX`). The per-file +/// `unreachableFlow` singleton is minted first, so it is **id 1 by +/// construction** — tsgo's pointer-identity unreachable test becomes id +/// equality against [`FlowNodeId::UNREACHABLE`]. +// +// tsgo: internal/ast/ids.go / internal/binder/binder.go (flowNodeArena — a +// per-file bump arena; ours is a per-file dense id space) +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct FlowNodeId(NonZeroU32); + +impl FlowNodeId { + /// The per-file `unreachableFlow` singleton — minted first, so id 1 + /// (binder.go:126). Pointer identity in tsgo becomes id equality here. + pub const UNREACHABLE: FlowNodeId = FlowNodeId(NonZeroU32::MIN); + + /// Build a `FlowNodeId` from a 0-based dense index (`index + 1`). + /// + /// Total by construction (a wrap clamps to [`FlowNodeId::UNREACHABLE`] + /// rather than panicking — the crate's `panic`/`unwrap_used` lints are + /// warn-level, so this stays panic-free without an `#[allow]`). + #[inline] + #[must_use] + pub fn from_index(index: usize) -> FlowNodeId { + let raw = (index as u32).wrapping_add(1); + match NonZeroU32::new(raw) { + Some(n) => FlowNodeId(n), + None => FlowNodeId::UNREACHABLE, + } + } + + /// Build a `FlowNodeId` from a raw 1-based value, or `None` for 0 (the + /// "no antecedent" / "no subject" sentinel in the SoA columns). + #[inline] + #[must_use] + pub fn from_raw(raw: u32) -> Option { + NonZeroU32::new(raw).map(FlowNodeId) + } + + /// The 0-based column index this id addresses (`id - 1`). + #[inline] + #[must_use] + pub const fn index(self) -> usize { + (self.0.get() - 1) as usize + } + + /// The raw 1-based id value. + #[inline] + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} + +/// A dense per-program file identity (0-based). Single-file callers use +/// [`FileId::ROOT`]. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)] +pub struct FileId(pub u32); + +impl FileId { + /// The first file in a program (the single unit of a single-file test). + pub const ROOT: FileId = FileId(0); + + /// The 0-based column index this id addresses. + #[inline] + #[must_use] + pub const fn index(self) -> usize { + self.0 as usize + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_id_index_round_trips() { + for i in [0usize, 1, 2, 41, 1000, 100_000] { + let id = NodeId::from_index(i); + assert_eq!(id.index(), i); + assert_eq!(id.get(), i as u32 + 1); + } + } + + #[test] + fn first_id_is_one() { + assert_eq!(NodeId::FIRST.get(), 1); + assert_eq!(NodeId::from_index(0), NodeId::FIRST); + } + + #[test] + fn option_node_id_is_four_bytes() { + // The niche is the whole point of starting ids at 1. + assert_eq!(size_of::>(), 4); + } + + #[test] + fn file_id_root_and_index() { + assert_eq!(FileId::ROOT, FileId(0)); + assert_eq!(FileId(3).index(), 3); + } +} diff --git a/crates/tsv_check/src/lib.rs b/crates/tsv_check/src/lib.rs new file mode 100644 index 000000000..ef06530e0 --- /dev/null +++ b/crates/tsv_check/src/lib.rs @@ -0,0 +1,83 @@ +//! `tsv_check` — experimental TypeScript type-checking for tsv. +//! +//! The checker consumes the `tsv_ts` internal AST and produces TypeScript +//! diagnostics. It is a **consumer crate** of `tsv_ts`'s concrete types (the +//! `tsv_svelte` precedent) — no `Language` trait, no registry, no dyn dispatch. +//! +//! ## Zero-cost invariant +//! +//! `tsv_check` is referenced only by the dev harness (`tsv_debug`); no +//! `tsv_ffi`/`tsv_wasm`/`tsv_napi`/`tsv_cli` format-or-parse artifact links it. +//! That exclusion is a crate boundary, not a cfg — stronger than a feature gate. +//! +//! ## Pipeline +//! +//! ```text +//! source units (+ arena) +//! -> parse (goal rule: Module first, Script retry) [program] +//! -> lower+bind: SoA node-identity walk + symbol bind [binder/mod,sym] +//! -> flow: per-file control-flow graph (third walk) [binder/flow] +//! -> merge (single-threaded global-scope fold) [merge] +//! -> check: syntactic per-node checks + the TS7027/7028 +//! unreachable/unused-label flow shim [check] +//! -> sort + dedup (tsgo's comparer) [diag] +//! -> owned diagnostics (+ a separate suggestion sink) +//! ``` +//! +//! [`check_program`] is the single entry point. The caller owns the parse arena +//! (`&bumpalo::Bump`, the tsv_ts caller-owns-arena contract) and the returned +//! [`CheckResult`] borrows nothing from it. +//! +//! ## Modules +//! +//! - [`ids`] — `NodeId` / `FileId` dense-integer identities. +//! - [`diag`] — the `Diagnostic` shape and the canonical sort/dedup kernel. +//! - `span_scan` (private) — the bracket-inclusive computed-key scan the binder +//! and check pass share, so their spans agree by construction. +//! - `binder` (private) — the two cooperating walks (pre-order SoA lowering + +//! functions-first symbol bind) plus the third flow-construction walk. +//! - `check` (private) — the syntactic per-node check pass ([`check_file_members`]: +//! duplicate member declarations today; a general walk for future per-node +//! checks) plus `unreachable` — the TS7027/TS7028 flow shim over the binder's +//! flow product. +//! - [`merge`] — the single-threaded global-scope fold (cross-declaration-space +//! conflicts, `globalThis`/`undefined`, module augmentations). +//! - `program` (private) — pipeline assembly: the unconditional per-unit +//! bind+check concat (the baseline-oracle parity path — a parse-rejected unit +//! contributes nothing, but never suppresses its siblings; the product-mode +//! short-circuit is deliberately not modelled here). +//! +//! ## Reference-anchor convention +//! +//! Semantic-core functions carry a `// tsgo: ` pointer to their +//! typescript-go counterpart (the lexer's ECMA-262-citation convention applied +//! to the checker), so the port stays diffable against the oracle. + +mod binder; +mod check; +mod options; +mod program; +mod span_scan; + +pub mod diag; +pub mod ids; +pub mod merge; + +pub use binder::flow::{FlowFlags, FlowGraph, FlowProduct, FlowStats, build_flow, render_flow_dot}; +pub use binder::{ + BoundFile, FileFacts, ModuleNess, NODE_FLAGS_UNREACHABLE, NodeKind, bind_file, module_ness, +}; +pub use check::check_file_members; +pub use check::unreachable::ModuleInstanceState; +pub use diag::{Category, Diagnostic}; +pub use ids::{FileId, FlowNodeId, NodeId}; +pub use merge::{LibBase, LibFile}; +pub use options::{CheckOptions, Tristate}; +pub use program::{ + BoundProgram, CheckResult, FileReport, ParseReport, ParsedFacts, SourceUnit, bind_lib, + bind_program, check_bound, check_program, check_program_with_lib, +}; + +// Re-exported so consumers can name the parse goal a `ParsedFacts` reports +// without a separate `tsv_ts` import. +pub use tsv_ts::Goal; diff --git a/crates/tsv_check/src/merge.rs b/crates/tsv_check/src/merge.rs new file mode 100644 index 000000000..60a8d2432 --- /dev/null +++ b/crates/tsv_check/src/merge.rs @@ -0,0 +1,1288 @@ +//! The single-threaded global merge — tsgo's `initializeChecker` merge sequence, +//! ported for the merge-path family (TS2397 / TS2664 / TS2649 / TS2671 and the +//! cross-declaration-space TS2300 / TS2451 / TS2567). +//! +//! Each file's bind produces a program-independent [`FileMerge`] product; this +//! phase folds them into one global scope by tsgo's rules. The phase order is +//! lifted verbatim from `initializeChecker` (checker.go:1296), **not** rediscovered: +//! +//! 1. regular locals of each **script** (non-external) file merge into the globals +//! table (`mergeGlobalSymbol`), preceded by the per-file `globalThis` check; +//! 2. **global** (`declare global`) augmentations merge their exports into globals; +//! 3. the `undefined` redeclaration check (`addUndefinedToGlobalsOrErrorOnRedeclaration`); +//! 4. global **ambient-module** declarations (deferred — they may need global +//! symbols resolved; tsgo regression #2953) merge last among the globals; +//! 5. non-global **module augmentations** (`declare module "X"`) resolve + merge. +//! +//! Iteration is **deterministic** (file order, then declaration order) — never a +//! hash-map's iteration order (the grimoire-recorded tsgo determinism landmine). +//! +//! **Two-level globals (overlay + lib base).** A resolved lib set folds into an +//! immutable [`LibBase`] (built once per distinct set, shared across variants); +//! the merge maintains a per-program **overlay** it consults *before* the base +//! ([`merge_global_symbol`]). A script's `var eval` / `class Symbol` / +//! `class Promise`, or a `declare global` `interface ElementTagNameMap`, conflicts +//! with the lib global of that name via the same `*Excludes` masks — issuing the +//! observable test-file primary whose related info is the priority-ordered lib +//! declarations (leading TS6203). Test symbols never mutate the base; on a conflict +//! a base declaration's lib-local file id translates into program FileId space +//! (`lib_file_offset + index`), so the lib file appends after the test units in the +//! diagnostic path space and its masked primaries carry the lib file name. With no +//! base (`lib = None`) the globals table starts empty, so the cross-space path +//! ([`report_merge_symbol_error`]) is exercised only by a **multi-file** program. +//! +//! Module resolution is trivial single-file: an augmentation resolves iff an +//! ambient module of that name exists in the same file, which for an +//! external-module file is never — so a single-file augmentation is **always** "not +//! found" (TS2664). The resolves-to-a-non-module errors (TS2649 / TS2671) need a +//! multi-file resolution target and are structurally unreachable at single-file +//! scope; their machinery is noted at the site, not emitted. +//! +//! `mergeSymbol`'s member/export **recursion** (merging a `declare global` +//! interface *into* a lib interface's member table) is not modelled: the family +//! keys on the top-level symbol conflict, and a legal interface↔interface merge is +//! silent (no member table to reconcile for the duplicate-identifier verdict). +// +// tsgo: internal/checker/checker.go initializeChecker (:1296, the phase order), +// mergeGlobalSymbol (:1386), mergeModuleAugmentation (:1397), +// addUndefinedToGlobalsOrErrorOnRedeclaration (:1452), mergeSymbol (:14072), +// reportMergeSymbolError (:14127), addDuplicateDeclarationError (:14158), +// lookupOrIssueError (:14196), getExcludedSymbolFlags (:14213) + +use crate::binder::symbols::SymbolFlags; +use crate::diag::{Category, Diagnostic}; +use crate::ids::FileId; +use tsv_lang::{FxHashMap, Span}; + +/// tsgo's `InternalSymbolNameDefault`-style reserved global identifiers the merge +/// checks by name. +const NAME_GLOBAL_THIS: &str = "globalThis"; +const NAME_UNDEFINED: &str = "undefined"; + +/// The `Module` composite (tsgo `SymbolFlagsModule`): a namespace/ambient module. +const MODULE_FLAGS: SymbolFlags = + SymbolFlags(SymbolFlags::VALUE_MODULE.0 | SymbolFlags::NAMESPACE_MODULE.0); + +/// tsgo `ast.IsAmbientModuleSymbolName` — a quoted module name (`"X"`), the key of +/// a `declare module "X"` symbol. +fn is_ambient_module_symbol_name(name: &str) -> bool { + name.starts_with('"') && name.ends_with('"') +} + +/// One bound lib (`.d.ts`) file's global-eligible product — its owned +/// [`FileMerge`] plus the display name a cross-file conflict points a masked +/// diagnostic at. Arena-independent (owned strings), so the caller may drop the +/// parse arena the moment the bind returns and reuse this across every variant. +pub struct LibFile { + /// The lib file's display name (e.g. `lib.es5.d.ts`). + pub name: String, + /// The bound merge product (a lib is an ambient script — its globals live in + /// `source_locals`). + pub merge: FileMerge, +} + +/// The immutable folded global scope of one resolved lib set — built once per +/// distinct sorted lib list and shared across every variant that resolves to it +/// (a test's globals consult it, never mutate it). +/// +/// tsgo binds the lib files in **priority order** (`fileloader.go sortLibs`) before +/// the test file, so each global symbol's declaration list is priority-ordered — +/// which fixes the TS6203/6204 related-info attribution (highest-priority lib leads +/// with TS6203). This mirror folds the same way and seeds `globalThis` +/// (`VALUE_MODULE|NAMESPACE_MODULE`), matching `initializeChecker`'s pre-merge +/// seed. +pub struct LibBase { + /// The lib file names in priority order — the index is a **lib-local file id**; + /// a cross-file conflict translates it into program FileId space + /// (`lib_file_offset + index`). + pub lib_files: Vec, + /// The folded globals: symbol name -> its accumulated flags + priority-ordered + /// declarations. + globals: FxHashMap, +} + +/// One folded lib global symbol. +struct LibEntry { + flags: SymbolFlags, + /// Declarations in priority-then-source order (drives the TS6203/6204 order). + decls: Vec, +} + +/// One declaration of a [`LibEntry`], keyed by its lib-local file id. +struct LibDecl { + /// Index into [`LibBase::lib_files`] — the lib file this declaration lives in. + lib_file: u32, + error_span: Span, + is_type_decl: bool, +} + +impl LibBase { + /// Build a lib base by folding the lib files' globals in the given order + /// (which **must** be priority order, so the related-info attribution matches + /// tsgo). Lib globals of the same name accumulate (flags union, declarations + /// appended) — well-formed libs merge cleanly, so no conflict is detected here. + /// + /// # Panics + /// + /// Never — the fold only inserts into an owned table. + #[must_use] + pub fn build(libs: &[&LibFile]) -> LibBase { + let mut globals: FxHashMap = FxHashMap::default(); + // Seed `globalThis` (tsgo's `initializeChecker` seed) so a test's + // `var globalThis` hits the NamespaceModule guard rather than a stray merge. + globals.insert( + NAME_GLOBAL_THIS.to_string(), + LibEntry { + flags: MODULE_FLAGS, + decls: Vec::new(), + }, + ); + for (index, lib) in libs.iter().enumerate() { + let lib_file = index as u32; + // A lib is an ambient script: its globals are its source-file locals. + for sym in &lib.merge.source_locals { + fold_lib_symbol(&mut globals, sym, lib_file); + } + // A lib could theoretically carry a `declare global {}` block; fold its + // exports too (empty for the bundled libs, cheap to be safe). + for aug in &lib.merge.global_augmentations { + for sym in aug { + fold_lib_symbol(&mut globals, sym, lib_file); + } + } + } + LibBase { + lib_files: libs.iter().map(|l| l.name.clone()).collect(), + globals, + } + } + + /// Look up a global by name. + fn get(&self, name: &str) -> Option<&LibEntry> { + self.globals.get(name) + } +} + +/// Fold one lib symbol into the base globals (accumulate flags + priority-ordered +/// declarations). +fn fold_lib_symbol(globals: &mut FxHashMap, sym: &MergeSymbol, lib_file: u32) { + let entry = globals.entry(sym.name.clone()).or_insert_with(|| LibEntry { + flags: SymbolFlags::NONE, + decls: Vec::new(), + }); + entry.flags.insert(sym.flags); + for decl in &sym.decls { + entry.decls.push(LibDecl { + lib_file, + error_span: decl.error_span, + is_type_decl: decl.is_type_decl, + }); + } +} + +/// The merge-relevant product of binding one file — program-independent (a C15 +/// requirement), fully resolved to owned strings so cross-file names reconcile by +/// value with no shared interner. +#[derive(Clone)] +pub struct FileMerge { + /// The file these declarations belong to. + pub file: FileId, + /// Whether the file is an external module (its top-level members reach the + /// module's exports, **not** global scope — so `source_locals` is empty). + pub is_external: bool, + /// The source-file locals, in declaration order — the symbols a **script** + /// contributes to global scope (empty for an external module). + pub source_locals: Vec, + /// Each `declare global {}` augmentation's exports (its members merge into + /// globals), in source order. + pub global_augmentations: Vec>, + /// Non-global `declare module "X"` augmentations, in source order (deduped by + /// name in [`merge_program`], matching tsgo's first-declaration-only merge). + pub module_augmentations: Vec, +} + +/// One symbol exposed to the merge: its accumulated flags, resolved name, and its +/// declarations (each pointing a diagnostic at a name span). +#[derive(Clone)] +pub struct MergeSymbol { + /// The resolved symbol-table key (identifier text). + pub name: String, + /// The accumulated classification flags. + pub flags: SymbolFlags, + /// The declarations that formed this symbol, in declaration order. + pub decls: Vec, +} + +/// One declaration of a [`MergeSymbol`], carrying its owning file so a cross-file +/// conflict can point at declarations in either file. +#[derive(Clone)] +pub struct MergeDecl { + /// The file the declaration lives in. + pub file: FileId, + /// The span a diagnostic points at (the declaration name). + pub error_span: Span, + /// tsgo `IsTypeDeclaration` (class / interface / enum / type-alias / + /// type-parameter) — the `undefined` check skips these. + pub is_type_decl: bool, +} + +/// A non-global `declare module "X"` augmentation: the unquoted module name (the +/// `{0}` argument) and the string-literal span a TS2664 points at. +#[derive(Clone)] +pub struct ModuleAug { + /// The file the augmentation lives in. + pub file: FileId, + /// The unquoted module name (`"M"` → `M`). + pub name: String, + /// The string-literal span (points at the opening quote). + pub name_span: Span, +} + +/// A live global-scope symbol accumulated across files. +struct GlobalEntry { + name: String, + flags: SymbolFlags, + decls: Vec, +} + +/// The merge phase's diagnostic sink. +/// +/// tsgo's `lookupOrIssueError` keys its dedup on the **full** `CompareDiagnostics` +/// (related-info length is a sort key), so a primary that has already accreted +/// related info is never found again — every conflicting merge issues a *fresh* +/// primary carrying its own leading TS6203, and the caller's final +/// `compact_and_merge_related_infos` unions the related infos across the duplicate +/// primaries at each node (the one-primary-per-node, all-6203, uncapped result). +/// So the merge just pushes fresh primaries; there is no issued-index map here. +struct MergeOut { + diags: Vec, +} + +impl MergeOut { + fn new() -> MergeOut { + MergeOut { diags: Vec::new() } + } + + /// Push a diagnostic. + fn push(&mut self, diag: Diagnostic) { + self.diags.push(diag); + } +} + +/// Run the global merge across a program's per-file bind products, consulting an +/// optional [`LibBase`], returning the merge diagnostics (unsorted — the caller +/// concatenates and canonically sorts). +/// +/// The **overlay** (a program's own globals) is consulted before the immutable +/// **base** (the lib globals): a test symbol merges into a prior test symbol of the +/// same name, else conflicts-or-merges against the base, else is inserted fresh. +/// Test symbols never mutate the base, so the base is shared across variants. On a +/// base conflict, a base declaration's lib-local file id is translated into program +/// FileId space via `lib_file_offset` (= the number of program units), so the lib +/// file appends after the test units in the diagnostic path space. +#[must_use] +pub fn merge_program( + files: &[&FileMerge], + lib: Option<&LibBase>, + lib_file_offset: u32, +) -> Vec { + let mut out = MergeOut::new(); + let mut globals: FxHashMap = FxHashMap::default(); + + // Ambient-module-name Module symbols (`declare module "X"` in a script) are + // deferred from phase 1 to the post-global-type phase — they may need other + // global symbols/types resolved first (tsgo regression #2953). + let mut deferred_ambient: Vec<&MergeSymbol> = Vec::new(); + + // --- Phase 1: script locals + the globalThis check (file order) --- + for &file in files { + if file.is_external { + continue; + } + // The globalThis check runs over the file's own locals, before merging. + if let Some(sym) = file + .source_locals + .iter() + .find(|s| s.name == NAME_GLOBAL_THIS) + { + for decl in &sym.decls { + out.push(conflict_2397(decl, NAME_GLOBAL_THIS)); + } + } + for sym in &file.source_locals { + if sym.flags.intersects(MODULE_FLAGS) && is_ambient_module_symbol_name(&sym.name) { + deferred_ambient.push(sym); + } else { + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); + } + } + } + + // --- Phase 2: global (`declare global`) augmentations --- + for &file in files { + for aug in &file.global_augmentations { + for sym in aug { + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); + } + } + } + + // --- Phase 3: the `undefined` redeclaration check --- + // tsgo seeds `c.globals["undefined"]` with the builtin `undefinedSymbol`; with + // no lib base, `globals["undefined"]` is present iff a file declared it, so a + // present entry is exactly the redeclaration case. + if let Some(entry) = globals.get(NAME_UNDEFINED) { + for decl in &entry.decls { + if !decl.is_type_decl { + out.push(conflict_2397(decl, NAME_UNDEFINED)); + } + } + } + + // --- Phase 4: global ambient-module declarations (deferred) --- + // tsgo merges these past global-type creation (regression #2953). A script's + // `declare module "X"` merges into globals here; a conflict needs another + // globals symbol of the same quoted name (multi-file or lib), so at single-file + // scope it merges into empty globals with no diagnostic. + for sym in deferred_ambient { + merge_global_symbol(&mut globals, lib, lib_file_offset, sym, &mut out); + } + + // --- Phase 5: non-global module augmentations (`declare module "X"`) --- + for &file in files { + // Dedup by name within the file (tsgo merges only a symbol's first + // declaration; same-name augmentations share one symbol). + let mut seen: Vec<&str> = Vec::new(); + for aug in &file.module_augmentations { + if seen.contains(&aug.name.as_str()) { + continue; + } + seen.push(&aug.name); + merge_module_augmentation(aug, &mut out); + } + } + + out.diags +} + +/// tsgo `mergeGlobalSymbol` — merge one symbol into the globals table, reporting a +/// cross-declaration-space conflict when the flags exclude each other. The overlay +/// (a program's own accumulated globals) is consulted first, then the immutable +/// lib base. +fn merge_global_symbol( + globals: &mut FxHashMap, + lib: Option<&LibBase>, + lib_file_offset: u32, + source: &MergeSymbol, + out: &mut MergeOut, +) { + if let Some(target) = globals.get_mut(&source.name) { + merge_symbol(target, source, out); + return; + } + if let Some(base) = lib.and_then(|l| l.get(&source.name)) { + // Conflict-or-merge against the immutable base without mutating it. This does + // NOT copy the base entry into the overlay, so a *later* phase that re-presents + // this name — a phase-2 `declare global` export or a phase-4 deferred ambient + // module — again finds it absent from the overlay and re-merges against the + // bare base, dropping this phase-1 symbol's contributed flags. Because the + // merge is a monotonic flag-union, losing an accumulated flag can only FAIL to + // detect a conflict the union would have caught: it can only under-report (a + // `missing`), never over-report (a `family_extra`, the hard gate). So the + // clean invariant holds at the single-file scope this slice grades. The + // multi-file slice — where the same global name legitimately recurs across + // files — needs the real fix. + // TODO: copy-on-write the base entry into the overlay on the first base-merge, + // so a subsequent same-name symbol accumulates against the union, not the bare base. + merge_symbol_against_base(base, &source.name, lib_file_offset, source, out); + return; + } + globals.insert( + source.name.clone(), + GlobalEntry { + name: source.name.clone(), + flags: source.flags, + decls: source.decls.clone(), + }, + ); +} + +/// tsgo `mergeSymbol` with the target an immutable [`LibBase`] entry — the +/// conflict decision when a test symbol merges into a lib global. On a clean merge +/// nothing is emitted (and the base is not mutated); on a conflict the base +/// declarations are translated into program FileId space. +fn merge_symbol_against_base( + base: &LibEntry, + name: &str, + lib_file_offset: u32, + source: &MergeSymbol, + out: &mut MergeOut, +) { + if !base.flags.intersects(excluded_symbol_flags(source.flags)) { + // No conflict: the test symbol legally augments/merges the lib global. + return; + } + if base.flags.intersects(SymbolFlags::NAMESPACE_MODULE) { + // A value merging into a non-instantiated namespace (TS2649) — but never when + // the base is the seeded `globalThis` (the phase-1 TS2397 already reports that, + // and a second TS2649 would not make sense; tsgo's `target != globalThisSymbol` + // guard). `name` is the merge target's name (the base entry is looked up by it), + // so this name check is the observable equivalent of tsgo's identity guard. + if name != NAME_GLOBAL_THIS + && let Some(decl) = source.decls.first() + { + out.push(augment_error(decl.file, decl.error_span, 2649, name)); + } + return; + } + report_merge_symbol_error_with_base(base, name, lib_file_offset, source, out); +} + +/// tsgo `reportMergeSymbolError` with the target a [`LibBase`] entry: the same +/// three-way message selection and both-direction emission as +/// [`report_merge_symbol_error`], the base declarations translated into program +/// FileId space. The test-side primaries (their related info the priority-ordered +/// lib declarations) are the observable ones; the lib-side primaries land on the +/// masked lib file the baseline hides. +fn report_merge_symbol_error_with_base( + base: &LibEntry, + name: &str, + lib_file_offset: u32, + source: &MergeSymbol, + out: &mut MergeOut, +) { + let base_decls: Vec = base + .decls + .iter() + .map(|d| MergeDecl { + file: FileId(lib_file_offset + d.lib_file), + error_span: d.error_span, + is_type_decl: d.is_type_decl, + }) + .collect(); + let is_either_enum = + base.flags.intersects(SymbolFlags::ENUM) || source.flags.intersects(SymbolFlags::ENUM); + let is_either_block = base.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE) + || source.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE); + let code = if is_either_enum { + 2567 + } else if is_either_block { + 2451 + } else { + 2300 + }; + let symbol_name = name.to_string(); + add_dup_errors(&source.decls, code, &symbol_name, &base_decls, out); + add_dup_errors(&base_decls, code, &symbol_name, &source.decls, out); +} + +/// tsgo `mergeSymbol` (the merge/conflict decision). No member/export recursion at +/// P1 (globals hold no members — see the module header). +fn merge_symbol(target: &mut GlobalEntry, source: &MergeSymbol, out: &mut MergeOut) { + if !target.flags.intersects(excluded_symbol_flags(source.flags)) { + // No conflict: accumulate flags + declarations. + target.flags.insert(source.flags); + target.decls.extend(source.decls.iter().cloned()); + } else if target.flags.intersects(SymbolFlags::NAMESPACE_MODULE) { + // A value merging into a non-instantiated namespace: "cannot augment module + // with value exports" (TS2649) — but NOT when the target is `globalThis` + // (tsgo `mergeSymbol`'s `target != globalThisSymbol` guard): the phase-1 + // TS2397 already reports that conflict, and a second TS2649 would not make + // sense. tsgo keys on symbol identity; a **name** check is the observable + // equivalent here — the globals table holds exactly one entry per name and a + // merge only ever pairs same-named symbols, so `target.name == "globalThis"` + // identifies it whether the entry arrived as the lib-base seed or (with no lib) + // as a user-declared overlay entry from an earlier file. The base path + // (`merge_symbol_against_base`) makes the same name-based choice. + if target.name != NAME_GLOBAL_THIS + && let Some(decl) = source.decls.first() + { + out.push(augment_error( + decl.file, + decl.error_span, + 2649, + &target.name, + )); + } + } else { + report_merge_symbol_error(target, source, out); + } +} + +/// tsgo `reportMergeSymbolError` — the same three-way message selection as the +/// bind-time cascade, emitting a fresh primary on **every** declaration of both +/// symbols with related info; the caller-side `compact_and_merge_related_infos` +/// collapses the duplicate primaries (see [`MergeOut`]'s header for why there is +/// no issued-index dedup here). +fn report_merge_symbol_error(target: &GlobalEntry, source: &MergeSymbol, out: &mut MergeOut) { + let is_either_enum = + target.flags.intersects(SymbolFlags::ENUM) || source.flags.intersects(SymbolFlags::ENUM); + let is_either_block = target.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE) + || source.flags.intersects(SymbolFlags::BLOCK_SCOPED_VARIABLE); + let code = if is_either_enum { + 2567 + } else if is_either_block { + 2451 + } else { + 2300 + }; + let symbol_name = source.name.clone(); + add_dup_errors(&source.decls, code, &symbol_name, &target.decls, out); + add_dup_errors(&target.decls, code, &symbol_name, &source.decls, out); +} + +/// tsgo `addDuplicateDeclarationErrorsForSymbols` — one call to +/// [`add_duplicate_declaration_error`] per declaration node of `decls`, each +/// carrying related info pointing at the *other* symbol's declarations. +fn add_dup_errors( + decls: &[MergeDecl], + code: u32, + symbol_name: &str, + related_nodes: &[MergeDecl], + out: &mut MergeOut, +) { + for decl in decls { + add_duplicate_declaration_error(decl, code, symbol_name, related_nodes, out); + } +} + +/// tsgo `addDuplicateDeclarationError`: issue a **fresh** primary at `decl` and +/// attach its related info — leading (TS6203) for the first related node, follow-on +/// (TS6204) after, capped at 5 *within this primary* and deduped by target node. +/// +/// Every conflicting merge issues a fresh primary (tsgo's `lookupOrIssueError` +/// never re-finds a primary that has accreted related info — related-length is a +/// `CompareDiagnostics` sort key), so the cross-merge union of related info across +/// duplicate primaries at one node is left to the caller's final +/// `compact_and_merge_related_infos`. That union is uncapped and all-TS6203 (each +/// primary's related loop starts empty, so each leads with a TS6203). +fn add_duplicate_declaration_error( + decl: &MergeDecl, + code: u32, + symbol_name: &str, + related_nodes: &[MergeDecl], + out: &mut MergeOut, +) { + let needs_name = code != 2567; + let args = if needs_name { + vec![symbol_name.to_string()] + } else { + Vec::new() + }; + let mut primary = Diagnostic { + file: Some(decl.file), + span: decl.error_span, + code, + category: Category::Error, + message: message_for(code, Some(symbol_name)), + args, + chain: Vec::new(), + related: Vec::new(), + }; + for related in related_nodes { + if related.file == decl.file && related.error_span == decl.error_span { + continue; + } + if primary.related.len() >= 5 + || primary + .related + .iter() + .any(|r| r.file == Some(related.file) && r.span == related.error_span) + { + continue; + } + let related_diag = if primary.related.is_empty() { + related_info(related, 6203, Some(symbol_name)) + } else { + related_info(related, 6204, None) + }; + primary.related.push(related_diag); + } + out.push(primary); +} + +/// tsgo `mergeModuleAugmentation` (the non-global arm) at single-file scope: the +/// augmentation's module name never resolves (no sibling module), so it is always +/// "not found" (TS2664). The resolves-to-a-non-module errors (TS2649 / TS2671) +/// need a multi-file resolution target and are unreachable here. +fn merge_module_augmentation(aug: &ModuleAug, out: &mut MergeOut) { + out.push(augment_error(aug.file, aug.name_span, 2664, &aug.name)); +} + +/// Build a TS2397 ("declaration name conflicts with built-in global identifier"). +fn conflict_2397(decl: &MergeDecl, name: &str) -> Diagnostic { + Diagnostic { + file: Some(decl.file), + span: decl.error_span, + code: 2397, + category: Category::Error, + message: message_for(2397, Some(name)), + args: vec![name.to_string()], + chain: Vec::new(), + related: Vec::new(), + } +} + +/// Build a module-augmentation error (TS2664 / TS2649 / TS2671), all `{0}` = the +/// module name. +fn augment_error(file: FileId, span: Span, code: u32, name: &str) -> Diagnostic { + Diagnostic { + file: Some(file), + span, + code, + category: Category::Error, + message: message_for(code, Some(name)), + args: vec![name.to_string()], + chain: Vec::new(), + related: Vec::new(), + } +} + +/// Build a related-info node (TS6203 / TS6204) pointing at `decl`'s name. +fn related_info(decl: &MergeDecl, code: u32, name: Option<&str>) -> Diagnostic { + Diagnostic { + file: Some(decl.file), + span: decl.error_span, + code, + // 6203/6204 are `Message` category (unobservable in code+span grading; + // faithful to tsgo's diagnosticMessages). + category: Category::Message, + message: message_for(code, name), + args: name.map(|n| vec![n.to_string()]).unwrap_or_default(), + chain: Vec::new(), + related: Vec::new(), + } +} + +/// tsgo `getExcludedSymbolFlags` — the union of the `*Excludes` masks for every +/// flag set on `flags` (with the `ReplaceableByMethod` special case). +fn excluded_symbol_flags(flags: SymbolFlags) -> SymbolFlags { + let mut result = SymbolFlags::NONE; + let add = |result: &mut SymbolFlags, present: SymbolFlags, mask: SymbolFlags| { + if flags.intersects(present) { + *result = result.union(mask); + } + }; + add( + &mut result, + SymbolFlags::BLOCK_SCOPED_VARIABLE, + SymbolFlags::BLOCK_SCOPED_VARIABLE_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::FUNCTION_SCOPED_VARIABLE, + SymbolFlags::FUNCTION_SCOPED_VARIABLE_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::PROPERTY, + SymbolFlags::PROPERTY_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::ENUM_MEMBER, + SymbolFlags::ENUM_MEMBER_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::FUNCTION, + SymbolFlags::FUNCTION_EXCLUDES, + ); + add(&mut result, SymbolFlags::CLASS, SymbolFlags::CLASS_EXCLUDES); + add( + &mut result, + SymbolFlags::INTERFACE, + SymbolFlags::INTERFACE_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::REGULAR_ENUM, + SymbolFlags::REGULAR_ENUM_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::CONST_ENUM, + SymbolFlags::CONST_ENUM_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::VALUE_MODULE, + SymbolFlags::VALUE_MODULE_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::METHOD, + SymbolFlags::METHOD_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::GET_ACCESSOR, + SymbolFlags::GET_ACCESSOR_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::SET_ACCESSOR, + SymbolFlags::SET_ACCESSOR_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::TYPE_PARAMETER, + SymbolFlags::TYPE_PARAMETER_EXCLUDES, + ); + add( + &mut result, + SymbolFlags::TYPE_ALIAS, + SymbolFlags::TYPE_ALIAS_EXCLUDES, + ); + add(&mut result, SymbolFlags::ALIAS, SymbolFlags::ALIAS_EXCLUDES); + // NamespaceModule contributes no excludes (it merges with anything). + if flags.intersects(SymbolFlags::REPLACEABLE_BY_METHOD) { + result = SymbolFlags(result.0 & !SymbolFlags::METHOD.0); + } + result +} + +/// The `.errors.txt` message text for a merge-path / related-info code. +fn message_for(code: u32, name: Option<&str>) -> String { + match code { + 2300 => format!("Duplicate identifier '{}'.", name.unwrap_or("")), + 2397 => format!( + "Declaration name conflicts with built-in global identifier '{}'.", + name.unwrap_or("") + ), + 2451 => format!( + "Cannot redeclare block-scoped variable '{}'.", + name.unwrap_or("") + ), + 2567 => "Enum declarations can only merge with namespace or other enum declarations." + .to_string(), + 2649 => format!( + "Cannot augment module '{}' with value exports because it resolves to a non-module entity.", + name.unwrap_or("") + ), + 2664 => { + format!( + "Invalid module name in augmentation, module '{}' cannot be found.", + name.unwrap_or("") + ) + } + 2671 => format!( + "Cannot augment module '{}' because it resolves to a non-module entity.", + name.unwrap_or("") + ), + 6203 => format!("'{}' was also declared here.", name.unwrap_or("")), + 6204 => "and here.".to_string(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::diag::sort_and_deduplicate; + + fn decl(file: u32, start: u32, name: &str, is_type_decl: bool) -> MergeDecl { + MergeDecl { + file: FileId(file), + error_span: Span::new(start, start + name.len() as u32), + is_type_decl, + } + } + + fn script(file: u32, locals: Vec) -> FileMerge { + FileMerge { + file: FileId(file), + is_external: false, + source_locals: locals, + global_augmentations: Vec::new(), + module_augmentations: Vec::new(), + } + } + + /// Two scripts sharing global scope, each declaring `let x`, conflict across + /// files (TS2451) — the merge-path analog of the bind-time cascade. + #[test] + fn cross_file_block_scoped_conflict_is_2451() { + let a = script( + 0, + vec![MergeSymbol { + name: "x".to_string(), + flags: SymbolFlags::BLOCK_SCOPED_VARIABLE, + decls: vec![decl(0, 4, "x", false)], + }], + ); + let b = script( + 1, + vec![MergeSymbol { + name: "x".to_string(), + flags: SymbolFlags::BLOCK_SCOPED_VARIABLE, + decls: vec![decl(1, 4, "x", false)], + }], + ); + let diags = merge_program(&[&a, &b], None, 0); + let codes: Vec = diags.iter().map(|d| d.code).collect(); + // One TS2451 on each declaration; each carries a TS6203 related info. + assert_eq!(codes, vec![2451, 2451]); + assert!( + diags + .iter() + .all(|d| d.related.len() == 1 && d.related[0].code == 6203) + ); + // Emitted on both files (raw order is source-then-target — the canonical + // sort in `check_program` reorders to path order). + let mut files: Vec = diags.iter().filter_map(|d| d.file.map(|f| f.0)).collect(); + files.sort_unstable(); + assert_eq!(files, vec![0, 1]); + } + + /// A globals conflict where neither side is block-scoped nor enum is TS2300. + #[test] + fn cross_file_duplicate_identifier_is_2300() { + let mk = |file: u32| { + script( + file, + vec![MergeSymbol { + name: "C".to_string(), + flags: SymbolFlags::CLASS, + decls: vec![decl(file, 6, "C", true)], + }], + ) + }; + let diags = merge_program(&[&mk(0), &mk(1)], None, 0); + assert_eq!( + diags.iter().map(|d| d.code).collect::>(), + vec![2300, 2300] + ); + } + + /// A regular enum and a const enum in separate files can't merge (TS2567). + #[test] + fn cross_file_enum_merge_is_2567() { + let a = script( + 0, + vec![MergeSymbol { + name: "E".to_string(), + flags: SymbolFlags::REGULAR_ENUM, + decls: vec![decl(0, 5, "E", true)], + }], + ); + let b = script( + 1, + vec![MergeSymbol { + name: "E".to_string(), + flags: SymbolFlags::CONST_ENUM, + decls: vec![decl(1, 11, "E", true)], + }], + ); + let diags = merge_program(&[&a, &b], None, 0); + assert_eq!( + diags.iter().map(|d| d.code).collect::>(), + vec![2567, 2567] + ); + // 2567 carries no `{0}` argument. + assert!(diags.iter().all(|d| d.args.is_empty())); + } + + /// A name conflicting across many files: the merge pushes a fresh primary per + /// conflicting merge (so the raw pool has duplicates at the first file's node), + /// and the caller's `sort_and_deduplicate` unions them into one primary per + /// node. The first file's node accretes a related entry per *other* file — all + /// **TS6203** (each fresh primary leads with a TS6203), uncapped by the + /// per-primary cap of 5. + #[test] + fn cross_merge_related_union_is_all_6203_uncapped() { + // Seven files each declaring `let x`. File 0 (globals[x]) is the recurring + // merge target, so its node accretes six related entries after the union. + let paths: Vec = (0..7).map(|f| format!("f{f}.ts")).collect(); + let files: Vec = (0..7) + .map(|f| { + script( + f, + vec![MergeSymbol { + name: "x".to_string(), + flags: SymbolFlags::BLOCK_SCOPED_VARIABLE, + decls: vec![decl(f, 4, "x", false)], + }], + ) + }) + .collect(); + let file_refs: Vec<&FileMerge> = files.iter().collect(); + let mut diags = merge_program(&file_refs, None, 0); + // Raw pool: every conflicting merge pushes a fresh primary (six merges, + // each emitting a source-side and a target-side primary = twelve). + assert_eq!(diags.len(), 12); + // After the caller's canonical sort + related-info union. + let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect(); + sort_and_deduplicate(&mut diags, &path_refs); + assert_eq!(diags.len(), 7); // one primary per file's node + let head = &diags[0]; // f0.ts, the recurring target + assert_eq!(head.file, Some(FileId(0))); + assert_eq!(head.related.len(), 6); // one per *other* file — uncapped + assert!( + head.related.iter().all(|r| r.code == 6203), + "every unioned related entry leads with TS6203" + ); + } + + /// The review's prescribed case: a name conflicting across three files whose + /// declarations sit in distinct files, asserting the related **codes** are all + /// TS6203 after the union (never a TS6204 on the accreting node). + #[test] + fn three_way_cross_file_conflict_related_codes_all_6203() { + let paths = vec!["a.ts", "b.ts", "c.ts"]; + let mk = |f: u32| { + script( + f, + vec![MergeSymbol { + name: "C".to_string(), + flags: SymbolFlags::CLASS, + decls: vec![decl(f, 6, "C", true)], + }], + ) + }; + let mut diags = merge_program(&[&mk(0), &mk(1), &mk(2)], None, 0); + sort_and_deduplicate(&mut diags, &paths); + assert_eq!(diags.len(), 3); + // All primaries are TS2300; a.ts (the recurring target) carries two related + // entries, both TS6203 (the union of two fresh single-related primaries). + assert!(diags.iter().all(|d| d.code == 2300)); + let a = diags + .iter() + .find(|d| d.file == Some(FileId(0))) + .expect("a.ts primary"); + assert_eq!(a.related.len(), 2); + assert!(a.related.iter().all(|r| r.code == 6203)); + } + + /// The WITHIN-primary related cap (five), distinct from the uncapped cross-merge + /// union. A single conflicting merge whose target already accreted six clean + /// declarations issues ONE primary carrying a leading TS6203 + four TS6204 — the + /// sixth related node is DROPPED at emission (tsgo `addDuplicateDeclarationError` + /// caps a single primary's related chain at five). Contrast + /// `cross_merge_related_union_is_all_6203_uncapped`, where the cap never bites + /// because each fresh primary starts its own chain. + #[test] + fn within_primary_related_cap_drops_the_sixth() { + // Six files declare `enum E` (regular enums merge cleanly), so globals[E] + // accretes six declarations; a seventh file's `const enum E` conflicts + // (TS2567) and its single primary points related info at all six — capped. + let mut files: Vec = (0..6) + .map(|f| { + script( + f, + vec![MergeSymbol { + name: "E".to_string(), + flags: SymbolFlags::REGULAR_ENUM, + decls: vec![decl(f, 5, "E", true)], + }], + ) + }) + .collect(); + files.push(script( + 6, + vec![MergeSymbol { + name: "E".to_string(), + flags: SymbolFlags::CONST_ENUM, + decls: vec![decl(6, 11, "E", true)], + }], + )); + let file_refs: Vec<&FileMerge> = files.iter().collect(); + let diags = merge_program(&file_refs, None, 0); + // The const-enum primary (file 6) carries the capped chain (asserted on the + // raw pool, before any cross-merge union, to isolate the within-primary cap). + let source_primary = diags + .iter() + .find(|d| d.file == Some(FileId(6))) + .expect("a const-enum primary at file 6"); + assert_eq!(source_primary.code, 2567); + let codes: Vec = source_primary.related.iter().map(|r| r.code).collect(); + // Leading TS6203 + four TS6204 = five related; the sixth conflicting decl is dropped. + assert_eq!(codes, vec![6203, 6204, 6204, 6204, 6204]); + } + + /// A single script declaring `var globalThis` triggers TS2397 per declaration. + #[test] + fn global_this_collision_is_2397() { + let f = script( + 0, + vec![MergeSymbol { + name: "globalThis".to_string(), + flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE, + decls: vec![decl(0, 4, "globalThis", false)], + }], + ); + let diags = merge_program(&[&f], None, 0); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 2397); + assert_eq!(diags[0].args, vec!["globalThis".to_string()]); + } + + /// The `undefined` check skips type declarations (class/interface) and fires + /// only on the value (namespace/var) declaration. + #[test] + fn undefined_redeclaration_skips_type_declarations() { + let f = script( + 0, + vec![MergeSymbol { + name: "undefined".to_string(), + flags: SymbolFlags::CLASS.union(SymbolFlags::VALUE_MODULE), + decls: vec![ + decl(0, 6, "undefined", true), // class + decl(0, 40, "undefined", false), // namespace + ], + }], + ); + let diags = merge_program(&[&f], None, 0); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 2397); + assert_eq!(diags[0].span.start, 40); + } + + /// A module augmentation single-file is always "not found" (TS2664), deduped + /// by name. + #[test] + fn module_augmentation_not_found_is_2664_deduped() { + let f = FileMerge { + file: FileId(0), + is_external: true, + source_locals: Vec::new(), + global_augmentations: Vec::new(), + module_augmentations: vec![ + ModuleAug { + file: FileId(0), + name: "M".to_string(), + name_span: Span::new(22, 25), + }, + ModuleAug { + file: FileId(0), + name: "M".to_string(), + name_span: Span::new(50, 53), + }, + ], + }; + let diags = merge_program(&[&f], None, 0); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 2664); + assert_eq!(diags[0].span.start, 22); + assert_eq!(diags[0].args, vec!["M".to_string()]); + } + + /// An external module's locals never reach global scope (no globalThis/undefined + /// check, no global merge). + #[test] + fn external_module_locals_do_not_reach_globals() { + let f = FileMerge { + file: FileId(0), + is_external: true, + source_locals: vec![MergeSymbol { + name: "globalThis".to_string(), + flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE, + decls: vec![decl(0, 4, "globalThis", false)], + }], + global_augmentations: Vec::new(), + module_augmentations: Vec::new(), + }; + assert!(merge_program(&[&f], None, 0).is_empty()); + } + + // --- lib base --------------------------------------------------------- + + /// A tiny lib file with two globals, in priority order. + fn lib(name: &str, symbols: Vec) -> LibFile { + LibFile { + name: name.to_string(), + merge: FileMerge { + file: FileId(0), + is_external: false, + source_locals: symbols, + global_augmentations: Vec::new(), + module_augmentations: Vec::new(), + }, + } + } + + fn lib_symbol(name: &str, flags: SymbolFlags, span: u32, is_type: bool) -> MergeSymbol { + MergeSymbol { + name: name.to_string(), + flags, + decls: vec![decl(0, span, name, is_type)], + } + } + + /// A test `class` conflicting with a lib global spanning three files reproduces + /// the priority-ordered TS6203/6204 related chain (leading TS6203 on the + /// highest-priority lib file), and the lib declarations translate into program + /// FileId space (offset = the number of program units). + #[test] + fn lib_conflict_emits_priority_ordered_related_chain() { + // Symbol declared across three lib files (priority order es5, es2015.symbol, + // es2015.symbol.wellknown): interface, var, interface -> flags Interface|Fsv. + let es5 = lib( + "lib.es5.d.ts", + vec![lib_symbol("Symbol", SymbolFlags::INTERFACE, 10, true)], + ); + let sym = lib( + "lib.es2015.symbol.d.ts", + vec![lib_symbol( + "Symbol", + SymbolFlags::FUNCTION_SCOPED_VARIABLE, + 20, + false, + )], + ); + let wk = lib( + "lib.es2015.symbol.wellknown.d.ts", + vec![lib_symbol("Symbol", SymbolFlags::INTERFACE, 30, true)], + ); + let base = LibBase::build(&[&es5, &sym, &wk]); + // The single test unit is FileId 0, so lib files map to FileIds 1..=3. + let test = script( + 0, + vec![MergeSymbol { + name: "Symbol".to_string(), + flags: SymbolFlags::CLASS, + decls: vec![decl(0, 6, "Symbol", true)], + }], + ); + let mut diags = merge_program(&[&test], Some(&base), 1); + let paths = vec![ + "test.ts", + "lib.es5.d.ts", + "lib.es2015.symbol.d.ts", + "lib.es2015.symbol.wellknown.d.ts", + ]; + sort_and_deduplicate(&mut diags, &paths); + // The observable primary is on the test file's `class Symbol`. + let test_primary = diags + .iter() + .find(|d| d.file == Some(FileId(0))) + .expect("a test-file primary"); + assert_eq!(test_primary.code, 2300); + assert_eq!(test_primary.span.start, 6); + // Its related chain is priority-ordered: TS6203 on es5, TS6204 on the rest, + // each pointing at the (translated) lib FileId. + let codes: Vec = test_primary.related.iter().map(|r| r.code).collect(); + assert_eq!(codes, vec![6203, 6204, 6204]); + let files: Vec> = test_primary.related.iter().map(|r| r.file).collect(); + assert_eq!( + files, + vec![Some(FileId(1)), Some(FileId(2)), Some(FileId(3))] + ); + // The lib-side primaries land on the (masked) lib files. + assert!( + diags + .iter() + .any(|d| d.file == Some(FileId(1)) && d.code == 2300) + ); + } + + /// A clean augmentation of a lib global (a test `interface` merging into a lib + /// interface) emits nothing. + #[test] + fn lib_clean_interface_merge_is_silent() { + let array = lib( + "lib.es5.d.ts", + vec![lib_symbol("Array", SymbolFlags::INTERFACE, 10, true)], + ); + let base = LibBase::build(&[&array]); + let test = script( + 0, + vec![MergeSymbol { + name: "Array".to_string(), + flags: SymbolFlags::INTERFACE, + decls: vec![decl(0, 10, "Array", true)], + }], + ); + assert!(merge_program(&[&test], Some(&base), 1).is_empty()); + } + + /// A test `var globalThis` hits the seeded-globalThis NamespaceModule guard (no + /// TS2649), leaving only the phase-1 TS2397. + #[test] + fn lib_var_globalthis_only_2397() { + let base = LibBase::build(&[]); // still seeds globalThis + let test = script( + 0, + vec![MergeSymbol { + name: "globalThis".to_string(), + flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE, + decls: vec![decl(0, 4, "globalThis", false)], + }], + ); + let diags = merge_program(&[&test], Some(&base), 1); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, 2397); + } + + /// The TS2649 `globalThis` guard is name-based, so it holds even when the + /// `globalThis` entry arrived via the **overlay** (a user declaration) rather than + /// the lib-base seed. With no lib, an earlier file's instantiated + /// `namespace globalThis` seeds the overlay entry (NamespaceModule | ValueModule); a + /// later `var globalThis` conflicts and hits the NamespaceModule arm — the guard + /// suppresses TS2649, leaving only the two phase-1 TS2397s. + #[test] + fn overlay_globalthis_guard_suppresses_2649() { + let ns = script( + 0, + vec![MergeSymbol { + name: "globalThis".to_string(), + flags: MODULE_FLAGS, // an instantiated `namespace globalThis {…}` + decls: vec![decl(0, 10, "globalThis", false)], + }], + ); + let var = script( + 1, + vec![MergeSymbol { + name: "globalThis".to_string(), + flags: SymbolFlags::FUNCTION_SCOPED_VARIABLE, // `var globalThis` + decls: vec![decl(1, 4, "globalThis", false)], + }], + ); + // No lib base — the overlay starts empty, so `globalThis` reaches it only via + // the user declarations (not the seed). + let diags = merge_program(&[&ns, &var], None, 0); + // Only the phase-1 globalThis checks fire; the NamespaceModule guard holds for + // the overlay entry, so no TS2649. + assert_eq!(diags.iter().filter(|d| d.code == 2397).count(), 2); + assert!(diags.iter().all(|d| d.code == 2397)); + } + + /// A `declare global` augmentation (an interface) conflicting with a lib type + /// alias of the same name is TS2300 (the ElementTagNameMap shape). + #[test] + fn lib_declare_global_interface_vs_type_alias_is_2300() { + let dom = lib( + "lib.dom.d.ts", + vec![lib_symbol( + "ElementTagNameMap", + SymbolFlags::TYPE_ALIAS, + 10, + true, + )], + ); + let base = LibBase::build(&[&dom]); + // An external module carrying a `declare global { interface ElementTagNameMap }`. + let test = FileMerge { + file: FileId(0), + is_external: true, + source_locals: Vec::new(), + global_augmentations: vec![vec![MergeSymbol { + name: "ElementTagNameMap".to_string(), + flags: SymbolFlags::INTERFACE, + decls: vec![decl(0, 40, "ElementTagNameMap", true)], + }]], + module_augmentations: Vec::new(), + }; + let diags = merge_program(&[&test], Some(&base), 1); + let test_primary = diags + .iter() + .find(|d| d.file == Some(FileId(0))) + .expect("primary"); + assert_eq!(test_primary.code, 2300); + assert_eq!(test_primary.span.start, 40); + assert_eq!(test_primary.related.len(), 1); + assert_eq!(test_primary.related[0].code, 6203); + assert_eq!(test_primary.related[0].file, Some(FileId(1))); + } +} diff --git a/crates/tsv_check/src/options.rs b/crates/tsv_check/src/options.rs new file mode 100644 index 000000000..e345b0be7 --- /dev/null +++ b/crates/tsv_check/src/options.rs @@ -0,0 +1,42 @@ +//! Checker options — tsv_check's first options surface. +//! +//! The checker's observable behavior is mostly option-independent (the +//! bind/merge duplicate-conflict family, the syntactic check pass), but the +//! reachability shims read a small set of compiler options: `allowUnreachableCode` +//! (TS7027), `allowUnusedLabels` (TS7028), and `preserveConstEnums` (which of an +//! unreachable module/enum member's declarations count as executable). This is the +//! whole of tsv_check's options model — deliberately minimal, ported only where a +//! diagnostic's category or existence actually depends on it. +// +// tsgo: internal/core/tristate.go Tristate; internal/core/compileroptions.go +// (AllowUnreachableCode / AllowUnusedLabels / PreserveConstEnums + +// ShouldPreserveConstEnums). + +/// A three-state boolean mirroring tsgo's `core.Tristate`: an **unset** option +/// (`Unknown`, the default) inherits rather than reading as `false`, so the +/// suggestion-vs-error routing needs the explicit-`False` distinction. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Tristate { + /// No explicit value — inherits (routes reachability diagnostics as + /// *suggestions*, tsgo's default when the flag is unset). + #[default] + Unknown, + /// Explicit `false` — the reachability diagnostic is an **error**. + False, + /// Explicit `true` — the reachability probe is **skipped** entirely. + True, +} + +/// The checker options tsv_check reads. `Default` is the all-unset state +/// (`Unknown` / `Unknown` / `false`), which every non-harness caller passes. +#[derive(Clone, Copy, Debug, Default)] +pub struct CheckOptions { + /// `allowUnreachableCode` — gates TS7027 (`Unreachable code detected.`): + /// `False` → error, `Unknown` → suggestion, `True` → no report. + pub allow_unreachable_code: Tristate, + /// `allowUnusedLabels` — gates TS7028 (`Unused label.`): same routing. + pub allow_unused_labels: Tristate, + /// `ShouldPreserveConstEnums()` — whether an unreachable `const enum` (and a + /// const-enum-only namespace) counts as executable and so is reportable. + pub preserve_const_enums: bool, +} diff --git a/crates/tsv_check/src/program.rs b/crates/tsv_check/src/program.rs new file mode 100644 index 000000000..ee7b3e176 --- /dev/null +++ b/crates/tsv_check/src/program.rs @@ -0,0 +1,540 @@ +//! Program pipeline assembly: parse (goal rule) -> bind -> check -> sort/dedup. +//! +//! **Two assembly modes, and this is the parity one.** The conformance harness +//! grades against tsgo's committed `.errors.txt` **baselines**, whose oracle path +//! is `harnessutil.go CompileFilesEx` (:634-645): it concatenates each unit's +//! syntactic + semantic diagnostics **unconditionally — no short-circuit**. So a +//! unit tsv parse-rejects contributes only its parse verdict (no AST to bind), +//! while every unit that parses contributes its bind/check diagnostics regardless +//! of a sibling's rejection. For the single-file tests this slice grades that +//! means a rejected file is simply ungradeable — its `CheckResult` has no +//! diagnostics because there is no AST, not because a program-wide guard fired. +//! +//! The **product path** (`tsv check`, mirroring the tsc CLI) is instead tsgo's +//! `GetDiagnosticsOfAnyProgram` (`program.go:1755`), which **short-circuits** at +//! :1770 — if syntactic diagnostics exist, semantic diagnostics are skipped +//! program-wide. Porting that short-circuit into this parity pipeline would +//! manufacture false `missing`s the moment multi-file grading starts, so it is a +//! deliberate mode distinction deferred to the product path, not modelled here. +//! +//! Each unit parses via the **goal rule**: `Goal::Module` first (correct for +//! ~all real TS), and on failure a `Goal::Script` retry (top-level `await` as an +//! identifier, no `import`/`export`). Both goals failing is a parse rejection. +//! +//! The caller owns the parse arena (`&'a Bump`) — the tsv_ts caller-owns-arena +//! contract scaled to a unit set. The returned [`CheckResult`] is fully owned +//! (diagnostics carry owned strings and `Copy` spans; nothing borrows the +//! arena), so the caller may reset and reuse the arena the moment this returns. +// +// tsgo: internal/testutil/harnessutil.go CompileFilesEx (:634-645) — the +// baseline-oracle parity path (unconditional syntactic+semantic concat); +// the bind-then-check concat is getBindAndCheckDiagnosticsWithChecker +// (program.go:1337); final sort+dedup is caller-side (execute/tsc/emit.go:120). +// The product-mode short-circuit lives at GetDiagnosticsOfAnyProgram +// (program.go:1755, :1770) and is deliberately NOT ported here. + +use crate::binder::flow::{FlowProduct, build_flow}; +use crate::binder::{ModuleNess, bind_file, module_ness}; +use crate::check::unreachable::{UnreachableCandidates, build_candidates}; +use crate::diag::{Diagnostic, sort_and_deduplicate}; +use crate::ids::FileId; +use crate::merge::{FileMerge, LibBase, LibFile, merge_program}; +use crate::options::CheckOptions; +use bumpalo::Bump; +use tsv_ts::ast::Program; +use tsv_ts::{Goal, parse_with_goal}; + +/// One source unit to check — a file name (its diagnostic path) and its source. +pub struct SourceUnit<'a> { + /// The unit's display name (the diagnostic path). + pub name: &'a str, + /// The unit's source text. + pub source: &'a str, +} + +impl<'a> SourceUnit<'a> { + /// Build a source unit. + #[must_use] + pub fn new(name: &'a str, source: &'a str) -> SourceUnit<'a> { + SourceUnit { name, source } + } +} + +/// The result of checking a program: its (sorted, deduped) diagnostics, its +/// suggestion-category diagnostics (a separate sink), a per-unit report, and +/// whether any unit parse-rejected. +pub struct CheckResult { + /// Diagnostics in canonical sorted order — the unconditional concat of every + /// unit that parsed (a rejected unit contributes none, having no AST). + pub diagnostics: Vec, + /// Suggestion-category diagnostics (the default-`Unknown` reachability + /// shims), kept **out of** [`CheckResult::diagnostics`] so the conformance + /// gate's error/expect-clean channel never sees them. + pub suggestions: Vec, + /// Per-unit parse/bind report, in input order. + pub files: Vec, + /// Whether any unit parse-rejected (a reported fact; it does **not** suppress + /// the other units' diagnostics — see the module header's parity note). + pub parse_rejected: bool, +} + +/// The per-unit parse/bind report. +pub struct FileReport { + /// The unit's file id. + pub file: FileId, + /// The unit's display name. + pub name: String, + /// The parse outcome and, when parsed, the bind facts. + pub parse: ParseReport, +} + +/// A unit's parse outcome. +#[derive(Clone)] +pub enum ParseReport { + /// The unit parsed (possibly via the `Goal::Script` retry). + Parsed(ParsedFacts), + /// Both goals failed; `message` is the primary-goal (`Module`) error. + Rejected { + /// The `Goal::Module` parse error message. + message: String, + }, +} + +/// Facts recorded for a parsed unit. +#[derive(Clone)] +pub struct ParsedFacts { + /// The goal the unit parsed under. + pub goal: Goal, + /// Whether the `Goal::Module` parse failed and the `Goal::Script` retry won. + pub used_script_retry: bool, + /// The unit's module-vs-script indicator (import/export presence). + pub module_ness: ModuleNess, + /// The bound node count (0 when the program short-circuited before binding). + pub node_count: u32, +} + +/// A parsed + bound program — variant-independent and fully owned +/// (arena-independent), so the caller may drop the parse arena the moment this +/// returns and merge it against any number of lib bases ([`check_bound`]). This is +/// the split that keeps parse+bind out of the per-variant loop: parse+bind once, +/// merge per resolved lib set. +pub struct BoundProgram { + /// Whether any unit parse-rejected (a reported fact; it does **not** suppress + /// the other units' diagnostics — the CompileFilesEx parity). + pub parse_rejected: bool, + units: Vec, + total_nodes: u64, +} + +/// One unit's owned bind product inside a [`BoundProgram`]. +struct BoundUnit { + file: FileId, + name: String, + parse: ParseReport, + /// The bind (+ check) diagnostics — variant-independent, cloned into each + /// [`check_bound`] result. + bind_diagnostics: Vec, + /// The merge product, `None` when the unit parse-rejected. + merge: Option, + /// The per-file flow product, carried **dark** — `--dump-flow` renders it and + /// F3's candidate table is built from it. `None` when the unit parse-rejected + /// (no AST to walk). + flow: Option, + /// The variant-independent unreachable-code / unused-label candidate table + /// (F3), built once at bind time and filtered per variant in [`check_bound`]. + /// `None` when the unit parse-rejected. + candidates: Option, +} + +impl BoundProgram { + /// Total bound nodes across parsed units (informational). + #[must_use] + pub fn total_node_count(&self) -> u64 { + self.total_nodes + } + + /// A unit's dark-carried flow product (`None` for a rejected unit or an + /// out-of-range index) — the natural accessor for a bound program's flow + /// product, which the CFA type engine (P3) will consume. + // TODO: no consumer wires this yet — P3's CFA reads a bound program's flow + // here (`--dump-flow` renders via `bind_file` + `build_flow` directly, not this). + #[must_use] + pub fn unit_flow(&self, index: usize) -> Option<&FlowProduct> { + self.units.get(index).and_then(|u| u.flow.as_ref()) + } + + /// The per-unit parse reports, in input order (a read-only view for the caller + /// that need not run [`check_bound`] to learn parse facts). + #[must_use] + pub fn parse_reports(&self) -> Vec<(&str, &ParseReport)> { + self.units + .iter() + .map(|u| (u.name.as_str(), &u.parse)) + .collect() + } +} + +/// Parse every unit via the goal rule and bind each, returning the owned +/// [`BoundProgram`]. The merge is deferred to [`check_bound`] (it depends on the +/// resolved lib set), so this is variant-independent. +#[must_use] +pub fn bind_program<'a>(units: &[SourceUnit<'a>], arena: &'a Bump) -> BoundProgram { + let mut bound_units: Vec = Vec::with_capacity(units.len()); + let mut parse_rejected = false; + let mut total_nodes = 0u64; + + for (i, unit) in units.iter().enumerate() { + let file = FileId(i as u32); + match parse_unit(unit.source, arena) { + Ok((program, goal, used_script_retry)) => { + let module_ness = module_ness(&program); + let bound = bind_file(&program, unit.source, file); + total_nodes += u64::from(bound.node_count); + // The third walk: the flow graph, built from the parsed program + // and F0's node identity. Borrows `&bound`, so it runs before the + // bind product's fields move out below. Carried dark in the unit. + let flow = build_flow(&program, unit.source, &bound); + // F3: the unreachable-code / unused-label candidate table, built + // once here (the flag bit, run grouping, and const-enum/module + // classification are all syntactic). Filtered per variant in + // `check_bound`, keeping `BoundProgram` variant-independent. + let candidates = build_candidates(&program, unit.source, &bound, &flow); + // Per file: bind diagnostics then check diagnostics — the + // getBindAndCheckDiagnostics concat. The check pass is a standalone + // syntactic walk over the program (it needs no `BoundFile`); its + // output folds in here, and the program-wide sort/dedup collapses any + // diagnostic the bind and check both emit. + let check_diags = crate::check::check_file_members(&program, unit.source, file); + let mut bind_diagnostics = bound.diagnostics; + bind_diagnostics.extend(check_diags); + bound_units.push(BoundUnit { + file, + name: unit.name.to_string(), + parse: ParseReport::Parsed(ParsedFacts { + goal, + used_script_retry, + module_ness, + node_count: bound.node_count, + }), + bind_diagnostics, + merge: Some(bound.merge), + flow: Some(flow), + candidates: Some(candidates), + }); + } + Err(message) => { + parse_rejected = true; + bound_units.push(BoundUnit { + file, + name: unit.name.to_string(), + parse: ParseReport::Rejected { message }, + bind_diagnostics: Vec::new(), + merge: None, + flow: None, + candidates: None, + }); + } + } + } + + BoundProgram { + parse_rejected, + units: bound_units, + total_nodes, + } +} + +/// Merge a [`BoundProgram`] against an optional [`LibBase`] under `options` and +/// return the final [`CheckResult`] (canonically sorted + deduped). The bind +/// diagnostics are the variant-independent concat (the CompileFilesEx parity +/// path); the merge phase consults the lib base, so the lib file names append +/// after the program units in the diagnostic path space. `options` drives the +/// per-variant reachability shims (TS7027/7028) — the only option-dependent +/// output, routed to `diagnostics` (error) or the separate `suggestions` sink. +// `options` is threaded by reference (uniform with `lib: Option<&LibBase>` and +// future-proof if `CheckOptions` grows) though it is currently `Copy`-small. +#[allow(clippy::trivially_copy_pass_by_ref)] +#[must_use] +pub fn check_bound( + bound: &BoundProgram, + lib: Option<&LibBase>, + options: &CheckOptions, +) -> CheckResult { + let mut diagnostics: Vec = Vec::new(); + let mut suggestions: Vec = Vec::new(); + for unit in &bound.units { + diagnostics.extend(unit.bind_diagnostics.iter().cloned()); + // F3: filter the unit's variant-independent candidate table under + // `options` — errors into `diagnostics`, suggestions into their own sink. + if let Some(candidates) = &unit.candidates { + candidates.emit(unit.file, options, &mut diagnostics, &mut suggestions); + } + } + // Borrow each unit's merge product (lib globals live in the base, not in + // `files`); the merge only reads it, so no clone is needed even per-variant. + let merges: Vec<&FileMerge> = bound + .units + .iter() + .filter_map(|u| u.merge.as_ref()) + .collect(); + let lib_file_offset = bound.units.len() as u32; + diagnostics.extend(merge_program(&merges, lib, lib_file_offset)); + + // Path space: program units first, then the lib files (their FileIds are + // `lib_file_offset + lib-local index`). Borrowed — the comparator only reads. + let mut paths: Vec<&str> = bound.units.iter().map(|u| u.name.as_str()).collect(); + if let Some(base) = lib { + paths.extend(base.lib_files.iter().map(String::as_str)); + } + sort_and_deduplicate(&mut diagnostics, &paths); + sort_and_deduplicate(&mut suggestions, &paths); + + let files = bound + .units + .iter() + .map(|u| FileReport { + file: u.file, + name: u.name.clone(), + parse: u.parse.clone(), + }) + .collect(); + CheckResult { + diagnostics, + suggestions, + files, + parse_rejected: bound.parse_rejected, + } +} + +/// Check a program with no lib base — parse every unit via the goal rule, bind, +/// merge, and return canonically sorted diagnostics. +#[allow(clippy::trivially_copy_pass_by_ref)] // `&CheckOptions` — see `check_bound` +#[must_use] +pub fn check_program<'a>( + units: &[SourceUnit<'a>], + arena: &'a Bump, + options: &CheckOptions, +) -> CheckResult { + check_bound(&bind_program(units, arena), None, options) +} + +/// Check a program against an optional lib base (the lib-aware entry point). +#[allow(clippy::trivially_copy_pass_by_ref)] // `&CheckOptions` — see `check_bound` +#[must_use] +pub fn check_program_with_lib<'a>( + units: &[SourceUnit<'a>], + lib: Option<&LibBase>, + arena: &'a Bump, + options: &CheckOptions, +) -> CheckResult { + check_bound(&bind_program(units, arena), lib, options) +} + +/// Parse + bind one lib `.d.ts` file, returning its owned global-eligible product +/// for folding into a [`LibBase`]. A lib is an ambient script; its globals are its +/// source-file locals (bound under FileId 0 — the fold re-keys by priority index). +/// +/// # Errors +/// +/// Returns the parse error message when the lib file does not parse under either +/// goal (expected never for the bundled libs; the caller counts it as a carve-out). +pub fn bind_lib(name: &str, source: &str) -> Result { + let arena = Bump::new(); + let (program, _goal, _retry) = parse_unit(source, &arena)?; + let bound = bind_file(&program, source, FileId::ROOT); + // A lib contributes its globals through the merge either as an ambient script + // (globals in `source_locals`) or, when the lib file is itself a module — e.g. + // `lib.es2025.iterator.d.ts`, which carries a top-level `export {}` and so binds + // external — through a `declare global {}` block (`global_augmentations`). A lib + // that bound external with NEITHER would silently fold to nothing. This + // `debug_assert!` is the fast local guard (dev builds only); the conformance + // harness's lib channel counts any such lib and fails its run on a nonzero count, + // so release/corpus builds catch the same no-op this compiles out of. + debug_assert!( + !bound.merge.is_external || !bound.merge.global_augmentations.is_empty(), + "lib {name} bound as an external module with no `declare global` block — its globals would be silently dropped", + ); + Ok(LibFile { + name: name.to_string(), + merge: bound.merge, + }) +} + +/// Parse a unit via the goal rule: `Module` first, `Script` on failure. Returns +/// the program, the goal it parsed under, and whether the `Script` retry won; on +/// double failure returns the `Module`-goal error message. +fn parse_unit<'a>(source: &'a str, arena: &'a Bump) -> Result<(Program<'a>, Goal, bool), String> { + match parse_with_goal(source, Goal::Module, arena) { + Ok(program) => Ok((program, Goal::Module, false)), + Err(module_err) => match parse_with_goal(source, Goal::Script, arena) { + Ok(program) => Ok((program, Goal::Script, true)), + // Both goals failed: report the primary (Module) goal's error. + Err(_script_err) => Err(module_err.to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(source: &str) -> CheckResult { + let arena = Bump::new(); + check_program( + &[SourceUnit::new("test.ts", source)], + &arena, + &CheckOptions::default(), + ) + } + + #[test] + fn clean_program_binds_and_grades_empty() { + let result = check("const x: number = 1;"); + assert!(!result.parse_rejected); + assert!(result.diagnostics.is_empty()); + assert_eq!(result.files.len(), 1); + match &result.files[0].parse { + ParseReport::Parsed(facts) => { + assert_eq!(facts.goal, Goal::Module); + assert!(!facts.used_script_retry); + assert!(facts.node_count >= 3); // Program + decl + declarator (+ id) + } + ParseReport::Rejected { .. } => panic!("expected a clean parse"), + } + } + + #[test] + fn parse_rejection_yields_no_diagnostics() { + // A hard syntax error both goals reject: no AST to bind, so no diagnostics + // (the single-file "ungradeable" case). + let result = check("const = = = ;"); + assert!(result.parse_rejected); + assert!(result.diagnostics.is_empty()); + assert!(matches!( + result.files[0].parse, + ParseReport::Rejected { .. } + )); + } + + #[test] + fn script_retry_on_top_level_import_free_await_ident() { + // `await` as a plain binding is a Module-goal error (reserved) but valid + // at Script goal — the retry should win. + let result = check("var await = 1;"); + match &result.files[0].parse { + ParseReport::Parsed(facts) => { + assert_eq!(facts.goal, Goal::Script); + assert!(facts.used_script_retry); + } + ParseReport::Rejected { .. } => panic!("expected the Script retry to win"), + } + } + + #[test] + fn sibling_rejection_does_not_suppress_a_parsed_unit() { + // The CompileFilesEx parity: a rejected sibling does NOT short-circuit the + // program — the unit that parsed still contributes its bind diagnostics. + let arena = Bump::new(); + let result = check_program( + &[ + SourceUnit::new("a.ts", "let x; let x;"), + SourceUnit::new("b.ts", "const = ;"), + ], + &arena, + &CheckOptions::default(), + ); + assert!(result.parse_rejected); + // a.ts's two TS2451 survive despite b.ts rejecting. + assert_eq!(result.diagnostics.len(), 2); + assert!(result.diagnostics.iter().all(|d| d.code == 2451)); + assert!(matches!(result.files[0].parse, ParseReport::Parsed(_))); + assert!(matches!( + result.files[1].parse, + ParseReport::Rejected { .. } + )); + } + + #[test] + fn computed_literal_key_bind_and_check_spans_collapse() { + // A computed-literal key that conflicts at BOTH bind (method vs property) and + // check (property vs property) once produced two differently-spanned + // diagnostics per declaration — the bind side spanned the bare literal, the + // check side the whole `[ … ]` node — so the sort/dedup couldn't collapse + // them (six TS2300 for three declarations). Both phases now span the + // bracket-inclusive name node with the raw `[0]` message arg, so identical + // diagnostics collapse: three declarations -> three TS2300. + let result = check("class C { [0]() {} [0] = 1; [0] = 2; }"); + let ts2300: Vec<_> = result + .diagnostics + .iter() + .filter(|d| d.code == 2300) + .collect(); + assert_eq!(ts2300.len(), 3); + assert!(ts2300.iter().all(|d| d.args == vec!["[0]".to_string()])); + assert_eq!( + ts2300 + .iter() + .map(|d| (d.span.start, d.span.end)) + .collect::>(), + vec![(10, 13), (19, 22), (28, 31)] + ); + } + + #[test] + fn private_name_bind_and_check_display_collapse() { + // A duplicate private member (`#x`) is reported by BOTH the bind cascade and + // the check pass. Both point at the same `#name` span with code 2300, but the + // bind side once built its message arg WITHOUT the leading `#` (bare `x`) while + // the check side built it WITH (`#x`), so the differing args blocked sort/dedup + // — a latent extra (six TS2300 for three declarations). Both phases now carry + // the `#x` form (matching tsgo's `Duplicate identifier '#foo'.`), so identical + // diagnostics collapse: three declarations -> three TS2300. + let result = check("class C { #x = 1; get #x() { return 1; } #x() {} }"); + let ts2300: Vec<_> = result + .diagnostics + .iter() + .filter(|d| d.code == 2300) + .collect(); + let summary: Vec<_> = ts2300 + .iter() + .map(|d| (d.args.clone(), d.span.start, d.span.end)) + .collect(); + // Every private-name TS2300 carries the `#x` arg (with the `#`), not bare `x`. + assert!( + ts2300.iter().all(|d| d.args == vec!["#x".to_string()]), + "expected all private-name TS2300 args to be `#x`, got {summary:?}", + ); + // No duplicated (code, span) pair survives dedup — the latent extra is closed. + let mut spans: Vec<_> = ts2300.iter().map(|d| (d.span.start, d.span.end)).collect(); + let n = spans.len(); + spans.sort_unstable(); + spans.dedup(); + assert_eq!( + spans.len(), + n, + "a duplicated (code, span) private-name TS2300 remains: {summary:?}", + ); + assert_eq!( + n, 3, + "expected three deduped private-name TS2300: {summary:?}" + ); + } + + #[test] + fn nested_type_literal_method_property_conflict_binds() { + // A nested type literal's method-vs-property conflict is bind-time; it was + // missed at depth >= 1 because a property signature's own type annotation + // never descended. The property/property nested dup was always caught (the + // check pass recurses at any depth) — this closes only the bind-time family + // gap. Depth-0 control and the nested case both fire two TS2300. + let ts2300 = |source: &str| { + check(source) + .diagnostics + .iter() + .filter(|d| d.code == 2300) + .count() + }; + assert_eq!(ts2300("var a: { m(): void; m: number };"), 2); + assert_eq!(ts2300("var a: { outer: { m(): void; m: number } };"), 2); + } +} diff --git a/crates/tsv_check/src/span_scan.rs b/crates/tsv_check/src/span_scan.rs new file mode 100644 index 000000000..cde17802d --- /dev/null +++ b/crates/tsv_check/src/span_scan.rs @@ -0,0 +1,56 @@ +//! Small source-scan helpers shared by the binder and the check pass. +//! +//! A computed member name (`[ … ]`) points its diagnostic at the whole +//! `ComputedPropertyName` node — bracket-inclusive — in tsgo. Both the bind +//! cascade (`binder::sym::resolve_member_key`) and the syntactic check +//! (`check::duplicate_members::member_key`) need the same `[`…`]` bounds so a +//! computed-literal key that conflicts at *both* phases produces byte-identical +//! spans that collapse in the program-wide sort/dedup (rather than two +//! differently-spanned diagnostics that survive as an extra). Lifting the scan +//! here keeps that agreement structural, not hand-mirrored. +// +// tsgo: internal/checker/checker.go reportDuplicateMemberErrors — the squiggle is +// getNameOfDeclaration -> the ComputedPropertyName node (bracket-inclusive). +// +// TODO: both scans are comment-blind — a raw byte loop takes the first `[`/`]` it +// meets, so a comment carrying one between the bracket and the key expression wins +// (`[/* a[0] */ 'k']` spans `[0] */ 'k']`). The wrong span also becomes the wrong +// `args` entry, which is the leg tsgo's comparer keys on. The fix is the +// comment-aware `tsv_lang::source_scan::{rfind,find}_char_skipping_comments`; +// `bracket_start` then needs a lower bound to scan forward from (the enclosing +// member's start), since the file-start bound it would otherwise take is O(offset) +// per computed key. + +/// The byte offset of the `[` opening a computed key, scanning back from the key +/// expression's start (a plain byte loop — `[` is ASCII). Falls back to the +/// expression start if no `[` precedes it (never for a well-formed computed name). +#[must_use] +pub(crate) fn bracket_start(source: &str, expr_start: u32) -> u32 { + let bytes = source.as_bytes(); + let mut i = expr_start as usize; + while i > 0 { + i -= 1; + if bytes[i] == b'[' { + return i as u32; + } + } + expr_start +} + +/// The byte offset just past the `]` closing a computed key, scanning forward from +/// the key expression's end (a plain byte loop — `]` is ASCII). Mirrors +/// [`bracket_start`] so the diagnostic spans the whole `[ … ]` name node (as tsgo +/// does). Falls back to the expression end if no `]` follows (never for a +/// well-formed computed name). +#[must_use] +pub(crate) fn bracket_end(source: &str, expr_end: u32) -> u32 { + let bytes = source.as_bytes(); + let mut i = expr_end as usize; + while i < bytes.len() { + if bytes[i] == b']' { + return i as u32 + 1; + } + i += 1; + } + expr_end +} diff --git a/crates/tsv_check/tests/clone_discipline.rs b/crates/tsv_check/tests/clone_discipline.rs new file mode 100644 index 000000000..a1231d3f8 --- /dev/null +++ b/crates/tsv_check/tests/clone_discipline.rs @@ -0,0 +1,466 @@ +//! The borrow-only discipline, enforced: `tsv_check` visitors borrow AST nodes and +//! never clone them. +//! +//! The binder keys its address map on `(std::ptr::from_ref(node) as usize, NodeKind)`, +//! which resolves only while every node the checker sees is the *same* arena node the +//! lowering walk numbered. Every tsv AST type derives `Clone`, so one accidental +//! `.clone()` mints a differently-addressed copy the map has never seen — and the two +//! resolution paths fail differently: the strict one (`BoundFile::require_node_id`, the +//! flow builder) aborts loudly, while the lenient unreachable-candidate lookup simply +//! misses, leaving a silently wrong candidate table rather than a crash. The quiet half +//! is why the convention needs a guard instead of a code review. +//! +//! This test scans every `src/**/*.rs` in the crate for clone-shaped calls (see +//! [`PATTERNS`]) and fails on any that is not in the reviewed [`ALLOW`] ledger — and on +//! any ledger entry whose line is no longer in the source, so a sanction can't outlive +//! the code it sanctions. Occurrences after a comment-starting `//` don't count, so +//! prose about cloning is inert; a `//` *inside a string literal* does not start a +//! comment (this crate embeds TS fixtures like `"\t// @ts-ignore\n"`, and truncating +//! there would hide a clone later on the line). +//! +//! An entry is keyed on `(path, exact trimmed line)`, so **one entry covers every +//! identical line in that file** — identical text carries identical review, and +//! reformatting a sanctioned line forces a fresh one. That equivalence holds only while +//! the reason is derivable from the text: a key generic enough to recur incidentally +//! (a bare `.clone()` left by a chain wrap) would blanket-sanction a file, so +//! `allow_ledger_keys_carry_context` requires every key to carry surrounding code. +//! +//! Test modules are scanned like the rest — a `#[cfg(test)]` clone gets the same +//! one-line review as any other. Oracle-free, std-only, and it rides `cargo test`, so +//! the guard lives and dies with the crate it guards. +//! +//! **What it cannot see.** A clone scanner is a syntax filter, so four re-addressing +//! routes stay outside it: (i) a `Copy` AST type needs no clone syntax at all — +//! `TSKeywordType` is `Copy` *and* address-map-keyed (`leaf(NodeKind::TSKeywordType, +//! …, addr_of(kw), …)`), so a plain `*kw` deref mints a fresh address invisibly; (ii) +//! `to_owned()` / `to_vec()` over an AST slice would copy nodes without a clone call +//! (the one live `decls.to_vec()` in `sym/declare.rs` copies `Decl` PODs, not AST); +//! (iii) clones performed in another crate are out of scope entirely; and (iv) the +//! converse — cloning a struct that merely *holds* `&'arena` references is safe, since +//! the referenced addresses are preserved, so the ledger should not be read as a ban on +//! all cloning. Keep `Copy` AST nodes borrowed for the same reason the ledger exists. + +use std::path::{Path, PathBuf}; + +/// One reviewed, sanctioned clone site: `(crate-relative path, exact trimmed source +/// line, why it is safe)`. Every entry must be a **non-AST** clone — an owned name, a +/// diagnostic, a POD of ids and spans — never an AST node. +type Allow = (&'static str, &'static str, &'static str); + +/// The reviewed ledger. Regenerate the candidate list with +/// `grep -rn '\.clone(\|\.cloned(\|Clone::clone(' crates/tsv_check/src`, then classify +/// each site by hand: what is cloned, and why cloning it can't perturb the address map. +const ALLOW: &[Allow] = &[ + // ── binder ─────────────────────────────────────────────────────────────── + ( + "src/binder/atoms.rs", + "self.names.push(owned.clone());", + "the interner's owned `Box` name — one copy for the id vector, one as the \ + lookup key; no AST node is involved", + ), + ( + "src/binder/flow/build/statements.rs", + "self.label_scratch.get(&label).cloned().unwrap_or_default()", + "a label's pending-antecedent `SmallVec<[FlowNodeId; 4]>` — dense flow-node ids, \ + not AST nodes", + ), + // ── check ──────────────────────────────────────────────────────────────── + ( + "src/check/duplicate_members.rs", + "display: key.clone(),", + "the member key `String` — an `Entry` carries it twice, as the bucket key and as \ + the diagnostic display text", + ), + ( + "src/check/duplicate_members.rs", + "Some((name.clone(), name, id.name_span()))", + "the identifier's owned name `String`, returned as both key and display; the AST \ + identifier itself is only read (`name_span`)", + ), + ( + "src/check/duplicate_members.rs", + "Expression::Literal(lit) => literal_key(ctx, lit).map(|k| (k.clone(), k, lit.span)),", + "the literal's owned key `String`, returned as both key and display; the AST \ + literal itself is only read (`.span`)", + ), + ( + "src/check/duplicate_members.rs", + "Some((keyed.clone(), keyed, pid.span))", + "the `#name` key `String` built by `format!`, returned as both key and display; \ + the AST private identifier is only read (`.span`)", + ), + // ── diag ───────────────────────────────────────────────────────────────── + ( + "src/diag.rs", + "let a = with_chain(diag(Some(0), 0, 0, 1), vec![mid.clone()]);", + "unit-test fixture: an owned `Diagnostic` reused as the chain of two comparands", + ), + ( + "src/diag.rs", + "let a = with_related(diag(Some(0), 0, 0, 1), vec![outer_r.clone()]);", + "unit-test fixture: an owned `Diagnostic` reused as the related info of two \ + comparands", + ), + // ── merge ──────────────────────────────────────────────────────────────── + ( + "src/merge.rs", + "lib_files: libs.iter().map(|l| l.name.clone()).collect(),", + "lib file-name `String`s copied into the base's path table", + ), + ( + "src/merge.rs", + "let entry = globals.entry(sym.name.clone()).or_insert_with(|| LibEntry {", + "the merge symbol's owned name `String` as a globals map key — `FileMerge` is \ + deliberately AST-free and program-independent", + ), + ( + "src/merge.rs", + "source.name.clone(),", + "the merge symbol's owned name `String` as the globals map key", + ), + ( + "src/merge.rs", + "name: source.name.clone(),", + "the same owned name `String`, stored on the `GlobalEntry` for diagnostics", + ), + ( + "src/merge.rs", + "decls: source.decls.clone(),", + "`Vec` — owned `{FileId, Span, bool}` PODs, no AST reference", + ), + ( + "src/merge.rs", + "target.decls.extend(source.decls.iter().cloned());", + "the same owned `MergeDecl` PODs, accumulated onto the merge target", + ), + ( + "src/merge.rs", + "let symbol_name = source.name.clone();", + "the merge symbol's owned name `String`, borrowed by both `add_dup_errors` calls", + ), + // ── program ────────────────────────────────────────────────────────────── + ( + "src/program.rs", + "diagnostics.extend(unit.bind_diagnostics.iter().cloned());", + "owned `Diagnostic`s copied out of the variant-independent bound product so each \ + variant's run gets its own vector", + ), + ( + "src/program.rs", + "name: u.name.clone(),", + "the unit's file-name `String` for its `FileReport`", + ), + ( + "src/program.rs", + "parse: u.parse.clone(),", + "`ParseReport` — an owned goal / module-ness / node-count record (or a rejection \ + message), never the AST", + ), + ( + "src/program.rs", + ".map(|d| (d.args.clone(), d.span.start, d.span.end))", + "unit-test fixture: a diagnostic's owned `Vec` args, for the assertion's \ + failure summary", + ), +]; + +/// The clone-shaped call forms the scan recognizes: the method calls `.clone(` / +/// `.cloned(` / `.clone_from(`, and the qualified forms `Clone::clone(` and `>::clone(` +/// (which catches UFCS `::clone(x)`). Classification is an `any`, so the +/// overlap between the two qualified forms costs nothing. +const PATTERNS: [&str; 5] = [ + ".clone(", + ".cloned(", + ".clone_from(", + "Clone::clone(", + ">::clone(", +]; + +/// The crate directory, prefixed onto reported paths so a violation line resolves from +/// the workspace root — where `cargo test` runs. Ledger keys stay crate-relative. +const CRATE_DIR: &str = "crates/tsv_check"; + +/// The floor on an [`ALLOW`] key's length. A key is a blanket sanction for every +/// identical line in its file, so it must carry enough surrounding code to be specific; +/// a bare `.clone();` left behind by a chain wrap must not qualify. +const MIN_ALLOW_KEY_LEN: usize = 16; + +/// What a new, unreviewed clone site costs — printed beside every violation, because +/// the failure mode this guards is silent and the fix depends on what was cloned. +const HAZARD_HELP: &str = "\ +Cloning an AST node mints a differently-addressed copy, and the binder's address map +keys on `(address, NodeKind)` — so the copy resolves to nothing. The strict path +(`BoundFile::require_node_id`, the flow builder) aborts on that miss, but the lenient +unreachable-candidate lookup just skips the node, leaving a silently wrong candidate +table instead of a crash. + +Two ways out: borrow the node (`&'arena`) instead of cloning it — or, if this is +genuinely a non-AST clone (an owned name, a diagnostic, a POD of ids and spans), add a +reviewed entry to ALLOW in crates/tsv_check/tests/clone_discipline.rs naming what is +cloned and why it cannot perturb the address map."; + +/// What a stale ledger entry means, and the one thing to do about it. +const STALE_HELP: &str = "\ +The sanctioned line is no longer in the source — the clone was removed, or the line was +reformatted (which invalidates its review). Remove the entry from ALLOW in +crates/tsv_check/tests/clone_discipline.rs."; + +/// A detected clone-shaped call site. +struct Site { + path: String, + line_no: usize, + code: String, +} + +#[test] +fn every_clone_site_is_reviewed() { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let src = crate_root.join("src"); + let mut files = Vec::new(); + // An unreadable path — file OR directory — means the scan covered less than it + // claims, which would let a clone through silently. Both land in one loud list. + let mut unreadable: Vec = Vec::new(); + collect_rs_files(&src, &mut files, &mut unreadable); + assert!( + !files.is_empty(), + "no .rs files found under {} — the scan would pass vacuously", + src.display() + ); + + let mut sites: Vec = Vec::new(); + for file in &files { + let Ok(text) = std::fs::read_to_string(file) else { + unreadable.push(file.clone()); + continue; + }; + let rel = crate_relative(file, crate_root); + for (i, line) in text.lines().enumerate() { + if let Some(code) = clone_site_line(line) { + sites.push(Site { + path: rel.clone(), + line_no: i + 1, + code, + }); + } + } + } + assert!( + unreadable.is_empty(), + "unreadable source path(s), so the scan is incomplete: {unreadable:?}" + ); + + let violations: Vec<&Site> = sites.iter().filter(|s| !is_allowed(s)).collect(); + // A sanctioned line that no longer exists: the clone was removed, or the line was + // reformatted (which invalidates the review). The ledger must mirror the live sites + // exactly, so a dead entry fails too. + let stale: Vec<&Allow> = ALLOW + .iter() + .filter(|entry| !sites.iter().any(|s| s.path == entry.0 && s.code == entry.1)) + .collect(); + + let mut report: Vec = Vec::new(); + if !violations.is_empty() { + report.push(format!( + "{} unreviewed clone site(s) in tsv_check:\n", + violations.len() + )); + for v in &violations { + report.push(format!( + " {CRATE_DIR}/{}:{}: {}", + v.path, v.line_no, v.code + )); + } + report.push(format!("\n{HAZARD_HELP}")); + } + if !stale.is_empty() { + if !report.is_empty() { + report.push(String::new()); + } + report.push(format!("{} stale ALLOW entr(y/ies):\n", stale.len())); + for (path, line, reason) in &stale { + report.push(format!(" {path}: {line}\n ({reason})")); + } + report.push(format!("\n{STALE_HELP}")); + } + assert!(report.is_empty(), "{}", report.join("\n")); +} + +#[test] +fn allow_ledger_has_no_duplicate_keys() { + // (path, line) must be unique: a second entry for the same key is dead weight that + // can never go stale on its own, so it would silently outlive its review. + let mut seen = std::collections::BTreeSet::new(); + for (path, line, _) in ALLOW { + assert!( + seen.insert((*path, *line)), + "duplicate ALLOW key: {path}: {line}" + ); + } +} + +#[test] +fn detector_recognizes_the_clone_forms() { + assert_eq!( + clone_site_line("let b = a.clone();").as_deref(), + Some("let b = a.clone();") + ); + assert_eq!( + clone_site_line("\t\txs.iter().cloned().collect()").as_deref(), + Some("xs.iter().cloned().collect()") + ); + assert_eq!( + clone_site_line("dst.clone_from(&src);").as_deref(), + Some("dst.clone_from(&src);") + ); + assert_eq!( + clone_site_line("let b = Clone::clone(&a);").as_deref(), + Some("let b = Clone::clone(&a);") + ); + // The UFCS form names no `Clone::` path of its own. + assert_eq!( + clone_site_line("let b = ::clone(a);").as_deref(), + Some("let b = ::clone(a);") + ); + // A derive is not a call site. + assert_eq!(clone_site_line("#[derive(Clone)]"), None); + assert_eq!(clone_site_line("let x = 1 + 2;"), None); +} + +#[test] +fn detector_ignores_comments() { + // Doc and line comments discussing clones are prose, not call sites. + assert_eq!(clone_site_line("//! one `.clone()` breaks the map"), None); + assert_eq!(clone_site_line("/// never call `.clone()` here"), None); + assert_eq!( + clone_site_line(" // node.clone() would mint a copy"), + None + ); + // …but a real clone with trailing commentary still counts, keyed on the whole line. + assert_eq!( + clone_site_line("let n = name.clone(); // owned String").as_deref(), + Some("let n = name.clone(); // owned String") + ); +} + +#[test] +fn detector_sees_past_a_slash_slash_inside_a_string() { + // The crate embeds TS fixtures carrying `//`; cutting the line there would hide + // every clone after it. Both a URL and an embedded line comment must stay open. + assert_eq!( + clone_site_line("let s = \"https://x\"; s.clone();").as_deref(), + Some("let s = \"https://x\"; s.clone();") + ); + assert_eq!( + clone_site_line("let src = \"a();\\n\\t// @ts-ignore\\n\"; src.clone();").as_deref(), + Some("let src = \"a();\\n\\t// @ts-ignore\\n\"; src.clone();") + ); + // A real comment after a balanced string still ends the code. + assert_eq!(clone_site_line("let s = \"//\"; // s.clone() here"), None); +} + +#[test] +fn allow_ledger_keys_carry_context() { + // A key blanket-sanctions every identical line in its file, so it has to be + // specific: it must not be (or start as) a bare clone call, and must carry + // surrounding code. A `.clone();` left by a rustfmt chain wrap fails both. + for (path, line, _) in ALLOW { + assert!( + !PATTERNS.iter().any(|pattern| line.starts_with(pattern)), + "ALLOW key is a bare clone call, so it would sanction any such line in \ + {path}: {line}" + ); + assert!( + line.len() >= MIN_ALLOW_KEY_LEN, + "ALLOW key is too generic ({} < {MIN_ALLOW_KEY_LEN} chars) in {path}: {line}", + line.len() + ); + } +} + +/// Whether `site` matches an [`ALLOW`] entry on path **and** exact trimmed line. +fn is_allowed(site: &Site) -> bool { + ALLOW + .iter() + .any(|(path, line, _)| *path == site.path && *line == site.code) +} + +/// The trimmed text of `line` if its code carries a clone-shaped call, else `None`. +/// +/// The returned key is the *whole* trimmed line, trailing comment included — the ledger +/// sanctions a line as written. +fn clone_site_line(line: &str) -> Option { + let code = code_before_comment(line); + PATTERNS + .iter() + .any(|pattern| code.contains(pattern)) + .then(|| line.trim().to_string()) +} + +/// The code portion of `line`: everything ahead of the first `//` that actually starts +/// a comment. Cutting there subsumes the whole-line-comment case (`//`, `///` and `//!` +/// all leave nothing but indentation ahead of it) and drops trailing commentary, so +/// prose about cloning never trips the scan — but a `//` inside a string literal is +/// data, not a comment, and is skipped so a clone later on the line stays visible. +fn code_before_comment(line: &str) -> &str { + let mut from = 0; + while let Some(rel) = line[from..].find("//") { + let at = from + rel; + if !ends_inside_string(&line[..at]) { + return &line[..at]; + } + from = at + 2; + } + line +} + +/// Whether `prefix` ends inside a double-quoted literal — an odd number of `"` that +/// aren't backslash-escaped. +/// +/// A heuristic, and deliberately biased: raw strings (`r#"…"#`), byte strings and a +/// `'"'` char literal all read as an unbalanced quote, which makes this answer *yes* +/// and keeps **more** of the line in scope. So a misjudgment can only surface a site +/// for review (a false positive the ledger resolves), never hide one. +fn ends_inside_string(prefix: &str) -> bool { + let bytes = prefix.as_bytes(); + let mut quotes = 0usize; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + // Skip the escaped byte, so `\"` never counts as a delimiter. + b'\\' => i += 1, + b'"' => quotes += 1, + _ => {} + } + i += 1; + } + quotes % 2 == 1 +} + +/// Every `.rs` file under `dir`, recursively, in deterministic (sorted) order. A +/// directory that cannot be read is recorded in `unreadable` rather than skipped — a +/// silently pruned subtree is a hole in the scan. +fn collect_rs_files(dir: &Path, out: &mut Vec, unreadable: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + unreadable.push(dir.to_path_buf()); + return; + }; + let mut paths: Vec = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .collect(); + paths.sort(); + for path in paths { + if path.is_dir() { + collect_rs_files(&path, out, unreadable); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } +} + +/// Crate-relative path: `/src/merge.rs` → `src/merge.rs`. +fn crate_relative(path: &Path, crate_root: &Path) -> String { + path.strip_prefix(crate_root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} diff --git a/crates/tsv_check/tests/lib_base.rs b/crates/tsv_check/tests/lib_base.rs new file mode 100644 index 000000000..cd3b938f8 --- /dev/null +++ b/crates/tsv_check/tests/lib_base.rs @@ -0,0 +1,112 @@ +//! End-to-end lib-base integration: parse + bind real (small) `.d.ts` lib sources, +//! fold them into a [`LibBase`], and check a program against it — the full S5 path +//! (`bind_lib` -> `LibBase::build` -> `check_program_with_lib`) a single move. +//! +//! Distinct from the `merge` unit tests (which drive synthetic `FileMerge`s): these +//! drive the *binder* over real lib TypeScript, so the lib global's flags come from +//! the same bind path the harness uses. + +use bumpalo::Bump; +use tsv_check::{CheckOptions, FileId, LibBase, SourceUnit, bind_lib, check_program_with_lib}; + +/// `var eval;` conflicts with the lib's `declare function eval` — the +/// `variableDeclarationInStrictMode1` shape, end to end. +#[test] +fn var_eval_conflicts_with_lib_function_eval() { + let es5 = + bind_lib("lib.es5.d.ts", "declare function eval(x: string): any;").expect("lib parses"); + let base = LibBase::build(&[&es5]); + + let arena = Bump::new(); + let units = [SourceUnit::new("t.ts", "\"use strict\";\nvar eval;")]; + let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default()); + + // The observable primary is on the test file (FileId 0); the lib-file primary + // (FileId 1 = lib.es5.d.ts) is present too but is what the baseline masks. + let test_primary = result + .diagnostics + .iter() + .find(|d| d.file == Some(FileId(0)) && d.code == 2300) + .expect("a TS2300 on the test file"); + // `var eval` starts on line 2, column 5 -> byte offset of the `eval` name. + assert_eq!(test_primary.related.len(), 1); + assert_eq!(test_primary.related[0].code, 6203); + assert_eq!(test_primary.related[0].file, Some(FileId(1))); // lib.es5.d.ts + // A masked lib-file primary exists (the runner drops it; the baseline hides it). + assert!( + result + .diagnostics + .iter() + .any(|d| d.file == Some(FileId(1)) && d.code == 2300) + ); +} + +/// `class Promise {}` conflicts with a lib global declared across several files, +/// producing the priority-ordered TS6203/6204 related chain. +#[test] +fn class_promise_conflicts_across_lib_files() { + // Priority order: es5 (interface), es2015.iterable (interface), es2015.promise + // (var) — the fold order fixes the related-info attribution. + let es5 = bind_lib("lib.es5.d.ts", "interface Promise {}").expect("parses"); + let iterable = bind_lib("lib.es2015.iterable.d.ts", "interface Promise {}").expect("parses"); + let promise = bind_lib( + "lib.es2015.promise.d.ts", + "declare var Promise: PromiseConstructor;", + ) + .expect("parses"); + let base = LibBase::build(&[&es5, &iterable, &promise]); + + let arena = Bump::new(); + let units = [SourceUnit::new( + "promiseDefinitionTest.ts", + "class Promise {}", + )]; + let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default()); + + let primary = result + .diagnostics + .iter() + .find(|d| d.file == Some(FileId(0)) && d.code == 2300) + .expect("a TS2300 on the test file"); + let codes: Vec = primary.related.iter().map(|r| r.code).collect(); + assert_eq!(codes, vec![6203, 6204, 6204]); + // Priority order: es5 (FileId 1), es2015.iterable (2), es2015.promise (3). + let files: Vec> = primary.related.iter().map(|r| r.file).collect(); + assert_eq!( + files, + vec![Some(FileId(1)), Some(FileId(2)), Some(FileId(3))] + ); +} + +/// A clean augmentation (`interface Array {}` merging into the lib's `Array`) +/// emits nothing — the lib base must not manufacture spurious conflicts. +#[test] +fn interface_augmentation_of_lib_is_silent() { + let es5 = bind_lib( + "lib.es5.d.ts", + "interface Array { length: number; }\ndeclare var Array: ArrayConstructor;", + ) + .expect("parses"); + let base = LibBase::build(&[&es5]); + + let arena = Bump::new(); + let units = [SourceUnit::new( + "t.ts", + "interface Array { extra(): void; }", + )]; + let result = check_program_with_lib(&units, Some(&base), &arena, &CheckOptions::default()); + assert!( + result.diagnostics.is_empty(), + "a legal interface merge must be silent" + ); +} + +/// With no lib base, the same program is clean (the conflict is lib-sourced) — +/// proving the base is what introduces the cross-declaration-space conflict. +#[test] +fn no_lib_base_no_conflict() { + let arena = Bump::new(); + let units = [SourceUnit::new("t.ts", "var eval;")]; + let result = check_program_with_lib(&units, None, &arena, &CheckOptions::default()); + assert!(result.diagnostics.is_empty()); +} diff --git a/crates/tsv_debug/CLAUDE.md b/crates/tsv_debug/CLAUDE.md index 82b77468f..bc584d732 100644 --- a/crates/tsv_debug/CLAUDE.md +++ b/crates/tsv_debug/CLAUDE.md @@ -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, `