diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2063fad..c9a882c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ before the implementation. The loop is **type → red → green → refactor**: `npm run verify` as the definition-of-done gate. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. +The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the +helper, `src/primitive.test.ts` for the matcher's popup) are the +exception — +the language server, not the type system, is the oracle (see +[development/testing.md § Autocomplete](./development/testing.md#autocomplete)). Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher factory), `// Act` exercises the subject once from them (not a second diff --git a/backlog.tasks b/backlog.tasks index 448a052..4593d94 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -27,6 +27,21 @@ v1.0: ☐ Achieve 100% branch coverage on `src/primitive.ts` ☐ Achieve 100% branch coverage on `src/index.ts` +Testing: +✔ Cover autocomplete with real completion test cases in the suite @medium @done + → `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete) + → wire it into `node --test` so a test asserts the offered labels + → note: `Parameters[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend>` + +Matcher: +✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done + → `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing) + → fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` +☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium + → today `_: (shape: T) => R`; desired `_: (shape: Exclude) => R` +☐ An exhaustive pattern that also carries `_` must be a compile error @medium + → `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected + Bugs: Enhancements: @@ -41,6 +56,7 @@ Documentation: ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation +✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done ☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API Workflow: diff --git a/development/library.md b/development/library.md index 280e467..5f89e22 100644 --- a/development/library.md +++ b/development/library.md @@ -3,4 +3,91 @@ The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). -Currently the library is placeholder code. +The matcher below is implemented in `src/primitive.ts` and re-exported from +`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is +placeholder code. + +## Matcher shape + +#### Decision (2026-09) + +A matcher is built by a factory and applied to a pattern: + +```ts +const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); +``` + +Whether the pattern is exhaustive or has a fallback is decided **at the call +site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness +axis remains, so there are two factories: + +- `getMatcher` — one common `R`, the best common return type of every handler; +- `getMatcherW` — the union of every handler's return type. + +Both are three overloads whose order is load-bearing: + +1. `ExhaustiveLoose` = `{ [K in T]: UnaryFn } & { _?: UnaryFn }` +2. `Fallback` = `Partial<…> & { _: UnaryFn }` +3. `Handlers` — the pure exhaustive shape + +#### Why + +- **Autocomplete reads the first overload, the error reads the last.** + TypeScript takes the first overload signature as the contextual type for the + object-literal popup, and the last for `No overload matches this call`. So + `ExhaustiveLoose` first yields the popup `_?, a, b` (T-keys required, `_` + optional) while `Handlers` last yields `Property 'b' is missing`. The two can be + tuned independently. +- Two factories, not four: the fallback is a pattern _shape_, not a separate API. +- The widened return union is derived from the pattern's handler types, so it + needs no fourth signature. + +#### Rejected + +- **Four factories** (exhaustive and fallback each split by return handling). + The exhaustive/fallback axis is expressible as one pattern type; four + signatures duplicate it. +- **Union merge** — one type `Exhaustive | (Partial<…> & { _: … })`, + explicit `()`. Type-safe and completable, but TypeScript reports the + near-miss union member, so a missing key reads `Property '_' is missing` + instead of naming the key. Arm order does not change the report; the overload + split does. +- **Overload merge with only the exhaustive arm last.** Fixes the missing-key + message, but a wrong `_` parameter is then reported against the exhaustive + arm, and `Parameters` sees only one arm. +- **Inferred universe** — `match(pattern)` with `T` taken from the keys + (exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer` and + `_?: never`, split by overloads (a plain union merges inference; measured + `T = "_" | "a"`). No explicit ``, and pipe-friendly. Rejected because: with + no declared universe the exhaustive popup offers only `_`; an unannotated `_` + widens `T` to `string | number`; and `NoInfer` leaks into the emitted `.d.ts`, + raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`). +- **Conditional `RequireKeys`** — parameter + `P & ("_" extends keyof P ? unknown : Handlers)`. Gives the good + missing-key message, but `keyof P` counts _optional_ keys: a widened value + whose declared type has `_?:` bypasses the completeness check. Demanding a + required `_` instead rejects that case but breaks `P` inference — `P` falls back + to its constraint and partial literals then demand every key. Typos also need a + `NoExtra` guard, whose message degrades to `not assignable to never`. +- **Cases-first curried** — `match(["a", "b"])({ a: …, b: … })`. Completion works + for exhaustive patterns, and the array is a single source of truth for the + runtime list and the union. Rejected as not pipe-friendly; it needs a runtime + array; and the single-call form `match(cases, pattern)` cannot infer `R` (the + mapped key type `K[number]` stays deferred, so `R` widens to `unknown`). + +#### Known issue + +- The `_` handler receives **all** of `T`, not the unhandled subset + (`Exclude`). +- An exhaustive pattern that also carries `_` is accepted; the redundant `_` + should be a compile error. +- The widened overloads carry a completeness guard + `keyof P extends T | "_" ? unknown : never`, because TypeScript does not apply + the excess-property check to a generic constraint: a generic parameter accepts + extra keys, a parameter typed as a concrete object type does not. + `PatternReturns` must be + `ReturnType, (...args: never[]) => unknown>>` so it survives + the closed, partly-optional `P` constraints. +- `Parameters[0]` resolves only the **last** overload, so it is + not a sound "rejected" oracle for a factory. Factory-negative tests use + `@ts-expect-error` call sites (the test file only — the general ban stands). diff --git a/development/testing.md b/development/testing.md index 7b6629e..edcd407 100644 --- a/development/testing.md +++ b/development/testing.md @@ -30,7 +30,8 @@ before the implementation. - Testing the type only: it would not catch handler dispatch or the `_` fallback (see `src/primitive.test.ts`). -The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in +The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are +in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a build step, and the runner relies on the `.ts` import-extension convention (see @@ -95,7 +96,11 @@ import { LspSession } from "#test-utils/lsp-completion.ts"; loader needed. - The helper uses `node:` builtins (it drives a language server), so `import/no-nodejs-modules` is off for `src/util/__tests__/**` in - `.oxlintrc.json` — the same exception the old `scripts/**` scope carried. + `.oxlintrc.json` — the same exception the `scripts/**` scope carries. +- Helpers carry no library coupling: their tests probe in-memory documents + whose contextual types are written inline, so only the helper's own contract + (marker handling, position, label extraction) is under test. A test that + asserts a _matcher's_ popup belongs with the matcher. #### Rejected @@ -111,11 +116,59 @@ import { LspSession } from "#test-utils/lsp-completion.ts"; still guards non-test helpers importing each other, and the exemption is only needed for the one specifier the mapping already solves cleanly. +## Autocomplete + +#### Decision (2026-09) + +Completion is verified by driving the repo's own language server +(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through +the test helper `#test-utils/lsp-completion.ts` +(`src/util/__tests__/lsp-completion.ts`), not through the type system: + +```sh +node --strip-types src/util/__tests__/lsp-completion.ts [] +``` + +The script prints the labels the server offers at a `/*COMPLETE*/` marker inside +`` (the marker is stripped before the document is sent). Its `LspSession` +is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the +helper itself against inline documents, never the library's code — and by +`src/primitive.test.ts`, where the same probe asserts the matcher's popup; +the CLI is for manual inspection. + +#### Why + +- Completion is a contextual-type property: it depends on which overload + signature TypeScript picks for the object literal, and no type-level assertion + observes that. +- `Parameters[0]` resolves only the _last_ overload, so it is + not the popup's contextual type either — see + [library.md § Matcher shape](./library.md#matcher-shape). +- The server is the only ground truth; the script reproduces what the editor + shows. + +#### Rejected + +- **`expect-type` would not work**: there is no operator for “the popup offers + these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say + which overload supplied the contextual type. +- **Checking by hand in the editor**: not reproducible in review or by an agent. +- **`@ts-expect-error` at a completion position**: it asserts the absence of a + compile error, not the presence of specific labels. + +#### Known issue + +- Each test spawns its own `tsc` server so the tests share no state and pass in + any order; the file is an integration test (~1.6 s) that needs `node_modules`. + `didOpen` is handled in order before the completion request, so no settle + delay is needed. +- The server answers some requests with a string id (`client/registerCapability`); + the client must tolerate `string | number` ids or the server stalls. + ## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - test files that use it (`src/primitive.test.ts`) carry a - file-level `oxlint-disable -typescript/no-floating-promises` with an explanatory comment. It is a known - false positive, not a rule worth disabling project-wide (see + test files that use it (`src/primitive.test.ts`) carry a file-level + `oxlint-disable typescript/no-floating-promises` with an explanatory comment. + It is a known false positive, not a rule worth disabling project-wide (see [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). diff --git a/src/index.ts b/src/index.ts index 096a964..413625f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1 @@ -export { - getPrimitiveUnionMatcher, - getPrimitiveUnionMatcherPartial, - getPrimitiveUnionMatcherPartialW, - getPrimitiveUnionMatcherW, -} from "./primitive.ts"; +export { getMatcher, getMatcherW } from "./primitive.ts"; diff --git a/src/primitive.test.ts b/src/primitive.test.ts index ee9f57e..6ec1899 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -1,50 +1,49 @@ /* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ import { strict as assert } from "node:assert"; +import path from "node:path"; import { test } from "node:test"; import { expectTypeOf } from "expect-type"; import { - getPrimitiveUnionMatcher, - getPrimitiveUnionMatcherPartial, - getPrimitiveUnionMatcherPartialW, - getPrimitiveUnionMatcherW, -} from "./primitive.ts"; + type CompletionTarget, + LspSession, +} from "#test-utils/lsp-completion.ts"; + +import { getMatcher, getMatcherW } from "./primitive.ts"; // ============================================================================ -// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict // ============================================================================ -test("getPrimitiveUnionMatcherW requires every literal key", () => { +test("getMatcher: exhaustive pattern infers one common return type", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<"a" | "b">(); + const factory = getMatcher<"a" | "b">(); // Act const matcher = factory({ - a: (s) => { + a: (s): number => { expectTypeOf(s).toEqualTypeOf<"a">(); - return 1 as const; + return 1; }, - b: (s) => { + b: (s): 1 | 2 => { expectTypeOf(s).toEqualTypeOf<"b">(); - return "two" as const; + return 2; }, }); // Assert - // ❌ ReturnsStrict: mixed handler returns widen to their union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">(); - // A pattern missing a key must not satisfy the parameter type. - expectTypeOf<{ - a: () => number; - }>().not.toExtend[0]>(); + // ✔️ ReturnsStrict: R is the best common return type, not a widening union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"c">().not.toExtend[0]>(); assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), "two"); + assert.equal(matcher("b"), 2); }); -test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { +test("getMatcher: dispatches on numeric literal keys", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<1 | 2>(); + const factory = getMatcher<1 | 2>(); // Act const matcher = factory({ @@ -64,9 +63,150 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { assert.equal(matcher(2), 20); }); -test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => { +test("getMatcher: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); + const factory = getMatcher<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): number => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + b: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return 2; + }, + 1: (n): number => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10; + }, + 2: (n): number => { + expectTypeOf(n).toEqualTypeOf<2>(); + return 20; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher(1), 10); + assert.equal(matcher(2), 20); +}); + +// ============================================================================ +// API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict +// ============================================================================ + +test("getMatcher: a `_` fallback makes the universe keys optional", () => { + // Arrange + const factory = getMatcher<"a" | "b" | "c">(); + + // Act + const matcher = factory({ + a: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + _: (s): 1 | 2 => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); + return 2; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"d">().not.toExtend[0]>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher("c"), 2); +}); + +test("getMatcher: a `_` fallback also accepts an exhaustive pattern", () => { + // Arrange + const factory = getMatcher<"a" | "b">(); + + // Act + const matcher = factory({ + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return "B"; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "B"); +}); + +test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { + // Arrange + const factory = getMatcher<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + 1: (n): string => { + expectTypeOf(n).toEqualTypeOf<1>(); + return "one"; + }, + _: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + return "fallback"; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "fallback"); + assert.equal(matcher(1), "one"); + assert.equal(matcher(2), "fallback"); +}); + +// ============================================================================ +// API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => { + // Arrange + const factory = getMatcherW<"x" | "y">(); + + // Act + const matcher = factory({ + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, + y: (s) => { + expectTypeOf(s).toEqualTypeOf<"y">(); + return "two" as const; + }, + }); + + // Assert + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"z">().not.toExtend[0]>(); + assert.equal(matcher("x"), 1); + assert.equal(matcher("y"), "two"); +}); + +test("getMatcherW: dispatches on mixed string and numeric keys", () => { + // Arrange + const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -99,181 +239,12 @@ test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () }); // ============================================================================ -// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict +// API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict // ============================================================================ -test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => { +test("getMatcherW: a `_` fallback widens gaps into the union", () => { // Arrange - const factory = getPrimitiveUnionMatcher<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s): number => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - b: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return 2; - }, - }); - - // Assert - // ✔️ ReturnsStrict: R is the best common return type, not a widening union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); -}); - -test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { - // Arrange - const factory = getPrimitiveUnionMatcher<"on" | "off">(); - - // Act - const matcher = factory({ - on: (s) => { - expectTypeOf(s).toEqualTypeOf<"on">(); - return `handler ${s}`; - }, - off: (s) => { - expectTypeOf(s).toEqualTypeOf<"off">(); - return `handler ${s}`; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>(); - assert.equal(matcher("on"), "handler on"); - assert.equal(matcher("off"), "handler off"); -}); - -test("getPrimitiveUnionMatcher infers one return type across mixed string and numeric keys", () => { - // Arrange - const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>(); - - // Act - const matcher = factory({ - a: (s): number => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - b: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return 2; - }, - 1: (n): number => { - expectTypeOf(n).toEqualTypeOf<1>(); - return 10; - }, - 2: (n): number => { - expectTypeOf(n).toEqualTypeOf<2>(); - return 20; - }, - }); - - // Assert - // ✔️ ReturnsStrict: R is the best common return type, not a widening union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); - assert.equal(matcher(1), 10); - assert.equal(matcher(2), 20); -}); - -// ============================================================================ -// API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict -// ============================================================================ - -test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">(); - // Two keys, so `{ a }` lacks only the `_` fallback, nothing else. - const sparseFactory = getPrimitiveUnionMatcherPartial<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - _: (s): 1 | 2 => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); - return 2; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); - // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. - expectTypeOf<{ - a: () => number; - }>().not.toExtend[0]>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); - assert.equal(matcher("c"), 2); -}); - -test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s) => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; - }, - b: (s) => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return "B"; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); - assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "B"); -}); - -test("getPrimitiveUnionMatcherPartial routes mixed string and numeric keys, gaps go to _", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | 1 | 2>(); - - // Act - const matcher = factory({ - a: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; - }, - 1: (n): string => { - expectTypeOf(n).toEqualTypeOf<1>(); - return "one"; - }, - _: (s): string => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); - return "fallback"; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); - assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "fallback"); - assert.equal(matcher(1), "one"); - assert.equal(matcher(2), "fallback"); -}); - -// ============================================================================ -// API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict -// ============================================================================ - -test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">(); - // Two keys, so `{ x }` lacks only the `_` fallback, nothing else. - const sparseFactory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + const factory = getMatcherW<"x" | "y" | "z">(); // Act const matcher = factory({ @@ -292,18 +263,16 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha expectTypeOf(matcher).toEqualTypeOf< (shape: "x" | "y" | "z") => 1 | "fallback" >(); - // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. - expectTypeOf<{ - x: () => number; - }>().not.toExtend[0]>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"w">().not.toExtend[0]>(); assert.equal(matcher("x"), 1); assert.equal(matcher("y"), "fallback"); assert.equal(matcher("z"), "fallback"); }); -test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => { +test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => { // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>(); + const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -316,7 +285,6 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur return 10 as const; }, _: (s) => { - // The fallback sees the whole union, not a single literal. expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); return "fallback" as const; }, @@ -333,9 +301,9 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur assert.equal(matcher(2), "fallback"); }); -test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => { +test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => { // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + const factory = getMatcherW<"x" | "y">(); // Act const matcher = factory({ @@ -350,8 +318,140 @@ test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () = }); // Assert - // ❌ ReturnsStrict: mixed handler returns widen to their union. expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); assert.equal(matcher("x"), 1); assert.equal(matcher("y"), "two"); }); + +// ============================================================================ +// Factory contracts — calls that must not compile +// ============================================================================ + +test("getMatcher factory rejects patterns outside its contract", () => { + // Arrange + const factory = getMatcher<"a" | "b">(); + + // Act / Assert — the calls below must not compile. + // `Parameters[0]` resolves only the *last* overload, so it + // is not a sound "rejected" oracle (an accepted partial pattern does not + // extend it either); the call sites are the oracle instead. + factory({ + a: () => 1, + b: () => 2, + // @ts-expect-error `c` is not part of the universe `"a" | "b"` + c: () => 3, + }); + // @ts-expect-error a gap without `_` is not exhaustive + factory({ a: () => 1 }); +}); + +test("getMatcherW factory rejects patterns outside its contract", () => { + // Arrange + const factory = getMatcherW<"x" | "y">(); + + // Act / Assert — the calls below must not compile + // @ts-expect-error `z` is not part of the universe `"x" | "y"` + factory({ + x: () => 1 as const, + y: () => 2 as const, + z: () => 3 as const, + }); + // @ts-expect-error a gap without `_` is not exhaustive + factory({ x: () => 1 as const }); +}); + +// ============================================================================ +// Autocomplete — the language server is the oracle, not the type system +// ============================================================================ + +// Completion is a contextual-type property that the type system cannot observe, +// so the cases below read the popup from the repo's language server (via the +// test helper) rather than pairing `expectTypeOf` with `assert` — see +// development/testing.md § Autocomplete. They probe the *matcher's* overloads, +// which is why they live with the matcher and not with the helper. +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const UNIVERSE = `"a" | "b" | "c"`; + +// One session per probe: the tests share no language-server state (open +// documents, project membership), so they pass in any order. +const labelsFor = ( + name: string, + factory: "getMatcher" | "getMatcherW", + body: string, +): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = { + file: `src/__autocomplete_${name}.ts`, + source: [ + `import { ${factory} } from "./index.ts";`, + `const m = ${factory}<${UNIVERSE}>()({`, + body, + "});", + "", + ].join("\n"), + }; + return session + .completionLabelsAt(target) + .then((result) => result.labels) + .finally(() => session.close()); +}; + +test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { + // Arrange + const name = "getMatcher_fresh"; + + // Act + const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/"); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "a", "b", "c"]); + }); +}); + +test("autocomplete: handled keys drop out of the popup", () => { + // Arrange + const name = "getMatcher_after_key"; + + // Act + const labels = labelsFor( + name, + "getMatcher", + " a: () => 1,\n /*COMPLETE*/", + ); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "b", "c"]); + }); +}); + +test("autocomplete: `_` makes the remaining keys optional", () => { + // Arrange + const name = "getMatcher_after_fallback"; + + // Act + const labels = labelsFor( + name, + "getMatcher", + " _: () => 0,\n /*COMPLETE*/", + ); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["a?", "b?", "c?"]); + }); +}); + +test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => { + // Arrange + const name = "getMatcherW_fresh"; + + // Act + const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/"); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "a", "b", "c"]); + }); +}); diff --git a/src/primitive.ts b/src/primitive.ts index 5046949..7c13a32 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -1,71 +1,63 @@ -import type { Simplify, ValueOf } from "type-fest"; +import type { ValueOf } from "type-fest"; type UnaryFn = (shape: T) => R; -// ============================================================================ -// ✔️ Exhaustive -// ❌ ReturnsStrict -// ============================================================================ -type PatternPrimitiveUnion = { - [K in T]: UnaryFn; +// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works +// when `P`'s constraint has optional keys. +type PatternReturns

