From ed7514acccaf92008cbcdcb5637a152cc2b4b03a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 4 Sep 2026 23:40:20 -0500 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20positional=20arguments=20?= =?UTF-8?q?and=20fold=20parameter=20pipelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/argument.ts | 82 ++++++++++ lib/env.ts | 6 +- lib/option.ts | 155 +++++-------------- lib/param.ts | 167 +++++---------------- lib/print.ts | 4 +- lib/read.ts | 13 +- lib/toggle.ts | 155 +++++-------------- lib/types.ts | 79 ++++++---- mod.ts | 2 + test/argument.test.ts | 338 ++++++++++++++++++++++++++++++++++++++++++ test/fold.test.ts | 23 +++ 11 files changed, 608 insertions(+), 416 deletions(-) create mode 100644 lib/argument.ts create mode 100644 test/argument.test.ts diff --git a/lib/argument.ts b/lib/argument.ts new file mode 100644 index 0000000..d47dda6 --- /dev/null +++ b/lib/argument.ts @@ -0,0 +1,82 @@ +import { type Param, param } from "./param.ts"; +import { + brand, + type Check, + type Fold, + type ParamElement, + type Unary, +} from "./pipeline.ts"; +import type { CLIRead, ReadCLI } from "./read.ts"; +import type { Word } from "./tokenize.ts"; +import type { AnyRoute, Definition } from "./types.ts"; + +export function argument< + const N extends string, + const E extends readonly Unary[], +>( + named: Definition, + ...elements: E & Check, E> +): ElementOf, E>> { + const added = elements.reduce( + (value, element) => element(value as never), + param(named, positional), + ) as Param; + + return brand, E>>>( + (route: AnyRoute) => { + let phases = [...route.phases]; + let phase = phases.pop()!; + phases.push({ + ...phase, + params: { + ...phase.params, + [added.name]: added, + }, + }); + + return { + ...route, + phases, + }; + }, + ); +} + +type ValueOf

= P extends Param ? T : never; + +type ElementOf = P extends Param + ? ParamElement> + : never; + +function positional

