diff --git a/.changeset/direct-route-composition.md b/.changeset/direct-route-composition.md new file mode 100644 index 0000000..ddbedaf --- /dev/null +++ b/.changeset/direct-route-composition.md @@ -0,0 +1,10 @@ +--- +"@bomb.sh/router": minor +--- + +Allows `route()`, `command()`, and `extend()` to accept child routes and +commands directly, preserving inferred route paths, methods, and models. + +The public `routes()` helper has been removed. Pass children directly inside +`route()` and `command()`, and use `extend(child, other)` for reusable groups or +dynamic resolver results. diff --git a/README.md b/README.md index 94281a9..92f748b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ import { name, option, route, - routes, schema, toggle, version, @@ -52,19 +51,15 @@ export const app = command( description("Run and manage local service simulators."), version("1.0.0"), toggle(name("verbose")), - routes( + command( + name("serve"), + option(name("port"), schema(z.number().default(4000))), + ), + route( + name("database"), command( - name("serve"), - option(name("port"), schema(z.number().default(4000))), - ), - route( - name("database"), - routes( - command( - name("clean"), - toggle(name("dryRun")), - ), - ), + name("clean"), + toggle(name("dryRun")), ), ), ); @@ -79,6 +74,10 @@ HELP /database HELP /database/clean EXECUTE /database/clean ``` +Nest `route()` and `command()` directly inside another route, command, or +`extend()`. Each child is mounted at that point in the pipeline. Use `extend()` +to package reusable groups of children and other elements. + The root name identifies the executable; it is not repeated in route IDs. `simulacrum serve` therefore selects `/serve`, not `/simulacrum/serve`. @@ -200,14 +199,14 @@ to `resume()`. The schema's input type determines what `resume()` accepts; its validated output is passed to the extension, including any schema transforms. ```ts -import { command, dynamic, name, parse, routes } from "@bomb.sh/router"; +import { command, dynamic, extend, name, parse } from "@bomb.sh/router"; import * as z from "zod"; const app = command( name("plugins"), dynamic( z.array(z.string()), - (plugins) => routes(...plugins.map((plugin) => command(name(plugin)))), + (plugins) => extend(...plugins.map((plugin) => command(name(plugin)))), ), ); diff --git a/examples/routes-checkpoint.ts b/examples/routes-checkpoint.ts index 10eea97..44443ec 100644 --- a/examples/routes-checkpoint.ts +++ b/examples/routes-checkpoint.ts @@ -5,7 +5,6 @@ import { name, option, route, - routes, schema, toggle, version, @@ -18,30 +17,26 @@ export const app = command( description("Run and manage local service simulators."), version("1.0.0"), toggle(name("verbose")), - routes( + command( + name("serve"), + option(name("port"), description("server port"), schema(z.number())), + checkpoint(), + option(name("host"), description("server host"), schema(z.string())), + option( + name("protocol"), + description("server protocol"), + schema(z.enum(["http", "https"])), + ), + ), + route( + name("database"), command( - name("serve"), - option(name("port"), description("server port"), schema(z.number())), - checkpoint(), - option(name("host"), description("server host"), schema(z.string())), + name("clean"), + toggle(name("dryRun")), option( - name("protocol"), - description("server protocol"), - schema(z.enum(["http", "https"])), - ), - ), - route( - name("database"), - routes( - command( - name("clean"), - toggle(name("dryRun")), - option( - name("output"), - description("output path"), - schema(z.string()), - ), - ), + name("output"), + description("output path"), + schema(z.string()), ), ), ), diff --git a/examples/routes.ts b/examples/routes.ts index dfa6eac..bc19b25 100644 --- a/examples/routes.ts +++ b/examples/routes.ts @@ -4,7 +4,6 @@ import { name, option, route, - routes, schema, toggle, version, @@ -17,29 +16,25 @@ export const app = command( description("Run and manage local service simulators."), version("1.0.0"), toggle(name("verbose")), - routes( + command( + name("serve"), + option(name("port"), description("server port"), schema(z.number())), + option(name("host"), description("server host"), schema(z.string())), + option( + name("protocol"), + description("server protocol"), + schema(z.enum(["http", "https"])), + ), + ), + route( + name("database"), command( - name("serve"), - option(name("port"), description("server port"), schema(z.number())), - option(name("host"), description("server host"), schema(z.string())), + name("clean"), + toggle(name("dryRun")), option( - name("protocol"), - description("server protocol"), - schema(z.enum(["http", "https"])), - ), - ), - route( - name("database"), - routes( - command( - name("clean"), - toggle(name("dryRun")), - option( - name("output"), - description("output path"), - schema(z.string()), - ), - ), + name("output"), + description("output path"), + schema(z.string()), ), ), ), diff --git a/lib/command.ts b/lib/command.ts index 1cd5acf..a73d6a3 100644 --- a/lib/command.ts +++ b/lib/command.ts @@ -1,5 +1,11 @@ // deno-lint-ignore-file ban-types -import type { Check, Fold, Materialize, Unary } from "./pipeline.ts"; +import type { Fold, Materialize } from "./pipeline.ts"; +import { + type Input, + type Normalize, + normalize, + type Validate, +} from "./route.ts"; import type { Definition, Done, Route } from "./types.ts"; export type CommandZero = Route< @@ -10,11 +16,11 @@ export type CommandZero = Route< export function command< const N extends string, - const E extends readonly Unary[], + const E extends readonly Input[], >( start: Definition, - ...elements: E & Check, E> -): Materialize, E>> { + ...elements: E & Validate, E> +): Materialize, Normalize>> { let zero: CommandZero = { ...start, methods: ["help", "execute"], @@ -29,8 +35,8 @@ export function command< }], }; - return elements.reduce( + return normalize(elements).reduce( (value, element) => element(value as never), zero, - ) as Materialize, E>>; + ) as Materialize, Normalize>>; } diff --git a/lib/extend.ts b/lib/extend.ts index 0aeb43e..94ebb3a 100644 --- a/lib/extend.ts +++ b/lib/extend.ts @@ -1,10 +1,12 @@ -import { brand, type Extension, type Unary } from "./pipeline.ts"; +import { type Input, type Normalize, normalize } from "./route.ts"; +import { brand, type Extension } from "./pipeline.ts"; -export function extend( +export function extend( ...elements: E -): Extension { - return brand>((start: unknown): unknown => - elements.reduce( +): Extension> { + let normalized = normalize(elements); + return brand>>((start: unknown): unknown => + normalized.reduce( (value, element) => element(value as never), start, ) diff --git a/lib/route.ts b/lib/route.ts index 2da3588..a309597 100644 --- a/lib/route.ts +++ b/lib/route.ts @@ -1,7 +1,6 @@ // deno-lint-ignore-file ban-types import { brand, - type Check, type Fold, type Materialize, type MethodElement, @@ -18,11 +17,11 @@ export type RouteZero = Route< export function route< const N extends string, - const E extends readonly Unary[], + const E extends readonly Input[], >( start: Definition, - ...elements: E & Check, E> -): Materialize, E>> { + ...elements: E & Validate, E> +): Materialize, Normalize>> { let zero: RouteZero = { ...start, methods: ["help"], @@ -37,10 +36,10 @@ export function route< }], }; - return elements.reduce( + return normalize(elements).reduce( (value, element) => element(value as never), zero, - ) as Materialize, E>>; + ) as Materialize, Normalize>>; } export function version(semver: string): MethodElement<"version"> { @@ -58,7 +57,25 @@ export function executable(): MethodElement<"execute"> { })); } -export function routes( +export type Input = Unary | AnyRoute; + +export type Normalize = { + [K in keyof E]: Normalized; +}; + +// Walk invalid pipelines only, reporting errors on the original arguments. +export type Validate = + [Fold>] extends [never] ? Invalid : unknown; + +export function normalize( + elements: E, +): Normalize { + return elements.map((element) => + typeof element === "function" ? element : routes(element) + ) as Normalize; +} + +function routes( ...children: C ): RoutesElement { return brand>((route: AnyRoute) => { @@ -75,3 +92,18 @@ export function routes( }; }); } + +// Distribute over unions so Fold can retain its conservative union handling. +type Normalized = E extends Unary ? E + : E extends AnyRoute ? RoutesElement + : never; + +type Invalid = E extends readonly [ + infer Head extends Input, + ...infer Tail extends readonly Input[], +] + ? Fold]> extends infer Next + ? [Next] extends [never] ? readonly [never, ...Tail] + : readonly [Head, ...Invalid] + : never + : E; diff --git a/mod.ts b/mod.ts index a2884ea..215f43e 100644 --- a/mod.ts +++ b/mod.ts @@ -35,7 +35,7 @@ export type { Symbol as CLISymbol, } from "./lib/read.ts"; -export { executable, route, routes, version } from "./lib/route.ts"; +export { executable, route, version } from "./lib/route.ts"; export type { RouteZero } from "./lib/route.ts"; export { toggle } from "./lib/toggle.ts"; diff --git a/test/argument.test.ts b/test/argument.test.ts index 198c947..c862ddb 100644 --- a/test/argument.test.ts +++ b/test/argument.test.ts @@ -9,7 +9,6 @@ import { name, option, parse, - routes, schema, toggle, } from "@bomb.sh/router"; @@ -168,7 +167,7 @@ describe("argument()", () => { let app = command( name("app"), argument(name("workspace"), schema(type("string"))), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["local", "auth0", "service.json"], @@ -191,7 +190,7 @@ describe("argument()", () => { let app = command( name("app"), argument(name("workspace"), schema(type("string | undefined"))), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["auth0"] }); @@ -345,7 +344,7 @@ describe("argument()", () => { argument(name("target"), schema(type("string"))), dynamic(type("unknown"), () => extend( - routes(command(name("auth0"))), + command(name("auth0")), )), ); let increment = parse(app, { argv: ["auth0"] }); diff --git a/test/dynamic.test.ts b/test/dynamic.test.ts index 9ef04b2..f72bcf4 100644 --- a/test/dynamic.test.ts +++ b/test/dynamic.test.ts @@ -13,7 +13,7 @@ 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 { route, version } from "../lib/route.ts"; import type { AnyRoute, ChildrenOf, @@ -207,7 +207,7 @@ describe("dynamic()", () => { name("simulacrum"), dynamic( z.custom((value) => value === plugin), - (plugin: Plugin) => extend(routes(plugin)), + (plugin: Plugin) => extend(plugin), ), ); type Next = ContinuationOf; @@ -239,7 +239,7 @@ describe("dynamic()", () => { name("simulacrum"), dynamic( commands, - (plugins: PluginSet) => extend(routes(...plugins.commands)), + (plugins: PluginSet) => extend(...plugins.commands), ), ); @@ -275,7 +275,7 @@ describe("dynamic()", () => { ), ), option(name("raw"), schema(type("string | undefined"))), - routes(serve), + serve, ); expectType, readonly string[]>>(true); @@ -441,13 +441,11 @@ describe("dynamic()", () => { version("1.2.0"), option(name("config"), schema(type("string"))), checkpoint(), - dynamic(type("unknown"), () => routes(command(name("dyn")))), + dynamic(type("unknown"), () => extend(command(name("dyn")))), option(name("delay"), schema(type("number"))), - routes( - command( - name("serve"), - option(name("port"), schema(type("number"))), - ), + command( + name("serve"), + option(name("port"), schema(type("number"))), ), ); @@ -582,10 +580,10 @@ describe("dynamic()", () => { plugins: z.array(z.object({ name: z.string() })), })), (value) => - routes(command(name(value.plugins[0].name.toUpperCase()))), + extend(command(name(value.plugins[0].name.toUpperCase()))), ), ); - let app = command(name("server"), routes(child)); + let app = command(name("server"), child); let first = parse(app, { argv: ["plugins", "--help"] }); assertIncrementAt(first, "/plugins"); @@ -604,7 +602,7 @@ describe("dynamic()", () => { name("server"), dynamic( z.string().min(1, "plugin name is required"), - (value) => routes(command(name(value))), + (value) => extend(command(name(value))), ), ); let first = parse(app, { argv: [] }); @@ -652,7 +650,7 @@ describe("dynamic()", () => { }; let app = command( name("server"), - dynamic(asynchronous, (value) => routes(command(name(value)))), + dynamic(asynchronous, (value) => extend(command(name(value)))), ); let first = parse(app, { argv: [] }); assertIncrement(first, {}); @@ -729,9 +727,9 @@ describe("dynamic()", () => { let clean = command( name("clean"), toggle(name("truncate")), - dynamic(type("unknown"), () => extend(routes(auth0))), + dynamic(type("unknown"), () => extend(auth0)), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "--truncate", "auth0"], }); @@ -771,9 +769,9 @@ describe("dynamic()", () => { let clean = command( name("clean"), toggle(name("verbose")), - dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(auth0)), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "auth0", "--verbose"], }); @@ -804,10 +802,10 @@ describe("dynamic()", () => { dynamic(plugins, (_plugins: Plugins) => extend( toggle(name("audit")), - routes(auth0), + auth0, )), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "--audit", "auth0"], }); @@ -838,10 +836,10 @@ describe("dynamic()", () => { dynamic(plugins, (_plugins: Plugins) => extend( option(name("target"), schema(type("string"))), - routes(auth0), + auth0, )), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "--target", "auth0"], }); @@ -864,10 +862,10 @@ describe("dynamic()", () => { dynamic(plugins, (_plugins: Plugins) => extend( toggle(name("audit")), - routes(auth0), + auth0, )), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "auth0", "--audit"], }); @@ -896,7 +894,7 @@ describe("dynamic()", () => { name("clean"), dynamic(plugins, (_plugins: Plugins) => extend()), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let increment = parse(app, { argv: ["clean", "auth0"] }); expect(increment).toMatchObject({ @@ -929,7 +927,7 @@ describe("dynamic()", () => { let app = command( name("simulacrum"), option(name("config"), schema(type("string"))), - routes(clean), + clean, dynamic( type({ root: "true" }), (_root: { readonly root: true }) => extend(), @@ -981,7 +979,7 @@ describe("dynamic()", () => { let auth0 = command(name("auth0")); let app = command( name("simulacrum"), - dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(auth0)), ); let first = parse(app, { argv: ["auth0", "--help"] }); @@ -1018,7 +1016,7 @@ describe("dynamic()", () => { option(name("config")), dynamic(plugins, (_plugins: Plugins) => option(name("dyno"))), option(name("delay")), - routes(command(name("serve"))), + command(name("serve")), ); let first = parse(app, { argv: ["--help"] }); @@ -1053,7 +1051,7 @@ Options: let release = route(name("release"), version("2.0.0")); let app = command( name("simulacrum"), - dynamic(plugins, (_plugins: Plugins) => extend(routes(release))), + dynamic(plugins, (_plugins: Plugins) => extend(release)), ); let first = parse(app, { argv: ["release", "--version"] }); @@ -1071,7 +1069,7 @@ Options: let serve = command(name("serve")); let app = command( name("simulacrum"), - dynamic(plugins, (_plugins: Plugins) => extend(routes(serve))), + dynamic(plugins, (_plugins: Plugins) => extend(serve)), ); let first = parse(app, { argv: ["serve"] }); diff --git a/test/env.test.ts b/test/env.test.ts index 5f2954d..8927294 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -9,7 +9,7 @@ import { extend } from "../lib/extend.ts"; import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; -import { route, routes } from "../lib/route.ts"; +import { route } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; import type { ModelOf, Schema } from "../lib/types.ts"; @@ -40,8 +40,8 @@ describe("environment binding", () => { name("clean"), option(name("dryRun"), schema(z.string())), ); - let database = route(name("database"), routes(clean)); - let app = command(name("simulacrum"), routes(database)); + let database = route(name("database"), clean); + let app = command(name("simulacrum"), database); let result = parse(app, { argv: ["database", "clean"], envs: [{ @@ -63,7 +63,7 @@ describe("environment binding", () => { name("pg:clean"), option(name("clientID"), schema(z.string())), ); - let app = command(name("simulacrum"), routes(clean)); + let app = command(name("simulacrum"), clean); let result = parse(app, { argv: ["pg:clean"], envs: [{ @@ -476,7 +476,7 @@ describe("environment binding", () => { name: "settings", value: { AUTH0_PORT: "9001" }, }]), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["auth0"] }); @@ -503,7 +503,7 @@ describe("environment binding", () => { name: "settings", value: { AUTH0_PORT: "9001" }, }]), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["auth0"] }); @@ -531,7 +531,7 @@ describe("environment binding", () => { env("PORT"), schema(z.number()), ), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["auth0"], @@ -603,7 +603,7 @@ describe("environment binding", () => { ); let app = command( name("simulacrum"), - dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(auth0)), ); let increment = parse(app, { argv: ["auth0"], @@ -660,7 +660,7 @@ describe("environment binding", () => { let app = command( name("simulacrum"), option(name("config"), schema(z.string())), - dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(auth0)), ); let increment = parse(app, { argv: ["auth0", "--help"], diff --git a/test/fold.test.ts b/test/fold.test.ts index 6bbd110..7f82e63 100644 --- a/test/fold.test.ts +++ b/test/fold.test.ts @@ -11,7 +11,7 @@ import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; import { mark, type Transform } from "../lib/pipeline.ts"; -import { route, routes, version } from "../lib/route.ts"; +import { route, version } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; import type { AnyRoute, @@ -40,11 +40,9 @@ const staticApp = command( withEnvs([]), version("1.2.0"), toggle(name("verbose")), - routes( - command( - name("serve"), - option(name("port"), schema(type("number"))), - ), + command( + name("serve"), + option(name("port"), schema(type("number"))), ), ); @@ -169,106 +167,106 @@ const options = [ ] as const; const children = [ - routes(command(name("c0"))), - routes(command(name("c1"))), - routes(command(name("c2"))), - routes(command(name("c3"))), - routes(command(name("c4"))), - routes(command(name("c5"))), - routes(command(name("c6"))), - routes(command(name("c7"))), - routes(command(name("c8"))), - routes(command(name("c9"))), - routes(command(name("c10"))), - routes(command(name("c11"))), - routes(command(name("c12"))), - routes(command(name("c13"))), - routes(command(name("c14"))), - routes(command(name("c15"))), - routes(command(name("c16"))), - routes(command(name("c17"))), - routes(command(name("c18"))), - routes(command(name("c19"))), - routes(command(name("c20"))), - routes(command(name("c21"))), - routes(command(name("c22"))), - routes(command(name("c23"))), - routes(command(name("c24"))), - routes(command(name("c25"))), - routes(command(name("c26"))), - routes(command(name("c27"))), - routes(command(name("c28"))), - routes(command(name("c29"))), - routes(command(name("c30"))), - routes(command(name("c31"))), - routes(command(name("c32"))), - routes(command(name("c33"))), - routes(command(name("c34"))), - routes(command(name("c35"))), - routes(command(name("c36"))), - routes(command(name("c37"))), - routes(command(name("c38"))), - routes(command(name("c39"))), - routes(command(name("c40"))), - routes(command(name("c41"))), - routes(command(name("c42"))), - routes(command(name("c43"))), - routes(command(name("c44"))), - routes(command(name("c45"))), - routes(command(name("c46"))), - routes(command(name("c47"))), - routes(command(name("c48"))), - routes(command(name("c49"))), - routes(command(name("c50"))), - routes(command(name("c51"))), - routes(command(name("c52"))), - routes(command(name("c53"))), - routes(command(name("c54"))), - routes(command(name("c55"))), - routes(command(name("c56"))), - routes(command(name("c57"))), - routes(command(name("c58"))), - routes(command(name("c59"))), - routes(command(name("c60"))), - routes(command(name("c61"))), - routes(command(name("c62"))), - routes(command(name("c63"))), - routes(command(name("c64"))), - routes(command(name("c65"))), - routes(command(name("c66"))), - routes(command(name("c67"))), - routes(command(name("c68"))), - routes(command(name("c69"))), - routes(command(name("c70"))), - routes(command(name("c71"))), - routes(command(name("c72"))), - routes(command(name("c73"))), - routes(command(name("c74"))), - routes(command(name("c75"))), - routes(command(name("c76"))), - routes(command(name("c77"))), - routes(command(name("c78"))), - routes(command(name("c79"))), - routes(command(name("c80"))), - routes(command(name("c81"))), - routes(command(name("c82"))), - routes(command(name("c83"))), - routes(command(name("c84"))), - routes(command(name("c85"))), - routes(command(name("c86"))), - routes(command(name("c87"))), - routes(command(name("c88"))), - routes(command(name("c89"))), - routes(command(name("c90"))), - routes(command(name("c91"))), - routes(command(name("c92"))), - routes(command(name("c93"))), - routes(command(name("c94"))), - routes(command(name("c95"))), - routes(command(name("c96"))), - routes(command(name("c97"))), - routes(command(name("c98"))), - routes(command(name("c99"))), + command(name("c0")), + command(name("c1")), + command(name("c2")), + command(name("c3")), + command(name("c4")), + command(name("c5")), + command(name("c6")), + command(name("c7")), + command(name("c8")), + command(name("c9")), + command(name("c10")), + command(name("c11")), + command(name("c12")), + command(name("c13")), + command(name("c14")), + command(name("c15")), + command(name("c16")), + command(name("c17")), + command(name("c18")), + command(name("c19")), + command(name("c20")), + command(name("c21")), + command(name("c22")), + command(name("c23")), + command(name("c24")), + command(name("c25")), + command(name("c26")), + command(name("c27")), + command(name("c28")), + command(name("c29")), + command(name("c30")), + command(name("c31")), + command(name("c32")), + command(name("c33")), + command(name("c34")), + command(name("c35")), + command(name("c36")), + command(name("c37")), + command(name("c38")), + command(name("c39")), + command(name("c40")), + command(name("c41")), + command(name("c42")), + command(name("c43")), + command(name("c44")), + command(name("c45")), + command(name("c46")), + command(name("c47")), + command(name("c48")), + command(name("c49")), + command(name("c50")), + command(name("c51")), + command(name("c52")), + command(name("c53")), + command(name("c54")), + command(name("c55")), + command(name("c56")), + command(name("c57")), + command(name("c58")), + command(name("c59")), + command(name("c60")), + command(name("c61")), + command(name("c62")), + command(name("c63")), + command(name("c64")), + command(name("c65")), + command(name("c66")), + command(name("c67")), + command(name("c68")), + command(name("c69")), + command(name("c70")), + command(name("c71")), + command(name("c72")), + command(name("c73")), + command(name("c74")), + command(name("c75")), + command(name("c76")), + command(name("c77")), + command(name("c78")), + command(name("c79")), + command(name("c80")), + command(name("c81")), + command(name("c82")), + command(name("c83")), + command(name("c84")), + command(name("c85")), + command(name("c86")), + command(name("c87")), + command(name("c88")), + command(name("c89")), + command(name("c90")), + command(name("c91")), + command(name("c92")), + command(name("c93")), + command(name("c94")), + command(name("c95")), + command(name("c96")), + command(name("c97")), + command(name("c98")), + command(name("c99")), ] as const; const hundredCommand = command(name("hundred"), ...options); @@ -276,6 +274,8 @@ const hundredRoute = route(name("hundred"), ...options); const hundredExtension = extend(...options); const extendedRoute = route(name("extended"), hundredExtension); const hundredChildren = route(name("children"), ...children); +const extendedChildren = command(name("children"), extend(...children)); +const mixedChildren = command(name("mixed"), ...options, ...children); describe("pipeline fold", () => { it("infers a concrete static route", () => { @@ -453,7 +453,7 @@ describe("pipeline fold", () => { withValues([]), withEnvs([]), version("1.0.0"), - routes(command(name("child"))), + command(name("child")), identity, option(name("port"), schema(type("number"))), ); @@ -597,7 +597,7 @@ describe("pipeline fold", () => { it("composes nested built-in batches through an identity macro", () => { let identity = extend(); let nested = extend( - routes(command(name("child"))), + command(name("child")), withValues([]), ); let hybrid = extend( @@ -633,7 +633,22 @@ describe("pipeline fold", () => { >(true); }); - it("folds one hundred child-route elements", () => { + it("folds one hundred direct children through routes, commands, and extensions", () => { + expectType< + Equal< + ChildrenOf, + ChildrenOf + > + >(true); + expectType< + Equal< + ChildrenOf, + ChildrenOf + > + >(true); + expectType< + Equal, ModelOf> + >(true); expectType[0]["name"], "c0">>( true, ); @@ -684,7 +699,7 @@ describe("pipeline fold", () => { it("types union-valued elements conservatively", () => { let element = Math.random() > 0.5 ? option(name("port"), schema(type("number"))) - : routes(route(name("child"))); + : extend(route(name("child"))); let app = route(name("union"), element); expectType>(true); diff --git a/test/multiple.test.ts b/test/multiple.test.ts index 4351d03..23e608b 100644 --- a/test/multiple.test.ts +++ b/test/multiple.test.ts @@ -6,7 +6,6 @@ import { dynamic } from "../lib/dynamic.ts"; import { extend } from "../lib/extend.ts"; import type { AnyParam, Param } from "../lib/param.ts"; import { brand, type IdentityElement } from "../lib/pipeline.ts"; -import { routes } from "../lib/route.ts"; import { command, type ModelOf, @@ -315,7 +314,7 @@ describe("multiple()", () => { let app = command( name("simulacrum"), option(name("config"), multiple(), schema(z.array(z.string()))), - routes(child), + child, ); let result = parse(app, { argv: [ @@ -346,7 +345,7 @@ describe("multiple()", () => { let app = command( name("simulacrum"), option(name("config"), multiple(), schema(z.array(z.string()))), - dynamic(type("unknown"), () => extend(routes(child))), + dynamic(type("unknown"), () => extend(child)), ); let first = parse(app, { argv: [ diff --git a/test/parse.test.ts b/test/parse.test.ts index c596b3f..589355b 100644 --- a/test/parse.test.ts +++ b/test/parse.test.ts @@ -6,7 +6,7 @@ import { name } from "../lib/definition.ts"; import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; -import { route, routes, version } from "../lib/route.ts"; +import { route, version } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; import type { AnyRoute, Done, IntentsOf, Route } from "../lib/types.ts"; @@ -14,28 +14,22 @@ let app = route( name("simulacrum"), version("1.2.0"), option(name("port"), schema(type("number"))), - routes( - route(name("auth0")), - route( - name("database"), - routes(route(name("clean"))), - ), + route(name("auth0")), + route( + name("database"), + route(name("clean")), ), ); let tree = command( name("simulacrum"), toggle(name("verbose")), - routes( + command( + name("database"), + toggle(name("verbose")), command( - name("database"), + name("clean"), toggle(name("verbose")), - routes( - command( - name("clean"), - toggle(name("verbose")), - ), - ), ), ), ); @@ -58,11 +52,9 @@ let options = command( let segments = command( name("simulacrum"), option(name("host"), schema(type("string"))), - routes( - command( - name("serve"), - option(name("port"), schema(type("number"))), - ), + command( + name("serve"), + option(name("port"), schema(type("number"))), ), ); @@ -448,19 +440,15 @@ const plain = cli(route(name("simulacrum"))); const scoped = cli( route( name("simulacrum"), - routes( - route(name("auth0"), version("2.0.0")), - ), + route(name("auth0"), version("2.0.0")), ), ); const commands = cli( command( name("simulacrum"), - routes( - command( - name("database"), - routes(command(name("clean"))), - ), + command( + name("database"), + command(name("clean")), ), ), ); diff --git a/test/print.test.ts b/test/print.test.ts index cc34c05..6e221b9 100644 --- a/test/print.test.ts +++ b/test/print.test.ts @@ -12,7 +12,7 @@ import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; import { printErrors, printHelp, printVersion } from "../lib/print.ts"; -import { route, routes, version } from "../lib/route.ts"; +import { route, version } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; let app = route( @@ -27,35 +27,31 @@ let app = route( name("verbose"), description("Show detailed startup and request diagnostics."), ), - routes( - route( - name("database"), - description("Inspect and maintain simulator state."), - routes( - command( - name("clean"), - description("Remove generated records and reset database state."), - version("2.0.0"), - option( - name("schema"), - description("Limit cleanup to a single database schema."), - ), - toggle( - name("dryRun"), - description("Show what would be removed without changing data."), - ), - ), - ), - ), + route( + name("database"), + description("Inspect and maintain simulator state."), command( - name("serve"), - description("Start every configured simulator."), + name("clean"), + description("Remove generated records and reset database state."), + version("2.0.0"), option( - name("port"), - description("Listen on this port."), + name("schema"), + description("Limit cleanup to a single database schema."), + ), + toggle( + name("dryRun"), + description("Show what would be removed without changing data."), ), ), ), + command( + name("serve"), + description("Start every configured simulator."), + option( + name("port"), + description("Listen on this port."), + ), + ), ); let required = command( diff --git a/test/route.test.ts b/test/route.test.ts index 29e2fed..05d6414 100644 --- a/test/route.test.ts +++ b/test/route.test.ts @@ -1,11 +1,16 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { type } from "arktype"; +import * as z from "zod"; +import { command } from "../lib/command.ts"; +import { dynamic } from "../lib/dynamic.ts"; +import { extend } from "../lib/extend.ts"; +import { parse } from "../lib/parse.ts"; import { description, name } from "../lib/definition.ts"; import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { mark, type Transform } from "../lib/pipeline.ts"; -import { route, routes, type RouteZero, version } from "../lib/route.ts"; +import { route, type RouteZero, version } from "../lib/route.ts"; import { toggle } from "../lib/toggle.ts"; import type { AnyRoute, ChildrenOf, Done, ModelOf } from "../lib/types.ts"; @@ -108,7 +113,7 @@ describe("route() types", () => { ); let result = route( name("simulacrum"), - routes(serve), + serve, ); expectType< @@ -233,6 +238,160 @@ describe("route() types", () => { }); }); +describe("direct child composition", () => { + it("preserves nested paths, methods, and models", () => { + let clean = command(name("clean"), toggle(name("dryRun"))); + let database = route(name("database"), clean); + let serve = command( + name("serve"), + option(name("port"), schema(type("number"))), + version("1.0.0"), + ); + let app = command( + name("app"), + toggle(name("verbose")), + database, + extend(route(name("status"))), + serve, + ); + let wrapped = command( + name("app"), + toggle(name("verbose")), + extend(route(name("database"), extend(clean))), + extend(route(name("status"))), + extend(serve), + ); + + expectType>(true); + let result = parse(app, { + argv: ["--verbose", "database", "clean", "--dry-run"], + }); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/database/clean", + model: { dryRun: true }, + models: { + "/": { verbose: true }, + "/database": {}, + "/database/clean": { dryRun: true }, + }, + }); + if (!result.ok || result.method !== "execute") { + throw Error("expected execute"); + } + if (result.route === "/database/clean") { + expectType>(true); + } + if (result.route === "/serve") { + expectType>(true); + } + expect(parse(app, { argv: ["database", "--help"] })).toMatchObject({ + ok: true, + method: "help", + route: "/database", + }); + expect(parse(app, { argv: ["serve", "--version"] })).toMatchObject({ + ok: true, + method: "version", + route: "/serve", + }); + expect(parse(app, { argv: ["database"] })).toMatchObject({ + ok: false, + code: "method-not-allowed", + route: "/database", + }); + }); + + it("packages children in reusable extensions and applies them directly", () => { + let child = command(name("child"), toggle(name("enabled"))); + let extension = extend(child, toggle(name("verbose"))); + let app = extension(command(name("app"))); + let nested = route(name("app"), route(name("group"), extension)); + + expectType, { verbose: boolean }>>(true); + expectType< + Equal, { enabled: boolean }> + >(true); + expect(parse(app, { argv: ["--verbose", "child", "--enabled"] })) + .toMatchObject({ + ok: true, + method: "execute", + route: "/child", + models: { "/": { verbose: true }, "/child": { enabled: true } }, + }); + expect(parse(nested, { argv: ["group", "child"] })).toMatchObject({ + ok: true, + method: "execute", + route: "/group/child", + model: { enabled: false }, + }); + }); + + it("introduces children through a dynamic extension", () => { + let app = command( + name("app"), + dynamic(type("number"), (port) => + extend( + command( + name("serve"), + option(name("port"), schema(z.number().default(port))), + ), + )), + command(name("clean")), + ); + let first = parse(app, { argv: ["serve"] }); + if (!first.ok) throw Error("expected increment"); + let result = first.resume(4000); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/serve", + model: { port: 4000 }, + }); + if (!result.ok || result.method !== "execute") { + throw Error("expected execute"); + } + if (result.route === "/serve") { + expectType>(true); + } + let clean = parse(app, { argv: ["clean"] }); + if (!clean.ok) throw Error("expected increment"); + expect(clean.resume(4000)).toMatchObject({ + ok: true, + method: "execute", + route: "/clean", + model: {}, + }); + }); + + it("retains conservative inference for arrays and unions", () => { + let children = [command(name("child"))]; + let app = route(name("app"), ...children); + expectType>(true); + let child = Math.random() > 0.5 ? command(name("one")) : route(name("two")); + let union = command(name("app"), child); + expectType>(true); + }); + + it("rejects children after non-route outputs and in parameter pipelines", () => { + let child = command(name("child")); + let count = (_value: AnyRoute) => 1; + check(() => { + // @ts-expect-error a child cannot be mounted on a number. + route(name("app"), count, child); + // @ts-expect-error a child cannot be mounted on a number. + command(name("app"), count, child); + // @ts-expect-error options accept parameter transformations. + option(name("port"), child); + // @ts-expect-error a child extension requires a route. + option(name("port"), extend(child)); + // @ts-expect-error the parameter schema cannot follow a child operation. + route(name("app"), child, schema(type("number"))); + }); + }); +}); + type Equal = (() => T extends L ? 1 : 2) extends (() => T extends R ? 1 : 2) ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true diff --git a/test/value.test.ts b/test/value.test.ts index 79ddc65..0ad463b 100644 --- a/test/value.test.ts +++ b/test/value.test.ts @@ -8,7 +8,6 @@ import { extend } from "../lib/extend.ts"; import { option } from "../lib/option.ts"; import { schema } from "../lib/param.ts"; import { parse } from "../lib/parse.ts"; -import { routes } from "../lib/route.ts"; import type { Parse, Schema } from "../lib/types.ts"; import { withValues } from "../lib/values.ts"; @@ -60,7 +59,7 @@ describe("value sources", () => { let app = command( name("simulacrum"), option(name("delay"), schema(type("number"))), - routes(auth0), + auth0, ); let input = { argv: ["auth0"], @@ -326,7 +325,7 @@ describe("value sources", () => { name: "settings", value: { auth0: { port: 9001 } }, }]), - routes(auth0), + auth0, ); let result = parse(app, { argv: ["auth0"] }); @@ -347,7 +346,7 @@ describe("value sources", () => { name("simulacrum"), option(name("port"), schema(type("number"))), option(name("target"), schema(type("string"))), - dynamic(plugins, (_plugins: Plugins) => extend(routes(auth0))), + dynamic(plugins, (_plugins: Plugins) => extend(auth0)), ); let increment = parse(app, { argv: ["--target", "local", "auth0", "--port", "9001"], @@ -455,7 +454,7 @@ describe("value sources", () => { let app = command( name("simulacrum"), option(name("auth0")), - routes(auth0), + auth0, ); let input = { argv: ["auth0"],