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.
14 kB · 577 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578import { bindPhase } from "./bind.ts";import { Envs } from "./env.ts";import type { Symbol } from "./read.ts";import type { Rest } from "./rest.ts";import type { Result } from "./result.ts";import { type AnyToken, type Literal, tokenize } from "./tokenize.ts";import { Tokenizer } from "./tokenizer.ts";import { Values } from "./values.ts";import type { AnyIntent, AnyPhase, AnyPhases, AnyRoute, Input, Issue, Method, ModelOperation, ModelsByRoute, ModelSchema, ModelTransformContext, Outcome, Parse, Path, RoutePath, UnprocessableContent,} from "./types.ts";
export function parse<const R extends AnyRoute>( route: R, input: Input,): Parse<R>;
export function parse( route: AnyRoute, input: Input,): unknown { let tokenizer = new Tokenizer(tokenize(input.argv)); let help = tokenizer.claimAll(flags("-h", "--help")); let version = help.rest.claimAll(flags("-v", "--version")); let escape = version.rest.claimAll((t) => t.type === "separator"); let literals = escape.rest.claimAll((t) => t.type === "literal");
let method: Method = "execute";
if (help.tokens.length > 0) { method = "help"; } else if (version.tokens.length > 0) { method = "version"; }
let rest: Rest = { tokens: literals.rest as Tokenizer<Symbol>, values: new Values().mount([], input.values ?? []), envs: new Envs().mount([], input.envs ?? []), };
return resume({ segments: [{ id: "/", route, phases: route.phases, tokens: Array.from(rest.tokens), path: [], start: -1, model: {}, issues: [], history: [], }], active: 0, rest, models: {}, method, literals: literals.tokens, });}function resume( state: ParserState,): Outcome<AnyIntent | AnyIncrement> { while (true) { // Starting from the deepest open segment, synchronously descend through // routes visible on each segment's head phase. state = search(state);
let index = state.active; let segment = state.segments[index]; let [phase] = segment.phases;
state = { ...state, rest: { ...state.rest, values: state.rest.values.mount(segment.path, phase.values), envs: state.rest.envs.mount(segment.path, phase.envs), }, };
// Controls are free of ordinary configuration validation. A dynamic phase // is the exception: its model is needed by the caller before the remaining // route graph (and therefore the final control target) can be known. if (state.method !== "execute" && !phase.resolver) { let models = { ...state.models, [segment.id]: segment.model, };
if (index + 1 < state.segments.length) { state = { ...state, active: index + 1, models, };
continue; }
return resolve({ ...state, models }, segment); }
// Bind only this phase, within this segment's token view. // // For an open dynamic segment, binding may consume the horizon but may not // look beyond it. For a committed segment, `end` is the hard boundary. let binding = bindPhase({ phase, segment: { range: { start: segment.start, end: segment.end, }, path: segment.path, }, rest: state.rest, });
segment = { ...segment, model: { ...segment.model, ...binding.model, }, issues: [ ...segment.issues, ...binding.issues, ], };
state = { ...state, rest: binding.rest, segments: replace(state.segments, index, segment), };
if (!binding.valid) { let issues = phase.resolver ? binding.issues : [...unexpected(state, segment), ...binding.issues];
return unprocessableContent( state.segments[state.segments.length - 1], issues, ); }
let modeled = applyTransforms(segment, phase); if (!modeled.ok) { return unprocessableContent(segment, modeled.issues); }
segment = { ...segment, model: modeled.value, };
if (phase.resolver) { // Binding the phase succeeded, but the requirement is still needed. let suspended = state;
return { ok: true, route: segment.id, model: modeled.value,
resume(result) { if (!result.ok) { return unprocessableContent(segment, result.issues); }
// AnyPhase erases these exact types with `never`; dynamic() already // proved them at its public boundary. let resolver = phase.resolver as unknown as ( requirement: unknown, ) => (route: AnyRoute) => AnyRoute;
// The extension operates against the same aggregate route metadata, // but begins with one fresh, empty phase. let continuation = resolver(result.value)( seed(segment.route), ); let phases = stitch( continuation.phases, segment.phases.slice(1), ); let history = [...segment.history, phase];
let next: Segment = { ...segment, route: { ...continuation, phases: [...history, ...phases] as unknown as AnyPhases, }, phases, history, issues: [ ...segment.issues, ...(result.issues ?? []), ], };
return resume({ ...suspended, segments: replace(suspended.segments, index, next), }); }, }; }
// A phase without a resolver is the final phase of this segment. let issues = unexpected(state, segment);
if (issues.length > 0) { return unprocessableContent( state.segments[state.segments.length - 1], issues, ); }
let id = `/${segment.path.join("/")}`; let models = { ...state.models, [id]: segment.model, };
// There is already a synchronously matched child segment. if (index + 1 < state.segments.length) { state = { ...state, active: index + 1, models, };
continue; }
// The deepest selected segment is fully resolved. return resolve({ ...state, models, }, segment); }}
interface ParserState { segments: [Segment, ...Segment[]]; active: number; rest: Rest; models: ModelsByRoute; method: Method; literals: Literal[];}
interface AnyIncrement { readonly ok: true; readonly route: RoutePath; readonly model: object; resume(result: Result<unknown>): Outcome<AnyIntent | AnyIncrement>;}
interface Segment { id: RoutePath; route: AnyRoute; path: Path; phases: AnyPhases; tokens: Symbol[]; start: number; end?: number; model: Record<string, unknown>; issues: Issue[]; history: AnyPhase[];}
function search(state: ParserState): ParserState { let segments = [...state.segments] as [Segment, ...Segment[]]; let rest = state.rest;
while (true) { let index = segments.length - 1; let segment = segments[index]; let [phase] = segment.phases; let remaining = new Set(Array.from(rest.tokens, (token) => token.index)); let selector = segment.tokens.find((token) => { return remaining.has(token.index) && token.type === "word" && phase.routes.some((route) => route.name === token.text); });
if (!selector) { return { ...state, segments, rest }; }
let child = phase.routes.find((route) => route.name === selector.text)!; let path = [...segment.path, child.name]; let before = segment.tokens.filter((token) => token.index < selector.index); let after = segment.tokens.filter((token) => token.index > selector.index);
segments[index] = { ...segment, tokens: before, end: selector.index, }; segments.push({ id: `/${path.join("/")}`, route: child, path, phases: child.phases, tokens: after, start: selector.index, model: {}, issues: [], history: [], });
rest = { ...rest, tokens: rest.tokens.claimOne((token) => token.index === selector.index) .rest, }; }}
function flags(...texts: string[]): (token: AnyToken) => boolean { return (token) => token.type === "flag" && texts.includes(token.text);}
function stitch( before: AnyPhases, after: readonly AnyPhase[],): AnyPhases { if (after.length === 0) { return before; }
let phases = [...before]; let phase = phases.pop()!; let [next, ...rest] = after;
phases.push({ ...phase, ...next, params: { ...phase.params, ...next.params, }, routes: [ ...phase.routes, ...next.routes, ], values: [ ...phase.values, ...next.values, ], envs: [ ...phase.envs, ...next.envs, ], }); phases.push(...rest);
return phases as unknown as AnyPhases;}
function resolve( state: ParserState, segment: Segment,): Outcome<AnyIntent> { let route = `/${segment.path.join("/")}` as RoutePath; let definition = segment.route;
if (!definition.methods.includes(state.method)) { return { ok: false, code: "method-not-allowed", route, definition, path: segment.path, method: state.method, allowed: definition.methods, }; }
let intent = { ok: true as const, route, definition, path: segment.path, literals: state.literals, };
switch (state.method) { case "help": return { ...intent, method: "help", };
case "version": return { ...intent, method: "version", };
case "execute": return { ...intent, method: "execute", model: state.models[route], models: state.models, issues: state.segments.flatMap((segment) => segment.issues), }; }}
function applyTransforms( segment: Segment, phase: AnyPhase,): Result<Record<string, unknown>> { let operations = phase.transforms ?? []; let model = segment.model; let issues: Issue[] = []; let addIssue = (issue: Issue) => issues.push(issue); let outputs = new Map<ModelOperation, readonly string[]>();
for (let op of operations) { let keys = scope(op, outputs); if ( typeof op.transform === "function" && !("~standard" in op.transform) ) { let context: ModelTransformContext = { options: pick(model, keys), phase: phase.params, addIssue, }; let result = op.transform(context); if (issues.length > 0) { return { ok: false, issues }; } if (result !== undefined) { if (!record(result)) { return invalidTransformResult(); } model = { ...model, ...result }; outputs.set(op, Object.keys(result)); } else { outputs.set(op, []); } continue; }
let schema = op.transform as ModelSchema<object>; let result = schema["~standard"].validate(pick(model, keys)); if (result instanceof Promise) { return { ok: false, issues: [{ message: "async schemas are not allowed" }], }; } if (result.issues) { return { ok: false, issues: result.issues }; } if (!record(result.value)) { return invalidTransformResult(); } model = { ...model, ...result.value }; outputs.set(op, Object.keys(result.value)); }
if (operations.length === 0) { return { ok: true, value: segment.model }; }
return { ok: true, value: model };}
function invalidTransformResult(): Result<Record<string, unknown>> { return { ok: false, issues: [{ message: "model transforms must return records" }], };}
function record(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value);}
function pick( model: Record<string, unknown>, keys: readonly string[],): Record<string, unknown> { let options: Record<string, unknown> = {}; for (let key of keys) { if (key in model) { options[key] = model[key]; } } return options;}
function scope( op: ModelOperation, outputs: ReadonlyMap<ModelOperation, readonly string[]>,): readonly string[] { let keys = [...op.keys]; for (let dep of op.deps ?? []) { keys.push(...outputs.get(dep) ?? []); } return [...new Set(keys)];}
function seed(route: AnyRoute): AnyRoute { return { ...route, phases: [{ params: {}, routes: [], values: [], envs: [], }], };}
function replace<T>( items: readonly [T, ...T[]], index: number, value: T,): [T, ...T[]] { let result = [...items] as [T, ...T[]]; result[index] = value; return result;}
function unprocessableContent( segment: Segment, issues: readonly Issue[],): UnprocessableContent { return { ok: false, code: "unprocessable-content", route: segment.id, definition: segment.route, path: segment.path, issues: [...issues], };}
function unexpected( state: ParserState, segment: Segment,): Issue[] { let members = new Set( segment.tokens.map((token) => token.index), );
return Array.from(state.rest.tokens) .filter((token) => members.has(token.index)) .map((token) => ({ message: `unexpected ${JSON.stringify(token.text)}`, }));}