Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/bombshell-dev/configliere. A statically typed entry-point router for command-line applications
Something went wrong. Try again.
21 kB · 785 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786import { expect as base, type Expected } from "@std/expect";import { describe, it } from "@std/testing/bdd";import { type } from "arktype";import { command } from "../lib/command.ts";import { name } from "../lib/definition.ts";import { option } from "../lib/option.ts";import { brand, type IdentityElement } from "../lib/pipeline.ts";import type { Param } from "../lib/param.ts";import { parse } from "../lib/parse.ts";import type { ReadCLI } from "../lib/read.ts";import { route, routes, version } from "../lib/route.ts";import { toggle } from "../lib/toggle.ts";import { multiple, schema } from "../mod.ts";import * as z from "zod";import type { AnyRoute, Done, IntentsOf, Route } from "../lib/types.ts";
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"))), ), ),);
let tree = command( name("simulacrum"), toggle(name("verbose")), routes( command( name("database"), toggle(name("verbose")), routes( command( name("clean"), toggle(name("verbose")), ), ), ), ),);
let toggles = command( name("simulacrum"), toggle(name("dryRun")),);
let fields = command( name("simulacrum"), option(name("port"), schema(type("number"))),);
let multipleOptions = command( name("simulacrum"), option(name("config"), multiple(), schema(type("string[]"))), option(name("port"), schema(type("number"))),);
let multipleNumbers = command( name("simulacrum"), option(name("port"), multiple(), schema(type("number[]"))),);
let multipleStrings = command( name("simulacrum"), option(name("config"), multiple(), schema(type("string[]"))),);
let optionalMultipleOptions = command( name("simulacrum"), option( name("config"), multiple(), schema(type("string[] | undefined")), ),);
let defaultedMultipleOptions = command( name("simulacrum"), option( name("config"), multiple(), schema(z.array(z.string()).default(["default.yml"])), ),);
let options = command( name("simulacrum"), option(name("dryRun"), schema(type("string | undefined"))),);
let segments = command( name("simulacrum"), option(name("host"), schema(type("string"))), routes( command( name("serve"), option(name("port"), schema(type("number"))), ), ),);
describe("parse()", () => { it("collects repeated options in argv order", () => { let result = parse(multipleOptions, { argv: ["--config", "one", "--port", "4100", "--config=two"], });
expectOk(result); expect(result).toMatchObject({ model: { config: ["one", "two"], port: 4100 }, }); }); it("decodes each repeated option value before validating the array", () => { let result = parse(multipleNumbers, { argv: ["--port", "4100", "--port", "4101"], });
expectOk(result); expect(result).toMatchObject({ model: { port: [4100, 4101] }, }); });
it("reports an incomplete occurrence after valid repeated options", () => { let result = parse(multipleStrings, { argv: ["--config", "one", "--config"], });
expect(result).toMatchObject({ ok: false, code: "unprocessable-content", route: "/", issues: [{ message: "--config requires a value" }], }); });
it("preserves numeric-looking repeated strings", () => { let result = parse(multipleStrings, { argv: ["--config", "0012", "--config", "0034"], });
expectOk(result); expect(result).toMatchObject({ model: { config: ["0012", "0034"] }, }); });
it("lets an optional repeated option remain undefined", () => { let result = parse(optionalMultipleOptions, { argv: [] });
expectOk(result); expect(result).toMatchObject({ model: { config: undefined } }); });
it("lets a defaulting schema supply an absent repeated option", () => { let result = parse(defaultedMultipleOptions, { argv: [] });
expectOk(result); expect(result).toMatchObject({ model: { config: ["default.yml"] } }); });
it("collects repeated options from a custom singular reader", () => { let app = command( name("simulacrum"), option( name("plugin"), multiple(), customOption("--plugin"), schema(type("string[]")), ), ); let result = parse(app, { argv: ["--plugin", "one", "--plugin", "two"], });
expectOk(result); expect(result).toMatchObject({ model: { plugin: ["one", "two"] } }); });
describe("help", () => { it("resolves either help flag against the root route", () => { expect( $("simulacrum -h"), ).toHaveRoute("HELP /");
expect( $("simulacrum --help"), ).toHaveRoute("HELP /"); });
it("resolves help before validating other arguments", () => { expect( $("simulacrum --unknown --help"), ).toHaveRoute("HELP /"); });
it("does not treat help after -- as a control", () => { let result = exec("simulacrum -- --help");
expect(result).toHaveRoute("EXECUTE /"); });
it.skip("accounts for options left unconsumed by help", () => undefined); });
describe("version", () => { it("resolves either version flag against a versioned route", () => { expect( $("simulacrum -v"), ).toHaveRoute("VERSION /");
expect( $("simulacrum --version"), ).toHaveRoute("VERSION /"); });
it("resolves version before validating other arguments", () => { expect( $("simulacrum --unknown --version"), ).toHaveRoute("VERSION /"); });
it("rejects version for a route without a version", () => { let result = plain("simulacrum --version");
expect(result).toMatchObject({ ok: false, code: "method-not-allowed", path: [], method: "version", allowed: ["help"], }); expect(typeof result.route).toBe("string"); expect(result.route).toBe("/"); expect(result.definition).toMatchObject({ name: "simulacrum" }); });
it("does not treat version after -- as a control", () => { let result = exec("simulacrum -- --version");
expect(result).toHaveRoute("EXECUTE /"); }); });
describe("routes", () => { it("resolves a direct child route", () => { expect( $("simulacrum auth0 --help"), ).toHaveRoute("HELP /auth0"); }); it("resolves the deepest matching route", () => { expect( $("simulacrum database clean --help"), ).toHaveRoute("HELP /database/clean"); }); it("resolves controls against the deepest matching route", () => { expect( $("simulacrum --help database clean"), ).toHaveRoute("HELP /database/clean"); }); it("discovers routes across unresolved parameter tokens", () => { expect( $("simulacrum --root value database --db=value clean --help"), ).toHaveRoute("HELP /database/clean"); }); it("stops discovering routes at --", () => { expect( $("simulacrum --help database -- clean"), ).toHaveRoute("HELP /database"); }); it("does not let an unknown option hide a known child route", () => { expect( $("simulacrum --target auth0 --help"), ).toHaveRoute("HELP /auth0"); }); });
describe("literals", () => { it("assigns literals to the matching route", () => { let result = $("simulacrum --help database -- clean --force");
expectOk(result);
expect(result).toHaveRoute("HELP /database"); expect(Array.from(result.literals, (literal) => literal.text)).toEqual([ "clean", "--force", ]); });
it("keeps literals separate from parameter tokens", () => { let result = $( "simulacrum --help --value before -- literal --force", );
expectOk(result);
expect(Array.from(result.literals, (literal) => literal.text)).toEqual([ "literal", "--force", ]); }); });
describe("route methods", () => { it("uses the methods supported by the matching route", () => { expect( scoped("simulacrum auth0 --version"), ).toHaveRoute("VERSION /auth0"); }); it("reports the matching route when a method is unsupported", () => { let result = $("simulacrum database clean --version");
expect(result).toMatchObject({ ok: false, code: "method-not-allowed", path: ["database", "clean"], method: "version", allowed: ["help"], }); expect(typeof result.route).toBe("string"); expect(result.route).toBe("/database/clean"); expect(result.definition).toMatchObject({ name: "clean" }); }); it("allows an executable route to contain executable children", () => { expect( commands("simulacrum database"), ).toHaveRoute("EXECUTE /database");
expect( commands("simulacrum database clean"), ).toHaveRoute("EXECUTE /database/clean"); }); });
describe("binding", () => { it("binds every route segment into its path-addressed model", () => { let result = segmented( "simulacrum --host localhost serve --port 4040", );
expect(result).toHaveModels({ "/": { host: "localhost" }, "/serve": { port: 4040 }, }); });
it("validates required parameters on every matched route", () => { let result = segmented("simulacrum serve --port 4040");
expect(result).toMatchObject({ ok: false, code: "unprocessable-content", path: ["serve"], issues: [{ path: ["host"] }], }); expect(result.route).toBe("/serve"); expect(result.definition).toMatchObject({ name: "serve" }); });
it("binds an option from a following token", () => { expect( configured("simulacrum --port 9001"), ).toHaveModels({ "/": { port: 9001 } }); });
it("binds an option from a setter", () => { expect( configured("simulacrum --port=9001"), ).toHaveModels({ "/": { port: 9001 } }); });
it("normalizes camel-case option names to kebab-case", () => { expect( normalized("simulacrum --dry-run yes"), ).toHaveModels({ "/": { dryRun: "yes" } });
expect( normalized("simulacrum --dry-run=yes"), ).toHaveModels({ "/": { dryRun: "yes" } }); });
it("does not retain the camel-case option spelling as an alias", () => { expect(normalized("simulacrum --dryRun yes")).toMatchObject({ ok: false, code: "unprocessable-content", issues: [ { message: `unexpected "--dryRun"` }, { message: `unexpected "yes"` }, ], }); });
it.skip("binds a positional argument", () => { // let input = cli(command( // name("simulacrum"), // argument("input", type("string")), // )); // // expect( // input("simulacrum input.txt"), // ).toHaveConfig({ input: "input.txt" }); });
it("reports flags left unconsumed after binding every parameter", () => { expect( exec("simulacrum --floop"), ).toMatchObject({ ok: false, code: "unprocessable-content", issues: [{ message: `unexpected "--floop"` }], }); });
it("reports an invalid option value as a binding error", () => { expect( configured("simulacrum --port nope"), ).toMatchObject({ ok: false, code: "unprocessable-content", issues: [{ path: ["port"] }], }); });
it("reports unconsumed tokens alongside terminal validation errors", () => { expect( configured("simulacrum --floop"), ).toMatchObject({ ok: false, code: "unprocessable-content", issues: [ { message: `unexpected "--floop"` }, { path: ["port"] }, ], }); });
it("reports a surplus argument as a binding error", () => { expect( exec("simulacrum extra"), ).toMatchObject({ ok: false, code: "unprocessable-content", }); });
it.skip("preserves parameter token order while binding", () => { // expect( // $("simulacrum --tag first --tag=second"), // ).toHaveConfig({ tag: ["first", "second"] }); });
it("binds parameters to the route segment that owns them", () => { let result = bound( "simulacrum --verbose database --verbose clean --verbose", );
expect(result).toHaveModels({ "/": { verbose: true }, "/database": { verbose: true }, "/database/clean": { verbose: true }, }); });
it("binds default, affirmative, and negative toggles", () => { expect( toggled("simulacrum"), ).toHaveModels({ "/": { dryRun: false } });
expect( toggled("simulacrum --dry-run"), ).toHaveModels({ "/": { dryRun: true } });
expect( toggled("simulacrum --no-dry-run"), ).toHaveModels({ "/": { dryRun: false } }); });
it("does not let declaration order change option adjacency", () => { let portFirst = command( name("simulacrum"), option(name("port"), schema(type("number"))), toggle(name("verbose")), ); let verboseFirst = command( name("simulacrum"), toggle(name("verbose")), option(name("port"), schema(type("number"))), ); let expected = { ok: false, code: "unprocessable-content", route: "/", issues: [ { message: 'unexpected "9000"' }, { message: "--port requires a value" }, ], };
expect(parse(portFirst, { argv: ["--port", "--verbose", "9000"], })).toMatchObject(expected);
expect(parse(verboseFirst, { argv: ["--port", "--verbose", "9000"], })).toMatchObject(expected); });
it("reports several surplus arguments as a binding error", () => { let result = exec("simulacrum databaes clean");
expectUnprocessable(result); expect(result.issues).toHaveLength(2); }); });
describe("types", () => { it("exposes only methods supported by a route", () => { type Plain = Route< "simulacrum", "help", Empty, [], readonly [Done<Empty, []>] >; type Versioned = Route< "simulacrum", "help" | "version", Empty, [], readonly [Done<Empty, []>] >;
expectType<Equal<IntentsOf<Plain>["method"], "help">>(true); expectType< Equal<IntentsOf<Versioned>["method"], "help" | "version"> >(true); });
it("exposes the exact intents of every reachable route", () => { type Actual = TargetOf<ReturnType<typeof $>>; type Expected = | ["help", "/", []] | ["version", "/", []] | ["help", "/auth0", ["auth0"]] | ["help", "/database", ["database"]] | ["help", "/database/clean", ["database", "clean"]];
expectType<Equal<Actual, Expected>>(true); }); });});
const requests = new WeakMap<object, Request>();const $ = cli(app);const bound = cli(tree);const toggled = cli(toggles);const configured = cli(fields);const normalized = cli(options);const segmented = cli(segments);const plain = cli(route(name("simulacrum")));const scoped = cli( route( name("simulacrum"), routes( route(name("auth0"), version("2.0.0")), ), ),);const commands = cli( command( name("simulacrum"), routes( command( name("database"), routes(command(name("clean"))), ), ), ),);const exec = cli(command(name("simulacrum")));
interface RouteExpected extends Expected { toHaveRoute(expected: Target): unknown; toHaveModels(expected: Models): unknown;}
interface Request { input: string; root: string;}
interface Outcome { input: string; root: string; definition: string; target: Target;}
type Target = `${string} /${string}`;type Models = Readonly<Record<string, object>>;type Empty = Record<never, never>;type TargetOf<T> = T extends { readonly ok: true; readonly method: infer M; readonly route: infer R; readonly path: infer P;} ? [M, R, P] : never;
type Equal<L, R> = (<T>() => T extends L ? 1 : 2) extends (<T>() => T extends R ? 1 : 2) ? (<T>() => T extends R ? 1 : 2) extends (<T>() => T extends L ? 1 : 2) ? true : false : false;
base.extend({ toHaveRoute(context, expected: Target) { let outcome = inspect(context.value); let route = expected.slice(expected.indexOf(" ") + 1); let leaf = route === "/" ? outcome?.root : route.slice(route.lastIndexOf("/") + 1); let pass = outcome?.target === expected && outcome.definition === leaf;
return { pass, message: () => outcome ? `Expected ${ JSON.stringify(outcome.input) } to resolve ${expected}, ` + `but it resolved ${outcome.target} with definition ${ JSON.stringify(outcome.definition) }` : `Expected a request resolving ${expected}, but received no route intent`, }; }, toHaveModels(context, expected: Models) { let value = context.value; let models = record(value) && value.ok === true && value.method === "execute" && record(value.models) ? value.models : undefined;
return { pass: models !== undefined && context.equal(models, expected), message: () => models === undefined ? `Expected a successful EXECUTE result with models, but received ${ show( record(value) ? { ok: value.ok, code: value.code, path: value.path, issues: value.issues, } : value, ) }` : `Expected models ${show(expected)}, but received ${show(models)}`, }; },});
const expect = base<RouteExpected>;
function cli<R extends AnyRoute>(app: R) { return (input: string) => { let [root, ...argv] = input.trim().split(/\s+/);
if (root !== app.name) { throw new Error( `Expected command ${JSON.stringify(app.name)}, received ${ JSON.stringify(root) }`, ); }
let result = parse(app, { argv }); requests.set(result, { input, root }); return result; };}
function inspect(value: unknown): Outcome | undefined { if (!record(value)) { return; }
let request = requests.get(value); if (!request) { return; }
let { input, root } = request; let { ok, method, route, definition } = value; if ( ok !== true || typeof method !== "string" || typeof route !== "string" || !route.startsWith("/") || !record(definition) || typeof definition.name !== "string" ) { return; }
return { input, root, definition: definition.name, target: `${method.toUpperCase()} ${route}` as Target, };}
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null;}
function show(value: unknown): string { return Deno.inspect(value, { colors: false, depth: Infinity, sorted: true });}
function expectType<T extends true>(_value: T): void { // Compile-time assertion.}
function customOption( name: string,): IdentityElement<Param<string, unknown>> { const read: ReadCLI = (tokens) => { let claim = tokens.claimPair((flag, value) => flag.type === "flag" && flag.text === name && value.type === "word" ); let [, value] = claim.tokens;
return value ? { claim, result: { ok: true, value: { exists: true, value: value.text }, issues: [], }, } : { claim, result: { ok: true, value: { exists: false }, issues: [], }, }; };
return brand<IdentityElement<Param<string, unknown>>>( (param: Param<string, unknown>) => ({ ...param, cli: { read }, }), );}
function expectOk<T extends { readonly ok: boolean }>( result: T,): asserts result is Extract<T, { readonly ok: true }> { expect(result.ok).toBe(true);}
function expectUnprocessable<T extends { readonly ok: boolean }>( result: T,): asserts result is T & { readonly ok: false; readonly code: "unprocessable-content"; readonly issues: readonly unknown[];} { expect(result).toMatchObject({ ok: false, code: "unprocessable-content", });}