From c94f8100dd67f26f8c236f36b3daf945bfb43222 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 12 May 2026 23:33:45 -0400 Subject: [PATCH] ref(schema): refactor to use schema-based args --- .changeset/async-parse-standard-schema.md | 48 +++ .changeset/camelcase-kebab-normalization.md | 19 ++ .changeset/parse-sync-env-terminator.md | 11 + .changeset/remove-json-file-schemas.md | 5 + .changeset/schema-metadata-methods.md | 26 ++ .changeset/schema-subpath.md | 20 ++ CHANGELOG.md | 2 +- README.md | 264 +++++++++++++++-- package.json | 13 +- pnpm-lock.yaml | 54 +++- spec.md | 164 +++++++++++ src/index.ts | 221 ++++++++------ src/schema.ts | 149 ++++++++++ src/types.ts | 311 +++----------------- test/flags.test.ts | 117 ++------ test/schema-helpers.test.ts | 258 ++++++++++++++++ test/schema.test.ts | 179 +++++++++++ 17 files changed, 1385 insertions(+), 476 deletions(-) create mode 100644 .changeset/async-parse-standard-schema.md create mode 100644 .changeset/camelcase-kebab-normalization.md create mode 100644 .changeset/parse-sync-env-terminator.md create mode 100644 .changeset/remove-json-file-schemas.md create mode 100644 .changeset/schema-metadata-methods.md create mode 100644 .changeset/schema-subpath.md create mode 100644 spec.md create mode 100644 src/schema.ts create mode 100644 test/schema-helpers.test.ts create mode 100644 test/schema.test.ts diff --git a/.changeset/async-parse-standard-schema.md b/.changeset/async-parse-standard-schema.md new file mode 100644 index 0000000..8dc21ed --- /dev/null +++ b/.changeset/async-parse-standard-schema.md @@ -0,0 +1,48 @@ +--- +"@bomb.sh/args": major +--- + +`parse()` is now async and returns `Promise`. Update call sites with `await`. + +`ParseOptions` no longer accepts `boolean`, `string`, `array`, or `default` options. Use your schema library's built-in type coercion and `.default()` instead. + +**Before:** + +```ts +const args = parse(argv, { + boolean: ["verbose"], + string: ["output"], + default: { port: 3000 }, + alias: { v: "verbose" }, +}); +``` + +**After (with Zod):** + +```ts +const args = await parse(argv, { + schema: z.object({ + verbose: z.boolean().default(false), + output: z.string(), + port: z.coerce.number().default(3000), + }), + alias: { v: "verbose" }, +}); +``` + +**After (built-in primitives, no Zod):** + +```ts +import { object, boolean, string, number } from "@bomb.sh/args/schema"; + +const args = await parse(argv, { + schema: object({ verbose: boolean(), output: string(), port: number() }), + alias: { v: "verbose" }, +}); +``` + +**After (sync, no schema):** + +```ts +const args = parseSync(argv, { alias: { v: "verbose" } }); +``` diff --git a/.changeset/camelcase-kebab-normalization.md b/.changeset/camelcase-kebab-normalization.md new file mode 100644 index 0000000..fd71055 --- /dev/null +++ b/.changeset/camelcase-kebab-normalization.md @@ -0,0 +1,19 @@ +--- +"@bomb.sh/args": minor +--- + +`parse()` now automatically resolves camelCase ↔ kebab-case flag variants when using an `object()` schema. No aliases required — `--moduleTypes` and `--module-types` both map to a `moduleTypes` field. + +```ts +const args = await parse(["--module-types", "esm"], { + schema: object({ moduleTypes: string() }), +}); +// args.moduleTypes → "esm" + +const args2 = await parse(["--moduleTypes", "esm"], { + schema: object({ moduleTypes: string() }), +}); +// args2.moduleTypes → "esm" +``` + +This eliminates the need for manual workarounds like `--moduleTypes` → `--module-types` normalization. diff --git a/.changeset/parse-sync-env-terminator.md b/.changeset/parse-sync-env-terminator.md new file mode 100644 index 0000000..c4fb381 --- /dev/null +++ b/.changeset/parse-sync-env-terminator.md @@ -0,0 +1,11 @@ +--- +"@bomb.sh/args": minor +--- + +New features alongside the Standard Schema rewrite: + +- **`parseSync(argv)`** — synchronous parsing with no schema validation, returns `RawArgs`. +- **`ParseError`** — thrown by `parse()` on schema validation failure. Carries a typed `issues: ReadonlyArray` array. +- **`env` option** — `env: { prefix: 'APP' }` auto-injects `APP_*` environment variables as flag defaults before schema validation. Argv takes precedence. +- **`--` terminator** — all arguments after `--` are collected into `_` verbatim, not parsed as flags. +- **Typed positionals** — define `_` in your schema for full type safety (`z.tuple([z.string()])` for fixed-length, `z.array(z.string())` for variadic). diff --git a/.changeset/remove-json-file-schemas.md b/.changeset/remove-json-file-schemas.md new file mode 100644 index 0000000..4e43766 --- /dev/null +++ b/.changeset/remove-json-file-schemas.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/args": major +--- + +Removed `json()` and `file()` from `@bomb.sh/args/schema`. diff --git a/.changeset/schema-metadata-methods.md b/.changeset/schema-metadata-methods.md new file mode 100644 index 0000000..81d2ceb --- /dev/null +++ b/.changeset/schema-metadata-methods.md @@ -0,0 +1,26 @@ +--- +"@bomb.sh/args": minor +--- + +Add `.alias()` and `.docs()` chainable methods to all built-in schema primitives. + +`.alias(...names)` registers per-field aliases that are automatically extracted by `parse()` when using an `object()` schema — no manual `alias` map required. + +`.docs(description)` attaches a description string to the schema for help text generation and tooling. + +```ts +import { object, number, boolean } from "@bomb.sh/args/schema"; + +const args = await parse(process.argv.slice(2), { + schema: object({ + port: number().alias("p").docs("Port to listen on"), + verbose: boolean().alias("v").docs("Enable verbose output"), + }), +}); +// parse(["-p", "3000"]) → { port: 3000 } +// parse(["-v"]) → { verbose: true } +``` + +Manual `opts.alias` entries take precedence over per-field aliases on conflict. + +Also exports `ArgsSchema`, `SchemaMeta`, and `ObjectSchema` types for consumers building on top of the built-in primitives. `object()` now exposes a `shape` property for introspection. diff --git a/.changeset/schema-subpath.md b/.changeset/schema-subpath.md new file mode 100644 index 0000000..96f0aa4 --- /dev/null +++ b/.changeset/schema-subpath.md @@ -0,0 +1,20 @@ +--- +"@bomb.sh/args": minor +--- + +Add `@bomb.sh/args/schema` subpath with built-in Standard Schema primitives requiring no external dependencies: `string`, `number`, `boolean`, `array`, `object`. + +These work with `parse()` directly or compose with Zod, Valibot, Arktype, and any other `@standard-schema/spec`-compliant library. + +```ts +import { parse } from "@bomb.sh/args"; +import { object, number, boolean, string, array } from "@bomb.sh/args/schema"; + +const args = await parse(process.argv.slice(2), { + schema: object({ + port: number(), + verbose: boolean(), + tags: array(string()), + }), +}); +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index bce4151..4f554de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# @bomb.sh/args (fka `ultraflag`) +# @bomb.sh/args ## 0.3.1 diff --git a/README.md b/README.md index 994018c..ae6f982 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # `@bomb.sh/args` -A <1kB library for parsing CLI flags. Inspired by Deno's `std/cli` [`parseArgs`](https://github.com/denoland/std/blob/main/cli/parse_args.ts) module. +A <1kB library for parsing CLI flags with first-class schema validation. ### Features @@ -10,35 +10,263 @@ A <1kB library for parsing CLI flags. Inspired by Deno's `std/cli` [`parseArgs`] 🏃 very fast (beats [`node:util`](https://nodejs.org/api/util.html#utilparseargsconfig)) -🔏 strongly typed +🔏 strongly typed via [`@standard-schema/spec`](https://standardschema.dev/) -### Usage +🧩 works with any schema library (Zod, Valibot, Arktype, or built-ins) -Basic usage does not require any configuration. +--- -```js -import { parse } from "@bomb.sh/args"; +## Usage -// my-cli build --bundle -rf --a value --b=value --c 1 -const argv = process.argv.slice(2); -const args = parse(argv); +### Basic (no schema) + +```ts +import { parseSync } from "@bomb.sh/args"; -console.log(args); +// my-cli build --bundle -rf --a value --b=value --c 1 +const args = parseSync(process.argv.slice(2)); // { _: ['build'], bundle: true, r: true, f: true, a: "value", b: "value", c: 1 } ``` -Parsing can be configured to ensure arguments are coerced to specific types, which enhances type safety. +### With schema (async) + +Pass any [`@standard-schema/spec`](https://standardschema.dev/)-compliant schema to `parse()` for validation and strong type inference. Works with [Zod](https://zod.dev), [Valibot](https://valibot.dev), [Arktype](https://arktype.io), and more. + +```ts +import { parse } from "@bomb.sh/args"; +import { z } from "zod"; + +const args = await parse(process.argv.slice(2), { + schema: z.object({ + port: z.coerce.number().default(3000), + verbose: z.boolean().default(false), + }), + alias: { v: "verbose" }, +}); + +// args.port → number +// args.verbose → boolean +``` + +### Built-in schema primitives + +No Zod? No problem. Import from `@bomb.sh/args/schema` for zero-dependency validation. + +```ts +import { parse } from "@bomb.sh/args"; +import { object, number, boolean, string, array } from "@bomb.sh/args/schema"; + +const args = await parse(process.argv.slice(2), { + schema: object({ + port: number(), + verbose: boolean(), + tags: array(string()), + }), +}); +``` + +### Per-field aliases and docs + +Built-in primitives support chainable `.alias()` and `.docs()` methods. Aliases registered on fields are automatically wired into `parse()` — no manual `alias` map needed. + +```ts +import { parse } from "@bomb.sh/args"; +import { object, number, boolean, string } from "@bomb.sh/args/schema"; + +const args = await parse(process.argv.slice(2), { + schema: object({ + port: number().alias("p").docs("Port to listen on"), + verbose: boolean().alias("v").docs("Enable verbose output"), + }), +}); + +// parse(["-p", "3000"]) → { port: 3000 } +// parse(["-v"]) → { verbose: true } +``` + +### camelCase ↔ kebab-case auto-normalization + +`parse()` automatically resolves camelCase ↔ kebab-case flag variants when using an `object()` schema. No aliases required — `--moduleTypes` and `--module-types` both resolve to the same field. + +```ts +const args = await parse(["--module-types", "esm"], { + schema: object({ moduleTypes: string() }), +}); +// args.moduleTypes → "esm" + +const args2 = await parse(["--moduleTypes", "esm"], { + schema: object({ moduleTypes: string() }), +}); +// args2.moduleTypes → "esm" +``` + +### Boolean negation + +Flags prefixed with `--no-` are automatically treated as `false` for the base flag name. + +```ts +// my-cli --no-verbose +// args.verbose → false +``` + +### Nested flags + +Dotted flag names create nested output objects. + +```ts +// my-cli --output.dir dist --output.format esm +// args.output → { dir: "dist", format: "esm" } +``` + +### `=` syntax + +Both `--flag value` and `--flag=value` forms are supported. + +```ts +// my-cli --port=3000 +// my-cli --port 3000 +// Both → args.port → 3000 +``` + +--- + +## ParseOptions + +```ts +interface ParseOptions { + /** Standard Schema-compliant schema. Drives return type and validation. */ + schema?: StandardSchemaV1; + + /** + * Map short flags to long names. When using built-in schema primitives, + * prefer per-field .alias() instead. Manual aliases take precedence on conflict. + */ + alias?: Record; + + /** + * Auto-inject environment variables as flag defaults before validation. + * --foo-bar maps to PREFIX_FOO_BAR (uppercased, hyphens → underscores). + */ + env?: false | { prefix: string }; +} +``` + +--- + +## Positional arguments -```js -const args = parse(argv, { - default: { a: 1, b: 2, c: "value" }, - alias: { h: "help" }, - boolean: ["foo", "bar"], - string: ["baz", "qux"], - array: ["input"], +Positionals land in `_` on the raw parsed object. Define `_` in your schema for full type safety. + +```ts +import { z } from "zod"; + +const args = await parse(process.argv.slice(2), { + schema: z.object({ + _: z.tuple([z.string()]), // first positional is a required string + port: z.coerce.number().default(3000), + }), }); + +// args._[0] → string (typed!) +``` + +Use `z.array()` for variadic positionals: + +```ts +schema: z.object({ + _: z.array(z.string()), + // ... +}) ``` +### `--` terminator + +Everything after `--` is collected into `_` verbatim, not parsed as flags. + +``` +my-cli --verbose -- --not-a-flag file.txt +``` + +```ts +// args.verbose → true +// args._ → ["--not-a-flag", "file.txt"] +``` + +--- + +## Environment variable fallback + +When `env: { prefix }` is set, any `PREFIX_FLAG_NAME` env var is injected as a default for missing flags before schema validation. + +```ts +// APP_PORT=9000 my-cli +const args = await parse([], { + schema: z.object({ port: z.coerce.number() }), + env: { prefix: "APP" }, +}); +// args.port → 9000 +``` + +Argv always takes precedence over env vars. + +--- + +## Error handling + +```ts +import { parse, ParseError } from "@bomb.sh/args"; + +try { + const args = await parse(argv, { schema }); +} catch (e) { + if (e instanceof ParseError) { + for (const issue of e.issues) { + console.error(issue.message); + } + } +} +``` + +--- + +## `@bomb.sh/args/schema` + +Built-in schema primitives that implement `@standard-schema/spec`. Composable with other schema libraries or usable standalone. + +### Primitives + +```ts +import { string, number, boolean, array, object } from "@bomb.sh/args/schema"; +``` + +| Primitive | Input | Output | +|-----------|-------|--------| +| `string()` | `string \| number \| boolean` | `string` | +| `number()` | `number \| string` (coerces numeric strings) | `number` | +| `boolean()` | `boolean \| "true" \| "false" \| "1" \| "0"` | `boolean` | +| `array(item?)` | `unknown[]` | `T[]` | +| `object(shape)` | `object` | `{ [K]: OutputType }` | + +All primitives support `.docs(description)` and `.alias(...names)` for metadata and per-field alias registration. + +--- + +## `parseSync()` + +Synchronous parsing with no schema validation. Returns `RawArgs`. + +```ts +import { parseSync } from "@bomb.sh/args"; + +const args = parseSync(process.argv.slice(2), { + alias: { v: "verbose" }, + env: { prefix: "APP" }, +}); +// args.verbose → string | number | boolean | undefined +``` + +--- + ## Benchmarks ``` diff --git a/package.json b/package.json index d9f5ad4..d83c9e9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./schema": { + "types": "./dist/schema.d.ts", + "import": "./dist/schema.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -27,11 +31,12 @@ "build": "bsh build", "format": "bsh format", "lint": "bsh lint", - "test": "vitest run" + "test": "bsh test" }, "devDependencies": { "@bomb.sh/tools": "^0.0.5", "@changesets/cli": "^2.29.8", + "@types/node": "^18.19.130", "benchmark": "^2.1.4", "chalk": "^5.6.2", "esbuild": "^0.27.3", @@ -42,7 +47,8 @@ "pretty-bytes": "^6.1.1", "typescript": "^4.9.5", "vitest": "^4.0.18", - "yargs-parser": "^21.1.1" + "yargs-parser": "^21.1.1", + "zod": "^4.3.6" }, "publishConfig": { "access": "public", @@ -67,5 +73,8 @@ "version": "10.7.0", "onFail": "error" } + }, + "dependencies": { + "@standard-schema/spec": "^1.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9cada3..3247d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,13 +7,20 @@ settings: importers: .: + dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 devDependencies: '@bomb.sh/tools': specifier: ^0.0.5 version: 0.0.5 '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8 + version: 2.29.8(@types/node@18.19.130) + '@types/node': + specifier: ^18.19.130 + version: 18.19.130 benchmark: specifier: ^2.1.4 version: 2.1.4 @@ -43,10 +50,13 @@ importers: version: 4.9.5 vitest: specifier: ^4.0.18 - version: 4.0.18 + version: 4.0.18(@types/node@18.19.130) yargs-parser: specifier: ^21.1.1 version: 21.1.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 packages: @@ -651,6 +661,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -1095,6 +1108,9 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1187,6 +1203,9 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@babel/runtime@7.28.6': {} @@ -1265,7 +1284,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8': + '@changesets/cli@2.29.8(@types/node@18.19.130)': dependencies: '@changesets/apply-release-plan': 7.0.14 '@changesets/assemble-release-plan': 6.0.9 @@ -1281,7 +1300,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3 + '@inquirer/external-editor': 1.0.3(@types/node@18.19.130) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -1536,10 +1555,12 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@inquirer/external-editor@1.0.3': + '@inquirer/external-editor@1.0.3(@types/node@18.19.130)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 18.19.130 '@jridgewell/sourcemap-codec@1.5.5': {} @@ -1659,6 +1680,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1668,13 +1693,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1)': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@18.19.130))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1 + vite: 7.3.1(@types/node@18.19.130) '@vitest/pretty-format@4.0.18': dependencies: @@ -2103,9 +2128,11 @@ snapshots: typescript@4.9.5: {} + undici-types@5.26.5: {} + universalify@0.1.2: {} - vite@7.3.1: + vite@7.3.1(@types/node@18.19.130): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -2114,12 +2141,13 @@ snapshots: rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 18.19.130 fsevents: 2.3.3 - vitest@4.0.18: + vitest@4.0.18(@types/node@18.19.130): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@18.19.130)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -2136,8 +2164,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1 + vite: 7.3.1(@types/node@18.19.130) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.130 transitivePeerDependencies: - jiti - less @@ -2161,3 +2191,5 @@ snapshots: stackback: 0.0.2 yargs-parser@21.1.1: {} + + zod@4.3.6: {} diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..2166dee --- /dev/null +++ b/spec.md @@ -0,0 +1,164 @@ +# Arg Parser — Design Specification + +## Overview + +A minimal, standalone TypeScript library for parsing CLI arguments with full type safety. Decoupled from any routing or framework layer. Accepts a spec string, a StandardSchema V1 flag schema, and an optional alias map. Returns a fully typed, validated context object. + +--- + +## Package API + +### `parse(argv, options)` + +```ts +function parse( + argv: string[], + options: { + spec: Spec; + schema: Schema; + alias?: Record< + string, + keyof Schema["~types"]["input"] | ExtractSpecNames + >; + }, +): Promise>; +``` + +The return type is a `Promise` because schema validation (via StandardSchema V1) may be async. + +--- + +## Spec String + +The `spec` string describes the full command signature using standard CLI notation, inspired by docopt and POSIX conventions. It is intended to be human-readable and suitable for direct use in help text. + +### Notation + +| Syntax | Meaning | Output type (no schema override) | +| ----------- | ----------------------------- | -------------------------------- | +| `bare` | Subcommand or literal segment | Not included in output | +| `` | Required positional | `string` | +| `[name]` | Optional positional | `string \| undefined` | +| `[...name]` | Variadic positional | `string[]` | + +### Examples + +``` +'deploy [region] [...targets]' +'git commit ' +'build' +``` + +### Rules + +- Bare words are treated as subcommand names and are not included in the parsed output. +- Required positionals (``) must precede optional positionals (`[name]`). +- The variadic positional (`[...name]`) must be the terminal segment if present. Only one variadic positional is allowed per spec. +- Spec names are extracted at the type level to produce `ExtractSpecNames`. + +--- + +## Schema + +The `schema` option accepts any StandardSchema V1-compatible schema (e.g. Zod, Valibot, ArkType). It serves two purposes: + +1. **Flag definitions** — any key in the schema that does not appear in the spec is treated as a named flag. +2. **Positional type overrides** — any key in the schema that matches a positional name in the spec overrides the default `string` type for that positional. + +### Positional Type Resolution + +For each name extracted from the spec, the output type is resolved as follows: + +| Spec form | No schema entry | Schema entry `T` | Schema entry `T[]` | +| ----------- | --------------------- | ---------------- | ------------------ | +| `` | `string` | `T` | `T[]` | +| `[name]` | `string \| undefined` | `T \| undefined` | `T[] \| undefined` | +| `[...name]` | `string[]` | `T[]` | `T[]` ← unwrapped | + +**Variadicity is always declared in the spec, never in the schema.** If a variadic spec entry (`[...name]`) maps to a schema entry of type `T[]`, the array is silently unwrapped to avoid `T[][]`. There is no CLI use case for a nested array output type. + +A schema entry typed as `T[]` on a non-variadic positional is left as-is — the user is expected to handle their own parsing of the raw string value (e.g. comma-separated input). + +--- + +## Aliases + +The `alias` option maps short or alternate names to their canonical targets. Alias targets must be either a key of the schema's input type or a positional name extracted from the spec. This constraint is enforced at the type level. + +```ts +alias: { + e: 'env', // positional alias + f: 'force', // flag alias + r: 'region' // optional positional alias +} +``` + +**Alias resolution happens before validation.** By the time the schema is called, all aliases have been resolved to their canonical names. The output object is always keyed by canonical names only — aliases are invisible in the output type. + +--- + +## Argument Parsing Rules + +### Positional filling + +Positionals are filled left-to-right from non-flag tokens in argv. A positional may also be passed by name (e.g. `--env prod`), in which case the named form takes precedence over positional filling. If both a named and positional form are provided for the same argument, the named form wins. + +### Variadic collection + +A variadic positional (`[...name]`) collects all remaining non-flag tokens after prior positionals are filled. When passed by name, it uses space-separated values (`--targets a b c`), stopping at the next flag token. + +### Flag parsing + +Flags are parsed from tokens starting with `--` (long form) or `-` (short form, alias only). Boolean flags are `true` when present, `false` when absent. Value flags consume the next token or use `=` syntax (`--tag=v1.0`). + +### Precedence summary + +1. Named form (`--name value`) wins over positional form for the same argument. +2. Aliases resolve to canonical names before any other processing. +3. Subcommand bare words are matched and consumed before positional filling begins. + +--- + +## Output Type + +The resolved output type is the intersection of spec-inferred positionals and the schema output type: + +```ts +type ParseResult< + Spec extends string, + Schema extends StandardSchemaV1, +> = InferSpecOutput & Schema["~types"]["output"]; +``` + +Where `InferSpecOutput` maps each spec positional to its resolved type (per the resolution table above), and `Schema['~types']['output']` contributes all flag types. Since the schema may also contain keys that match positional names (for type overrides), positional types in the intersection take their resolved form — the schema's contribution for that key is subsumed. + +--- + +## Validation + +Parsing and validation are two distinct phases: + +1. **Parse** — tokenise argv, resolve aliases, fill positionals, collect flags. Produces a raw unvalidated record. +2. **Validate** — pass the raw record through the StandardSchema. Returns `Promise` to accommodate async validators. + +If validation fails, `parse()` rejects with the schema's validation error. The library does not catch or transform schema errors — they propagate as-is. + +--- + +## Constraints and Invariants + +- A positional name and a flag schema key must not collide. If `spec` contains `` and `schema` also defines `env` as a flag (rather than a type override), this is ambiguous. The spec takes ownership of the name; the schema entry is treated as a type override, not a flag. +- Variadic positionals must be terminal in the spec. A non-terminal variadic is a runtime error thrown before parsing begins. +- Optional positionals must all be terminal and contiguous. A required positional may not follow an optional one. +- Only one variadic positional is permitted per spec. +- Alias targets that do not resolve to a known spec name or schema key produce a type error. + +--- + +## Subpath: `cli-parser/schema` + +A companion subpath exposes helpers for CLI-specific schema constructs: + +- `path()` — a schema for filesystem paths, with coercion and existence validation hooks. + +These helpers are thin wrappers over StandardSchema-compatible schemas and are designed to be passed directly as values in the `schema` option. diff --git a/src/index.ts b/src/index.ts index 524e527..09600d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,64 +1,45 @@ import type { - Aliases, - Args, - BooleanType, - Collectable, + ArgsSchema, + EnvOptions, NestedMapping, ParseOptions, - StringType, - Values, + ParseResult, + RawArgs, + RawValue, + SchemaMeta, + StandardSchemaV1, } from "./types.js"; -export { ParseOptions, Args } from "./types.js"; +export { ParseError, ParseOptions, RawArgs, StandardSchemaV1, ArgsSchema, SchemaMeta } from "./types.js"; +import { ParseError } from "./types.js"; +import type { ObjectSchema } from "./schema.js"; const BOOL_RE = /^(true|false)$/; const QUOTED_RE = /^('|").*\1$/; -const set = (obj: NestedMapping, key: string, value: any, type?: string) => { +const set = (obj: NestedMapping, key: string, value: unknown, collect?: boolean) => { if (key.includes(".")) { const parts = key.split("."); for (let i = 0; i < parts.length - 1; i++) { const k = parts[i]; - const tmp = {}; + const tmp: NestedMapping = {}; set(obj, k, tmp); obj = tmp; } key = parts[parts.length - 1]; } - if (type === "array" && obj[key] !== undefined) { + if (collect && obj[key] !== undefined) { if (Array.isArray(obj[key])) { - (obj[key] as any[]).push(value); + (obj[key] as unknown[]).push(value); } else { obj[key] = [obj[key], value]; } } else { - obj[key] = type === "array" ? [value] : value; + obj[key] = collect ? [value] : value; } }; -const type = ( - key: string, - opts: Record<"boolean" | "string" | "array", string[]>, -): "boolean" | "string" | "array" | undefined => { - if (opts.array && opts.array.length > 0 && opts.array.includes(key)) - return "array"; - if (opts.string && opts.string.length > 0 && opts.string.includes(key)) - return "string"; - if (opts.boolean && opts.boolean.length > 0 && opts.boolean.includes(key)) - return "boolean"; - return; -}; - -const defaultValue = (type?: "boolean" | "string" | "array") => { - if (type === "string") return ""; - if (type === "array") return []; - return true; -}; - -const coerce = (value?: string, type?: "string" | "boolean" | "array") => { - if (type === "string") return value; - if (type === "boolean") return value === undefined ? true : value === "true"; - - if (!value) return value; +const coerce = (value?: string): RawValue | undefined => { + if (value === undefined) return undefined; if (value.length > 3 && BOOL_RE.test(value)) return value === "true"; if (value.length > 2 && QUOTED_RE.test(value)) return value.slice(1, -1); if ((value[0] === "." && /\d/.test(value[1])) || /\d/.test(value[0])) @@ -66,56 +47,100 @@ const coerce = (value?: string, type?: "string" | "boolean" | "array") => { return value; }; -export function parse< - TArgs extends Values< - TBooleans, - TStrings, - TCollectable, - undefined, - TDefaults, - TAliases - >, - TBooleans extends BooleanType = undefined, - TStrings extends StringType = undefined, - TCollectable extends Collectable = undefined, - TDefaults extends Record | undefined = undefined, - TAliases extends Aliases | undefined = undefined, - TAliasArgNames extends string = string, - TAliasNames extends string = string, ->( +function applyEnv(raw: RawArgs, { prefix }: EnvOptions): void { + const p = prefix.toUpperCase() + "_"; + for (const [envKey, envVal] of Object.entries(process.env)) { + if (!envKey.startsWith(p) || envVal === undefined) continue; + const flag = envKey.slice(p.length).toLowerCase().replace(/_/g, "-"); + if (!(flag in raw)) { + const coerced = coerce(envVal); + if (coerced !== undefined) raw[flag] = coerced; + } + } +} + +/** Convert camelCase to kebab-case: "moduleTypes" → "module-types" */ +function toKebab(str: string): string { + return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +} + +/** Convert kebab-case to camelCase: "module-types" → "moduleTypes" */ +function toCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +/** + * Extract aliases from a schema's shape (if it's an ObjectSchema). + * For each field: + * - Per-field `.alias()` names → { aliasName: fieldName } + * - camelCase field names → auto-add kebab-case alias: { "module-types": "moduleTypes" } + * - kebab-case field names → auto-add camelCase alias: { "moduleTypes": "module-types" } + */ +function extractAliases(schema: StandardSchemaV1): Record { + const result: Record = {}; + if (!("shape" in schema) || typeof (schema as any).shape !== "object" || (schema as any).shape === null) { + return result; + } + const shape = (schema as ObjectSchema>).shape; + for (const [fieldName, fieldSchema] of Object.entries(shape)) { + // Per-field aliases from .alias() + if ("~meta" in fieldSchema) { + const meta = (fieldSchema as ArgsSchema)["~meta"] as SchemaMeta; + if (meta.aliases) { + for (const a of meta.aliases) { + result[a] = fieldName; + } + } + } + // Auto camelCase↔kebab-case + if (/[A-Z]/.test(fieldName)) { + // camelCase field → add kebab alias + const kebab = toKebab(fieldName); + if (!(kebab in result)) result[kebab] = fieldName; + } else if (fieldName.includes("-")) { + // kebab-case field → add camelCase alias + const camel = toCamel(fieldName); + if (!(camel in result)) result[camel] = fieldName; + } + } + return result; +} + +function parseRaw( argv: string[], - { - default: defaults, - alias: aliases, - ...types - }: ParseOptions = {}, -): Args { - const obj = { ...defaults, _: [] } as unknown as Args; + aliases?: Record, +): RawArgs { + const obj: RawArgs = { _: [] }; if (argv.length === 0) return obj; for (let i = 0; i < argv.length; i++) { const curr = argv[i]; const next = argv[i + 1]; - let t: "string" | "boolean" | "array" | undefined; + // -- terminator: everything after is a raw positional + if (curr === "--") { + for (let j = i + 1; j < argv.length; j++) { + const v = coerce(argv[j]); + obj._.push(v !== undefined ? v : argv[j] as RawValue); + } + break; + } + let key = ""; let value: string | undefined; if (curr.length > 1 && curr[0] === "-") { if (curr[1] !== "-" && curr.length > 2 && !curr.includes("=")) { + // Short combined flags: -abc or -a.b (dotted short) if (curr.includes(".")) { key = curr.slice(1, 2); value = curr.slice(2); } else { + // Expand all but last as boolean flags const keys = curr.slice(1, -1); - for (let key of keys) { - if ( - aliases && - (aliases as Record)[key] !== undefined - ) { - key = aliases[key as keyof typeof aliases] as string; - } - set(obj, key, defaultValue(t), t); + for (let k of keys) { + if (aliases?.[k] !== undefined) k = aliases[k]; + set(obj, k, true); } key = curr.slice(-1); if (next && next[0] !== "-") { @@ -124,16 +149,12 @@ export function parse< } } } else if (!curr.includes("=") && next && next[0] !== "-") { + // --flag value key = curr.replace(/^-{1,2}/, ""); - t = type(key, types as any); - // treat boolean as flag without parameter - if (t === "boolean") { - value = "true"; - } else { - value = next; - i++; - } + value = next; + i++; } else { + // --flag or --flag=value const eq = curr.indexOf("="); if (eq === -1) { key = curr.replace(/^-{1,2}/, ""); @@ -141,22 +162,52 @@ export function parse< key = curr.slice(0, eq).replace(/^-{1,2}/, ""); value = curr.slice(eq + 1); } - t = type(key, types as any); } - if ((!t || t === "boolean") && key.length > 3 && key.startsWith("no-")) { + if (key.length > 3 && key.startsWith("no-")) { set(obj, key.slice(3), false); } else { - if (aliases && (aliases as Record)[key] !== undefined) { - key = aliases[key as keyof typeof aliases] as string; - } - set(obj, key, coerce(value, t) ?? defaultValue(t), t); + if (aliases?.[key] !== undefined) key = aliases[key]; + set(obj, key, coerce(value) ?? true); } } else if (curr) { - (obj as any)._.push(coerce(curr)); - continue; + const v = coerce(curr); + obj._.push(v !== undefined ? v : curr as RawValue); } } return obj; } + +export async function parse< + TSchema extends StandardSchemaV1 | undefined = undefined, + TAliases extends Record | undefined = undefined, +>( + argv: string[], + opts?: ParseOptions, +): Promise> { + const schemaAliases = opts?.schema ? extractAliases(opts.schema) : {}; + const mergedAliases = { ...schemaAliases, ...opts?.alias }; + const hasAliases = Object.keys(mergedAliases).length > 0; + + const raw = parseRaw(argv, hasAliases ? mergedAliases : undefined); + if (opts?.env) applyEnv(raw, opts.env); + + if (!opts?.schema) return raw as ParseResult; + + let result = opts.schema["~standard"].validate(raw); + if (result instanceof Promise) result = await result; + if (result.issues) { + throw new ParseError(result.issues); + } + return result.value as ParseResult; +} + +export function parseSync( + argv: string[], + opts?: Omit>, "schema">, +): RawArgs { + const raw = parseRaw(argv, opts?.alias); + if (opts?.env) applyEnv(raw, opts.env); + return raw; +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..2789de2 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,149 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { ArgsSchema, SchemaMeta } from "./types.js"; + +type Issue = StandardSchemaV1.Issue; +type Result = StandardSchemaV1.Result; + +function ok(value: T): Result { + return { value }; +} + +function fail(message: string, path?: PropertyKey[]): Result { + const issue: Issue = path ? { message, path } : { message }; + return { issues: [issue] }; +} + +function makeSchema( + validate: (value: I) => Result | Promise>, +): ArgsSchema { + const schema = { + "~standard": { + version: 1, + vendor: "@bomb.sh/args", + validate: validate as (value: unknown) => Result | Promise>, + }, + "~meta": {} as SchemaMeta, + docs(description: string) { + this["~meta"].docs = description; + return this; + }, + alias(...names: string[]) { + this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names]; + return this; + }, + } as ArgsSchema; + return schema; +} + +export function string(): ArgsSchema { + return makeSchema((value) => { + if (typeof value === "string") return ok(value); + if (typeof value === "number" || typeof value === "boolean") + return ok(String(value)); + return fail(`Expected string, received ${typeof value}`); + }); +} + +export function number(): ArgsSchema { + return makeSchema((value) => { + if (typeof value === "number") return ok(value); + if (typeof value === "string") { + const n = Number(value); + if (!Number.isNaN(n)) return ok(n); + } + return fail(`Expected number, received ${typeof value}`); + }); +} + +export function boolean(): ArgsSchema { + return makeSchema((value) => { + if (typeof value === "boolean") return ok(value); + if (value === "true" || value === "1" || value === 1) return ok(true); + if (value === "false" || value === "0" || value === 0) return ok(false); + return fail(`Expected boolean, received ${typeof value}`); + }); +} + +export function array( + item?: StandardSchemaV1, +): ArgsSchema { + return makeSchema(async (value) => { + if (!Array.isArray(value)) + return fail(`Expected array, received ${typeof value}`); + if (!item) return ok(value as T[]); + + const out: T[] = []; + const issues: Issue[] = []; + for (let i = 0; i < value.length; i++) { + let r = item["~standard"].validate(value[i]); + if (r instanceof Promise) r = await r; + if (r.issues) { + for (const issue of r.issues) { + issues.push({ + message: issue.message, + path: [i, ...(issue.path ?? [])], + }); + } + } else { + out.push(r.value); + } + } + return issues.length > 0 ? { issues } : ok(out); + }); +} + +type ShapeOutput> = { + [K in keyof T]: StandardSchemaV1.InferOutput; +}; + +export interface ObjectSchema> + extends ArgsSchema> { + readonly shape: T; +} + +export function object>( + shape: T, +): ObjectSchema { + const validate = async (value: unknown): Promise>> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return fail(`Expected object, received ${typeof value}`); + + const input = value as Record; + const out: Record = {}; + const issues: Issue[] = []; + + for (const [key, schema] of Object.entries(shape)) { + let r = schema["~standard"].validate(input[key]); + if (r instanceof Promise) r = await r; + if (r.issues) { + for (const issue of r.issues) { + issues.push({ + message: issue.message, + path: [key, ...(issue.path ?? [])], + }); + } + } else { + out[key] = r.value; + } + } + return issues.length > 0 ? { issues } : ok(out as ShapeOutput); + }; + + return { + "~standard": { + version: 1, + vendor: "@bomb.sh/args", + validate, + }, + "~meta": {} as SchemaMeta, + shape, + docs(description: string) { + this["~meta"].docs = description; + return this; + }, + alias(...names: string[]) { + this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names]; + return this; + }, + } as ObjectSchema; +} diff --git a/src/types.ts b/src/types.ts index b3ea5c5..028e6d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,285 +1,58 @@ -/** Combines recursively all intersection types and returns a new single type. */ -type Id = TRecord extends Record - ? TRecord extends infer InferredRecord - ? { [Key in keyof InferredRecord]: Id } - : never - : TRecord; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +export type { StandardSchemaV1 }; -/** Converts a union type `A | B | C` into an intersection type `A & B & C`. */ -type UnionToIntersection = ( - TValue extends unknown - ? (args: TValue) => unknown - : never -) extends (args: infer R) => unknown - ? R extends Record - ? R - : never - : never; - -export type BooleanType = boolean | string | undefined; -export type StringType = string | undefined; -export type ArgType = StringType | BooleanType; - -export type Collectable = string | undefined; -export type Negatable = string | undefined; - -type UseTypes< - TBooleans extends BooleanType, - TStrings extends StringType, - TCollectable extends Collectable, -> = undefined extends (false extends TBooleans ? undefined : TBooleans) & - TCollectable & - TStrings - ? false - : true; - -/** - * Creates a record with all available flags with the corresponding type and - * default type. - */ -export type Values< - TBooleans extends BooleanType, - TStrings extends StringType, - TCollectable extends Collectable, - TNegatable extends Negatable, - TDefault extends Record | undefined, - TAliases extends Aliases | undefined, -> = UseTypes extends true - ? Record & - AddAliases< - SpreadDefaults< - CollectValues & - RecursiveRequired> & - CollectUnknownValues, - DedotRecord - >, - TAliases - > - : // deno-lint-ignore no-explicit-any - Record; - -export type Aliases< - TArgNames = string, - TAliasNames extends string = string, -> = Partial< - Record, TAliasNames | ReadonlyArray> ->; - -type AddAliases = { - [TArgName in keyof TArgs as AliasNames]: TArgs[TArgName]; -}; - -type AliasNames< - TArgName, - TAliases extends Aliases | undefined, -> = TArgName extends keyof TAliases - ? string extends TAliases[TArgName] - ? TArgName - : TAliases[TArgName] extends string - ? TArgName | TAliases[TArgName] - : TAliases[TArgName] extends Array - ? TArgName | TAliases[TArgName][number] - : TArgName - : TArgName; - -/** - * Spreads all default values of Record `TDefaults` into Record `TArgs` - * and makes default values required. - * - * **Example:** - * `SpreadValues<{ foo?: boolean, bar?: number }, { foo: number }>` - * - * **Result:** `{ foo: boolean | number, bar?: number }` - */ -type SpreadDefaults = TDefaults extends undefined - ? TArgs - : TArgs extends Record - ? Omit & { - [Default in keyof TDefaults]: Default extends keyof TArgs - ? - | (TArgs[Default] & TDefaults[Default]) - | TDefaults[Default] extends Record - ? NonNullable> - : TDefaults[Default] | NonNullable - : unknown; - } - : never; - -/** - * Defines the Record for the `default` option to add - * auto-suggestion support for IDE's. - */ -type Defaults = Id< - UnionToIntersection< - Record & - // Dedotted auto suggestions: { foo: { bar: unknown } } - MapTypes & - MapTypes & - // Flat auto suggestions: { "foo.bar": unknown } - MapDefaults & - MapDefaults - > ->; - -type MapDefaults = Partial< - Record ->; - -type RecursiveRequired = TRecord extends Record - ? { - [Key in keyof TRecord]-?: RecursiveRequired; - } - : TRecord; - -/** Same as `MapTypes` but also supports collectable options. */ -type CollectValues< - TArgNames extends ArgType, - TType, - TCollectable extends Collectable, - TNegatable extends Negatable = undefined, -> = UnionToIntersection< - Extract extends string - ? (Exclude extends never - ? Record - : MapTypes, TType, TNegatable>) & - (Extract extends never - ? Record - : RecursiveRequired< - MapTypes< - Extract, - Array, - TNegatable - > - >) - : MapTypes ->; - -/** Same as `Record` but also supports dotted and negatable options. */ -type MapTypes< - TArgNames extends ArgType, - TType, - TNegatable extends Negatable = undefined, -> = undefined extends TArgNames - ? Record - : TArgNames extends `${infer Name}.${infer Rest}` - ? { - [Key in Name]?: MapTypes< - Rest, - TType, - TNegatable extends `${Name}.${infer Negate}` ? Negate : undefined - >; - } - : TArgNames extends string - ? Partial< - Record< - TArgNames, - TNegatable extends TArgNames ? TType | false : TType - > - > - : Record; - -type CollectUnknownValues< - TBooleans extends BooleanType, - TStrings extends StringType, - TCollectable extends Collectable, - TNegatable extends Negatable, -> = UnionToIntersection< - TCollectable extends TBooleans & TStrings - ? Record - : DedotRecord< - // Unknown collectable & non-negatable args. - Record< - Exclude< - Extract, string>, - Extract - >, - Array - > & - // Unknown collectable & negatable args. - Record< - Exclude< - Extract, string>, - Extract - >, - Array | false - > - > ->; +export interface SchemaMeta { + docs?: string; + aliases?: string[]; +} -/** Converts `{ "foo.bar.baz": unknown }` into `{ foo: { bar: { baz: unknown } } }`. */ -type DedotRecord = Record extends TRecord - ? TRecord - : TRecord extends Record - ? UnionToIntersection< - ValueOf<{ - [Key in keyof TRecord]: Key extends string - ? Dedot - : never; - }> - > - : TRecord; +export interface ArgsSchema extends StandardSchemaV1 { + readonly "~meta": SchemaMeta; + docs(description: string): this; + alias(...names: string[]): this; +} -type Dedot< - TKey extends string, - TValue, -> = TKey extends `${infer Name}.${infer Rest}` - ? { [Key in Name]: Dedot } - : { [Key in TKey]: TValue }; +export type RawValue = string | number | boolean; -type ValueOf = TValue[keyof TValue]; +export interface RawArgs { + _: RawValue[]; + [key: string]: RawValue | RawValue[] | RawArgs; +} -/** The value returned from `parse`. */ -export type Args< - // deno-lint-ignore no-explicit-any - TArgs extends Record = Record, -> = Id< - TArgs & { - /** Contains all the arguments that didn't have an option associated with - * them. */ - _: Array; - } ->; +export interface EnvOptions { + prefix: string; +} -/** The options for the `parse` call. */ export interface ParseOptions< - TBooleans extends BooleanType = BooleanType, - TStrings extends StringType = StringType, - TCollectable extends Collectable = Collectable, - TDefault extends Record | undefined = - | Record - | undefined, - TAliases extends Aliases | undefined = Aliases | undefined, + TSchema extends StandardSchemaV1 | undefined = undefined, + TAliases extends Record | undefined = undefined, > { - /** - * An object mapping string names to strings or arrays of string argument - * names to use as aliases. - */ + /** Standard Schema-compliant schema. Output type drives `parse()` return type. */ + schema?: TSchema; + /** Map short flags to long names. */ alias?: TAliases; - /** - * A boolean, string or array of strings to always treat as booleans. If - * `true` will treat all double hyphenated arguments without equal signs as - * `boolean` (e.g. affects `--foo`, not `-f` or `--foo=bar`). - * All `boolean` arguments will be set to `false` by default. + * When set, auto-defaults flags from environment variables before schema + * validation. Flag `--foo-bar` maps to `PREFIX_FOO_BAR` (uppercased, + * hyphens replaced with underscores). */ - boolean?: TBooleans | ReadonlyArray>; - - /** An object mapping string argument names to default values. */ - default?: TDefault & Defaults; - - /** A string or array of strings argument names to always treat as strings. */ - string?: TStrings | ReadonlyArray>; - - /** - * A string or array of strings argument names to always treat as arrays. - * Array options can be used multiple times. All values will be - * collected into one array. If a non-array option is used multiple - * times, the last value is used. - * All Collectable arguments will be set to `[]` by default. - */ - array?: TCollectable | ReadonlyArray>; + env?: false | EnvOptions; } +export type ParseResult = + TSchema extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : RawArgs; + export interface NestedMapping { [key: string]: NestedMapping | unknown; } + +export class ParseError extends Error { + readonly issues: ReadonlyArray; + constructor(issues: ReadonlyArray) { + super(issues.map((i) => i.message).join("\n")); + this.name = "ParseError"; + this.issues = issues; + } +} diff --git a/test/flags.test.ts b/test/flags.test.ts index 61e01d2..d2b27a1 100644 --- a/test/flags.test.ts +++ b/test/flags.test.ts @@ -1,29 +1,29 @@ -import { describe, expect, it, test } from "vitest"; -import { parse } from "../src"; +import { describe, expect, it } from "vitest"; +import { parseSync } from "../src"; describe("flags", () => { it("a b c", () => { const input = ["a", "b", "c"]; const output = { _: ["a", "b", "c"] }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("-a -b -c", () => { const input = ["-a", "-b", "-c"]; const output = { _: [], a: true, b: true, c: true }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("-a 1 -b 2 -c 3 -d -e", () => { const input = ["-a", "1", "-b", "2", "-c", "3", "-d", "-e"]; const output = { _: [], a: 1, b: 2, c: 3, d: true, e: true }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("-a=1 -b 2 -c=3 -d -e", () => { const input = ["-a", "1", "-b", "2", "-c", "3", "-d", "-e"]; const output = { _: [], a: 1, b: 2, c: 3, d: true, e: true }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it(`-a aaa bbb -b ccc ddd -c 3 -d -e`, () => { @@ -47,7 +47,7 @@ describe("flags", () => { d: true, e: true, }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it(`-a "aaa bbb" -b "ccc ddd" -c 3 -d -e`, () => { @@ -60,7 +60,7 @@ describe("flags", () => { d: true, e: true, }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("comprehensive", () => { @@ -95,7 +95,7 @@ describe("flags", () => { name: "meowmers", _: ["bare"], }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); }); @@ -110,7 +110,7 @@ describe("dotted", () => { d: true, e: true, }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("--a.a1 1 --b.b1 2 --c.c1 3 -d -e", () => { @@ -123,7 +123,7 @@ describe("dotted", () => { d: true, e: true, }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("--a.a1.a2 1 --b.b1.b2 2 --c.c1.c2 3 -d -e", () => { @@ -145,7 +145,7 @@ describe("dotted", () => { d: true, e: true, }; - expect(parse(input)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); }); @@ -157,33 +157,7 @@ describe("negated", () => { bundle: false, watch: true, }; - expect(parse(input)).toEqual(output); - }); - - it("ignores strings", () => { - const input = ["--no-bundle", "--watch"]; - const opts = { - string: ['no-bundle'] - } - const output = { - _: [], - 'no-bundle': '', - watch: true, - }; - expect(parse(input, opts)).toEqual(output); - }); - - it("ignores arrays", () => { - const input = ["--no-bundle", '1', "--watch"]; - const opts = { - array: ['no-bundle'] - } - const output = { - _: [], - 'no-bundle': [1], - watch: true, - }; - expect(parse(input, opts)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); }); @@ -194,35 +168,9 @@ describe("aliases", () => { _: [], help: true }; - const result = parse(input, { alias: { h: 'help' } }); + const result = parseSync(input, { alias: { h: 'help' } }); expect(result).toEqual(output); }); - - it("ignores strings", () => { - const input = ["--no-bundle", "--watch"]; - const opts = { - string: ['no-bundle'] - } - const output = { - _: [], - 'no-bundle': '', - watch: true, - }; - expect(parse(input, opts)).toEqual(output); - }); - - it("ignores arrays", () => { - const input = ["--no-bundle", '1', "--watch"]; - const opts = { - array: ['no-bundle'] - } - const output = { - _: [], - 'no-bundle': [1], - watch: true, - }; - expect(parse(input, opts)).toEqual(output); - }); }); describe("special cases", () => { @@ -231,50 +179,39 @@ describe("special cases", () => { const output = { _: ['-'], }; - const result = parse(input); + const result = parseSync(input); expect(result).toEqual(output); }); - it("just a hyphen", () => { - const input = ["-"]; + it("-- terminator: remaining args become positionals", () => { + const input = ["--verbose", "--", "--not-a-flag", "also-not"]; const output = { - _: ['-'], + _: ["--not-a-flag", "also-not"], + verbose: true, }; - const result = parse(input); - expect(result).toEqual(output); + expect(parseSync(input)).toEqual(output); }); - it("string after boolean should be treated as positional", () => { - const input = ["--get", "http://my-url.com"]; - const opts = { - boolean: ['get'] - } + it("-- terminator: mixed positionals", () => { + const input = ["cmd", "--", "file.txt"]; const output = { - "_": ["http://my-url.com"], - "get": true, + _: ["cmd", "file.txt"], }; - const result = parse(input, opts); - expect(result).toEqual(output); + expect(parseSync(input)).toEqual(output); }); }); describe("boolean flags", () => { it("should handle long-form boolean flags correctly", () => { const input = ["--add"]; - const opts = { - boolean: ['add'] - }; const output = { _: [], add: true }; - expect(parse(input, opts)).toEqual(output); + expect(parseSync(input)).toEqual(output); }); it("should handle alias boolean flags correctly", () => { const input = ["-a"]; - const opts = { - boolean: ['add'], - alias: { a: 'add' } - }; + const result = parseSync(input, { alias: { a: "add" } }); const output = { _: [], add: true }; - expect(parse(input, opts)).toEqual(output); + expect(result).toEqual(output); }); }); diff --git a/test/schema-helpers.test.ts b/test/schema-helpers.test.ts new file mode 100644 index 0000000..58ce81e --- /dev/null +++ b/test/schema-helpers.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { parse } from "../src"; +import { array, boolean, number, object, string } from "../src/schema"; + +describe("string()", () => { + it("passes strings through", async () => { + const s = string(); + const r = s["~standard"].validate("hello"); + expect(r).toEqual({ value: "hello" }); + }); + + it("coerces numbers to string", async () => { + const r = string()["~standard"].validate(42); + expect(r).toEqual({ value: "42" }); + }); + + it("coerces booleans to string", async () => { + const r = string()["~standard"].validate(true); + expect(r).toEqual({ value: "true" }); + }); + + it("fails for objects", async () => { + const r = string()["~standard"].validate({}); + expect(r).toHaveProperty("issues"); + }); +}); + +describe("number()", () => { + it("passes numbers through", async () => { + const r = number()["~standard"].validate(42); + expect(r).toEqual({ value: 42 }); + }); + + it("coerces numeric strings", async () => { + const r = number()["~standard"].validate("3.14"); + expect(r).toEqual({ value: 3.14 }); + }); + + it("fails for non-numeric strings", async () => { + const r = number()["~standard"].validate("abc"); + expect(r).toHaveProperty("issues"); + }); + + it("fails for booleans", async () => { + const r = number()["~standard"].validate(true); + expect(r).toHaveProperty("issues"); + }); +}); + +describe("boolean()", () => { + it("passes booleans through", async () => { + expect(boolean()["~standard"].validate(true)).toEqual({ value: true }); + expect(boolean()["~standard"].validate(false)).toEqual({ value: false }); + }); + + it("coerces 'true' string", async () => { + expect(boolean()["~standard"].validate("true")).toEqual({ value: true }); + }); + + it("coerces 'false' string", async () => { + expect(boolean()["~standard"].validate("false")).toEqual({ value: false }); + }); + + it("fails for arbitrary strings", async () => { + const r = boolean()["~standard"].validate("yes"); + expect(r).toHaveProperty("issues"); + }); +}); + +describe("array()", () => { + it("passes arrays through", async () => { + let r = array()["~standard"].validate([1, 2, 3]); + if (r instanceof Promise) r = await r; + expect(r).toEqual({ value: [1, 2, 3] }); + }); + + it("fails for non-arrays", async () => { + let r = array()["~standard"].validate("not an array"); + if (r instanceof Promise) r = await r; + expect(r).toHaveProperty("issues"); + }); + + it("validates items with item schema", async () => { + let r = array(number())["~standard"].validate([1, 2, 3]); + if (r instanceof Promise) r = await r; + expect(r).toEqual({ value: [1, 2, 3] }); + }); + + it("fails when item schema fails", async () => { + let r = array(number())["~standard"].validate([1, "abc", 3]); + if (r instanceof Promise) r = await r; + expect(r).toHaveProperty("issues"); + }); + + it("includes path in item errors", async () => { + let r = array(number())["~standard"].validate(["bad"]); + if (r instanceof Promise) r = await r; + if ("issues" in r) { + expect(r.issues[0].path).toContain(0); + } + }); +}); + +describe("object()", () => { + it("validates object shape", async () => { + const schema = object({ name: string(), age: number() }); + let r = schema["~standard"].validate({ name: "Nate", age: 30 }); + if (r instanceof Promise) r = await r; + expect(r).toEqual({ value: { name: "Nate", age: 30 } }); + }); + + it("fails for non-objects", async () => { + let r = object({ name: string() })["~standard"].validate("not an object"); + if (r instanceof Promise) r = await r; + expect(r).toHaveProperty("issues"); + }); + + it("includes key in nested errors", async () => { + const schema = object({ port: number() }); + let r = schema["~standard"].validate({ port: "bad" }); + if (r instanceof Promise) r = await r; + if ("issues" in r) { + expect(r.issues[0].path).toContain("port"); + } + }); + + it("composes as parse() schema (no Zod needed)", async () => { + const result = await parse(["--port", "8080", "--name", "cli"], { + schema: object({ + port: number(), + name: string(), + _: array(), + }), + }); + expect(result.port).toBe(8080); + expect(result.name).toBe("cli"); + }); + + it("exposes shape for introspection", () => { + const shape = { port: number(), name: string() }; + const schema = object(shape); + expect(schema.shape).toBe(shape); + }); +}); + +describe(".docs()", () => { + it("stores description in ~meta", () => { + const s = string().docs("A string flag"); + expect(s["~meta"].docs).toBe("A string flag"); + }); + + it("overwrites on second call", () => { + const s = string().docs("first").docs("second"); + expect(s["~meta"].docs).toBe("second"); + }); + + it("validation still works after chaining", () => { + const r = string().docs("desc")["~standard"].validate("hello"); + expect(r).toEqual({ value: "hello" }); + }); + + it("works on all primitives", () => { + expect(number().docs("n")["~meta"].docs).toBe("n"); + expect(boolean().docs("b")["~meta"].docs).toBe("b"); + expect(array().docs("a")["~meta"].docs).toBe("a"); + expect(object({}).docs("o")["~meta"].docs).toBe("o"); + }); +}); + +describe(".alias()", () => { + it("stores alias in ~meta", () => { + const s = string().alias("t"); + expect(s["~meta"].aliases).toEqual(["t"]); + }); + + it("accepts multiple names at once", () => { + const s = string().alias("t", "timeout"); + expect(s["~meta"].aliases).toEqual(["t", "timeout"]); + }); + + it("accumulates across multiple calls", () => { + const s = string().alias("t").alias("timeout"); + expect(s["~meta"].aliases).toEqual(["t", "timeout"]); + }); + + it("validation still works after chaining", () => { + const r = string().alias("t")["~standard"].validate("hello"); + expect(r).toEqual({ value: "hello" }); + }); + + it("works on all primitives", () => { + expect(number().alias("n")["~meta"].aliases).toEqual(["n"]); + expect(boolean().alias("b")["~meta"].aliases).toEqual(["b"]); + expect(array().alias("a")["~meta"].aliases).toEqual(["a"]); + expect(object({}).alias("o")["~meta"].aliases).toEqual(["o"]); + }); +}); + +describe("parse() alias auto-extraction", () => { + it("resolves per-field .alias() short flags", async () => { + const result = await parse(["-t", "5000"], { + schema: object({ + timeout: number().alias("t"), + _: array(), + }), + }); + expect(result.timeout).toBe(5000); + }); + + it("auto-resolves kebab-case flag to camelCase field", async () => { + const result = await parse(["--module-types", "esm"], { + schema: object({ + moduleTypes: string(), + _: array(), + }), + }); + expect(result.moduleTypes).toBe("esm"); + }); + + it("auto-resolves camelCase flag to camelCase field", async () => { + const result = await parse(["--moduleTypes", "esm"], { + schema: object({ + moduleTypes: string(), + _: array(), + }), + }); + expect(result.moduleTypes).toBe("esm"); + }); + + it("auto-resolves camelCase flag to kebab-case field", async () => { + const result = await parse(["--moduleTypes", "esm"], { + schema: object({ + "module-types": string(), + _: array(), + }), + }); + expect(result["module-types"]).toBe("esm"); + }); + + it("manual opts.alias overrides per-field alias on conflict", async () => { + const result = await parse(["-t", "999"], { + schema: object({ + threads: number(), + _: array(), + }), + alias: { t: "threads" }, + }); + // manual alias wins: -t → threads + expect(result.threads).toBe(999); + }); + + it("schemas without per-field aliases work unchanged", async () => { + const result = await parse(["--port", "3000"], { + schema: object({ port: number(), _: array() }), + }); + expect(result.port).toBe(3000); + }); +}); diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..96eb3ac --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; +import { parse, ParseError } from "../src"; + +describe("parse() with schema", () => { + it("validates and returns typed output", async () => { + const result = await parse(["--port", "3000", "--verbose"], { + schema: z.object({ + port: z.coerce.number(), + verbose: z.boolean().optional(), + _: z.array(z.unknown()).optional(), + }), + }); + + expect(result.port).toBe(3000); + expect(result.verbose).toBe(true); + expectTypeOf(result.port).toBeNumber(); + expectTypeOf(result.verbose).toEqualTypeOf(); + }); + + it("infers output type from schema", async () => { + const result = await parse(["--name", "world"], { + schema: z.object({ + name: z.string(), + _: z.array(z.unknown()).optional(), + }), + }); + + expectTypeOf(result).toEqualTypeOf<{ name: string; _?: unknown[] | undefined }>(); + }); + + it("returns RawArgs when no schema provided", async () => { + const result = await parse(["--foo", "bar"]); + expect(result.foo).toBe("bar"); + expect(result._).toEqual([]); + expectTypeOf(result._).toEqualTypeOf<(string | number | boolean)[]>(); + }); + + it("throws ParseError on validation failure", async () => { + await expect( + parse(["--port", "notanumber"], { + schema: z.object({ port: z.number() }), + }) + ).rejects.toThrow(ParseError); + }); + + it("ParseError carries issues array", async () => { + try { + await parse(["--port", "bad"], { + schema: z.object({ port: z.number() }), + }); + } catch (e) { + expect(e).toBeInstanceOf(ParseError); + expect((e as ParseError).issues).toBeDefined(); + expect((e as ParseError).issues.length).toBeGreaterThan(0); + } + }); + + it("works with async schema validation", async () => { + const asyncSchema = { + "~standard": { + version: 1 as const, + vendor: "test", + validate: async (value: unknown) => { + await new Promise((r) => setTimeout(r, 0)); + const v = value as { port: string }; + return { value: { port: Number(v.port) } }; + }, + }, + }; + + const result = await parse(["--port", "8080"], { schema: asyncSchema }); + expect(result.port).toBe(8080); + }); + + it("handles default values via schema", async () => { + const result = await parse([], { + schema: z.object({ + port: z.coerce.number().default(3000), + _: z.array(z.unknown()).default([]), + }), + }); + expect(result.port).toBe(3000); + }); + + it("supports aliases", async () => { + const result = await parse(["-v"], { + schema: z.object({ + verbose: z.boolean().optional(), + _: z.array(z.unknown()).optional(), + }), + alias: { v: "verbose" }, + }); + expect(result.verbose).toBe(true); + }); +}); + +describe("positionals via _ in schema", () => { + it("z.tuple for typed positionals", async () => { + const result = await parse(["build", "--port", "3000"], { + schema: z.object({ + _: z.tuple([z.string()]), + port: z.coerce.number().optional(), + }), + }); + + expect(result._[0]).toBe("build"); + expect(result.port).toBe(3000); + expectTypeOf(result._).toEqualTypeOf<[string]>(); + }); + + it("z.array for variadic positionals", async () => { + const result = await parse(["a", "b", "c"], { + schema: z.object({ + _: z.array(z.string()), + }), + }); + expect(result._).toEqual(["a", "b", "c"]); + }); + + it("-- terminator: passthrough args land in _", async () => { + const result = await parse(["cmd", "--", "--not-a-flag"], { + schema: z.object({ + _: z.array(z.string()), + }), + }); + expect(result._).toEqual(["cmd", "--not-a-flag"]); + }); +}); + +describe("env fallback", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.APP_PORT = "9000"; + process.env.APP_VERBOSE = "true"; + }); + + afterEach(() => { + // Restore original env + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + }); + + it("injects env vars for missing flags", async () => { + const result = await parse([], { + schema: z.object({ + port: z.coerce.number(), + verbose: z.coerce.boolean(), + _: z.array(z.unknown()).default([]), + }), + env: { prefix: "APP" }, + }); + expect(result.port).toBe(9000); + expect(result.verbose).toBe(true); + }); + + it("argv takes precedence over env", async () => { + const result = await parse(["--port", "4000"], { + schema: z.object({ + port: z.coerce.number(), + _: z.array(z.unknown()).default([]), + }), + env: { prefix: "APP" }, + }); + expect(result.port).toBe(4000); + }); + + it("env works with parseSync", () => { + const result = parseSync([], { env: { prefix: "APP" } }); + expect(result.port).toBe(9000); + expect(result.verbose).toBe(true); // coerce() converts "true" → boolean + }); +}); + +// Keep parseSync import for env test +import { parseSync } from "../src";