= ReturnType< + Extract, (...args: never[]) => unknown> +>; + +type Handlers = { [K in T]: UnaryFn }; + +// Fallback form: `_` is a required key, the T-keys are optional. +type Fallback = Partial> & { + _: UnaryFn; }; -type PatternReturns< - P extends Record>, -> = ReturnType>; +// Completion form: T-keys required, `_` optional. Overload #1, because +// TypeScript takes the *first* overload as the contextual type for the popup. +type ExhaustiveLoose = Handlers & { + _?: UnaryFn; +}; -export const getPrimitiveUnionMatcherW: () => < - P extends PatternPrimitiveUnion, ->( - pattern: Simplify

, -) => UnaryFn> = () => (pattern) => (shape) => - // Rewrite not to use any is possible, was evaluated and solutions were - // more complex than the current solution. +// oxlint-disable typescript/unified-signatures +// Strict returns: one common `R`. Overload order is load-bearing: +// #1 ExhaustiveLoose -> autocomplete `_?, a, b` +// #2 Fallback -> accepts a partial pattern +// #3 Handlers (last) -> "Property 'b' is missing" is the reported error +interface MatcherStrict { + (pattern: ExhaustiveLoose): UnaryFn; + (pattern: Fallback): UnaryFn; + (pattern: Handlers): UnaryFn; +} +// oxlint-enable typescript/unified-signatures - // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion - (pattern[shape] as any)(shape); +// Widened returns: the union of every handler's return type. `P` is inferred +// from the whole parameter, whose closed constraint supplies the +// contextual/autocomplete type; the `keyof P` guard appended to each overload +// rejects keys outside `T`/`_`. +interface MatcherWidening { +

