diff --git a/lib/argument.ts b/lib/argument.ts index 99a2ea0..c74e4ec 100644 --- a/lib/argument.ts +++ b/lib/argument.ts @@ -1,4 +1,4 @@ -import { type Param, param, type ParamModel } from "./param.ts"; +import { type AnyParam, type Param, param, type ParamModel } from "./param.ts"; import { brand, type Check, @@ -15,14 +15,14 @@ export function argument< const E extends readonly Unary[], >( named: Definition, - ...elements: E & Check, E> -): ElementOf, E>> { + ...elements: E & Check, E> +): ElementOf, E>> { const added = elements.reduce( (value, element) => element(value as never), param(named, positional), - ) as Param; + ) as AnyParam; - return brand, E>>>( + return brand, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; @@ -51,13 +51,13 @@ export function argument< ); } -type ValueOf

= P extends Param ? T : never; +type Zero = Param; -type ElementOf = P extends Param - ? ModelElement>> +type ElementOf = P extends + Param ? ModelElement> : never; -function positional

>(param: P): P { +function positional

(param: P): P { return { ...param, cli: { diff --git a/lib/bind.ts b/lib/bind.ts index b970709..32abbeb 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -1,11 +1,11 @@ -import type { Maybe } from "./maybe.ts"; -import type { Param } from "./param.ts"; +import { Just, type Maybe } from "./maybe.ts"; +import type { AnyParam, 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"; +import type { AnyPhase, Issue, OutputOf, Path } from "./types.ts"; export interface Binding { readonly rest: Rest; @@ -24,20 +24,20 @@ export interface PhaseSegment { readonly path: Path; } -export function fromCLI(options: { - readonly param: Param; +export function fromCLI

>(options: { + readonly param: P; readonly view: TokenInput; readonly rest: Rest; -}): Maybe> { +}): Maybe>> { let { param, view, rest } = options; return fromRead(param, param.cli.read(view), rest); } -export function fromValues(options: { - readonly param: Param; +export function fromValues

(options: { + readonly param: P; readonly route: Path; readonly rest: Rest; -}): Maybe> { +}): Maybe>> { let { param, route, rest } = options; let claim = rest.values.claim({ route, @@ -60,11 +60,11 @@ export function fromValues(options: { }; } -export function fromEnv(options: { - readonly param: Param; +export function fromEnv

(options: { + readonly param: P; readonly route: Path; readonly rest: Rest; -}): Maybe> { +}): Maybe>> { let { param, route, rest } = options; let claim = rest.envs.claim({ route, @@ -78,6 +78,10 @@ export function fromEnv(options: { let value = claim.result.value.value; + let candidates = param.cardinality === "one" + ? param.decode(value) + : param.decode([value]); + return { exists: true, value: { @@ -85,7 +89,7 @@ export function fromEnv(options: { ...rest, envs: claim.rest, }, - result: decode(param, value, param.decode(value), [param.name]), + result: decode(param, value, candidates, [param.name]), }, }; } @@ -97,12 +101,12 @@ export function bindPhase(options: { }): PhaseBinding { let { phase, segment } = options; let rest = options.rest; - let params = Object.values(phase.model.params) as Param[]; + let params = Object.values(phase.model.params) as AnyParam[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map>(); function settle( - param: Param, + param: AnyParam, binding: Binding, ): void { rest = binding.rest; @@ -111,7 +115,7 @@ export function bindPhase(options: { } function accept( - param: Param, + param: AnyParam, attempt: Maybe>, ): void { if (!attempt.exists) { @@ -121,6 +125,8 @@ export function bindPhase(options: { settle(param, attempt.value); } + let captures = new Map(); + // 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 @@ -128,7 +134,7 @@ export function bindPhase(options: { while (true) { let horizon = first(rest.tokens, segment.range); let offer: { - param: Param; + param: AnyParam; read: CLIRead; index: number; } | undefined; @@ -154,9 +160,58 @@ export function bindPhase(options: { break; } + if (offer.param.cardinality === "many") { + rest = { + ...rest, + tokens: offer.read.claim.rest, + }; + + let current = captures.get(offer.param.name) ?? { + param: offer.param, + values: [], + issues: [], + failed: false, + }; + + current.issues.push(...offer.read.result.issues ?? []); + + if (offer.read.result.ok && offer.read.result.value.exists) { + current.values.push(offer.read.result.value.value); + } else { + current.failed = true; + } + captures.set(offer.param.name, current); + + continue; + } + accept(offer.param, fromRead(offer.param, offer.read, rest)); } + // deal wil all multiple + for (let capture of captures.values()) { + if (capture.failed) { + settle(capture.param, { + rest, + result: { ok: false, issues: capture.issues }, + }); + } else { + let values = capture.values; + + let candidates: Maybe[] = values.every( + (value): value is string => typeof value === "string", + ) + ? capture.param.decode(values) + : [Just(values)]; + + let result = merge( + decode(capture.param, values, candidates, [capture.param.name]), + capture.issues, + ); + settle(capture.param, { result, rest }); + } + } + // 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. @@ -210,11 +265,11 @@ export function bindPhase(options: { return { rest, model, issues, valid }; } -function fromRead( - param: Param, +function fromRead

>( + param: P, read: CLIRead, rest: Rest, -): Maybe> { +): Maybe>> { let path = [param.name]; if (!read.result.ok) { @@ -235,7 +290,10 @@ function fromRead( } let value = read.result.value.value; - let candidates = typeof value === "string" ? param.decode(value) : [value]; + + let candidates = typeof value === "string" + ? param.decode(value) + : [Just(value)]; let result = merge( decode(param, value, candidates, path), read.result.issues, @@ -253,12 +311,12 @@ function fromRead( }; } -function decode( - param: Param, +function decode

( + param: P, value: unknown, - candidates: unknown[], + candidates: Maybe[], path: string[], -): Result { +): Result> { if (candidates.length === 0) { return { ok: false, @@ -271,7 +329,11 @@ function decode( let issues: readonly Issue[] | undefined; - for (let candidate of candidates) { + let values = candidates.flatMap((candidate) => + candidate.exists ? [candidate.value] : [] + ); + + for (let candidate of values) { let result = validate(param, candidate, path); if (result.ok) { return result; @@ -285,11 +347,11 @@ function decode( }; } -function validate( - param: Param, +function validate

( + param: P, value: unknown, path: string[], -): Result { +): Result> { let validated = param.schema["~standard"].validate(value); if (validated instanceof Promise) { return { @@ -350,3 +412,10 @@ function earliest(tokens: readonly Symbol[]): number { return first; } + +interface Capture { + param: Param; + values: (string | boolean)[]; + issues: Issue[]; + failed: boolean; +} diff --git a/lib/decode.ts b/lib/decode.ts index 674b645..55d0031 100644 --- a/lib/decode.ts +++ b/lib/decode.ts @@ -1,26 +1,55 @@ -export type Decoder = (value: string) => unknown[]; +import { Just, type Maybe, Nothing } from "./maybe.ts"; -export const number: Decoder = (value) => { +export type Decoder = (value: A) => Maybe[]; + +export const number: Decoder = (value) => { if (!numeric.test(value)) { - return []; + return [Nothing()]; } let decoded = Number(value); - return Number.isFinite(decoded) ? [decoded] : []; + return Number.isFinite(decoded) ? [Just(decoded)] : [Nothing()]; }; -export const scalar: Decoder = (value) => { - return [...number(value), value]; +export const scalar: Decoder = (value) => { + return [...number(value), Just(value)]; }; -export const boolean: Decoder = (value) => { +export const boolean: Decoder = (value) => { if (value === "true") { - return [true]; + return [Just(true)]; } if (value === "false") { - return [false]; + return [Just(false)]; } - return []; + return [Nothing()]; }; +export function multiple(decoder: Decoder): Decoder { + return (input: A[]): Maybe[] => { + let decodings = input.map(decoder); + + let result: Maybe[] = []; + + for (let i = 0; i < decodings.length; i++) { + let decoding = decodings[i]; + for (let j = 0; j < decoding.length; j++) { + let row: Maybe | undefined = result[j]; + if (!row) { + row = result[j] = Just([]); + } + if (row.exists) { + let candidate = decoding[j]; + if (candidate.exists) { + row.value[i] = candidate.value; + } else { + result[j] = Nothing(); + } + } + } + } + return result; + }; +} + const numeric = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; diff --git a/lib/env.ts b/lib/env.ts index e24adca..99087b5 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,5 +1,5 @@ import type { Maybe } from "./maybe.ts"; -import type { Param } from "./param.ts"; +import type { AnyParam } from "./param.ts"; import { brand, type IdentityElement } from "./pipeline.ts"; import type { AnyRoute, RoutePath } from "./types.ts"; @@ -28,9 +28,9 @@ export interface EnvClaimOptions { export function env( key: string, -): IdentityElement> { - return brand>>( - (param: Param) => ({ ...param, env: key }), +): IdentityElement { + return brand>( + (param: AnyParam) => ({ ...param, env: key }), ); } diff --git a/lib/multiple.ts b/lib/multiple.ts new file mode 100644 index 0000000..6b7e969 --- /dev/null +++ b/lib/multiple.ts @@ -0,0 +1,23 @@ +import { multiple as decode } from "./decode.ts"; +import type { Param } from "./param.ts"; +import { mark, type Transform, type TransformElement } from "./pipeline.ts"; + +export function multiple(): TransformElement { + return mark( + (param: Param) => ({ + ...param, + cardinality: "many", + decode: decode(param.decode), + }), + ); +} + +interface MultipleTransform extends Transform { + readonly input: Param; + readonly output: this["input"] extends Param< + infer K, + infer Model, + "one" + > ? Param + : never; +} diff --git a/lib/option.ts b/lib/option.ts index 91d0a61..fa2eb87 100644 --- a/lib/option.ts +++ b/lib/option.ts @@ -1,4 +1,4 @@ -import { type Param, param, type ParamModel } from "./param.ts"; +import { type AnyParam, type Param, param, type ParamModel } from "./param.ts"; import { dasherize } from "./dasherize.ts"; import { brand, @@ -15,14 +15,14 @@ export function option< const E extends readonly Unary[], >( named: Definition, - ...elements: E & Check, E> -): ElementOf, E>> { + ...elements: E & Check, E> +): ElementOf, E>> { const added = elements.reduce( (value, element) => element(value as never), param(named, cli([`--${dasherize(named.name)}`])), - ) as Param; + ) as AnyParam; - return brand, E>>>( + return brand, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; @@ -50,8 +50,8 @@ export function option< ); } -type ValueOf

= P extends Param ? T : never; +type Zero = Param; -type ElementOf = P extends Param - ? ModelElement>> +type ElementOf = P extends + Param ? ModelElement> : never; diff --git a/lib/param.ts b/lib/param.ts index 60c3383..abe1740 100644 --- a/lib/param.ts +++ b/lib/param.ts @@ -11,25 +11,37 @@ import { import type { CLIBinding } from "./read.ts"; import type { Definition, OutputOf, Schema } from "./types.ts"; -export interface Param extends Definition { +export interface Param + extends Definition { schema: Schema; + cardinality: C; cli: CLIBinding; - decode: Decoder; + decode: Decoder, Decoded>; env?: string; } +export type Cardinality = "one" | "many"; + +export type AnyParam = + | Param + | Param; + export type ParamModel = ModelPatch<{ [P in K]: V }>; +export type Representation = C extends "many" ? string[] + : string; + export function param< const K extends string, const E extends readonly Unary[], >( start: Definition, - ...elements: E & Check, E> -): Fold, E> { - let zero: Param = { + ...elements: E & Check, E> +): Fold, E> { + let zero: ParamZero = { ...start, schema: unknown, + cardinality: "one", cli: { read(tokens) { let claim = tokens.claimAll(() => false); @@ -49,14 +61,14 @@ export function param< return elements.reduce( (value, element) => element(value as never), zero, - ) as Fold, E>; + ) as Fold, E>; } export function schema( schema: S, ): TransformElement>> { return mark>>( - (param: Param) => ({ + (param: AnyParam) => ({ ...param, schema, }), @@ -64,9 +76,9 @@ export function schema( } interface SchemaTransform extends Transform { - readonly input: Param; - readonly output: this["input"] extends Param - ? Param + readonly input: AnyParam; + readonly output: this["input"] extends Param + ? Param : never; } @@ -77,3 +89,8 @@ const unknown: Schema = { validate: (value) => ({ value }), }, }; + +type ParamZero = Param; + +type Decoded = C extends "many" ? unknown[] + : unknown; diff --git a/lib/print.ts b/lib/print.ts index 86d22db..9349cda 100644 --- a/lib/print.ts +++ b/lib/print.ts @@ -2,7 +2,7 @@ * 😵‍💫 This file has been vibe coded 😵‍💫 */ -import type { Param } from "./param.ts"; +import type { AnyParam } from "./param.ts"; import type { AnyRoute, Help, @@ -107,9 +107,9 @@ export function printErrors( type Row = readonly [label: string, description?: string]; -function params(route: AnyRoute): Param[] { +function params(route: AnyRoute): AnyParam[] { return route.phases.flatMap((phase) => - Object.values(phase.model.params) as Param[] + Object.values(phase.model.params) as AnyParam[] ); } diff --git a/lib/read.ts b/lib/read.ts index f23d54d..b58c517 100644 --- a/lib/read.ts +++ b/lib/read.ts @@ -1,5 +1,5 @@ import type { Maybe } from "./maybe.ts"; -import type { Param } from "./param.ts"; +import type { AnyParam } from "./param.ts"; import { brand, type IdentityElement } from "./pipeline.ts"; import type { Result } from "./result.ts"; import type { Flag, Setter, Word } from "./tokenize.ts"; @@ -32,7 +32,7 @@ export interface CLIOptions { export function cli( names: readonly string[], options: CLIOptions = {}, -): IdentityElement> { +): IdentityElement { const read: ReadCLI = (tokens) => { if (options.switch) { let s = tokens.claimOne((t): t is Flag => { @@ -103,8 +103,8 @@ export function cli( return nothing(tokens); }; - return brand>>( - (param: Param) => ({ + return brand>( + (param: AnyParam) => ({ ...param, cli: { read, diff --git a/lib/toggle.ts b/lib/toggle.ts index e314a16..8326d2c 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -1,6 +1,12 @@ import { boolean as decode } from "./decode.ts"; import { dasherize } from "./dasherize.ts"; -import { type Param, param, type ParamModel, schema } from "./param.ts"; +import { + type AnyParam, + type Param, + param, + type ParamModel, + schema, +} from "./param.ts"; import { brand, type Check, @@ -17,17 +23,17 @@ export function toggle< const E extends readonly Unary[], >( named: Definition, - ...elements: E & Check, E> -): ElementOf, E>> { + ...elements: E & Check, E> +): ElementOf, E>> { const added = elements.reduce( (value, element) => element(value as never), { ...param(named, binding(named.name), schema(bool)), decode, }, - ) as Param; + ) as AnyParam; - return brand, E>>>( + return brand, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; @@ -56,15 +62,13 @@ export function toggle< ); } -type ValueOf

= P extends Param ? T : never; +type Zero = Param; -type ElementOf = P extends Param - ? ModelElement>> +type ElementOf = P extends + Param ? ModelElement> : never; -function binding( - name: string, -):

>(param: P) => P { +function binding(name: string):

(param: P) => P { const stem = dasherize(name); const yes = `--${stem}`; const no = `--no-${stem}`; diff --git a/lib/types.ts b/lib/types.ts index 27494f8..c0b0dc2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,7 +1,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { EnvSource } from "./env.ts"; import type { Literal } from "./tokenize.ts"; -import type { Param } from "./param.ts"; +import type { AnyParam } from "./param.ts"; import type { Result } from "./result.ts"; import type { ValueSource } from "./values.ts"; @@ -74,7 +74,7 @@ export type Done< readonly envs: readonly EnvSource[]; }; -export type Params = Readonly>>; +export type Params = Readonly>; export interface ParseIncrement< R extends AnyRoute, diff --git a/mod.ts b/mod.ts index af1b798..6db3300 100644 --- a/mod.ts +++ b/mod.ts @@ -12,6 +12,7 @@ export { env, withEnvs } from "./lib/env.ts"; export type { Environment, EnvSource } from "./lib/env.ts"; export { option } from "./lib/option.ts"; +export { multiple } from "./lib/multiple.ts"; export { param, schema } from "./lib/param.ts"; export type { Param } from "./lib/param.ts"; diff --git a/test/bind.test.ts b/test/bind.test.ts index 97cb9bd..4bfb1a6 100644 --- a/test/bind.test.ts +++ b/test/bind.test.ts @@ -5,7 +5,7 @@ import { type Binding, bindPhase, fromCLI } from "../lib/bind.ts"; import { name } from "../lib/definition.ts"; import { Envs } from "../lib/env.ts"; import type { Maybe } from "../lib/maybe.ts"; -import { type Param, param, schema } from "../lib/param.ts"; +import { type AnyParam, param, schema } from "../lib/param.ts"; import { cli, type Symbol } from "../lib/read.ts"; import type { Rest } from "../lib/rest.ts"; import { tokenize } from "../lib/tokenize.ts"; @@ -279,7 +279,7 @@ function state(argv: string[]): Rest { }; } -function phase(params: Record>) { +function phase(params: Record) { return { model: { params, diff --git a/test/decode.test.ts b/test/decode.test.ts index 3148cf7..b4826c8 100644 --- a/test/decode.test.ts +++ b/test/decode.test.ts @@ -1,19 +1,47 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; -import { boolean, number, scalar } from "../lib/decode.ts"; +import { boolean, multiple, number, scalar } from "../lib/decode.ts"; +import { Just, Nothing } from "../lib/maybe.ts"; describe("decoders", () => { - it("offers numeric and text interpretations of a numeral", () => { - expect(scalar("0012")).toEqual([12, "0012"]); + it("keeps decoder alternatives in stable slots", () => { + expect(scalar("0012")).toEqual([Just(12), Just("0012")]); + expect(scalar("word")).toEqual([Nothing(), Just("word")]); }); - it("returns no candidate when a number cannot be decoded", () => { - expect(number("twelve")).toEqual([]); + it("represents a failed alternative without removing its slot", () => { + expect(number("12")).toEqual([Just(12)]); + expect(number("twelve")).toEqual([Nothing()]); }); it("decodes boolean text without treating other text as boolean", () => { - expect(boolean("true")).toEqual([true]); - expect(boolean("false")).toEqual([false]); - expect(boolean("yes")).toEqual([]); + expect(boolean("true")).toEqual([Just(true)]); + expect(boolean("false")).toEqual([Just(false)]); + expect(boolean("yes")).toEqual([Nothing()]); }); + + it("lifts each alternative across a complete representation collection", () => { + expect(multiple(scalar)(["001", "002"])).toEqual([ + Just([1, 2]), + Just(["001", "002"]), + ]); + }); + + it("rejects an aggregate alternative when any occurrence cannot use it", () => { + expect(multiple(scalar)(["word", "002"])).toEqual([ + Nothing(), + Just(["word", "002"]), + ]); + }); + + it("bounds aggregate candidates by decoder width", () => { + expect(multiple(scalar)(["1", "2", "3", "4"])).toHaveLength(2); + }); + + // it("rejects decoders whose candidate width changes between values", () => { + // let uneven: Decoder = (value) => + // value === "one" ? [Just(1)] : [Just(2), Just("two")]; + + // expect(() => multiple(uneven)(["one", "two"])).toThrow(); + // }); }); diff --git a/test/multiple.test.ts b/test/multiple.test.ts new file mode 100644 index 0000000..bb88f75 --- /dev/null +++ b/test/multiple.test.ts @@ -0,0 +1,452 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { type } from "arktype"; +import * as z from "zod"; +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, + multiple, + name, + option, + param, + parse, + type ReadCLI, + schema, +} from "../mod.ts"; + +describe("multiple()", () => { + it("changes parameter cardinality without changing its model", () => { + let result = param( + name("config"), + schema(z.array(z.string())), + multiple(), + ); + + expectType< + Equal> + >(true); + }); + + it("preserves the schema output as the model type", () => { + let app = command( + name("simulacrum"), + option( + name("config"), + multiple(), + schema(z.array(z.string()).transform((values) => new Set(values))), + ), + ); + + expectType, { config: Set }>>(true); + }); + + it("composes on either side of schema()", () => { + command( + name("before"), + option(name("config"), multiple(), schema(type("string[]"))), + ); + command( + name("after"), + option(name("config"), schema(type("string[]")), multiple()), + ); + }); + + it("collects repeated option representations in argv order", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + option(name("port"), schema(z.number())), + ); + let result = parse(app, { + argv: ["--config", "one", "--port", "4100", "--config=two"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["one", "two"], port: 4100 }, + }); + }); + + it("decodes every representation with one consistent alternative", () => { + let app = command( + name("simulacrum"), + option(name("port"), multiple(), schema(z.array(z.number()))), + ); + let result = parse(app, { + argv: ["--port", "4100", "--port", "4101"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { port: [4100, 4101] }, + }); + }); + + it("preserves numeric-looking strings as one aggregate alternative", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: ["--config", "0012", "--config", "0034"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["0012", "0034"] }, + }); + }); + + it("does not form mixed Cartesian interpretations", () => { + let app = command( + name("simulacrum"), + option( + name("value"), + multiple(), + schema(z.tuple([z.number(), z.string()])), + ), + ); + let result = parse(app, { + argv: ["--value", "1", "--value", "2"], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ path: ["value"] }], + }); + }); + + it("lets an optional schema interpret total source absence", () => { + let app = command( + name("simulacrum"), + option( + name("config"), + multiple(), + schema(z.array(z.string()).optional()), + ), + ); + + expect(parse(app, { argv: [] })).toMatchObject({ + ok: true, + method: "execute", + model: { config: undefined }, + }); + }); + + it("lets a defaulting schema interpret total source absence", () => { + let app = command( + name("simulacrum"), + option( + name("config"), + multiple(), + schema(z.array(z.string()).default(["default.yml"])), + ), + ); + + expect(parse(app, { argv: [] })).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["default.yml"] }, + }); + }); + + it("lets an absent CLI source fall through to environment", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { CONFIG: "environment.yml" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["environment.yml"] }, + }); + }); + + it("treats an environment string as one occurrence without splitting it", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: [], + envs: [{ + name: "process", + value: { CONFIG: "one.yml,two.yml" }, + }], + values: [{ + name: "settings", + value: { config: ["fallback-one.yml", "fallback-two.yml"] }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["one.yml,two.yml"] }, + }); + }); + + it("lets absent CLI and Env sources fall through to JavaScript values", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: [], + values: [{ + name: "settings", + value: { config: ["value.yml"] }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["value.yml"] }, + }); + }); + + it("prefers repeated CLI options over lower-priority sources", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: ["--config", "one.yml", "--config", "two.yml"], + envs: [{ name: "process", value: { CONFIG: "environment.yml" } }], + values: [{ + name: "settings", + value: { config: ["value.yml"] }, + }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { config: ["one.yml", "two.yml"] }, + }); + }); + + it("does not hide an incomplete occurrence with lower-priority sources", () => { + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let result = parse(app, { + argv: ["--config", "one.yml", "--config"], + envs: [{ name: "process", value: { CONFIG: "environment.yml" } }], + values: [{ + name: "settings", + value: { config: ["value.yml"] }, + }], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ message: "--config requires a value" }], + }); + }); + + it("preserves successful-read diagnostics when a later occurrence fails", () => { + let app = command( + name("simulacrum"), + option( + name("config"), + multiple(), + deprecated(), + schema(z.array(z.string())), + ), + ); + let result = parse(app, { + argv: ["--config", "one.yml", "--config"], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [ + { message: "--config is deprecated" }, + { message: "--config requires a value" }, + ], + }); + }); + + it("collects occurrences through a custom singular reader", () => { + let app = command( + name("simulacrum"), + option( + name("plugin"), + multiple(), + custom("--plugin"), + schema(z.array(z.string())), + ), + ); + let result = parse(app, { + argv: ["--plugin", "one", "--plugin", "two"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { plugin: ["one", "two"] }, + }); + }); + + it("does not capture occurrences across a known child selector", () => { + let child = command( + name("child"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + routes(child), + ); + let result = parse(app, { + argv: [ + "--config", + "root.yml", + "child", + "--config", + "child.yml", + ], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/child", + models: { + "/": { config: ["root.yml"] }, + "/child": { config: ["child.yml"] }, + }, + }); + }); + + it("finalizes safely visible occurrences before a dynamic route is known", () => { + let child = command( + name("child"), + option(name("config"), multiple(), schema(z.array(z.string()))), + ); + let app = command( + name("simulacrum"), + option(name("config"), multiple(), schema(z.array(z.string()))), + dynamic(() => extend(routes(child))), + ); + let first = parse(app, { + argv: [ + "--config", + "root.yml", + "child", + "--config", + "child.yml", + ], + }); + + expect(first).toMatchObject({ + ok: true, + route: "/", + model: { config: ["root.yml"] }, + }); + expect("resume" in first).toBe(true); + if (!("resume" in first)) return; + + let result = first.resume({ ok: true, value: undefined }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/child", + models: { + "/": { config: ["root.yml"] }, + "/child": { config: ["child.yml"] }, + }, + }); + }); +}); + +function custom(flag: string): IdentityElement { + const read: ReadCLI = (tokens) => { + let claim = tokens.claimPair((name, value) => + name.type === "flag" && name.text === flag && 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>( + (param: AnyParam) => ({ + ...param, + cli: { read }, + }), + ); +} + +function deprecated(): IdentityElement { + return brand>( + (param: AnyParam) => { + let read = param.cli.read; + return { + ...param, + cli: { + ...param.cli, + read(tokens: Parameters[0]) { + let result = read(tokens); + if (!result.result.ok || !result.result.value.exists) { + return result; + } + return { + ...result, + result: { + ...result.result, + issues: [{ message: "--config is deprecated" }], + }, + }; + }, + }, + }; + }, + ); +} + +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 + : false + : false; + +function expectType(_value: T): void { + // Compile-time assertion. +} diff --git a/test/read.test.ts b/test/read.test.ts index 924e92e..f4b9a64 100644 --- a/test/read.test.ts +++ b/test/read.test.ts @@ -7,6 +7,7 @@ import { tokenize } from "../lib/tokenize.ts"; import { Tokenizer } from "../lib/tokenizer.ts"; describe("CLI reader", () => { + // Readers claim one occurrence; phase binding repeats them for multiple params. it("claims an incomplete option while reporting its missing value", () => { let read = param( name("port"), @@ -20,6 +21,34 @@ describe("CLI reader", () => { expect(texts(read.claim.tokens)).toEqual(["--port"]); expect(texts(read.claim.rest)).toEqual(["--verbose"]); }); + + it("reads only the first option occurrence with a separate value", () => { + let read = param( + name("config"), + cli(["--config"]), + ).cli.read(symbols(["--config", "one", "--config", "two"])); + + expect(read.result).toEqual({ + ok: true, + value: { exists: true, value: "one" }, + issues: [], + }); + expect(texts(read.claim.rest)).toEqual(["--config", "two"]); + }); + + it("reads only the first setter occurrence", () => { + let read = param( + name("config"), + cli(["--config"]), + ).cli.read(symbols(["--config=one", "--config=two"])); + + expect(read.result).toEqual({ + ok: true, + value: { exists: true, value: "one" }, + issues: [], + }); + expect(texts(read.claim.rest)).toEqual(["--config=two"]); + }); }); function symbols(argv: string[]): Tokenizer {