import 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 { readonly rest: Rest; readonly result: Result; } export interface PhaseBinding { readonly rest: Rest; readonly model: Record; readonly issues: Issue[]; readonly valid: boolean; } export interface PhaseSegment { readonly range: TokenRange; readonly path: Path; } export function fromCLI(options: { readonly param: Param; readonly view: TokenInput; readonly rest: Rest; }): Maybe> { let { param, view, rest } = options; return fromRead(param, param.cli.read(view), rest); } export function fromValues(options: { readonly param: Param; readonly route: Path; readonly rest: Rest; }): Maybe> { 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(options: { readonly param: Param; readonly route: Path; readonly rest: Rest; }): Maybe> { 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[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map>(); let captures = new Map(); function settle( param: Param, binding: Binding, ): void { rest = binding.rest; results.set(param.name, binding.result); pending.delete(param.name); } function accept( param: Param, attempt: Maybe>, ): 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; 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 = {}; 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( param: Param, read: CLIRead, rest: Rest, ): Maybe> { 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( param: Param, values: unknown[], path: string[], ): Result { return decode(param, values, candidates(param, values), path); } function* candidates( param: Param, values: readonly unknown[], index = 0, prefix: readonly unknown[] = [], ): Iterable { 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( param: Param, value: unknown, candidates: Iterable, path: string[], ): Result { 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( param: Param, value: unknown, path: string[], ): Result { 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( result: Result, issues: readonly Issue[] | undefined, ): Result { if (!issues || issues.length === 0) { return result; } return { ...result, issues: [...issues, ...(result.issues ?? [])], }; } function first(tokens: Tokenizer, 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; }