>( + pattern: P & (keyof P extends T | "_" ? unknown : never), + ): UnaryFn>; +

>( + pattern: P & (keyof P extends T | "_" ? unknown : never), + ): UnaryFn>; +

>( + pattern: P & (keyof P extends T | "_" ? unknown : never), + ): UnaryFn>; +} -// ============================================================================ -// ✔️ Exhaustive -// ✔️ ReturnsStrict -// ============================================================================ -export const getPrimitiveUnionMatcher: () => ( - pattern: Simplify>, -) => UnaryFn = getPrimitiveUnionMatcherW; +type HandlerMap = Record | undefined>; -// ============================================================================ -// ❌ Exhaustive -// ✔️ ReturnsStrict -// ============================================================================ -type PatternPrimitiveUnionPartial = - | PatternPrimitiveUnion - | (Partial> & { - _: UnaryFn; - }); +const dispatch = + (pattern: HandlerMap) => + (shape: string | number): unknown => + // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion + (pattern[shape] ?? pattern["_"]!)(shape as never); -export const getPrimitiveUnionMatcherPartial: () => < - R, ->( - pattern: Simplify>, -) => UnaryFn = () => (pattern) => (shape) => - // Rewrite not to use any is possible, was evaluated and solutions were - // more complex than the current solution. - - // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion - (pattern[shape] ?? (pattern as any)["_"])(shape); - -// ============================================================================ -// ❌ Exhaustive -// ❌ ReturnsStrict -// ============================================================================ -export const getPrimitiveUnionMatcherPartialW: < - T extends string | number, ->() =>

