diff --git a/lib/pipeline.ts b/lib/pipeline.ts index a74d23e..8d2ae77 100644 --- a/lib/pipeline.ts +++ b/lib/pipeline.ts @@ -268,7 +268,7 @@ type Apply = Delta extends D ? Conservative : D extends Batch ? Fold : D extends Dynamic ? S extends AnyRoute - ? Apply, DeltaOf> extends infer After extends AnyRoute ? Route< + ? DynamicAfter, E> extends infer After extends AnyRoute ? Route< After["name"], MethodsOf, ConjoinPhases @@ -281,6 +281,14 @@ type Apply = Delta extends D ? Conservative type DeltaOf = E[typeof operation]; +type DynamicAfter< + S extends AnyRoute, + E extends AnyElement, +> = DeltaOf extends Batch + ? number extends Elements["length"] ? S + : Apply> + : Apply>; + type IsUnion = T extends unknown ? [Whole] extends [T] ? false : true : never; diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index 4337b09..fcb2fd2 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -15,6 +15,7 @@ import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; import { printHelp } from "../lib/print.ts"; +import { cli } from "../lib/read.ts"; import { route, routes, version } from "../lib/route.ts"; import type { AnyRoute, @@ -147,6 +148,196 @@ describe("dynamic()", () => { >(true); }); + it("preserves a concrete command supplied through dynamic input", () => { + let plugin = command( + name("serve"), + option(name("port"), schema(type("number"))), + ); + type Plugin = typeof plugin; + let app = command( + name("simulacrum"), + dynamic((plugin: Plugin) => extend(routes(plugin))), + ); + type Next = ContinuationOf; + + expectType, Plugin>>(true); + expectType[0]["name"], "serve">>(true); + expectType[0]>, { port: number }>>(true); + + let first = parse(app, { + argv: ["serve", "--port", "4100"], + }); + assertIncrement(first, {}); + + let result = first.resume({ ok: true, value: plugin }); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/serve", + model: { port: 4100 }, + }); + }); + + it("preserves the resume boundary for runtime-sized command extensions", () => { + let app = command( + name("simulacrum"), + dynamic((plugins: PluginSet) => extend(routes(...plugins.commands))), + ); + + expectType, PluginSet>>(true); + expectType["model"], {}>>(true); + + let first = parse(app, { argv: ["serve"] }); + assertIncrement(first, {}); + + let result = first.resume({ + ok: true, + value: { commands: [command(name("serve"))] }, + }); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/serve", + }); + }); + + it("preserves the resume boundary for runtime-sized extensions", () => { + let serve = command( + name("serve"), + option(name("port"), schema(type("number"))), + ); + let app = command( + name("xmd"), + option(name("path"), schema(type("string"))), + dynamic((declared: readonly string[]) => + extend( + ...declared.map((property) => + option(name(property), schema(type("string"))) + ), + ) + ), + option(name("raw"), schema(type("string | undefined"))), + routes(serve), + ); + + expectType, readonly string[]>>(true); + expectType< + Equal["model"], { + path: string; + }> + >(true); + + type Continued = ModelOf>; + expectType>(true); + expectType>(true); + expectType, { port: number }>>(true); + + let first = parse(app, { + argv: ["--path", "doc.md", "--author", "Ada", "--raw", "true"], + }); + assertIncrement(first, { path: "doc.md" }); + + let result = first.resume({ ok: true, value: ["author"] }); + expect(result).toMatchObject({ + ok: true, + model: { path: "doc.md", author: "Ada", raw: "true" }, + }); + if (!result.ok || result.method !== "execute" || result.route !== "/") { + throw new Error("expected root execute result"); + } + expectType< + Equal + >(true); + }); + + it("preserves the boundary for generated options with explicit CLI readers", () => { + let generated = (property: string) => + option( + name(property), + cli([`--${property}`]), + schema(type("string | undefined")), + ); + let app = command( + name("xmd"), + option( + name("file"), + cli(["--file"]), + schema(type("string")), + ), + dynamic((frontmatter: Frontmatter) => + extend(...frontmatter.properties.map(generated)) + ), + option( + name("raw"), + cli(["--raw"], { switch: true }), + schema(type("boolean | undefined")), + ), + ); + + expectType, Frontmatter>>(true); + + let step = parse(app, { + argv: ["--file", "doc.md", "--props-name", "Ada"], + }); + if (!step.ok || !("resume" in step)) throw new Error("expected increment"); + expectType>(true); + + let result = step.resume({ + ok: true, + value: { properties: ["props-name"] }, + }); + if (!result.ok || result.method !== "execute") { + throw new Error("expected execute result"); + } + expect(result).toMatchObject({ + model: { file: "doc.md", "props-name": "Ada" }, + }); + expectType< + Equal + >(true); + }); + + it("preserves the resume boundary for unknown requirements", () => { + let app = command( + name("xmd"), + option(name("path"), schema(type("string"))), + dynamic((input: unknown) => { + let declared = Array.isArray(input) + ? input.filter((value): value is string => typeof value === "string") + : []; + return extend( + ...declared.map((property) => + option(name(property), schema(type("string"))) + ), + ); + }), + ); + + expectType, unknown>>(true); + expectType< + Equal["model"], { + path: string; + }> + >(true); + + let first = parse(app, { + argv: ["--path", "doc.md", "--author", "Ada"], + }); + assertIncrement(first, { path: "doc.md" }); + + let result = first.resume({ ok: true, value: ["author"] }); + expect(result).toMatchObject({ + ok: true, + model: { path: "doc.md", author: "Ada" }, + }); + }); + it("preserves route-level controls across phases", () => { let app = command( name("simulacrum"), @@ -797,6 +988,14 @@ interface Plugins { readonly names: readonly string[]; } +interface PluginSet { + readonly commands: readonly AnyRoute[]; +} + +interface Frontmatter { + readonly properties: readonly string[]; +} + interface Services { readonly count: number; }