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.
9.3 kB · 413 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414import type { Maybe } from "./maybe.ts";import type { Param } from "./param.ts";import type { CLIRead, Symbol } from "./read.ts";import type { Rest } from "./rest.ts";import type { Result } from "./result.ts";import type { Word } from "./tokenize.ts";import type { TokenInput, Tokenizer, TokenRange } from "./tokenizer.ts";import type { AnyPhase, Issue, Path } from "./types.ts";
export interface Binding<T> { readonly rest: Rest; readonly result: Result<T>;}
export interface PhaseBinding { readonly rest: Rest; readonly model: Record<string, unknown>; readonly issues: Issue[]; readonly valid: boolean;}
export interface PhaseSegment { readonly range: TokenRange; readonly path: Path;}
export function fromCLI<const K extends string, T>(options: { readonly param: Param<K, T>; readonly view: TokenInput<Symbol>; readonly rest: Rest;}): Maybe<Binding<T>> { let { param, view, rest } = options; return fromRead(param, param.cli.read(view), rest);}
export function fromValues<const K extends string, T>(options: { readonly param: Param<K, T>; readonly route: Path; readonly rest: Rest;}): Maybe<Binding<T>> { let { param, route, rest } = options; let claim = rest.values.claim({ route, address: [param.name], });
if (!claim.result.exists) { return { exists: false }; }
return { exists: true, value: { rest: { ...rest, values: claim.rest, }, result: validate(param, claim.result.value.value, [param.name]), }, };}
export function fromEnv<const K extends string, T>(options: { readonly param: Param<K, T>; readonly route: Path; readonly rest: Rest;}): Maybe<Binding<T>> { let { param, route, rest } = options; let claim = rest.envs.claim({ route, address: [param.name], key: param.env, });
if (!claim.result.exists) { return { exists: false }; }
let value = claim.result.value.value;
return { exists: true, value: { rest: { ...rest, envs: claim.rest, }, result: decode(param, value, param.decode(value), [param.name]), }, };}
export function bindPhase(options: { readonly phase: AnyPhase; readonly segment: PhaseSegment; readonly rest: Rest;}): PhaseBinding { let { phase, segment } = options; let rest = options.rest; let params = Object.values(phase.params) as Param<string, unknown>[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map<string, Result<unknown>>(); let captures = new Map<string, Capture>();
function settle( param: Param<string, unknown>, binding: Binding<unknown>, ): void { rest = binding.rest; results.set(param.name, binding.result); pending.delete(param.name); }
function accept( param: Param<string, unknown>, attempt: Maybe<Binding<unknown>>, ): void { if (!attempt.exists) { return; }
settle(param, attempt.value); }
// Every pending reader proposes a claim against the same immutable view. // Commit the proposal beginning earliest in argv, then recompute the view. // This lets `--port 9000` outrank a positional claim on `9000` without // attaching binding-policy metadata to either reader. while (true) { let horizon = first(rest.tokens, segment.range); let offer: { param: Param<string, unknown>; read: CLIRead; index: number; } | undefined;
for (let param of pending.values()) { let view = rest.tokens.view({ range: segment.range, through: horizon?.index, }); let read = param.cli.read(view);
if (read.result.ok && !read.result.value.exists) { continue; }
let index = earliest(read.claim.tokens); if (!offer || index < offer.index) { offer = { param, read, index }; } }
if (!offer) { break; }
if ( offer.param.multiple && offer.read.result.ok && offer.read.result.value.exists ) { rest = { ...rest, tokens: offer.read.claim.rest }; let capture = captures.get(offer.param.name) ?? { values: [], issues: [], }; captures.set(offer.param.name, { values: [ ...capture.values, offer.read.result.value.value, ], issues: [ ...capture.issues, ...(offer.read.result.issues ?? []), ], }); continue; }
accept(offer.param, fromRead(offer.param, offer.read, rest)); }
for (let param of pending.values()) { let capture = captures.get(param.name); if (!capture) { continue; }
results.set( param.name, merge( decodeMany(param, capture.values, [param.name]), capture.issues, ), ); pending.delete(param.name); }
// Address sources have stable visibility once the route is known. They are // tried only after CLI has reached its fixed point so a provisional CLI miss // cannot let a lower-priority source settle the parameter too early. for (let source of [fromEnv, fromValues]) { for (let param of pending.values()) { accept( param, source({ param, route: segment.path, rest, }), ); } }
// Only total absence reaches the schema as undefined. This is where required, // optional, and defaulting parameters diverge. for (let param of pending.values()) { results.set(param.name, validate(param, undefined, [param.name])); }
let model: Record<string, unknown> = {}; let issues: Issue[] = []; let valid = true;
// Collect in declaration order, independent of the source or sweep that // settled each parameter. for (let param of params) { let result = results.get(param.name)!; issues.push(...result.issues ?? []);
if (result.ok) { model[param.name] = result.value; } else { valid = false; } }
return { rest, model, issues, valid };}
type Capture = { values: unknown[]; issues: Issue[];};
function fromRead<T>( param: Param<string, T>, read: CLIRead, rest: Rest,): Maybe<Binding<T>> { let path = [param.name];
if (!read.result.ok) { return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result: read.result, }, }; }
if (!read.result.value.exists) { return { exists: false }; }
let value = read.result.value.value; let candidates = typeof value === "string" ? param.decode(value) : [value]; let result = merge( decode(param, value, candidates, path), read.result.issues, );
return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result, }, };}
function decodeMany<T>( param: Param<string, T>, values: unknown[], path: string[],): Result<T> { return decode(param, values, candidates(param, values), path);}
function* candidates<T>( param: Param<string, T>, values: readonly unknown[], index = 0, prefix: readonly unknown[] = [],): Iterable<unknown[]> { if (index === values.length) { yield [...prefix]; return; }
let value = values[index]; let decoded = typeof value === "string" ? param.decode(value) : [value]; for (let candidate of decoded) { yield* candidates(param, values, index + 1, [...prefix, candidate]); }}
function decode<T>( param: Param<string, T>, value: unknown, candidates: Iterable<unknown>, path: string[],): Result<T> { let issues: readonly Issue[] | undefined; let found = false;
for (let candidate of candidates) { found = true; let result = validate(param, candidate, path); if (result.ok) { return result; } issues = issues ?? result.issues; }
if (!found) { return { ok: false, issues: [{ message: `unable to decode ${JSON.stringify(value)}`, path, }], }; }
return { ok: false, issues: issues ?? [], };}
function validate<T>( param: Param<string, T>, value: unknown, path: string[],): Result<T> { let validated = param.schema["~standard"].validate(value); if (validated instanceof Promise) { return { ok: false, issues: [{ message: `async schemas are not allowed`, path, }], }; }
if (validated.issues) { return { ok: false, issues: validated.issues.map((issue) => ({ ...issue, message: issue.message, path, })), }; }
return { ok: true, issues: [], value: validated.value, };}
function merge<T>( result: Result<T>, issues: readonly Issue[] | undefined,): Result<T> { if (!issues || issues.length === 0) { return result; }
return { ...result, issues: [...issues, ...(result.issues ?? [])], };}
function first(tokens: Tokenizer<Symbol>, range: TokenRange): Word | undefined { for (let token of tokens.view({ range })) { if (token.type === "word") { return token; } }}
function earliest(tokens: readonly Symbol[]): number { let first = Infinity;
for (let token of tokens) { first = Math.min(first, token.index); }
return first;}