>( - // `Simplify

` is the inference hook: callers infer `P` from the argument. - // the second half pins the impl parameter's `R` to `PatternReturns

`. - // that makes the `= getPrimitiveUnionMatcherPartial` assignment type-check. - // neither half works alone. - // without the witness the union's `_` arm demands `_ ∈ keyof P`. - // without `Simplify

` the parameter types do not compare. - pattern: Simplify

& PatternPrimitiveUnionPartial, T>, -) => UnaryFn> = getPrimitiveUnionMatcherPartial; +export const getMatcher = (): MatcherStrict => + dispatch; +export const getMatcherW = (): MatcherWidening => + dispatch; diff --git a/src/util/__tests__/lsp-completion.test.ts b/src/util/__tests__/lsp-completion.test.ts index fcaab51..c02f74e 100644 --- a/src/util/__tests__/lsp-completion.test.ts +++ b/src/util/__tests__/lsp-completion.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ import { strict as assert } from "node:assert"; +import path from "node:path"; import { test } from "node:test"; import { expectTypeOf } from "expect-type"; @@ -10,6 +11,8 @@ import { LspSession, } from "#test-utils/lsp-completion.ts"; +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + // The `#test-utils/*` self-reference (package.json#imports) is the one route // scattered test files take to reach test helpers; this pins that it resolves // and types without starting a language server @@ -30,3 +33,116 @@ test("test-helper home: `#test-utils/…` resolves to the helper and types it", assert.equal(typeof LspSession, "function"); assert.equal(target.file, "src/util/__tests__/lsp-completion.ts"); }); + +// The helper drives a language server, so its contract is asserted against the +// server. Every document below is probed from memory and carries its +// contextual type inline, so the helper is tested without the library's code. +const probe = (source: string, marker?: string): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = + marker === undefined + ? { file: "src/__probe.ts", source } + : { file: "src/__probe.ts", source, marker }; + return session.completionLabelsAt(target).finally(() => session.close()); +}; + +const HANDLERS = [ + "type Handlers = { a: () => number; b: () => number; _?: () => number };", + "declare const apply: (handlers: Handlers) => Handlers;", +]; + +test("helper: a fresh object literal completes with its contextual keys", () => { + // Arrange + const source = [ + ...HANDLERS, + "const done = apply({", + " /*COMPLETE*/", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert — the position is the marker's, which the helper strips + expectTypeOf(probed).toEqualTypeOf>(); + return probed.then((result) => { + assert.deepEqual(result.position, { line: 3, character: 4 }); + assert.deepEqual([...result.labels], ["_?", "a", "b"]); + }); +}); + +test("helper: a handled key drops out of the popup", () => { + // Arrange + const source = [ + ...HANDLERS, + "const done = apply({", + " b: () => 1,", + " /*COMPLETE*/", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert + expectTypeOf().toEqualTypeOf< + readonly string[] + >(); + return probed.then((result) => { + assert.deepEqual([...result.labels], ["_?", "a"]); + }); +}); + +test("helper: a custom marker is located at the line start", () => { + // Arrange + const source = [ + "type Handlers = { a: () => number; _?: () => number };", + "declare const apply: (handlers: Handlers) => Handlers;", + "const done = apply({", + "@@@", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source, "@@@"); + + // Assert + expectTypeOf(probed).resolves.toEqualTypeOf(); + return probed.then((result) => { + assert.deepEqual(result.position, { line: 3, character: 0 }); + assert.deepEqual([...result.labels], ["_?", "a"]); + }); +}); + +test("helper: labels are read from the server, not from a pattern literal", () => { + // Arrange + const source = [ + 'const text = "x";', + "const upper = text./*COMPLETE*/;", + "export { upper };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert + expectTypeOf(probed).resolves.toHaveProperty("labels"); + return probed.then((result) => { + assert.ok(result.labels.includes("toUpperCase")); + }); +}); + +test("helper: a source without the marker rejects", () => { + // Arrange + const source = "const text = 1;\nexport { text };\n"; + + // Act + const probed = probe(source); + + // Assert + expectTypeOf(probed).resolves.toEqualTypeOf(); + return assert.rejects(probed, /marker not found/); +});