>(param: P): P { + return { ...param, cli: read }; +} + +const read: ReadCLI = (tokens): CLIRead => { + let claim = tokens.claimOne((token): token is Word => token.type === "word"); + let [word] = claim.tokens; + + return word + ? { + claim, + result: { + ok: true, + value: { exists: true, value: word.text }, + issues: [], + }, + } + : nothing(tokens); +}; + +function nothing(tokens: Parameters[0]): CLIRead { + let claim = tokens.claimAll(() => false); + + return { + claim, + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + }; +} diff --git a/lib/env.ts b/lib/env.ts index 3ccf786..e24adca 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -28,8 +28,10 @@ export interface EnvClaimOptions { export function env( key: string, -):

>(param: P) => P { - return (param) => ({ ...param, env: key }); +): IdentityElement> { + return brand>>( + (param: Param) => ({ ...param, env: key }), + ); } export function withEnvs( diff --git a/lib/option.ts b/lib/option.ts index d96d396..2b52a7a 100644 --- a/lib/option.ts +++ b/lib/option.ts @@ -1,137 +1,48 @@ import { type Param, param } from "./param.ts"; import { dasherize } from "./dasherize.ts"; -import { brand, type ParamElement } from "./pipeline.ts"; +import { + brand, + type Check, + type Fold, + type ParamElement, + type Unary, +} from "./pipeline.ts"; import { cli } from "./read.ts"; import type { AnyRoute, Definition } from "./types.ts"; -export function option( - named: Definition, -): ParamElement; - -export function option( - named: Definition, - nt: (value: Param) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - at: (value: A) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bt: (value: B) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - ct: (value: C) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - dt: (value: D) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - et: (value: E) => Param, -): ParamElement; - -export function option( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - ft: (value: F) => Param, -): ParamElement; - -export function option< - const N extends string, - A, - B, - C, - D, - E, - F, - G, - T, ->( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gt: (value: G) => Param, -): ParamElement; - export function option< const N extends string, - A, - B, - C, - D, - E, - F, - G, - H, - T, + const E extends readonly Unary[], >( named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gh: (value: G) => H, - ht: (value: H) => Param, -): ParamElement; - -export function option( - named: Definition, - ...elements: readonly ((value: never) => unknown)[] -): unknown { + ...elements: E & Check, E> +): ElementOf, E>> { const added = elements.reduce( (value, element) => element(value as never), param(named, cli([`--${dasherize(named.name)}`])), ) as Param; - return brand>((route: AnyRoute) => { - let phases = [...route.phases]; - let phase = phases.pop()!; - phases.push({ - ...phase, - params: { - ...phase.params, - [added.name]: added, - }, - }); - return { - ...route, - phases: phases, - }; - }); + return brand, E>>>( + (route: AnyRoute) => { + let phases = [...route.phases]; + let phase = phases.pop()!; + phases.push({ + ...phase, + params: { + ...phase.params, + [added.name]: added, + }, + }); + return { + ...route, + phases, + }; + }, + ); } + +type ValueOf

= P extends Param ? T : never; + +type ElementOf = P extends Param + ? ParamElement> + : never; diff --git a/lib/param.ts b/lib/param.ts index b14bdd3..4329992 100644 --- a/lib/param.ts +++ b/lib/param.ts @@ -1,7 +1,13 @@ import { type Decoder, scalar } from "./decode.ts"; +import { + type Check, + type Fold, + mark, + type Transform, + type TransformElement, + type Unary, +} from "./pipeline.ts"; import type { ReadCLI } from "./read.ts"; -import type { AnyToken } from "./tokenize.ts"; -import type { TokenInput } from "./tokenizer.ts"; import type { Definition, Schema } from "./types.ts"; export interface Param extends Definition { @@ -11,154 +17,49 @@ export interface Param extends Definition { env?: string; } -export function param( - start: Definition, -): Param; - -export function param( - start: Definition, - ka: (value: Param) => A, -): A; - -export function param( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, -): B; - -export function param( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, -): C; - -export function param( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, -): D; - -export function param( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, -): E; - -export function param( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, -): F; - export function param< const K extends string, - A, - B, - C, - D, - E, - F, - G, + const E extends readonly Unary[], >( start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, -): G; - -export function param< - const K extends string, - A, - B, - C, - D, - E, - F, - G, - H, ->( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gh: (value: G) => H, -): H; - -export function param< - const K extends string, - A, - B, - C, - D, - E, - F, - G, - H, - I, ->( - start: Definition, - ka: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gh: (value: G) => H, - hi: (value: H) => I, -): I; - -export function param( - start: Definition, - ...elements: readonly ((value: never) => unknown)[] -): unknown { - let zero = { + ...elements: E & Check, E> +): Fold, E> { + let zero: Param = { ...start, schema: unknown, - cli: (tokens: TokenInput) => ({ - result: { - ok: true, - value: { exists: false }, - issues: [], - }, - claim: { - tokens: [], - rest: tokens, - }, - }), + cli: (tokens) => { + let claim = tokens.claimAll(() => false); + return { + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + claim, + }; + }, decode: scalar, }; + return elements.reduce( (value, element) => element(value as never), zero, - ); + ) as Fold, E>; } export function schema( schema: Schema, -): >(param: P) => Param { - return (param) => ({ +): TransformElement> { + return mark>((param: Param) => ({ ...param, schema, - }); + })); +} + +interface SchemaTransform extends Transform { + readonly input: Param; + readonly output: this["input"] extends Param ? Param + : never; } const unknown: Schema = { diff --git a/lib/print.ts b/lib/print.ts index 32d2f5a..3843ad9 100644 --- a/lib/print.ts +++ b/lib/print.ts @@ -16,7 +16,7 @@ import type { } from "./types.ts"; export function printHelp< - const H extends Help, + const H extends Help, >(intent: H): string { let route = intent.definition; let subject = title(intent); @@ -59,7 +59,7 @@ export function printHelp< } export function printVersion< - const V extends Version, + const V extends Version, >(intent: V): string { let version = intent.definition.version; if (!version) { diff --git a/lib/read.ts b/lib/read.ts index 1dbc42e..a80e93e 100644 --- a/lib/read.ts +++ b/lib/read.ts @@ -1,5 +1,6 @@ import type { Maybe } from "./maybe.ts"; import type { Param } from "./param.ts"; +import { brand, type IdentityElement } from "./pipeline.ts"; import type { Result } from "./result.ts"; import type { Flag, Setter, Word } from "./tokenize.ts"; import type { Claim, TokenInput } from "./tokenizer.ts"; @@ -22,7 +23,7 @@ export interface CLIOptions { export function cli( names: string[], options: CLIOptions = {}, -):

>(param: P) => P { +): IdentityElement> { const read: ReadCLI = (tokens) => { if (options.switch) { let s = tokens.claimOne((t): t is Flag => { @@ -93,10 +94,12 @@ export function cli( return nothing(tokens); }; - return (param) => ({ - ...param, - cli: read, - }); + return brand>>( + (param: Param) => ({ + ...param, + cli: read, + }), + ); } function nothing(tokenizer: TokenInput): CLIRead { diff --git a/lib/toggle.ts b/lib/toggle.ts index 0ae9e03..e488689 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -1,121 +1,24 @@ import { boolean as decode } from "./decode.ts"; import { dasherize } from "./dasherize.ts"; import { type Param, param, schema } from "./param.ts"; -import { brand, type ParamElement } from "./pipeline.ts"; +import { + brand, + type Check, + type Fold, + type ParamElement, + type Unary, +} from "./pipeline.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; import type { Flag } from "./tokenize.ts"; import type { AnyRoute, Definition, Schema } from "./types.ts"; -export function toggle( - named: Definition, -): ParamElement; - -export function toggle( - named: Definition, - nt: (value: Param) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - at: (value: A) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bt: (value: B) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - ct: (value: C) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - dt: (value: D) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - et: (value: E) => Param, -): ParamElement; - -export function toggle( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - ft: (value: F) => Param, -): ParamElement; - -export function toggle< - const N extends string, - A, - B, - C, - D, - E, - F, - G, - T, ->( - named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gt: (value: G) => Param, -): ParamElement; - export function toggle< const N extends string, - A, - B, - C, - D, - E, - F, - G, - H, - T, + const E extends readonly Unary[], >( named: Definition, - na: (value: Param) => A, - ab: (value: A) => B, - bc: (value: B) => C, - cd: (value: C) => D, - de: (value: D) => E, - ef: (value: E) => F, - fg: (value: F) => G, - gh: (value: G) => H, - ht: (value: H) => Param, -): ParamElement; - -export function toggle( - named: Definition, - ...elements: readonly ((value: never) => unknown)[] -): unknown { + ...elements: E & Check, E> +): ElementOf, E>> { const added = elements.reduce( (value, element) => element(value as never), { @@ -124,24 +27,32 @@ export function toggle( }, ) as Param; - return brand>((route: AnyRoute) => { - let phases = [...route.phases]; - let phase = phases.pop()!; - phases.push({ - ...phase, - params: { - ...phase.params, - [added.name]: added, - }, - }); + return brand, E>>>( + (route: AnyRoute) => { + let phases = [...route.phases]; + let phase = phases.pop()!; + phases.push({ + ...phase, + params: { + ...phase.params, + [added.name]: added, + }, + }); - return { - ...route, - phases, - }; - }); + return { + ...route, + phases, + }; + }, + ); } +type ValueOf

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

>(param: P) => P { diff --git a/lib/types.ts b/lib/types.ts index c58e8c1..ef4b636 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -161,53 +161,49 @@ export type PathOf = R extends "/" ? [] : never; export type AnyIntent = - | Help - | Version - | Execute; + | Help + | Version + | Execute; export type ChildIntents< C extends readonly AnyRoute[], P extends RoutePath, - T extends ModelsByRoute, + Models extends ModelsByRoute, > = C extends readonly [ infer Head extends AnyRoute, ...infer Tail extends readonly AnyRoute[], ] ? ( - | IntentsAt, T> - | ChildIntents + | IntentsAt, Models> + | ChildIntents ) : never; export interface Intent< M extends Method, - R extends AnyRoute, P extends RoutePath, > { readonly ok: true; readonly method: M; readonly route: P; - readonly definition: R; + readonly definition: AnyRoute; readonly path: PathOf

; readonly literals: Iterable; } -export type Help = Intent< +export type Help

= Intent< "help", - R, P >; -export type Version = Intent< +export type Version

= Intent< "version", - R, P >; export interface Execute< - R extends AnyRoute, P extends RoutePath, Models extends ModelsByRoute, -> extends Intent<"execute", R, P> { +> extends Intent<"execute", P> { readonly issues: readonly Issue[]; readonly model: Models[P]; readonly models: Models; @@ -251,14 +247,30 @@ type ParseAt< P extends RoutePath, Models extends ModelsByRoute, > = [RequirementOf] extends [never] ? ( - | RouteIntents>> + | Help

+ | ( + "version" extends MethodsOf ? Version

+ : never + ) + | ( + "execute" extends MethodsOf ? AddModel< + Models, + P, + ModelOf + > extends infer Bound extends ModelsByRoute ? Execute< + P, + { [K in keyof Bound]: Bound[K] } + > + : never + : never + ) | ParseChildren< ChildrenOf, P, AddModel> > ) - : ParseIncrement; + : ParseIncrement; type ParseChildren< C extends readonly AnyRoute[], @@ -281,11 +293,13 @@ type RequirementsIn

= P extends readonly [ : readonly []; type AddModel< - M extends ModelsByRoute, + Models extends ModelsByRoute, P extends RoutePath, T extends object, > = { - [K in keyof M | P]: K extends P ? T : K extends keyof M ? M[K] : never; + [K in keyof Models | P]: K extends P ? T + : K extends keyof Models ? Models[K] + : never; }; type Split = string extends S ? Path @@ -381,20 +395,25 @@ type IntentsAt< P extends RoutePath, Models extends ModelsByRoute, > = - | RouteIntents>> - | ChildIntents, P, AddModel>>; - -type RouteIntents< - R extends AnyRoute, - P extends RoutePath, - T extends ModelsByRoute, -> = - | Help + | Help

| ( - "version" extends MethodsOf ? Version + "version" extends MethodsOf ? Version

: never ) | ( - "execute" extends MethodsOf ? Execute + "execute" extends MethodsOf ? AddModel< + Models, + P, + ModelOf + > extends infer Bound extends ModelsByRoute ? Execute< + P, + { [K in keyof Bound]: Bound[K] } + > : never - ); + : never + ) + | ChildIntents< + ChildrenOf, + P, + AddModel> + >; diff --git a/mod.ts b/mod.ts index e28aaf4..206f159 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,5 @@ +export { argument } from "./lib/argument.ts"; + export { extend } from "./lib/extend.ts"; export { command } from "./lib/command.ts"; export type { CommandZero } from "./lib/command.ts"; diff --git a/test/argument.test.ts b/test/argument.test.ts new file mode 100644 index 0000000..51f0d51 --- /dev/null +++ b/test/argument.test.ts @@ -0,0 +1,338 @@ +import { describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { type } from "arktype"; +import { + argument, + command, + extend, + type ModelOf, + name, + option, + parse, + routes, + schema, + toggle, +} from "@frontside/configliere"; +import { dynamic } from "../lib/dynamic.ts"; + +describe("argument()", () => { + describe("types", () => { + it("adds its exact schema output to the route model", () => { + let app = command( + name("copy"), + argument(name("source"), schema(type("string"))), + argument(name("destination"), schema(type("string"))), + ); + + expectType< + Equal< + ModelOf, + { source: string; destination: string } + > + >(true); + }); + }); + + describe("CLI binding", () => { + it("binds a positional word", () => { + let app = command( + name("copy"), + argument(name("source"), schema(type("string"))), + ); + let result = parse(app, { argv: ["input.txt"] }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { source: "input.txt" }, + }); + }); + + it("binds several arguments in declaration order", () => { + let app = command( + name("copy"), + argument(name("source"), schema(type("string"))), + argument(name("destination"), schema(type("string"))), + ); + let result = parse(app, { + argv: ["input.txt", "output.txt"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { + source: "input.txt", + destination: "output.txt", + }, + }); + }); + + it("binds a positional word before a later option", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + option(name("port"), schema(type("number"))), + ); + let result = parse(app, { + argv: ["input.txt", "--port", "9000"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "input.txt", port: 9000 }, + }); + }); + + it.skip("does not let a positional reader steal an option value", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + option(name("port"), schema(type("number"))), + ); + let result = parse(app, { + argv: ["--port", "9000", "input.txt"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "input.txt", port: 9000 }, + }); + }); + + it("binds a positional word following a switch", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + toggle(name("verbose")), + ); + let result = parse(app, { + argv: ["--verbose", "input.txt"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "input.txt", verbose: true }, + }); + }); + }); + + describe("routing", () => { + it("binds words on each side of a selector to their route segments", () => { + let auth0 = command( + name("auth0"), + argument(name("manifest"), schema(type("string"))), + ); + let app = command( + name("app"), + argument(name("workspace"), schema(type("string"))), + routes(auth0), + ); + let result = parse(app, { + argv: ["local", "auth0", "service.json"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + model: { manifest: "service.json" }, + models: { + "/": { workspace: "local" }, + "/auth0": { manifest: "service.json" }, + }, + }); + }); + + it("gives a visible child selector priority over an argument", () => { + let auth0 = command(name("auth0")); + let app = command( + name("app"), + argument(name("workspace"), schema(type("string | undefined"))), + routes(auth0), + ); + let result = parse(app, { argv: ["auth0"] }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/auth0", + models: { + "/": { workspace: undefined }, + "/auth0": {}, + }, + }); + }); + }); + + describe("sources", () => { + it("prefers a positional CLI value over environment and values", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { + argv: ["cli.txt"], + envs: [{ name: "process", value: { INPUT: "env.txt" } }], + values: [{ name: "config", value: { input: "value.txt" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "cli.txt" }, + }); + }); + + it("uses environment when the positional value is absent", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { + argv: [], + envs: [{ name: "process", value: { INPUT: "env.txt" } }], + values: [{ name: "config", value: { input: "value.txt" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "env.txt" }, + }); + }); + + it("uses a JavaScript value when CLI and environment are absent", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { + argv: [], + values: [{ name: "config", value: { input: "value.txt" } }], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { input: "value.txt" }, + }); + }); + }); + + describe("validation", () => { + it("reports a missing required argument at its parameter address", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { argv: [] }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ path: ["input"] }], + }); + }); + + it("reports a word left after all arguments are bound", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { + argv: ["input.txt", "extra.txt"], + }); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + issues: [{ message: 'unexpected "extra.txt"' }], + }); + }); + }); + + describe("phases", () => { + it("lets a later phase bind an unclaimed argument on the same route", () => { + let app = command( + name("app"), + option(name("config"), schema(type("string"))), + dynamic(() => + extend( + argument(name("input"), schema(type("string"))), + ) + ), + ); + let increment = parse(app, { + argv: ["--config", "app.json", "input.txt"], + }); + + expect(increment).toMatchObject({ + ok: true, + route: "/", + model: { config: "app.json" }, + }); + assertIncrement(increment); + + let result = increment.resume({ ok: true, value: undefined }); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { config: "app.json", input: "input.txt" }, + }); + }); + + it("does not reinterpret an argument claimed before a route is introduced", () => { + let app = command( + name("app"), + argument(name("target"), schema(type("string"))), + dynamic(() => + extend( + routes(command(name("auth0"))), + ) + ), + ); + let increment = parse(app, { argv: ["auth0"] }); + + expect(increment).toMatchObject({ + ok: true, + route: "/", + model: { target: "auth0" }, + }); + assertIncrement(increment); + + let result = increment.resume({ ok: true, value: undefined }); + expect(result).toMatchObject({ + ok: true, + method: "execute", + route: "/", + model: { target: "auth0" }, + }); + }); + }); +}); + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) ? true + : false; + +function expectType(_value: T): void {} + +function assertIncrement( + result: unknown, +): asserts result is { + resume(result: { readonly ok: true; readonly value: undefined }): unknown; +} { + expect(result).toMatchObject({ ok: true }); + expect( + typeof (result as { readonly resume?: unknown }).resume, + ).toBe("function"); +} diff --git a/test/fold.test.ts b/test/fold.test.ts index 6a17295..3327e7e 100644 --- a/test/fold.test.ts +++ b/test/fold.test.ts @@ -396,6 +396,29 @@ describe("pipeline fold", () => { expectType>(true); }); + it("folds parameter modifiers without an arity limit", () => { + let app = command( + name("app"), + option( + name("port"), + description("one"), + description("two"), + description("three"), + description("four"), + description("five"), + description("six"), + description("seven"), + description("eight"), + description("nine"), + description("ten"), + description("eleven"), + schema(type("number")), + ), + ); + + expectType, { port: number }>>(true); + }); + it("composes an identity macro between built-ins", () => { let identity = extend(); let app = route( From 459656fc95d7ce122e7f93028bc87236e7b094ad Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 5 Sep 2026 09:31:39 -0500 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Prevent=20arguments=20from?= =?UTF-8?q?=20stealing=20option=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/binding.md | 68 +++++++++++++++----- lib/bind.ts | 145 ++++++++++++++++++++++++++---------------- test/argument.test.ts | 64 ++++++++++++++++++- 3 files changed, 205 insertions(+), 72 deletions(-) diff --git a/docs/binding.md b/docs/binding.md index 9a7f38f..013bf52 100644 --- a/docs/binding.md +++ b/docs/binding.md @@ -117,9 +117,8 @@ argv: -c app.json auth0 --port 9001 view: [---] |--- hidden ---| ``` -Nothing in the current phase claims `auth0`, so the horizon remains unchanged -after a complete parameter sweep. CLI binding has reached a fixed point and must -stop. +Nothing in the current phase offers a claim for `auth0`. CLI binding has reached +a fixed point and must stop. If `resume()` introduces an `auth0` route, route search can now claim index 2 as its selector. The child receives `--port 9001`. Without the horizon, a parent @@ -157,8 +156,32 @@ moves to `auth0`: horizon ``` -No parameter claims it, so the horizon is stable and CLI binding stops. This is -a fixed-point calculation rather than an ordinary left-to-right cursor. +No parameter offers a claim for it, so CLI binding stops. This is a fixed-point +calculation rather than an ordinary left-to-right cursor. + +### Competing claims + +Every pending reader inspects the same immutable view before any claim is +committed. If several readers offer claims, the claim beginning earliest in the +original argv wins. Exact ties preserve parameter declaration order. + +For example: + +```text +argv: --port 9000 input.txt +option: [-----------] +argument: [--] +``` + +The option wins because its claim begins at `--port`; the positional reader +cannot steal `9000`. On the next step, the argument claims `input.txt`. Two +positional readers instead offer the same word, so their declaration order +decides which one receives it. + +This arbitration comes entirely from proposed claims. Readers carry no +option-versus-argument tag, and custom readers participate in exactly the same +protocol. Failed reads compete normally, ensuring an invalid option still owns +the tokens it captured. Route search always runs before phase binding. A currently discoverable route therefore beats an option value. A route introduced only after the current @@ -251,23 +274,33 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding { pending.delete(param.name); } - // Positional source: expand safe lookahead to a fixed point. + // Positional source: arbitrate claims until no reader can make progress. while (true) { - let before = firstWord(rest.tokens, segment.range); + let horizon = firstWord(rest.tokens, segment.range); + let offer; for (let param of pending.values()) { let view = rest.tokens.view({ range: segment.range, - through: before?.index, + through: horizon?.index, }); + let read = param.cli(view); - accept(param, fromCLI({ param, view, rest })); + if (read.result.ok && !read.result.value.exists) { + continue; + } + + let index = earliest(read.claim.tokens); + if (!offer || index < offer.index) { + offer = { param, read, index }; + } } - let after = firstWord(rest.tokens, segment.range); - if (after?.index === before?.index) { + if (!offer) { break; } + + accept(offer.param, fromRead(offer.param, offer.read, rest)); } // Stable address sources: array order is precedence. @@ -298,12 +331,12 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding { The source-outer address loop makes precedence explicit. Deleting a parameter from `pending` on every existing attempt prevents fallback after invalid input. -Comparing the first remaining word before and after a sweep expresses the fixed -point directly and removes `has()` and `next()` cursor bookkeeping. +Recomputing proposals after each committed claim advances the horizon without +letting one reader observe another reader's speculative remainder. -When the final word is consumed, the changed before/after comparison causes one -additional unbounded sweep. That sweep exposes trailing flags or setters. With -no remaining words before or after it, the next comparison terminates. +When the final word is consumed, the absent horizon creates an unbounded view, +which exposes trailing flags or setters. The loop terminates when no reader +offers another claim. ## Parser integration @@ -353,6 +386,9 @@ The design should be established with observable tests for these cases: - Consuming the horizon exposes the next word and retries previously absent parameters. - A stable horizon leaves the entire suffix untouched. +- A named option claim outranks an overlapping positional claim without reader + metadata. +- Positional readers retain declaration order when they offer the same word. - CLI overrides Values even when its option is not visible until a later sweep. - Env overrides Values when that is the declared source order. - Invalid CLI blocks valid Env and Values. diff --git a/lib/bind.ts b/lib/bind.ts index b2a7185..4cf6bce 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -1,6 +1,6 @@ import type { Maybe } from "./maybe.ts"; import type { Param } from "./param.ts"; -import type { Symbol } from "./read.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"; @@ -30,43 +30,7 @@ export function fromCLI(options: { readonly rest: Rest; }): Maybe> { let { param, view, rest } = options; - let path = [param.name]; - let read = param.cli(view); - - 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, - }, - }; + return fromRead(param, param.cli(view), rest); } export function fromValues(options: { @@ -137,6 +101,15 @@ export function bindPhase(options: { let pending = new Map(params.map((param) => [param.name, param])); let results = 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>, @@ -145,34 +118,43 @@ export function bindPhase(options: { return; } - rest = attempt.value.rest; - results.set(param.name, attempt.value.result); - pending.delete(param.name); + settle(param, attempt.value); } - // CLI visibility grows whenever a parameter consumes the current horizon. - // Keep retrying provisional misses until a complete sweep leaves the first - // remaining word unchanged. + // 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 before = first(rest.tokens, segment.range); + let horizon = first(rest.tokens, segment.range); + let offer: { + param: Param; + read: CLIRead; + index: number; + } | undefined; for (let param of pending.values()) { - let attempt = fromCLI({ - param, - view: rest.tokens.view({ - range: segment.range, - through: before?.index, - }), - rest, + let view = rest.tokens.view({ + range: segment.range, + through: horizon?.index, }); + let read = param.cli(view); + + if (read.result.ok && !read.result.value.exists) { + continue; + } - accept(param, attempt); + let index = earliest(read.claim.tokens); + if (!offer || index < offer.index) { + offer = { param, read, index }; + } } - let after = first(rest.tokens, segment.range); - if (after?.index === before?.index) { + if (!offer) { break; } + + accept(offer.param, fromRead(offer.param, offer.read, rest)); } // Address sources have stable visibility once the route is known. They are @@ -217,6 +199,49 @@ export function bindPhase(options: { return { rest, model, issues, valid }; } +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 decode( param: Param, value: unknown, @@ -304,3 +329,13 @@ function first(tokens: Tokenizer, range: TokenRange): Word | undefined { } } } + +function earliest(tokens: readonly Symbol[]): number { + let first = Infinity; + + for (let token of tokens) { + first = Math.min(first, token.index); + } + + return first; +} diff --git a/test/argument.test.ts b/test/argument.test.ts index 51f0d51..2459723 100644 --- a/test/argument.test.ts +++ b/test/argument.test.ts @@ -87,7 +87,7 @@ describe("argument()", () => { }); }); - it.skip("does not let a positional reader steal an option value", () => { + it("does not let a positional reader steal an option value", () => { let app = command( name("app"), argument(name("input"), schema(type("string"))), @@ -104,6 +104,43 @@ describe("argument()", () => { }); }); + it("keeps an invalid option value attached to its option", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + option(name("port"), schema(type("number"))), + ); + let result = parse(app, { + argv: ["--port", "nope", "input.txt"], + }); + + assertUnprocessable(result); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toMatchObject({ path: ["port"] }); + }); + + it("preserves positional order around an option", () => { + let app = command( + name("copy"), + argument(name("source"), schema(type("string"))), + option(name("port"), schema(type("number"))), + argument(name("destination"), schema(type("string"))), + ); + let result = parse(app, { + argv: ["input.txt", "--port", "9000", "output.txt"], + }); + + expect(result).toMatchObject({ + ok: true, + method: "execute", + model: { + source: "input.txt", + port: 9000, + destination: "output.txt", + }, + }); + }); + it("binds a positional word following a switch", () => { let app = command( name("app"), @@ -257,6 +294,19 @@ describe("argument()", () => { issues: [{ message: 'unexpected "extra.txt"' }], }); }); + + it("does not reserve an argument for an unknown flag", () => { + let app = command( + name("app"), + argument(name("input"), schema(type("string"))), + ); + let result = parse(app, { + argv: ["--floop", "input.txt"], + }); + + assertUnprocessable(result); + expect(result.issues).toEqual([{ message: 'unexpected "--floop"' }]); + }); }); describe("phases", () => { @@ -336,3 +386,15 @@ function assertIncrement( typeof (result as { readonly resume?: unknown }).resume, ).toBe("function"); } + +function assertUnprocessable( + result: unknown, +): asserts result is { + readonly issues: readonly unknown[]; +} { + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + route: "/", + }); +} From bf59fe718e99e860b98633c29eb7a35a5af7965f Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Sat, 5 Sep 2026 09:38:40 -0500 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20Render=20positional=20arguments?= =?UTF-8?q?=20in=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/binding.md | 2 +- lib/argument.ts | 11 ++++++- lib/bind.ts | 4 +-- lib/param.ts | 26 +++++++++-------- lib/print.ts | 71 ++++++++++++++++------------------------------ lib/read.ts | 21 ++++++++++++-- lib/toggle.ts | 16 +++++++++-- test/bind.test.ts | 27 ++++++++++-------- test/print.test.ts | 42 +++++++++++++++++++++++++++ test/read.test.ts | 2 +- 10 files changed, 142 insertions(+), 80 deletions(-) diff --git a/docs/binding.md b/docs/binding.md index 013bf52..ef8273d 100644 --- a/docs/binding.md +++ b/docs/binding.md @@ -284,7 +284,7 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding { range: segment.range, through: horizon?.index, }); - let read = param.cli(view); + let read = param.cli.read(view); if (read.result.ok && !read.result.value.exists) { continue; diff --git a/lib/argument.ts b/lib/argument.ts index d47dda6..6037b1e 100644 --- a/lib/argument.ts +++ b/lib/argument.ts @@ -49,7 +49,16 @@ type ElementOf = P extends Param : never; function positional

>(param: P): P { - return { ...param, cli: read }; + return { + ...param, + cli: { + read, + syntax: { + type: "argument", + label: `<${param.name.toUpperCase()}>`, + }, + }, + }; } const read: ReadCLI = (tokens): CLIRead => { diff --git a/lib/bind.ts b/lib/bind.ts index 4cf6bce..6d84d34 100644 --- a/lib/bind.ts +++ b/lib/bind.ts @@ -30,7 +30,7 @@ export function fromCLI(options: { readonly rest: Rest; }): Maybe> { let { param, view, rest } = options; - return fromRead(param, param.cli(view), rest); + return fromRead(param, param.cli.read(view), rest); } export function fromValues(options: { @@ -138,7 +138,7 @@ export function bindPhase(options: { range: segment.range, through: horizon?.index, }); - let read = param.cli(view); + let read = param.cli.read(view); if (read.result.ok && !read.result.value.exists) { continue; diff --git a/lib/param.ts b/lib/param.ts index 4329992..271a4c9 100644 --- a/lib/param.ts +++ b/lib/param.ts @@ -7,12 +7,12 @@ import { type TransformElement, type Unary, } from "./pipeline.ts"; -import type { ReadCLI } from "./read.ts"; +import type { CLIBinding } from "./read.ts"; import type { Definition, Schema } from "./types.ts"; export interface Param extends Definition { schema: Schema; - cli: ReadCLI; + cli: CLIBinding; decode: Decoder; env?: string; } @@ -27,16 +27,18 @@ export function param< let zero: Param = { ...start, schema: unknown, - cli: (tokens) => { - let claim = tokens.claimAll(() => false); - return { - result: { - ok: true, - value: { exists: false }, - issues: [], - }, - claim, - }; + cli: { + read(tokens) { + let claim = tokens.claimAll(() => false); + return { + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + claim, + }; + }, }, decode: scalar, }; diff --git a/lib/print.ts b/lib/print.ts index 3843ad9..46f33fb 100644 --- a/lib/print.ts +++ b/lib/print.ts @@ -3,8 +3,6 @@ */ import type { Param } from "./param.ts"; -import type { Flag } from "./tokenize.ts"; -import { Tokenizer } from "./tokenizer.ts"; import type { AnyRoute, Help, @@ -21,8 +19,27 @@ export function printHelp< let route = intent.definition; let subject = title(intent); let heading = route.version ? `${subject} ${route.version}` : subject; - let usage = `${subject} [OPTIONS]`; let children = route.phases.flatMap((phase) => phase.routes); + let args: Row[] = []; + let options: Row[] = []; + + for (let param of params(route)) { + let syntax = param.cli.syntax; + if (!syntax) { + continue; + } + + let row: Row = [syntax.label, param.description]; + if (syntax.type === "argument") { + args.push(row); + } else { + options.push(row); + } + } + + let usage = [subject, "[OPTIONS]", ...args.map(([label]) => label)].join( + " ", + ); if (children.length > 0) { usage += route.methods.includes("execute") ? " [COMMAND]" : " "; @@ -35,6 +52,10 @@ export function printHelp< lines.push("", "Usage:", ` ${usage}`); + if (args.length > 0) { + lines.push("", "Arguments:", ...list(args)); + } + if (children.length > 0) { lines.push( "", @@ -43,11 +64,6 @@ export function printHelp< ); } - let options: Row[] = params(route).map((param) => [ - label(param), - param.description, - ]); - options.push(["-h, --help", "Print help"]); if (route.methods.includes("version")) { options.push(["-v, --version", "Print version"]); @@ -149,45 +165,6 @@ function wrap(text: string, size: number): string[] { return lines; } -function label(param: Param): string { - let stem = dash(param.name); - let yes = `--${stem}`; - let no = `--no-${stem}`; - let positive = boolean(param, yes); - let negative = boolean(param, no); - - return typeof positive === "boolean" - ? negative === false ? `${yes}, ${no}` : yes - : `--${param.name} `; -} - -function boolean( - param: Param, - text: string, -): boolean | undefined { - let flag: Flag = { - type: "flag", - index: 0, - text, - flagText: text.slice(text.startsWith("--") ? 2 : 1), - flagType: text.startsWith("--") ? "long" : "short", - }; - let read = param.cli(new Tokenizer([flag])); - - return read.result.ok && read.result.value.exists && - typeof read.result.value.value === "boolean" - ? read.result.value.value - : undefined; -} - -function dash(name: string): string { - return name - .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") - .replace(/([a-z\d])([A-Z])/g, "$1-$2") - .replace(/[\s_]+/g, "-") - .toLowerCase(); -} - function problem(issue: Issue): string { let location = address(issue.path); return location ? `${location}: ${issue.message}` : message(issue.message); diff --git a/lib/read.ts b/lib/read.ts index a80e93e..f23d54d 100644 --- a/lib/read.ts +++ b/lib/read.ts @@ -11,6 +11,15 @@ export type ReadCLI = ( tokens: TokenInput, ) => CLIRead; +export interface CLIBinding { + readonly read: ReadCLI; + readonly syntax?: CLISyntax; +} + +export type CLISyntax = + | { readonly type: "argument"; readonly label: string } + | { readonly type: "option"; readonly label: string }; + export interface CLIRead { result: Result>; claim: Claim; @@ -21,7 +30,7 @@ export interface CLIOptions { } export function cli( - names: string[], + names: readonly string[], options: CLIOptions = {}, ): IdentityElement> { const read: ReadCLI = (tokens) => { @@ -97,7 +106,15 @@ export function cli( return brand>>( (param: Param) => ({ ...param, - cli: read, + cli: { + read, + syntax: { + type: "option", + label: options.switch + ? names.join(", ") + : `${names.join(", ")} `, + }, + }, }), ); } diff --git a/lib/toggle.ts b/lib/toggle.ts index e488689..10b07a1 100644 --- a/lib/toggle.ts +++ b/lib/toggle.ts @@ -56,8 +56,20 @@ type ElementOf = P extends Param function binding( name: string, ):

>(param: P) => P { - const cli = reader(name); - return (param) => ({ ...param, cli }); + const stem = dasherize(name); + const yes = `--${stem}`; + const no = `--no-${stem}`; + + return (param) => ({ + ...param, + cli: { + read: reader(name), + syntax: { + type: "option", + label: `${yes}, ${no}`, + }, + }, + }); } function reader(name: string): ReadCLI { diff --git a/test/bind.test.ts b/test/bind.test.ts index 5555b71..ab69985 100644 --- a/test/bind.test.ts +++ b/test/bind.test.ts @@ -125,20 +125,23 @@ describe("binding", () => { cli(["--port"]), schema(z.number()), ); - let read = base.cli; + let read = base.cli.read; let port = { ...base, - cli(tokens: Parameters[0]) { - let capture = read(tokens); - return capture.result.ok - ? { - ...capture, - result: { - ...capture.result, - issues: [{ message: "--port is deprecated" }], - }, - } - : capture; + cli: { + ...base.cli, + read(tokens: Parameters[0]) { + let capture = read(tokens); + return capture.result.ok + ? { + ...capture, + result: { + ...capture.result, + issues: [{ message: "--port is deprecated" }], + }, + } + : capture; + }, }, }; let result = bindPhase({ diff --git a/test/print.test.ts b/test/print.test.ts index bd33c06..cc34c05 100644 --- a/test/print.test.ts +++ b/test/print.test.ts @@ -5,6 +5,7 @@ import { expect } from "@std/expect"; import { describe, it } from "@std/testing/bdd"; import { type } from "arktype"; +import { argument } from "../lib/argument.ts"; import { command } from "../lib/command.ts"; import { description, name } from "../lib/definition.ts"; import { option } from "../lib/option.ts"; @@ -81,6 +82,47 @@ Options: -v, --version Print version`); }); + it("prints positional arguments in usage and their own section", () => { + let copy = command( + name("copy"), + description("Copy one file."), + argument( + name("source"), + description("File to copy."), + schema(type("string")), + ), + option( + name("format"), + description("Output format."), + ), + argument( + name("destination"), + description("Destination path."), + schema(type("string")), + ), + ); + let result = parse(copy, { argv: ["--help"] }); + + expect(result).toMatchObject({ ok: true, method: "help" }); + if (!result.ok || result.method !== "help") { + throw new Error("expected help"); + } + + expect(printHelp(result)).toBe(`copy +Copy one file. + +Usage: + copy [OPTIONS] + +Arguments: + File to copy. + Destination path. + +Options: + --format Output format. + -h, --help Print help`); + }); + it("prints help relative to a deeply nested route", () => { expect(help(["database", "clean"])).toBe(`database clean 2.0.0 Remove generated records and reset database state. diff --git a/test/read.test.ts b/test/read.test.ts index a3e0891..924e92e 100644 --- a/test/read.test.ts +++ b/test/read.test.ts @@ -11,7 +11,7 @@ describe("CLI reader", () => { let read = param( name("port"), cli(["--port"]), - ).cli(symbols(["--port", "--verbose"])); + ).cli.read(symbols(["--port", "--verbose"])); expect(read.result).toMatchObject({ ok: false,