diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 4e43839..220b0f9 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -28,5 +28,8 @@ jobs: - name: lint run: deno lint + - name: check + run: deno check mod.ts + - name: test - run: deno test + run: deno task test diff --git a/AGENTS.md b/AGENTS.md index d204ffb..b5dd1bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,19 +1,13 @@ # Configliere -Functional configuration parser that validates all program inputs (config files, -environment variables, CLI options) ahead of time using a single schema based on -the [Standard Schema](https://standardschema.dev) spec. +Configuration for programs. -## Architecture +## Current state -Configliere is a parser-combinator library. Small parsers compose into larger -ones. Every parser implements the `Parser` interface from `lib/types.ts` and -returns an `Increment` — either `Done|Fail` for single-step parsers or -`Next|Fail` for multi-step (phased) parsers. - -### Key design concepts - -- **Source tracking**: Every parsed value carries metadata about where it came +Configliere is undergoing a ground-up rebuild. No implementation architecture is +established yet. Design from the bottom up with small API sketches, exact type +expectations, and tests before implementation. Do not carry architectural +assumptions forward from pre-rebuild history. ## Conventions @@ -30,20 +24,14 @@ Strongly prefer one-word variable and function names (e.g. `dir` not `tmpDir`, ### Testing - Framework: `@std/testing/bdd` (`describe`/`it`) with `@std/expect` -- Schema library in tests: `arktype` (via `type()`) -- Test helpers in `test/test-helpers.ts`: `parseOk()` and `parseNotOk()` wrap - `parseSync` with assertions -- Tests live in `test/` and mirror `lib/` module names (e.g. - `test/field.test.ts`) +- Tests live in `test/` and mirror the source layout. ### Runtime and tooling - **Runtime**: Deno - **Registry**: JSR (`@frontside/configliere`) -- **Linting**: `deno lint` (excludes `prefer-const` and `require-yield`) -- **Testing**: `deno test` -- **Examples**: Files in `examples/` must compile (`deno check`) and run - (`deno run`) without errors +- **Linting**: `deno lint` (excludes `prefer-const`) +- **Testing**: `deno task test` - **Build**: `deno task build:npm` (dnt) and `deno task build:jsr` ### Dependencies diff --git a/README.md b/README.md index ba3c62c..d906d30 100644 --- a/README.md +++ b/README.md @@ -1,327 +1,212 @@ -# configliere logo Configliere - -Smart, FP configuration parser that validates all program inputs ahead of time, -including config files, environment variables, and command line options using a -single schema. - -## Introduction - -Conceptually, "config" is a palette of settings and switches that is accessed -from various portions of our program to alter its behavior without altering its -code. However the story around config is often made very messy by the fact that -it is read at _different_ times during program execution and from many -_different_ sources such as configuration files and environment variables. - -before configliere - -As a result, configuration is often fragile and difficult to understand. Some of -the symptoms of a fragmented config are: - -- **The deferred crash** Happens when a program begins running, perhaps even for - awhile, but then it reaches for a configuration value via an interface like - `app.getConfig("field")` only to find that the value is invalid, or missing - altogether. Happens a lot with config files and enviroment variables. -- **conflicting configs** When a program has more than one way of specifying the - same parameter, and they clobber each other. - - **Unclear priority** Which parameter wins? The one coming from a CLI option - or an environment variable? - - **Mysterious provenance** You can see a value for a github token, but where - does it come from? It could be from `app-config.yaml`, or - `app-config.production.yaml`. Then again, it could be specified in the - `GITHUB_TOKEN` environment variable. But don't forget there is also a - `--github-token` command line option. - - **magical behavior** A furiously frustrating deployment-specific failure is - tracked down to some random environment variable overriding a parameter that - isn't configured like that in any other enviroment. -- **Ad-hoc validation** - There are different ways to verify the runtime type of - a configuration value depending on whether it comes from the CLI, environment, - or a configuration file. - -Configliere solves all of these problems by re-imagining "config" not as a -constellation of globally floating objects from which we can read in values at -any point, but instead as a _single_, _pre-validated_, type-safe data structure -that is passed as the input of our program's entry point. It tracks the _source_ -of each value that ends up in the final config, so where a parameter is set is -never a mystery. - -after configliere - -This has a profound impact on our program as a whole because it lets us treat -the entire process as one function call that takes a single value as its input. - -## Quick Start - -Configliere uses [Standard Schema][standard-schema] to define the static type of -each configuration parameter as well as to validate that type at runtime. In -these examples, we'll use [Arktype][arktype], but you can use -[any library that implements the Standard Schema spec][schema-libs]. - -### Simple program - -A basic program with options, flags, and positional arguments: +# Configliere -```ts -import { type } from "arktype"; -import { cli, field, object, program } from "configliere"; - -let serve = program({ - name: "serve", - version: "1.0.0", - config: object({ - host: { - description: "hostname to bind", - aliases: ["-H"], - ...field(type("string"), field.default("localhost")), - }, - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - debug: { - description: "enable debug logging", - aliases: ["-d"], - ...field(type("boolean"), field.default(false)), - }, - entry: { - description: "entrypoint file", - ...field(type("string"), cli.argument()), - }, - }), -}); - -let parser = serve.createParser({ - args: process.argv.slice(2), - envs: [{ name: "env", value: process.env }], -}); - -switch (parser.type) { - case "help": - console.log(parser.print()); - break; - case "version": - console.log(parser.print()); - break; - case "main": { - let parsed = parser.parse(); - if (!parsed.ok) { - console.error(parsed.error.message); - process.exit(1); - } - let { host, port, debug, entry } = parsed.value; - // start your server... - } -} -``` - -Running `serve --help` prints: +> [!NOTE] +> Configliere is undergoing a ground-up rebuild. The ideas below are working, +> but the API is not yet stable. -``` -Usage: serve [OPTIONS] +Configliere models a command-line program as a tree of typed routes. Parsing +does not merely produce a bag of options: it resolves an intent such as +`HELP /`, `VERSION /`, or `EXECUTE /serve` and, for execution, binds a model for +every route that led there. -Arguments: - entrypoint file +The architecture has four small pieces: -Options: - -H, --host hostname to bind [default: localhost] - -p, --port port to listen on [default: 3000] - -d, --debug enable debug logging [default: false] - -h, --help show help - -v, --version show version -``` +- A **route** is an application-relative address such as `/` or `/serve`. +- A **method** says what to do with that route. Every route supports help; + `version()` adds version and `command()` creates an executable route. +- Each route owns its own **model**. An execute intent contains the current + `model` and a path-addressed `models` map for the entire matched lineage. +- Definitions are immutable composition pipelines. Elements such as + `description()`, `option()`, `toggle()`, `version()`, and `routes()` enrich + both the runtime definition and its TypeScript type. -All of these are equivalent: +Parameters are validated with [Standard Schema](https://standardschema.dev/), so +Configliere works with ArkType, Zod, Valibot, and other conforming schema +libraries. The examples below use ArkType. -```sh -serve app.ts -p 8080 --host localhost -HOST=localhost serve app.ts -p 8080 -serve app.ts --port=8080 -H localhost -``` +## One command, one model -### Commands - -For programs with subcommands, use `commands()`: +`command()` is an executable route. Here it creates the root route `/` with +help, version, and execute methods: ```ts +import process from "node:process"; +import { + command, + description, + name, + option, + parse, + printHelp, + printVersion, + schema, + toggle, + version, +} from "@frontside/configliere"; import { type } from "arktype"; -import { cli, commands, field, help, object, program } from "configliere"; - -let app = program({ - name: "myapp", - version: "3.2.0", - config: commands({ - help, - dev: { - description: "start dev server", - ...object({ - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - open: { - description: "open browser on start", - ...field(type("boolean"), field.default(false)), - }, - }), - }, - build: { - description: "build for production", - ...object({ - outdir: { - description: "output directory", - aliases: ["-o"], - ...field(type("string"), field.default("dist")), - }, - }), - }, - }), -}); -``` - -Running `myapp -h` prints: -``` -Usage: myapp [OPTIONS] +const app = command( + name("server"), + description("Run the HTTP server."), + version("1.0.0"), + option( + name("port"), + description("Port to listen on."), + schema(type("number")), + ), + toggle( + name("verbose"), + description("Print request diagnostics."), + ), +); -Commands: - help show help for a command - dev start dev server - build build for production +const result = parse(app, { argv: process.argv.slice(2) }); -Options: - -h, --help show help - -v, --version show version -``` +if (!result.ok) { + console.error(result.code, result); + process.exit(1); +} -The built-in `help` command provides per-command help. Running `myapp help dev` -prints: +switch (result.method) { + case "help": + console.log(printHelp(result)); + break; + case "version": + console.log(printVersion(result)); + break; + case "execute": + // Exactly { port: number; verbose: boolean }, not Record. + serve(result.model); + break; +} +function serve(model: { port: number; verbose: boolean }): void { + console.log(`listening on ${model.port}; verbose=${model.verbose}`); +} ``` -Usage: dev [OPTIONS] -Options: - -p, --port port to listen on [default: 3000] - --open open browser on start [default: false] -``` +`parse()` receives the arguments _after_ the executable name, which is why the +example passes `process.argv.slice(2)`. The full command lines above resolve as +follows: -Parse results are discriminated unions on `name`: +| Invocation | Result | +| ------------------------------ | ---------------------------------------------------------------------------- | +| `server --help` | `HELP /`; `printHelp()` renders the route's usage, options, and descriptions | +| `server --version` | `VERSION /`; `printVersion()` returns `server 1.0.0` | +| `server --port 4000` | `EXECUTE /`; `model` is `{ port: 4000, verbose: false }` | +| `server --port=4000 --verbose` | `EXECUTE /`; `model` is `{ port: 4000, verbose: true }` | +| `server --port nope` | `unprocessable-content`; execution never receives an invalid model | -```ts -let parsed = result.parse(); -if (parsed.ok) { - switch (parsed.value.name) { - case "dev": - // parsed.value.config is { port: number; open: boolean } - break; - case "build": - // parsed.value.config is { outdir: string } - break; - case "help": - // parsed.value.config is { command: string; text: string } - console.log(parsed.value.config.text); - break; - } -} -``` +The method is a discriminant. Once the switch reaches `"execute"`, TypeScript +knows that `model` exists and has exactly the shape assembled by the command's +parameters. Help and version are resolved before execution validation, so +`server --help` does not require a port. -### Loading config files +## Add commands, keep dispatch flat -Use `sequence()` to split parsing into phases. The first phase reads the config -file path, then you load the file and feed its contents into the second phase: +Routes form a tree, but callers do not have to mirror that tree with nested +conditionals. `IntentsOf` is a flat union of every method and route +the definition can reach, so `result.route` is another useful discriminant. ```ts -import { readFileSync } from "node:fs"; +import process from "node:process"; +import { + command, + name, + option, + parse, + printHelp, + routes, + schema, + toggle, +} from "@frontside/configliere"; import { type } from "arktype"; -import { cli, field, object, program, sequence } from "configliere"; - -let app = program({ - name: "myctl", - version: "2.0.0", - config: sequence( - object({ - config: { - description: "config file", - aliases: ["-c"], - ...field(type("string")), - }, - }), - object({ - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - host: { - description: "hostname to bind", - aliases: ["-H"], - ...field(type("string"), field.default("localhost")), - }, - }), + +const app = command( + name("simulacrum"), + toggle(name("verbose")), + routes( + command( + name("serve"), + option(name("port"), schema(type("number"))), + ), + command( + name("inspect"), + toggle(name("json")), + ), ), -}); - -let result = app.createParser({ - args: process.argv.slice(2), - envs: [{ name: "env", value: process.env }], -}); - -if (result.type === "main") { - // phase 1: extract the config file path - let step1 = result.parse(); - if (!step1.ok) { - console.error(step1.error.message); - process.exit(1); - } +); - // load the config file - let config = JSON.parse(readFileSync(step1.value.config, "utf-8")); - - // phase 2: parse remaining args merged with config file values - let step2 = step1.parse({ - args: step1.remainder.args, - values: [{ name: step1.value.config, value: config }], - }); - if (!step2.ok) { - console.error(step2.error.message); - process.exit(1); - } +const result = parse(app, { argv: process.argv.slice(2) }); - let { port, host } = step2.value; - // start your server... +if (!result.ok) { + console.error(result.code, result); + process.exit(1); } -``` - -Running `myctl -c app.json -p 8080` first extracts `config: "app.json"`, then -loads `app.json` and merges its values with the remaining CLI args. Values from -the CLI take priority over the config file, and all values go through the same -schema validation. -## Configuration Sources +if (result.method === "help") { + console.log(printHelp(result)); +} else { + switch (result.route) { + case "/": + // model: { verbose: boolean } + // models: { "/": { verbose: boolean } } + start(result.model); + break; -Configliere unifies three input sources. Every value, regardless of source, -passes through the same Standard Schema validation. + case "/serve": + // model is only the selected route's model. + result.model.port; // number -### CLI arguments + // models contains every model on the matched route lineage. + result.models["/"].verbose; // boolean + result.models["/serve"].port; // number -Options are specified with `--name value` or `--name=value`. Boolean fields are -switches: `--debug` enables, `--no-debug` disables. Positional arguments are -declared with `cli.argument()`. Array fields accept repeated values: -`--user alice --user bob`. + // @ts-expect-error: /inspect was not part of this match. + result.models["/inspect"]; + serve(result.model, result.models["/"]); + break; -### Environment variables + case "/inspect": + // model: { json: boolean } + // models: { "/": { verbose: boolean }, "/inspect": { json: boolean } } + inspect(result.model, result.models["/"]); + break; + } +} -Field names are automatically mapped to `UPPER_SNAKE_CASE` environment -variables. Nested scopes (e.g. command names) are joined with `_`. For example, -a `port` field inside a `serve` command reads from `SERVE_PORT`. +function start(model: { verbose: boolean }): void { + console.log(`simulacrum; verbose=${model.verbose}`); +} -### Object values +function serve( + model: { port: number }, + root: { verbose: boolean }, +): void { + console.log(`serving on ${model.port}; verbose=${root.verbose}`); +} -Plain objects can be passed directly (e.g. from a parsed JSON/YAML config file). -Each object value is tagged with a source name for provenance tracking. When -multiple sources provide the same field, CLI args take priority over env vars, -which take priority over object values. +function inspect( + model: { json: boolean }, + root: { verbose: boolean }, +): void { + console.log(`inspecting; json=${model.json}; verbose=${root.verbose}`); +} +``` -[standard-schema]: https://standardschema.dev -[arktype]: https://arktype.io -[schema-libs]: https://standardschema.dev/#what-schema-libraries-implement-the-spec +| Invocation | Result | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `simulacrum --verbose` | `EXECUTE /`; `model` and `models["/"]` are `{ verbose: true }` | +| `simulacrum --verbose serve --port 4000` | `EXECUTE /serve`; `model` is `{ port: 4000 }`, while `models` also contains `"/": { verbose: true }` | +| `simulacrum inspect --json` | `EXECUTE /inspect`; `model` is `{ json: true }` and the root model has `{ verbose: false }` | +| `simulacrum serve --help` | `HELP /serve`; no root or command model is validated | + +Route tokens select the deepest matching route; they are not positional +arguments. Options on each segment bind only to the model owned by that route. +The root definition's name identifies the executable but is not repeated in +route IDs: the root is `/`, not `/simulacrum`. + +For a deeper route such as `/database/clean`, the same rule applies: +`result.model` is the clean command's model, while `result.models` has exact +entries for `/`, `/database`, and `/database/clean`. Sibling models are absent +at runtime and from the TypeScript type. diff --git a/deno.json b/deno.json index fc99879..bea876c 100644 --- a/deno.json +++ b/deno.json @@ -2,18 +2,19 @@ "name": "@frontside/configliere", "exports": "./mod.ts", "license": "MIT", - "publish": { "include": ["vendor", "lib", "mod.ts", "README.md"] }, + "publish": { "include": ["lib", "mod.ts", "README.md", "LICENSE.md"] }, "imports": { - "@standard-schema/spec": "npm:@standard-schema/spec@^1.0.0", - "@std/expect": "jsr:@std/expect@^1.0.16", - "@std/testing": "jsr:@std/testing@^1.0.15", - "arktype": "npm:arktype@^2.1.20", - "dnt": "jsr:@deno/dnt@0.42.3" + "@std/expect": "jsr:@std/expect@^1.0.19", + "@std/testing/bdd": "jsr:@std/testing@^1.0.19/bdd", + "arktype": "npm:arktype@^2.2.3", + "dnt": "jsr:@deno/dnt@0.42.3", + "@standard-schema/spec": "npm:@standard-schema/spec@^1.1.0" }, "lint": { - "rules": { "exclude": ["prefer-const", "require-yield", "ban-types"] } + "rules": { "exclude": ["prefer-const"] } }, "tasks": { + "test": "deno test", "build:npm": "deno run -A tasks/build-npm.ts", "build:jsr": "deno run -A tasks/build-jsr.ts" } diff --git a/deno.lock b/deno.lock index e5bb209..bedac31 100644 --- a/deno.lock +++ b/deno.lock @@ -1,121 +1,92 @@ { "version": "5", "specifiers": { - "jsr:@david/code-block-writer@^13.0.3": "13.0.3", - "jsr:@deno/dnt@0.42.3": "0.42.3", - "jsr:@std/assert@^1.0.14": "1.0.18", - "jsr:@std/assert@^1.0.17": "1.0.18", - "jsr:@std/expect@^1.0.16": "1.0.17", - "jsr:@std/fmt@1": "1.0.8", - "jsr:@std/fs@1": "1.0.22", - "jsr:@std/internal@^1.0.10": "1.0.12", - "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/path@1": "1.1.4", - "jsr:@std/path@^1.1.4": "1.1.4", - "jsr:@std/testing@^1.0.15": "1.0.17", - "jsr:@ts-morph/bootstrap@0.27": "0.27.0", - "jsr:@ts-morph/common@0.27": "0.27.0", - "npm:@standard-schema/spec@1": "1.1.0", - "npm:arktype@^2.1.20": "2.1.20" + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/expect@^1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/internal@^1.0.13": "1.0.14", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.4": "1.1.6", + "jsr:@std/testing@^1.0.19": "1.0.19", + "npm:@standard-schema/spec@*": "1.1.0", + "npm:@standard-schema/spec@1.1.0": "1.1.0", + "npm:@standard-schema/spec@^1.1.0": "1.1.0", + "npm:arktype@^2.2.3": "2.2.3", + "npm:zod@*": "4.4.3" }, "jsr": { - "@david/code-block-writer@13.0.3": { - "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" - }, - "@deno/dnt@0.42.3": { - "integrity": "62a917a0492f3c8af002dce90605bb0d41f7d29debc06aca40dba72ab65d8ae3", - "dependencies": [ - "jsr:@david/code-block-writer", - "jsr:@std/fmt", - "jsr:@std/fs", - "jsr:@std/path@1", - "jsr:@ts-morph/bootstrap" - ] - }, - "@std/assert@1.0.18": { - "integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78", + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ "jsr:@std/internal@^1.0.12" ] }, - "@std/expect@1.0.17": { - "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", + "@std/expect@1.0.19": { + "integrity": "25271785688c7ff902fa54875d6aa4001f552c5578f7f0fefb5b5aac1fc2ed8a", "dependencies": [ - "jsr:@std/assert@^1.0.14", - "jsr:@std/internal@^1.0.10" - ] - }, - "@std/fmt@1.0.8": { - "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" - }, - "@std/fs@1.0.22": { - "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", - "dependencies": [ - "jsr:@std/internal@^1.0.12", - "jsr:@std/path@^1.1.4" + "jsr:@std/assert", + "jsr:@std/internal@^1.0.13", + "jsr:@std/path" ] }, "@std/internal@1.0.8": { "integrity": "fc66e846d8d38a47cffd274d80d2ca3f0de71040f855783724bb6b87f60891f5" }, - "@std/internal@1.0.12": { - "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" - }, - "@std/path@1.1.4": { - "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/testing@1.0.17": { - "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", - "dependencies": [ - "jsr:@std/assert@^1.0.17", - "jsr:@std/internal@^1.0.12" - ] + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, - "@ts-morph/bootstrap@0.27.0": { - "integrity": "b8d7bc8f7942ce853dde4161b28f9aa96769cef3d8eebafb379a81800b9e2448", + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@ts-morph/common" + "jsr:@std/internal@^1.0.14" ] }, - "@ts-morph/common@0.27.0": { - "integrity": "c7b73592d78ce8479b356fd4f3d6ec3c460d77753a8680ff196effea7a939052", + "@std/testing@1.0.19": { + "integrity": "f4236172365b216728dc3cc8b5e80a9f4c33083d1e4ede7613d5b25b4014898e", "dependencies": [ - "jsr:@std/fs", - "jsr:@std/path@1" + "jsr:@std/assert", + "jsr:@std/internal@^1.0.14" ] } }, "npm": { - "@ark/schema@0.46.0": { - "integrity": "sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==", + "@ark/schema@0.56.2": { + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", "dependencies": [ "@ark/util" ] }, - "@ark/util@0.46.0": { - "integrity": "sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==" + "@ark/util@0.56.2": { + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==" }, "@standard-schema/spec@1.1.0": { "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" }, - "arktype@2.1.20": { - "integrity": "sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==", + "arkregex@0.0.8": { + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", "dependencies": [ - "@ark/schema", "@ark/util" ] + }, + "arktype@2.2.3": { + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", + "dependencies": [ + "@ark/schema", + "@ark/util", + "arkregex" + ] + }, + "zod@4.4.3": { + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" } }, "workspace": { "dependencies": [ "jsr:@deno/dnt@0.42.3", - "jsr:@std/expect@^1.0.16", - "jsr:@std/testing@^1.0.15", - "npm:@standard-schema/spec@1", - "npm:arktype@^2.1.20" + "jsr:@std/expect@^1.0.19", + "jsr:@std/testing@^1.0.19", + "npm:@standard-schema/spec@^1.1.0", + "npm:arktype@^2.2.3" ] } } diff --git a/examples/commands.ts b/examples/commands.ts deleted file mode 100644 index 48b26f7..0000000 --- a/examples/commands.ts +++ /dev/null @@ -1,84 +0,0 @@ -import assert from "node:assert"; -import { type } from "arktype"; -import { cli, field } from "../lib/field.ts"; -import { object } from "../lib/object.ts"; -import { commands } from "../lib/commands.ts"; -import { program } from "../lib/program.ts"; - -let app = program({ - name: "myapp", - version: "3.2.0", - config: commands({ - dev: { - description: "start dev server", - ...object({ - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - open: { - description: "open browser on start", - ...field(type("boolean"), field.default(false)), - }, - }), - }, - build: { - description: "build for production", - ...object({ - outdir: { - description: "output directory", - aliases: ["-o"], - ...field(type("string"), field.default("dist")), - }, - minify: { - description: "minify output", - ...field(type("boolean"), field.default(true)), - }, - }), - }, - deploy: { - description: "deploy to target", - ...object({ - target: { - description: "deployment target", - ...field(type("string"), cli.argument()), - }, - dry: { - description: "dry run without deploying", - ...field(type("boolean"), field.default(false)), - }, - }), - }, - }), -}); - -console.log("=== -h ===\n"); -let r1 = app.parse({ args: ["-h"] }); -assert(r1.ok); -assert(r1.value.help); -console.log(app.help()); - -console.log("\n=== -v ===\n"); -let r2 = app.parse({ args: ["-v"] }); -assert(r2.ok); -assert(r2.value.version); -console.log(r2.value.version); - -console.log("\n=== dev --help ===\n"); -let r3 = app.parse({ args: ["dev", "--help"] }); -assert(r3.ok); -let cmd3 = r3.value.config; -assert(cmd3.name === "dev"); -assert(cmd3.help); -console.log(cmd3.text); - -console.log("\n=== dev --open -p 4000 ===\n"); -let r4 = app.parse({ args: ["dev", "--open", "-p", "4000"] }); -assert(r4.ok); -let cmd4 = r4.value.config; -assert(cmd4.name === "dev" && !cmd4.help); -console.log(cmd4.config); - -console.log("\n=== help() ===\n"); -console.log(app.help()); diff --git a/examples/phased-commands.ts b/examples/phased-commands.ts deleted file mode 100644 index dd779b7..0000000 --- a/examples/phased-commands.ts +++ /dev/null @@ -1,95 +0,0 @@ -import assert from "node:assert"; -import { type } from "arktype"; -import { cli, field } from "../lib/field.ts"; -import { object } from "../lib/object.ts"; -import { commands } from "../lib/commands.ts"; -import { inject } from "../lib/inject.ts"; -import { program } from "../lib/program.ts"; -import type { Parser } from "../lib/types.ts"; - -let app = program({ - name: "myctl", - version: "2.0.0", - config: object({ - config: { - description: "config file", - aliases: ["-c"], - ...field(type("string")), - }, - next: inject((_config: { serve?: { host?: string } }) => - commands({ - init: { - description: "initialize a new project", - ...object({ - template: { - description: "project template", - aliases: ["-t"], - ...field(type("string"), field.default("default")), - }, - }), - }, - serve: { - description: "start the server", - ...object({ - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - host: { - description: "hostname to bind", - aliases: ["-H"], - ...field(type("string"), field.default("localhost")), - }, - }), - }, - migrate: { - description: "run database migrations", - ...object({ - target: { - description: "migration target version", - ...field(type("string"), cli.argument()), - }, - dry: { - description: "dry run without applying", - ...field(type("boolean"), field.default(false)), - }, - }), - }, - }) - ), - }), -}); - -console.log("=== --help ===\n"); -let rh = app.parse({ args: ["--help"] }); -assert(rh.ok); -assert(rh.value.help); -console.log(app.help()); - -console.log("\n=== --version ===\n"); -let rv = app.parse({ args: ["--version"] }); -assert(rv.ok); -assert(rv.value.version); -console.log(rv.value.version); - -console.log("\n=== -c app.json serve -p 8080 ===\n"); -let r = app.parse({ args: ["-c", "app.json", "serve", "-p", "8080"] }); -assert(r.ok); - -let phase1 = r.value.config; -console.log("phase 1 config:", phase1.config); - -// simulate loading config, resume into phase 2 -let resume = phase1.next as unknown as ( - deps: { serve?: { host?: string } }, -) => Parser; -let parser2 = resume({ serve: { host: "0.0.0.0" } }); -let phase2 = parser2.parse({ - values: [{ name: "app.json", value: { serve: { host: "0.0.0.0" } } }], -}); -assert(phase2.ok); -console.log("phase 2:", phase2.value); - -console.log("\n=== help() ===\n"); -console.log(app.help()); diff --git a/examples/simple.ts b/examples/simple.ts deleted file mode 100644 index 24a7c8e..0000000 --- a/examples/simple.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert"; -import { type } from "arktype"; -import { cli, field } from "../lib/field.ts"; -import { object } from "../lib/object.ts"; -import { program } from "../lib/program.ts"; -import { createContext } from "../lib/context.ts"; - -let serve = program({ - name: "serve", - version: "1.0.0", - config: object({ - host: { - description: "hostname to bind", - aliases: ["-H"], - ...field(type("string"), field.default("localhost")), - }, - port: { - description: "port to listen on", - aliases: ["-p"], - ...field(type("number"), field.default(3000)), - }, - debug: { - description: "enable debug logging", - aliases: ["-d"], - ...field(type("boolean"), field.default(false)), - }, - entry: { - description: "entrypoint file", - ...field(type("string"), cli.argument()), - }, - }), -}); - -console.log("=== --help ===\n"); -let r1 = serve.parse({ args: ["--help"] }); -assert(r1.ok); -assert(r1.value.help); -console.log(serve.help()); - -console.log("\n=== --version ===\n"); -let r2 = serve.parse({ args: ["--version"] }); -assert(r2.ok); -assert(r2.value.version); -console.log(r2.value.version); - -console.log("\n=== app.ts -p 8080 --debug ===\n"); -let r3 = serve.parse({ args: ["app.ts", "-p", "8080", "--debug"] }); -assert(r3.ok); -console.log(r3.value.config); - -console.log("\n=== inspect() ===\n"); -console.log(serve.inspect(createContext())); - -console.log("\n=== help() ===\n"); -console.log(serve.help()); - -console.log("\n=== inspect() with env source ===\n"); -console.log(serve.inspect(createContext({ - envs: [{ name: "env", value: { PORT: "9090" } }], -}))); diff --git a/images/configliere-after.svg b/images/configliere-after.svg deleted file mode 100644 index 657d558..0000000 --- a/images/configliere-after.svg +++ /dev/null @@ -1,870 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CLI - - - - - - - - - ENV - - - - - - - - - Config Files... - - - - - - - - - - - - - - - - - - - - - - - Parse/Validate - - - - - - - - - - - - - - - - - - - - - - - Parse/Validate - - - - - - - - - - - - - - - - - - - - - - - Parse/Validate - - - - - - - - - Merge - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Config C - - - Configliere - - - - - - - - - C.x - - - - - - - - - C.z - - - - - - - - - C.y - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Program - - diff --git a/images/configliere-before.svg b/images/configliere-before.svg deleted file mode 100644 index 48878e2..0000000 --- a/images/configliere-before.svg +++ /dev/null @@ -1,602 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Args.x - - - - - - - - - c.z - - - - - - - - - y - - - - - - - - - - - - - - - - - - - - - - - Parse Args - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Program - - - - - - - - - CLI - - - - - - - - - ENV - - - - - - - - - - - - - - - - - - - - - - - y = proces.env.Y - - - - - - - - - - - - - - - - - - - - - Config Files... - - - - - - - - - - - - - - - - - - - - - - - json = read("c.json") - - diff --git a/lib/bind.ts b/lib/bind.ts new file mode 100644 index 0000000..b49ebdf --- /dev/null +++ b/lib/bind.ts @@ -0,0 +1,99 @@ +import type { Param } from "./param.ts"; +import type { Symbol } from "./read.ts"; +import type { Result } from "./result.ts"; +import type { Flag, Setter, Word } from "./tokenize.ts"; +import type { Tokenizer } from "./tokenizer.ts"; +import type { Issue } from "./types.ts"; + +export interface Binding { + rest: Tokenizer; + result: Result; +} + +export function bind>(options: { + param: P; + tokens: Tokenizer; +}): Binding { + let { param, tokens } = options; + let path = [param.name]; + let cli = param.cli(tokens); + + if (cli.result.ok) { + let read = cli.result.value; + let value: unknown = read.exists ? read.value : undefined; + + let candidates = typeof value === "string" ? param.decode(value) : [value]; + + if (candidates.length === 0) { + return { + rest: cli.claim.rest, + result: { + ok: false, + issues: [{ + message: `unable to decode ${JSON.stringify(value)}`, + path, + }], + }, + }; + } + + let issues: readonly Issue[] | undefined = undefined; + for (let candidate of candidates) { + let result = validate(param, candidate, path); + if (result.ok) { + return { + rest: cli.claim.rest, + result, + }; + } else { + issues = issues ?? result.issues; + } + } + + return { + rest: cli.claim.rest, + result: { + ok: false, + issues: issues ?? [], + }, + }; + } + + return { + rest: cli.claim.rest, + result: { + ok: false, + issues: cli.result.issues, + }, + }; +} + +function validate>( + param: P, + value: unknown, + path: string[], +): Result { + let validated = param.schema["~standard"].validate(value); + if (validated instanceof Promise) { + return { + ok: false, + issues: [{ + message: `async schemas are not allowed`, + path, + }], + }; + } + + if (validated.issues) { + return { + ok: false, + issues: validated.issues.map((i) => ({ ...i, path })), + }; + } else { + return { + ok: true, + issues: [], + value: validated.value, + }; + } +} diff --git a/lib/case.ts b/lib/case.ts deleted file mode 100644 index a45c479..0000000 --- a/lib/case.ts +++ /dev/null @@ -1,206 +0,0 @@ -type Upper = - | "A" - | "B" - | "C" - | "D" - | "E" - | "F" - | "G" - | "H" - | "I" - | "J" - | "K" - | "L" - | "M" - | "N" - | "O" - | "P" - | "Q" - | "R" - | "S" - | "T" - | "U" - | "V" - | "W" - | "X" - | "Y" - | "Z"; - -type Lower = Lowercase; - -type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; - -type Separator = "_" | "-" | "." | " "; - -type Kind = C extends Separator ? "sep" - : C extends Upper ? "upper" - : C extends Lower ? "lower" - : C extends Digit ? "digit" - : "other"; - -type Push = S extends "" ? T : [...T, S]; - -type HasMany = S extends `${infer _}${infer Rest}` - ? Rest extends "" ? false : true - : false; - -type NextKind = S extends `${infer Head}${string}` - ? Kind - : "end"; - -type ShouldBreak< - Current extends string, - Prev extends "start" | "sep" | "upper" | "lower" | "digit" | "other", - CurrentKind extends "upper" | "lower" | "digit" | "other", - Next extends "upper" | "lower" | "digit" | "sep" | "other" | "end", -> = Current extends "" ? false - : CurrentKind extends "upper" ? Prev extends "lower" | "digit" ? true - : Prev extends "upper" ? Next extends "lower" ? HasMany - : false - : false - : CurrentKind extends "lower" ? Prev extends "digit" ? true - : Prev extends "upper" ? HasMany - : false - : CurrentKind extends "digit" ? Prev extends "lower" ? true : false - : false; - -type SnakeWords< - S extends string, - Prev extends "start" | "sep" | "upper" | "lower" | "digit" | "other" = - "start", - Current extends string = "", - Words extends string[] = [], -> = S extends `${infer Head}${infer Tail}` - ? Kind extends "sep" ? SnakeWords> - : Kind extends infer CurrentKind - ? CurrentKind extends "upper" | "lower" | "digit" | "other" - ? ShouldBreak> extends true - ? SnakeWords< - Tail, - CurrentKind, - `${Lowercase}`, - Push - > - : SnakeWords< - Tail, - CurrentKind, - `${Current}${Lowercase}`, - Words - > - : never - : never - : Push; - -type JoinWithUnderscores = T extends - [infer Head, ...infer Tail] - ? Head extends string - ? Tail extends string[] - ? Tail["length"] extends 0 ? Head : `${Head}_${JoinWithUnderscores}` - : never - : never - : ""; - -type SnakeCase = JoinWithUnderscores>; - -type ReplaceUnderscores = S extends - `${infer Head}_${infer Tail}` ? `${Head}-${ReplaceUnderscores}` - : S; - -export type EnvCase = Uppercase>; - -export type KebabCase = ReplaceUnderscores>; - -export function toKebabCase(str: S): KebabCase { - return normalize(str).replaceAll("_", "-") as KebabCase; -} - -export function toEnvCase(str: S): EnvCase { - return normalize(str).toUpperCase() as EnvCase; -} - -export function toOptionPath(path: string[]): string { - return path.map((part) => toKebabCase(part)).join(".").toLowerCase(); -} - -export function toEnvKey(path: string[]): string { - return path.map((part) => toEnvCase(part)).join("_"); -} - -function normalize(str: string): string { - let words: string[] = []; - let current = ""; - let prev: "start" | "sep" | "upper" | "lower" | "digit" | "other" = "start"; - - for (let index = 0; index < str.length; index += 1) { - let char = str[index]; - let kind = classify(char); - - if (kind === "sep") { - if (current !== "") { - words.push(current); - current = ""; - } - prev = "sep"; - continue; - } - - if (shouldBreak(current, prev, kind, classify(str[index + 1] ?? ""))) { - words.push(current); - current = char.toLowerCase(); - } else { - current += char.toLowerCase(); - } - - prev = kind; - } - - if (current !== "") { - words.push(current); - } - - return words.join("_"); -} - -function classify(char: string): "sep" | "upper" | "lower" | "digit" | "other" { - if (char === "_" || char === "-" || char === "." || char === " ") { - return "sep"; - } - if (char >= "A" && char <= "Z") { - return "upper"; - } - if (char >= "a" && char <= "z") { - return "lower"; - } - if (char >= "0" && char <= "9") { - return "digit"; - } - return "other"; -} - -function shouldBreak( - current: string, - prev: "start" | "sep" | "upper" | "lower" | "digit" | "other", - kind: "upper" | "lower" | "digit" | "other", - next: "sep" | "upper" | "lower" | "digit" | "other", -): boolean { - if (current === "") { - return false; - } - - switch (kind) { - case "upper": - if (prev === "lower" || prev === "digit") { - return true; - } - return prev === "upper" && next === "lower" && current.length > 1; - case "lower": - if (prev === "digit") { - return true; - } - return prev === "upper" && current.length > 1; - case "digit": - return prev === "lower"; - default: - return false; - } -} diff --git a/lib/command.ts b/lib/command.ts new file mode 100644 index 0000000..cdcf915 --- /dev/null +++ b/lib/command.ts @@ -0,0 +1,1101 @@ +// deno-lint-ignore-file ban-types +import { executable, route } from "./route.ts"; +import type { Definition, Route } from "./types.ts"; + +export type CommandZero = Route< + N, + "help" | "execute", + {}, + [] +>; + +export function command( + start: Definition, +): CommandZero; + +export function command( + start: Definition, + na: (value: CommandZero) => A, +): A; + +export function command( + start: Definition, + na: (value: CommandZero) => A, + ab: (value: A) => B, +): B; + +export function command( + start: Definition, + na: (value: CommandZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, +): C; + +export function command( + start: Definition, + na: (value: CommandZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, +): D; + +export function command( + start: Definition, + na: (value: CommandZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, + de: (value: D) => E, +): E; + +export function command( + start: Definition, + na: (value: CommandZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, + de: (value: D) => E, + ef: (value: E) => F, +): F; + +export function command< + const N extends string, + A, + B, + C, + D, + E, + F, + G, +>( + start: Definition, + na: (value: CommandZero) => 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 command< + const N extends string, + A, + B, + C, + D, + E, + F, + G, + H, +>( + start: Definition, + na: (value: CommandZero) => 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 command< + const N extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + start: Definition, + na: (value: CommandZero) => 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 command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, +): J; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, +): K; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, +): L; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, +): M; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, +): N; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, +): O; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, +): P; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, +): Q; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, +): R; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, +): S; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, +): T; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, +): U; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, +): V; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, +): W; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, +): X; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, +): Y; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, +): Z; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, +): AA; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, +): AB; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, + AC, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, + abac: (value: AB) => AC, +): AC; + +export function command< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, + AC, + AD, +>( + start: Definition, + na: (value: CommandZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, + abac: (value: AB) => AC, + acad: (value: AC) => AD, +): AD; + +export function command( + start: Definition, + ...elements: readonly ((value: never) => unknown)[] +): unknown { + return elements.reduce( + (value, element) => element(value as never), + route(start, executable()), + ); +} diff --git a/lib/commands.ts b/lib/commands.ts deleted file mode 100644 index 1880d95..0000000 --- a/lib/commands.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { - Command, - CommandInfo, - CommandsInfo, - HelpInfo, - Input, - ParseContext, - Parser, -} from "./types.ts"; -import { constant } from "./constant.ts"; -import { toEnvCase } from "./case.ts"; -import { format } from "./help.ts"; -import { helpOpt } from "./program.ts"; -import { createContext } from "./context.ts"; - -export type CommandEntry = Partial>; - -export type CommandEntries> = { - [K in keyof T & string]: Partial>; -}; - -export interface CommandParser> - extends Parser> { -} - -export interface CommandsParser> - extends Parser> { - default?: string; -} - -export function commands>( - map: CommandEntries, - opts?: { default?: string }, -): CommandsParser< - { [K in keyof T & string]: Command }[keyof T & string] -> { - let cmds: Record>> = {}; - - let parser = { - default: opts?.default, - parse(input: Input, ctx?: ParseContext) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): CommandsInfo> { - let withCmds = { ...ctx, commands: cmds }; - let args = ctx.args; - let matched = match(args, cmds, opts); - let infos: Record>> = {}; - for (let [name, cmd] of Object.entries(cmds)) { - let info = cmd.inspect(scope(name, withCmds)); - infos[name] = info; - } - - let help = { - progname: ctx.progname, - args: [] as HelpInfo["args"], - opts: [] as HelpInfo["opts"], - commands: Object.values(infos).map((info) => ({ - name: info.name, - description: info.description, - aliases: info.aliases, - })) as HelpInfo["commands"], - }; - - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - - if (!matched) { - return { - type: "commands", - parser, - result: { - ok: false, - error: new NoCommandMatchError(Object.keys(cmds)), - remainder, - }, - remainder, - commands: infos, - help, - } as unknown as CommandsInfo>; - } - - let [name, cmd, rest] = matched; - let inner = cmd.inspect(scope(name, { ...withCmds, args: rest })); - - return { - type: "commands", - parser, - result: inner.result, - remainder, - commands: infos, - help, - } as unknown as CommandsInfo>; - }, - help(input: Input = {}, ctx?: ParseContext): string { - let c = ctx ?? createContext(input); - let matched = match(c.args, cmds); - if (matched) { - let [name, cmd, rest] = matched; - return cmd.help( - { ...input, args: rest }, - scope(name, { ...c, commands: cmds, args: rest }), - ); - } - return format(parser.inspect(c)); - }, - } as CommandsParser>; - - for ( - let [name, entry] of Object.entries(map) as [string, CommandEntry][] - ) { - let p = typeof entry.parse === "function" - ? entry as Parser - : Object.assign(constant(true), entry); - cmds[name] = command(name, p) as CommandParser< - Command - >; - } - - return parser as unknown as CommandsParser< - { [K in keyof T & string]: Command }[keyof T & string] - >; -} - -export class NoCommandMatchError extends Error { - constructor(public available: string[]) { - super(`No command matched. Available: ${available.join(", ")}`); - this.name = "NoCommandMatchError"; - } -} - -// --- internal --- - -function match( - args: string[], - cmds: Record>>, - opts?: { default?: string }, -): [string, CommandParser>, string[]] | undefined { - if (args.length > 0) { - if (args[0] in cmds) return [args[0], cmds[args[0]], args.slice(1)]; - for (let [name, cmd] of Object.entries(cmds)) { - if (cmd.aliases?.includes(args[0])) return [name, cmd, args.slice(1)]; - } - } - if (opts?.default && cmds[opts.default]) { - return [opts.default, cmds[opts.default], args]; - } - return undefined; -} - -function command( - name: Name, - inner: Parser, -): CommandParser> { - let parser: CommandParser> = { - description: inner.description, - aliases: inner.aliases, - parse(input: Input, ctx?: ParseContext) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): CommandInfo> { - let cmdCtx = { - ...ctx, - progname: [...ctx.progname, name], - commands: ctx.commands, - }; - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - - if (ctx.args[0] === "--help" || ctx.args[0] === "-h") { - let config = inner.inspect({ ...cmdCtx, args: [] }); - let help = { - ...config.help, - progname: [...ctx.progname, name], - opts: [...config.help.opts, helpOpt], - }; - let text = format({ ...config, help }); - return { - type: "command", - parser, - result: { - ok: true as const, - value: { name, help: true as const, text } as Command, - remainder, - }, - name, - description: inner.description, - aliases: inner.aliases, - config, - commands: {}, - help, - remainder, - }; - } - - let config = inner.inspect(cmdCtx); - let result = config.result.ok - ? { - ok: true as const, - value: { name, config: config.result.value } as Command, - remainder: { ...remainder, args: config.result.remainder.args }, - } - : config.result; - - return { - type: "command", - parser, - result, - name, - description: inner.description, - aliases: inner.aliases, - config, - commands: {}, - help: { - progname: [...ctx.progname, name], - args: config.help.args, - opts: [...config.help.opts, helpOpt], - commands: config.help.commands, - }, - remainder, - }; - }, - help(input: Input = {}, ctx?: ParseContext): string { - return format(parser.inspect(ctx ?? createContext(input))); - }, - }; - return parser; -} - -function scope(name: string, ctx: ParseContext): ParseContext { - let prefix = toEnvCase(name) + "_"; - let values = ctx.values.flatMap((v) => { - if (v.value == null) return []; - let obj = v.value as Record; - if (!(name in obj)) return []; - return [{ name: v.name, value: obj[name] }]; - }); - let envs = ctx.envs.map((env) => { - let scoped: Record = {}; - for (let [key, val] of Object.entries(env.value)) { - if (key.startsWith(prefix)) { - scoped[key.slice(prefix.length)] = val; - } else { - scoped[key] = val; - } - } - return { name: env.name, value: scoped }; - }); - return { ...ctx, values, envs }; -} diff --git a/lib/constant.ts b/lib/constant.ts deleted file mode 100644 index 910cb65..0000000 --- a/lib/constant.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ConstantInfo, ParseContext, Parser } from "./types.ts"; -import { createContext } from "./context.ts"; - -export function constant(value: T): Parser> { - let parser: Parser> = { - parse(input, ctx) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): ConstantInfo { - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - return { - type: "constant", - parser, - value, - result: { ok: true, value, remainder }, - remainder, - help: { progname: [], args: [], opts: [], commands: [] }, - }; - }, - help() { - return ""; - }, - }; - return parser; -} diff --git a/lib/context.ts b/lib/context.ts deleted file mode 100644 index e98f9fa..0000000 --- a/lib/context.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Input, ParseContext } from "./types.ts"; - -export function createContext(input: Input = {}): ParseContext { - return { - progname: [], - path: [], - commands: {}, - args: input.args ?? [], - values: input.values ?? [], - envs: input.envs ?? [], - }; -} diff --git a/lib/dasherize.ts b/lib/dasherize.ts new file mode 100644 index 0000000..a35f0c5 --- /dev/null +++ b/lib/dasherize.ts @@ -0,0 +1,7 @@ +export function dasherize(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(); +} diff --git a/lib/decode.ts b/lib/decode.ts new file mode 100644 index 0000000..c88f36b --- /dev/null +++ b/lib/decode.ts @@ -0,0 +1,16 @@ +export type Decoder = (value: string) => unknown[]; + +export const number: Decoder = (value) => { + if (!numeric.test(value)) { + return []; + } + + let decoded = Number(value); + return Number.isFinite(decoded) ? [decoded] : []; +}; + +export const scalar: Decoder = (value) => { + return [...number(value), value]; +}; + +const numeric = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/; diff --git a/lib/definition.ts b/lib/definition.ts new file mode 100644 index 0000000..b781db3 --- /dev/null +++ b/lib/definition.ts @@ -0,0 +1,14 @@ +import type { Definition } from "./types.ts"; + +export function name(name: N): Definition { + return { name }; +} + +export function description( + description: string, +): >(definition: D) => D { + return (definition) => ({ + ...definition, + description, + }); +} diff --git a/lib/field.ts b/lib/field.ts deleted file mode 100644 index e5ef8ff..0000000 --- a/lib/field.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; - -import { validate, ValidationError } from "./validate.ts"; -import type { Field, FieldInfo, ParseContext, Source } from "./types.ts"; -import { toEnvKey } from "./case.ts"; -import { isBoolean } from "./parse-args.ts"; -import { format } from "./help.ts"; -import { createContext } from "./context.ts"; - -export function field( - schema: StandardSchemaV1, - ...mod: Mod[] -): Field { - let mods = mod.reduce((all, current) => { - return current(all); - }, { - argument: false, - array: false, - } as Mods); - - let f: Field = { - required: !!validate(schema, undefined).issues, - schema, - argument: mods.argument, - array: mods.array, - default: mods.default, - boolean: isBoolean(schema), - parse(input, ctx) { - return f.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): FieldInfo { - let sources: Source[] = []; - - // none is always a source (lowest priority) - let none = validate(schema, undefined); - sources.push({ - sourceName: "none", - sourceType: "none", - value: (none.issues ? undefined : none.value) as T, - issues: none.issues, - }); - - // default comes after none but before actual input sources - if (f.default !== undefined) { - let { issues } = validate(schema, f.default); - sources.push({ - sourceName: "default", - sourceType: "default", - value: f.default as T, - issues, - }); - } - - // collect value sources - for (let value of ctx.values) { - let result = validate(schema, value.value); - sources.push({ - sourceName: value.name, - sourceType: "value", - value: (result.issues ? value.value : result.value) as T, - issues: result.issues, - }); - } - - // collect env sources - let envKey = toEnvKey(ctx.path); - for (let env of ctx.envs) { - let strval = env.value[envKey]; - if (strval === undefined) continue; - let value = parseEnvValue(f, strval); - let result = validate(schema, value); - sources.push({ - sourceName: env.name, - sourceType: "env", - value: (result.issues ? value : result.value) as T, - issues: result.issues, - }); - } - - // pick the best valid source (last one wins), fall back to last source - let winner = sources.findLast((s) => !s.issues) ?? - sources[sources.length - 1]; - - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - - let result = winner.issues - ? { - ok: false as const, - error: new ValidationError(sources), - remainder, - } - : { ok: true as const, value: winner.value as T, remainder }; - - // read description/aliases from the actual object (may be a spread copy) - let desc = (this as unknown as Partial>)?.description ?? - f.description; - let ali = (this as unknown as Partial>)?.aliases ?? f.aliases; - - let info: FieldInfo = { - type: "field", - parser: f, - result, - remainder, - path: ctx.path, - required: f.required, - argument: f.argument, - array: f.array, - aliases: ali, - description: desc, - default: f.default, - boolean: f.boolean, - source: winner, - sources, - help: { progname: [], args: [], opts: [], commands: [] }, - }; - if (f.argument) { - info.help.args.push(info); - } else { - info.help.opts.push(info); - } - return info; - }, - help(input, ctx) { - let info = f.inspect(ctx ?? createContext(input)); - return format(info, info.path.join(".")); - }, - }; - return f; -} - -// --- internal --- - -function parseEnvValue(field: Field, value: string): unknown { - if (isBoolean(field.schema)) { - return parseEnvBoolean(value); - } else if (field.array) { - return parseEnvArray(field, value); - } else if (!isNaN(Number(value))) { - return Number(value); - } else { - return value; - } -} - -function parseEnvBoolean(value: string) { - switch (value.toLowerCase().trim()) { - case "true": - case "yes": - case "1": - return true; - case "false": - case "no": - case "0": - return false; - default: - return value; - } -} - -function parseEnvArray(field: Field, value: string) { - return value.split(",").map((s) => s.trim()).map((s) => - parseEnvValue(field, s) - ); -} - -field.array = (): Mod => { - return (field) => ({ - ...field, - array: true, - }); -}; - -field.default = (value: T): Mod => { - return (mods) => ({ - ...mods, - default: value, - }); -}; - -export const cli: CLIMods = { - argument(): Mod { - return (mods) => ({ - ...mods, - argument: true, - }); - }, -}; - -export interface CLIMods { - argument: () => Mod; -} - -export interface Mod { - (mods: Mods): Mods; -} - -interface Mods { - default?: unknown; - argument: boolean; - array: boolean; -} diff --git a/lib/help.ts b/lib/help.ts deleted file mode 100644 index e5f792d..0000000 --- a/lib/help.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { FieldInfo, ParserInfo } from "./types.ts"; -import { optionKey } from "./parse-args.ts"; -import { toEnvKey } from "./case.ts"; - -export function format(info: ParserInfo, name?: string): string { - let { progname, args, opts, commands } = info.help; - let sections: string[] = []; - - // usage line - let label = progname.length ? progname.join(" ") : name ?? ""; - let usage = [`Usage: ${label}`]; - if (commands.length > 0) usage.push(""); - if (opts.length > 0) usage.push("[OPTIONS]"); - for (let arg of args) { - usage.push(printArg(arg)); - } - sections.push(usage.join(" ")); - - // commands section - if (commands.length > 0) { - let lines = commands.map((cmd) => { - let label = cmd.name; - if (cmd.aliases && cmd.aliases.length > 0) { - label += ` (${cmd.aliases.join(", ")})`; - } - let desc = cmd.description ?? ""; - return ` ${label.padEnd(25)} ${desc}`; - }); - sections.push(["Commands:", ...lines].join("\n")); - } - - // arguments section - if (args.length > 0) { - let lines = args.map((arg) => { - let name = printArg(arg); - let desc = arg.description ?? ""; - let source = formatSource(arg); - let right = [desc, source].filter(Boolean).join(" "); - return ` ${name.padEnd(25)} ${right}`; - }); - sections.push(["Arguments:", ...lines].join("\n")); - } - - // options section - if (opts.length > 0) { - let lines = opts.map((opt) => { - let aliases = opt.aliases?.length ? opt.aliases.join(", ") + ", " : ""; - let key = optionKey(opt.path); - let value = opt.boolean ? "" : " " + printArg(opt).toUpperCase(); - let optStr = `${aliases}${key}${value}`; - let desc = opt.description ?? ""; - let source = formatSource(opt); - let right = [desc, source].filter(Boolean).join(" "); - return ` ${optStr.padEnd(25)} ${right}`; - }); - sections.push(["Options:", ...lines].join("\n")); - } - - return sections.join("\n\n"); -} - -// --- internal --- - -function printArg(info: FieldInfo): string { - let key = info.path.join("."); - if (info.array) { - return `<${key}>...`; - } - return info.required ? `<${key}>` : `[${key}]`; -} - -function formatSource(info: FieldInfo): string { - if (!info.source || info.source.issues) return ""; - let { sourceType, sourceName, value } = info.source; - switch (sourceType) { - case "env": { - let key = toEnvKey(info.path); - return `[env: ${key}=${value}]`; - } - case "value": - return `[${sourceName}: ${info.path.join(".")}=${value}]`; - case "default": - return `[default: ${info.default}]`; - case "none": - if (info.default !== undefined) { - return `[default: ${info.default}]`; - } - return ""; - default: - return `[${sourceType}: ${value}]`; - } -} diff --git a/lib/inject.ts b/lib/inject.ts deleted file mode 100644 index 2184016..0000000 --- a/lib/inject.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ParseContext, Parser, ParserInfo } from "./types.ts"; -import { createContext } from "./context.ts"; -import { format } from "./help.ts"; - -export function inject( - fn: (dep: D) => Parser, -): Parser<(dep: D) => Parser> { - let parser: Parser<(dep: D) => Parser> = { - parse(input, ctx) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): ParserInfo<(dep: D) => Parser> { - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - let resolve = (dep: D): Parser => { - let inner = fn(dep); - return { - ...inner, - parse(input, override) { - return inner.inspect( - override ?? { - ...ctx, - args: input?.args ?? ctx.args, - values: input?.values ?? ctx.values, - envs: input?.envs ?? ctx.envs, - }, - ).result; - }, - inspect(override) { - return inner.inspect(override); - }, - help(input, override) { - return inner.help({ - args: input?.args ?? ctx.args, - values: input?.values ?? ctx.values, - envs: input?.envs ?? ctx.envs, - }, override ?? ctx); - }, - }; - }; - return { - type: "inject", - parser, - result: { ok: true, value: resolve, remainder }, - remainder, - help: { progname: ctx.progname, args: [], opts: [], commands: [] }, - }; - }, - help(input, ctx) { - return format(parser.inspect(ctx ?? createContext(input))); - }, - }; - return parser; -} diff --git a/lib/lens.ts b/lib/lens.ts deleted file mode 100644 index 192b735..0000000 --- a/lib/lens.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const lens = { - set(path: string[], value: unknown, target: T): T { - if (path.length === 0) { - return value as T; - } - let [key, ...rest] = path; - - let current = (target ?? {} as T)[key as keyof T] ?? {}; - - return { - ...target, - [key]: lens.set(rest, value, current), - }; - }, -}; diff --git a/lib/maybe.ts b/lib/maybe.ts new file mode 100644 index 0000000..f384e57 --- /dev/null +++ b/lib/maybe.ts @@ -0,0 +1,22 @@ +export type Maybe = { + readonly exists: false; +} | { + readonly exists: true; + readonly value: T; +}; + +export function Just(): Maybe; +export function Just(value: T): Maybe; +export function Just(value?: T): Maybe { + if (typeof value === "undefined") { + return { exists: true } as Maybe; + } else { + return { exists: true, value }; + } +} + +export function Nothing(): Maybe { + return nothing as Maybe; +} + +const nothing = { exists: false }; diff --git a/lib/mod.ts b/lib/mod.ts deleted file mode 100644 index 2120e44..0000000 --- a/lib/mod.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./types.ts"; -export * from "./constant.ts"; -export * from "./field.ts"; -export * from "./object.ts"; -export * from "./commands.ts"; -export * from "./help.ts"; -export * from "./program.ts"; -export * from "./inject.ts"; -export * from "./context.ts"; diff --git a/lib/object.ts b/lib/object.ts deleted file mode 100644 index 21bba78..0000000 --- a/lib/object.ts +++ /dev/null @@ -1,195 +0,0 @@ -import type { - Field, - ObjectInfo, - ParseContext, - Parser, - ParserInfo, -} from "./types.ts"; -import { - matchAll, - parseArgument, - parseLongOption, - parseLongOptionEql, - parseNegativeSwitch, - parseSwitch, - primitive, -} from "./parse-args.ts"; -import { field } from "./field.ts"; -import { format } from "./help.ts"; -import { optionalBoolean } from "./schema.ts"; -import { createContext } from "./context.ts"; - -export type Attrs = { - [K in keyof T]: Partial>; -}; - -export function object( - attrs: Attrs, -): Parser> { - let entries = Object.entries(attrs).map(([key, value]) => { - let entry = value as Partial>; - let parser = typeof entry.parse === "function" - ? entry as Parser - : Object.assign(field(optionalBoolean), entry); - return [key as keyof T, parser] as [keyof T, Parser]; - }); - - let parser: Parser> = { - parse(input, ctx) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): ObjectInfo { - let { scoped, args } = scopeInput(entries, ctx); - let attrs: Record> = {}; - let value = {} as Record; - let errors: { path: string[]; error: Error }[] = []; - - for (let [key, child] of entries) { - let path = [...ctx.path, String(key)]; - let childCtx = { ...ctx, args, ...(scoped.get(key) ?? {}), path }; - let info = child.inspect(childCtx); - attrs[String(key)] = info; - if (info.result.ok) { - value[key] = info.result.value; - } else { - errors.push({ path, error: info.result.error }); - } - } - - let remainder = { args, values: ctx.values, envs: ctx.envs }; - let result = errors.length > 0 - ? { - ok: false as const, - error: new ObjectValidationError(errors), - remainder, - } - : { ok: true as const, value: value as T, remainder }; - - let help = { - progname: [] as string[], - args: [], - opts: [], - commands: [], - } as ObjectInfo["help"]; - for (let child of Object.values(attrs) as ParserInfo[]) { - help.args.push(...child.help.args); - help.opts.push(...child.help.opts); - help.commands.push(...child.help.commands); - } - - return { - type: "object", - parser, - result, - remainder, - attrs, - help, - } as ObjectInfo; - }, - help(input, ctx) { - return format(parser.inspect(ctx ?? createContext(input))); - }, - }; - - return parser; -} - -// --- internal --- - -function asField(parser: Parser): Field | undefined { - return "schema" in parser ? parser as Field : undefined; -} - -function scopeInput( - entries: [keyof V, Parser][], - ctx: ParseContext, -): { - scoped: Map< - keyof V, - { - values: { name: string; value: unknown }[]; - envs: { name: string; value: Record }[]; - } - >; - args: string[]; -} { - let cliValues = new Map(); - let args = ctx.args; - - if (args.length > 0) { - let matched = new Set(); - let prev: string[] | undefined; - while ( - args.length > 0 && (prev === undefined || args.length < prev.length) - ) { - prev = args; - for (let [key, parser] of entries) { - let f = asField(parser); - if (!f) continue; - if (matched.has(key) && !f.array) continue; - let path = [...ctx.path, String(key)]; - let matcher = matchAll( - parseSwitch(f.schema, f.aliases ?? []), - parseNegativeSwitch(f.schema), - parseLongOption(f.aliases ?? []), - parseLongOptionEql(), - parseArgument(f.argument), - ); - let match = matcher(args, path); - if (match.matched) { - let val = primitive(match.value); - if (f.array) { - let existing = cliValues.get(key); - if (existing && existing.length > 0) { - let last = existing[existing.length - 1]; - (last.value as unknown[]).push(val); - } else { - cliValues.set(key, [{ name: "cli", value: [val] }]); - } - } else { - let existing = cliValues.get(key) ?? []; - existing.push({ name: "cli", value: val }); - cliValues.set(key, existing); - } - matched.add(key); - args = match.remainder; - break; - } - } - } - } - - let scoped = new Map< - keyof V, - { - values: { name: string; value: unknown }[]; - envs: { name: string; value: Record }[]; - } - >(); - for (let [key] of entries) { - scoped.set(key, { - values: [ - ...ctx.values.flatMap((v) => { - if (v.value == null) return []; - let obj = v.value as Record; - if (!(String(key) in obj)) return []; - return [{ name: v.name, value: obj[String(key)] }]; - }), - ...(cliValues.get(key) ?? []), - ], - envs: ctx.envs, - }); - } - - return { scoped, args }; -} - -export class ObjectValidationError extends Error { - constructor(public fields: { path: string[]; error: Error }[]) { - let message = fields.map(({ path, error }) => { - return `${path.join(".")}: ${error.message}`; - }).join("\n"); - super(message); - this.name = "ObjectValidationError"; - } -} diff --git a/lib/option.ts b/lib/option.ts new file mode 100644 index 0000000..50e15b8 --- /dev/null +++ b/lib/option.ts @@ -0,0 +1,146 @@ +import { type Param, param } from "./param.ts"; +import { dasherize } from "./dasherize.ts"; +import { cli } from "./read.ts"; +import type { AnyRoute, Definition, Method, Route } from "./types.ts"; + +export function option( + named: Definition, +): Option; + +export function option( + named: Definition, + nt: (value: Param) => Param, +): Option; + +export function option( + named: Definition, + na: (value: Param) => A, + at: (value: A) => Param, +): Option; + +export function option( + named: Definition, + na: (value: Param) => A, + ab: (value: A) => B, + bt: (value: B) => Param, +): Option; + +export function option( + named: Definition, + na: (value: Param) => A, + ab: (value: A) => B, + bc: (value: B) => C, + ct: (value: C) => Param, +): Option; + +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, +): Option; + +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, +): Option; + +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, +): Option; + +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, +): Option; + +export function option< + const N extends string, + A, + B, + C, + D, + E, + F, + G, + H, + 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, + gh: (value: G) => H, + ht: (value: H) => Param, +): Option; + +export function option( + named: Definition, + ...elements: readonly ((value: never) => unknown)[] +): unknown { + const added = elements.reduce( + (value, element) => element(value as never), + param(named, cli([`--${dasherize(named.name)}`])), + ) as Param; + + return (route: AnyRoute) => ({ + ...route, + params: { + ...route.params, + [added.name]: added, + }, + }); +} + +type Option = < + const N extends string, + const M extends Method, + const T extends object, + const C extends readonly AnyRoute[], +>( + route: Route, +) => Route< + N, + M, + { + [P in keyof ({ [Q in K]: V } & T)]: ( + { [Q in K]: V } & T + )[P]; + }, + C +>; diff --git a/lib/param.ts b/lib/param.ts new file mode 100644 index 0000000..39c4723 --- /dev/null +++ b/lib/param.ts @@ -0,0 +1,169 @@ +import { type Decoder, scalar } from "./decode.ts"; +import type { ReadCLI } from "./read.ts"; +import type { AnyToken } from "./tokenize.ts"; +import type { Tokenizer } from "./tokenizer.ts"; +import type { Definition, Schema } from "./types.ts"; + +export interface Param extends Definition { + schema: Schema; + cli: ReadCLI; + decode: Decoder; +} + +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, +>( + 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 = { + ...start, + schema: unknown, + cli: (tokens: Tokenizer) => ({ + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + claim: { + tokens: [], + rest: tokens, + }, + }), + decode: scalar, + }; + return elements.reduce( + (value, element) => element(value as never), + zero, + ); +} + +export function schema( + schema: Schema, +): >(param: P) => Param { + return (param) => ({ + ...param, + schema, + }); +} + +const unknown: Schema = { + "~standard": { + version: 1, + vendor: "configliere", + validate: (value) => ({ value }), + }, +}; diff --git a/lib/parse-args.ts b/lib/parse-args.ts deleted file mode 100644 index c50d1f8..0000000 --- a/lib/parse-args.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { validate } from "./validate.ts"; -import { toOptionPath } from "./case.ts"; - -export type MatchResult = { - matched: true; - value: string | boolean; - remainder: string[]; -} | { - matched: false; -}; - -export interface Matcher { - (input: string[], path: string[]): MatchResult; -} - -export function matchAll(...matchers: Matcher[]): Matcher { - return (input, path) => { - for (let matcher of matchers) { - let result = matcher(input, path); - if (result.matched) { - return result; - } - } - return { matched: false }; - }; -} - -export function parseLongOption(aliases: string[]): Matcher { - return (input, path) => { - let [option, value, ...remainder] = input; - - if (option === optionKey(path) || aliases.includes(option)) { - return { matched: true, remainder, value }; - } else { - return { matched: false }; - } - }; -} - -export function parseLongOptionEql(): Matcher { - return (input, path) => { - let [first, ...remainder] = input; - let prefix = `${optionKey(path)}=`; - if (first.startsWith(prefix)) { - return { matched: true, remainder, value: first.replace(prefix, "") }; - } else { - return { matched: false }; - } - }; -} - -export function parseSwitch( - schema: StandardSchemaV1, - aliases: string[], -): Matcher { - return (input, path) => { - let [head, ...remainder] = input; - if ( - isBoolean(schema) && (head === optionKey(path) || aliases.includes(head)) - ) { - return { matched: true, value: true, remainder }; - } else { - return { matched: false }; - } - }; -} - -export function parseNegativeSwitch(schema: StandardSchemaV1): Matcher { - return (input, path) => { - let [head, ...remainder] = input; - if (isBoolean(schema) && head === negativeSwitchKey(path)) { - return { matched: true, value: false, remainder }; - } else { - return { matched: false }; - } - }; -} - -export function parseArgument(isArgument: boolean): Matcher { - return (input) => { - let [value, ...remainder] = input; - if (isArgument && !value.startsWith("-")) { - return { matched: true, value, remainder }; - } else { - return { matched: false }; - } - }; -} - -export function optionKey(path: string[]): string { - return `--${toOptionPath(path)}`; -} - -function negativeSwitchKey(path: string[]): string { - return `--no-${toOptionPath(path)}`; -} - -export function primitive(val: string | boolean): string | number | boolean { - if (typeof val === "boolean") { - return val; - } - let numval = Number(val); - if (typeof numval === "number" && !isNaN(numval)) { - return numval; - } else { - return val; - } -} - -export function isBoolean>( - schema: S, -): boolean { - return !validate(schema, false).issues && !validate(schema, true).issues; -} diff --git a/lib/parse.ts b/lib/parse.ts new file mode 100644 index 0000000..0e5a7c6 --- /dev/null +++ b/lib/parse.ts @@ -0,0 +1,198 @@ +import { bind } from "./bind.ts"; +import { + type AnyToken, + type Flag, + type Literal, + type Setter, + tokenize, + type Word, +} from "./tokenize.ts"; +import { Tokenizer } from "./tokenizer.ts"; +import type { + AnyIntent, + AnyRoute, + Input, + IntentsOf, + Issue, + Method, + Outcome, + Path, + RoutePath, +} from "./types.ts"; + +export function parse( + route: R, + input: Input, +): Outcome>; + +export function parse( + route: AnyRoute, + input: Input, +): Outcome { + let tokenizer = new Tokenizer(tokenize(input.argv)); + let help = tokenizer.claimAll(flags("-h", "--help")); + let version = help.rest.claimAll(flags("-v", "--version")); + let escape = version.rest.claimAll((t) => t.type === "separator"); + let literals = escape.rest.claimAll((t) => t.type === "literal"); + + let method: Method = "execute"; + + if (help.tokens.length > 0) { + method = "help"; + } else if (version.tokens.length > 0) { + method = "version"; + } + + let segments = search({ route, tokenizer: literals.rest, path: [] }); + let [match] = segments; + + if (match.route.methods.includes(method)) { + if (method !== "execute") { + return { + ok: true, + method: method, + route: match.id, + definition: match.route, + path: match.path, + literals: literals.tokens as Iterable, + }; + } + + let models: Record = {}; + let issues: Issue[] = []; + let valid = true; + + for (let segment of segments) { + let result = model(segment); + let path = segment.path.length === 0 ? "/" : `/${segment.path.join("/")}`; + + models[path] = result.config; + issues.push(...result.issues); + valid &&= result.valid; + } + + if (!valid) { + return { + ok: false, + code: "unprocessable-content", + route: match.id, + definition: match.route, + path: match.path, + issues, + }; + } + + return { + ok: true, + method, + route: match.id, + model: models[match.id], + models: models, + definition: match.route, + path: match.path, + literals: literals.tokens, + issues, + }; + } else { + return { + ok: false, + code: "method-not-allowed", + route: match.id, + definition: match.route, + path: match.path, + method: method, + allowed: match.route.methods, + }; + } +} + +function flags(...texts: string[]): (token: AnyToken) => boolean { + return (token) => token.type === "flag" && texts.includes(token.text); +} + +interface SearchOptions { + route: AnyRoute; + tokenizer: Tokenizer; + path: Path; +} + +interface Segment { + route: AnyRoute; + path: Path; + id: RoutePath; + tokens: Array; +} + +function search(options: SearchOptions): [Segment, ...Segment[]] { + const { route, tokenizer } = options; + const segment: Segment = { + route, + id: `/${options.path.join("/")}`, + path: options.path, + tokens: [], + }; + + let claim = tokenizer.claimNext(); + + while (claim.tokens.length > 0) { + let [token] = claim.tokens; + if (token.type === "flag" || token.type === "setter") { + segment.tokens.push(token); + } else if (token.type === "word") { + const { text } = token; + const child = route.children.find((child) => child.name === text); + if (child) { + return [ + ...search({ + route: child, + tokenizer: claim.rest, + path: options.path.concat(child.name), + }), + segment, + ]; + } else { + segment.tokens.push(token); + } + } else { + throw new TypeError(`unexpected token ${JSON.stringify(token)}`); + } + + claim = claim.rest.claimNext(); + } + return [segment]; +} + +interface Model { + config: Record; + issues: Issue[]; + valid: boolean; +} + +function model(segment: Segment): Model { + let tokens = new Tokenizer(segment.tokens); + let config: Record = {}; + let issues: Issue[] = []; + let valid = true; + + for (let param of Object.values(segment.route.params)) { + let binding = bind({ param, tokens }); + + tokens = binding.rest; + issues.push(...binding.result.issues); + + if (!binding.result.ok) { + valid = false; + } else { + config[param.name] = binding.result.value; + } + } + + for (let token of tokens) { + valid = false; + issues.push({ + message: `unexpected ${JSON.stringify(token.text)}`, + }); + } + + return { config, issues, valid }; +} diff --git a/lib/print.ts b/lib/print.ts new file mode 100644 index 0000000..8f45f94 --- /dev/null +++ b/lib/print.ts @@ -0,0 +1,156 @@ +/** + * 😵‍💫 This file has been vibe coded 😵‍💫 + */ + +import type { Param } from "./param.ts"; +import type { Flag } from "./tokenize.ts"; +import { Tokenizer } from "./tokenizer.ts"; +import type { AnyRoute, Help, RoutePath, Version } from "./types.ts"; + +export function printHelp< + const H extends Help, +>(intent: H): string { + let route = intent.definition; + let subject = title(intent); + let heading = route.version ? `${subject} ${route.version}` : subject; + let usage = `${subject} [OPTIONS]`; + + if (route.children.length > 0) { + usage += route.methods.includes("execute") ? " [COMMAND]" : " "; + } + + let lines = [heading]; + if (route.description) { + lines.push(...wrap(route.description, width)); + } + + lines.push("", "Usage:", ` ${usage}`); + + if (route.children.length > 0) { + lines.push( + "", + "Commands:", + ...list(route.children.map((child) => [child.name, child.description])), + ); + } + + let options: Row[] = Object.values(route.params).map((param) => [ + label(param), + param.description, + ]); + + options.push(["-h, --help", "Print help"]); + if (route.methods.includes("version")) { + options.push(["-v, --version", "Print version"]); + } + + lines.push("", "Options:", ...list(options)); + + return lines.join("\n"); +} + +export function printVersion< + const V extends Version, +>(intent: V): string { + let version = intent.definition.version; + if (!version) { + throw new TypeError(`route ${JSON.stringify(intent.route)} has no version`); + } + + return `${title(intent)} ${version}`; +} + +type Row = readonly [label: string, description?: string]; + +function title(intent: { + readonly path: readonly string[]; + readonly definition: AnyRoute; +}): string { + return intent.path.length > 0 + ? intent.path.join(" ") + : intent.definition.name; +} + +function list(rows: readonly Row[]): string[] { + let size = Math.max(...rows.map(([label]) => label.length)); + let available = Math.max(24, width - size - 4); + let lines: string[] = []; + + for (let [label, description] of rows) { + let prefix = ` ${label.padEnd(size)}`; + if (!description) { + lines.push(prefix.trimEnd()); + continue; + } + + let [first, ...rest] = wrap(description, available); + lines.push(`${prefix} ${first}`); + lines.push(...rest.map((line) => `${" ".repeat(size + 4)}${line}`)); + } + + return lines; +} + +function wrap(text: string, size: number): string[] { + let words = text.trim().split(/\s+/); + let lines: string[] = []; + let line = ""; + + for (let word of words) { + if (line.length === 0) { + line = word; + } else if (line.length + word.length + 1 <= size) { + line += ` ${word}`; + } else { + lines.push(line); + line = word; + } + } + + if (line) { + lines.push(line); + } + + 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(); +} + +const width = 80; diff --git a/lib/program.ts b/lib/program.ts deleted file mode 100644 index 4f2f2cf..0000000 --- a/lib/program.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { - ConfigType, - FieldInfo, - Input, - ParseContext, - Parser, - ParserInfo, -} from "./types.ts"; -import { format } from "./help.ts"; -import { createContext } from "./context.ts"; - -export interface Program { - help?: boolean; - version?: string; - config: T; -} - -export type ProgramType

>> = - ConfigType

extends Program ? T : never; - -export interface ProgramInfo extends ParserInfo> { - type: "program"; - name: string; - version?: string; - main: ParserInfo; -} - -export function program( - opts: { - name: string; - version?: string; - config: Parser; - }, -): Parser, ProgramInfo> { - let { name, version, config } = opts; - - let parser = { - parse(input: Input, ctx?: ParseContext) { - return parser.inspect(ctx ?? createContext(input)).result; - }, - inspect(ctx: ParseContext): ProgramInfo { - let args = ctx.args; - let help = false; - let ver: string | undefined; - - if (args[0] === "--help" || args[0] === "-h") { - help = true; - args = args.slice(1); - } else if (version && (args[0] === "--version" || args[0] === "-v")) { - ver = version; - args = args.slice(1); - } - - let rootCtx = { ...ctx, progname: [name], args }; - let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs }; - let main = config.inspect(rootCtx); - - let value: Program = { - ...(help ? { help: true } : {}), - ...(ver ? { version: ver } : {}), - config: main.result.ok ? main.result.value : undefined as T, - }; - - let result = (main.result.ok || help || ver) - ? { - ok: true as const, - value, - remainder: main.result.ok ? main.result.remainder : remainder, - } - : main.result; - - return { - type: "program", - parser, - result, - remainder, - name, - version, - main, - help: { - progname: [name], - args: main.help.args, - opts: [ - ...main.help.opts, - ...preamble(version), - ], - commands: main.help.commands, - }, - }; - }, - help(input: Input = {}, ctx?: ParseContext): string { - return format(parser.inspect(ctx ?? createContext(input))); - }, - } as Parser, ProgramInfo>; - - return parser; -} - -// --- internal --- - -export const helpOpt = { - path: ["help"], - aliases: ["-h"], - boolean: true, - description: "show help", -} as FieldInfo; - -function preamble(version?: string): FieldInfo[] { - if (version) { - return [ - helpOpt, - { - path: ["version"], - aliases: ["-v"], - boolean: true, - description: "show version", - } as FieldInfo, - ]; - } - return [helpOpt]; -} diff --git a/lib/read.ts b/lib/read.ts new file mode 100644 index 0000000..45ea77b --- /dev/null +++ b/lib/read.ts @@ -0,0 +1,114 @@ +import type { Maybe } from "./maybe.ts"; +import type { Param } from "./param.ts"; +import type { Result } from "./result.ts"; +import type { Flag, Setter, Word } from "./tokenize.ts"; +import type { Claim, Tokenizer } from "./tokenizer.ts"; + +export type Symbol = Flag | Setter | Word; + +export type ReadCLI = ( + tokens: Tokenizer, +) => CLIRead; + +export interface CLIRead { + result: Result>; + claim: Claim; +} + +export interface CLIOptions { + switch?: true; +} + +export function cli( + names: string[], + options: CLIOptions = {}, +):

>(param: P) => P { + const read: ReadCLI = (tokens) => { + if (options.switch) { + let s = tokens.claimOne((t): t is Flag => { + return t.type === "flag" && names.includes(t.text); + }); + let [flag] = s.tokens; + return flag + ? { + result: { + ok: true, + value: { exists: true, value: true }, + issues: [], + }, + claim: s, + } + : nothing(tokens); + } + let setter = tokens.claimOne((token): token is Setter => { + return (token.type === "setter" && names.includes(`--${token.nameText}`)); + }); + let [token] = setter.tokens; + if (token) { + return { + claim: setter, + result: { + ok: true, + value: { + exists: true, + value: token.valueText, + }, + issues: [], + }, + }; + } + let pair = tokens.claimPair((name, value) => { + return name.type === "flag" && names.includes(name.text) && + value.type === "word"; + }); + let [, value] = pair.tokens; + if (value) { + return { + claim: pair, + result: { + ok: true, + value: { + exists: true, + value: value.text, + }, + issues: [], + }, + }; + } + let bare = tokens.claimOne((t): t is Flag => { + return t.type === "flag" && names.includes(t.text); + }); + let [incomplete] = bare.tokens; + if (incomplete) { + return { + claim: bare, + result: { + ok: false, + issues: [{ + message: `${incomplete.text} requires a value`, + }], + }, + }; + } + return nothing(tokens); + }; + + return (param) => ({ + ...param, + cli: read, + }); +} + +function nothing(tokenizer: Tokenizer): CLIRead { + return { + claim: { + tokens: [], + rest: tokenizer, + }, + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + }; +} diff --git a/lib/remaining.ts b/lib/remaining.ts deleted file mode 100644 index d787f34..0000000 --- a/lib/remaining.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Input } from "./types.ts"; - -export function remaining(input?: Input): Input | undefined { - let remainder = { - ...input, - }; - - if (remainder.objects && remainder.objects.length === 0) { - delete remainder.objects; - } - - if (remainder.args && remainder.args.length === 0) { - delete remainder.args; - } - - if (remainder.envs && remainder.envs.length === 0) { - delete remainder.envs; - } - - return Object.keys(remainder).length === 0 ? void 0 : remainder; -} diff --git a/lib/result.ts b/lib/result.ts new file mode 100644 index 0000000..f1efddf --- /dev/null +++ b/lib/result.ts @@ -0,0 +1,10 @@ +import type { Issue } from "./types.ts"; + +export type Result = { + ok: true; + value: T; + issues: readonly Issue[]; +} | { + ok: false; + issues: readonly Issue[]; +}; diff --git a/lib/route.ts b/lib/route.ts new file mode 100644 index 0000000..a100bbb --- /dev/null +++ b/lib/route.ts @@ -0,0 +1,1165 @@ +// deno-lint-ignore-file ban-types +import type { + AnyRoute, + Definition, + Method, + MethodsOf, + ModelOf, + Route, +} from "./types.ts"; + +export type RouteZero = Route; + +export function route( + start: Definition, +): RouteZero; + +export function route( + start: Definition, + na: (value: RouteZero) => A, +): A; + +export function route( + start: Definition, + na: (value: RouteZero) => A, + ab: (value: A) => B, +): B; + +export function route( + start: Definition, + na: (value: RouteZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, +): C; + +export function route( + start: Definition, + na: (value: RouteZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, +): D; + +export function route( + start: Definition, + na: (value: RouteZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, + de: (value: D) => E, +): E; + +export function route( + start: Definition, + na: (value: RouteZero) => A, + ab: (value: A) => B, + bc: (value: B) => C, + cd: (value: C) => D, + de: (value: D) => E, + ef: (value: E) => F, +): F; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, +>( + start: Definition, + na: (value: RouteZero) => 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 route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, +>( + start: Definition, + na: (value: RouteZero) => 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 route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, +>( + start: Definition, + na: (value: RouteZero) => 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 route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, +): J; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, +): K; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, +): L; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, +): M; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, +): N; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, +): O; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, +): P; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, +): Q; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, +): R; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, +): S; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, +): T; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, +): U; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, +): V; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, +): W; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, +): X; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, +): Y; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, +): Z; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, +): AA; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, +): AB; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, + AC, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, + abac: (value: AB) => AC, +): AC; + +export function route< + const Name extends string, + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + V, + W, + X, + Y, + Z, + AA, + AB, + AC, + AD, +>( + start: Definition, + na: (value: RouteZero) => 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, + ij: (value: I) => J, + jk: (value: J) => K, + kl: (value: K) => L, + lm: (value: L) => M, + mn: (value: M) => N, + no: (value: N) => O, + op: (value: O) => P, + pq: (value: P) => Q, + qr: (value: Q) => R, + rs: (value: R) => S, + st: (value: S) => T, + tu: (value: T) => U, + uv: (value: U) => V, + vw: (value: V) => W, + wx: (value: W) => X, + xy: (value: X) => Y, + yz: (value: Y) => Z, + zaa: (value: Z) => AA, + aaab: (value: AA) => AB, + abac: (value: AB) => AC, + acad: (value: AC) => AD, +): AD; + +export function route( + start: Definition, + ...elements: readonly ((value: never) => unknown)[] +): unknown { + let zero: RouteZero = { + ...start, + methods: ["help"], + params: {}, + children: [], + }; + + return elements.reduce( + (value, element) => element(value as never), + zero, + ); +} + +export function version( + semver: string, +): < + const N extends string, + const M extends Method, + const T extends object, + const C extends readonly AnyRoute[], +>(route: Route) => Route { + return (route) => ({ + ...route, + methods: [...route.methods, "version"] as const, + version: semver, + }); +} + +export function executable(): < + const N extends string, + const M extends Method, + const T extends object, + const C extends readonly AnyRoute[], +>(route: Route) => Route { + return (route) => ({ + ...route, + methods: [...route.methods, "execute"] as const, + }); +} + +export function routes( + ...children: C +): ( + route: R, +) => Route< + R["name"], + MethodsOf, + ModelOf, + readonly [...R["children"], ...C] +> { + return (route: R) => { + type Output = Route< + R["name"], + MethodsOf, + ModelOf, + readonly [...R["children"], ...C] + >; + + return { + ...route, + children: [ + ...route.children, + ...children, + ] as unknown as Output["children"], + params: route.params as Output["params"], + }; + }; +} diff --git a/lib/schema.ts b/lib/schema.ts deleted file mode 100644 index 659ff18..0000000 --- a/lib/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; - -export const optionalBoolean: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "configliere", - validate(value): StandardSchemaV1.Result { - if (value === undefined || value === true || value === false) { - return { value }; - } - return { - issues: [{ message: `expected boolean, got ${typeof value}` }], - }; - }, - }, -}; - -export const string: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "configliere", - validate(value) { - if (typeof value === "string") return { value }; - return { issues: [{ message: "expected a command name" }] }; - }, - }, -}; diff --git a/lib/toggle.ts b/lib/toggle.ts new file mode 100644 index 0000000..621eec3 --- /dev/null +++ b/lib/toggle.ts @@ -0,0 +1,200 @@ +import { dasherize } from "./dasherize.ts"; +import { type Param, param, schema } from "./param.ts"; +import type { CLIRead, ReadCLI } from "./read.ts"; +import type { Flag } from "./tokenize.ts"; +import type { AnyRoute, Definition, Method, Route, Schema } from "./types.ts"; + +export function toggle( + named: Definition, +): Toggle; + +export function toggle( + named: Definition, + nt: (value: Param) => Param, +): Toggle; + +export function toggle( + named: Definition, + na: (value: Param) => A, + at: (value: A) => Param, +): Toggle; + +export function toggle( + named: Definition, + na: (value: Param) => A, + ab: (value: A) => B, + bt: (value: B) => Param, +): Toggle; + +export function toggle( + named: Definition, + na: (value: Param) => A, + ab: (value: A) => B, + bc: (value: B) => C, + ct: (value: C) => Param, +): Toggle; + +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, +): Toggle; + +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, +): Toggle; + +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, +): Toggle; + +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, +): Toggle; + +export function toggle< + const N extends string, + A, + B, + C, + D, + E, + F, + G, + H, + 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, + gh: (value: G) => H, + ht: (value: H) => Param, +): Toggle; + +export function toggle( + named: Definition, + ...elements: readonly ((value: never) => unknown)[] +): unknown { + const added = elements.reduce( + (value, element) => element(value as never), + param(named, binding(named.name), schema(bool)), + ) as Param; + + return (route: AnyRoute) => ({ + ...route, + params: { + ...route.params, + [added.name]: added, + }, + }); +} + +type Toggle = < + const N extends string, + const M extends Method, + const T extends object, + const C extends readonly AnyRoute[], +>( + route: Route, +) => Route< + N, + M, + { + [P in keyof ({ [Q in K]: V } & T)]: ( + { [Q in K]: V } & T + )[P]; + }, + C +>; + +function binding( + name: string, +):

>(param: P) => P { + const cli = reader(name); + return (param) => ({ ...param, cli }); +} + +function reader(name: string): ReadCLI { + const stem = dasherize(name); + const yes = `--${stem}`; + const no = `--no-${stem}`; + + return (tokens): CLIRead => { + let claim = tokens.claimOne((token): token is Flag => { + return token.type === "flag" && + (token.text === yes || token.text === no); + }); + let [flag] = claim.tokens; + + return flag + ? { + claim, + result: { + ok: true, + value: { exists: true, value: flag.text === yes }, + issues: [], + }, + } + : { + claim, + result: { + ok: true, + value: { exists: false }, + issues: [], + }, + }; + }; +} + +const bool: Schema = { + "~standard": { + version: 1, + vendor: "configliere", + validate(value) { + return typeof value === "undefined" + ? { value: false } + : typeof value === "boolean" + ? { value } + : { issues: [{ message: "expected boolean" }] }; + }, + }, +}; diff --git a/lib/tokenize.ts b/lib/tokenize.ts new file mode 100644 index 0000000..31b457c --- /dev/null +++ b/lib/tokenize.ts @@ -0,0 +1,74 @@ +export interface Token { + type: T; + index: number; + text: string; +} +export type AnyToken = + | Word + | Flag + | Setter + | Separator + | Literal; + +export interface Setter extends Token<"setter"> { + nameText: string; + valueText: string; +} + +export interface Flag extends Token<"flag"> { + flagText: string; + flagType: "short" | "long"; +} + +export type Word = Token<"word">; + +export interface Separator extends Token<"separator"> { + readonly text: "--"; +} + +export type Literal = Token<"literal">; + +export function tokenize(argv: string[]): readonly AnyToken[] { + let quote = false; + let tokens: AnyToken[] = []; + + for (let i = 0; i < argv.length; i++) { + let index = i; + let text = argv[i]; + if (quote) { + tokens.push({ type: "literal", index, text }); + continue; + } + if (text === "--") { + tokens.push({ type: "separator", index, text }); + quote = true; + continue; + } + let matchSetter = setterMatch.exec(text); + if (matchSetter?.groups) { + let { nameText, valueText } = matchSetter.groups; + tokens.push({ type: "setter", index, text, nameText, valueText }); + continue; + } + let matchFlag = flagMatch.exec(text); + if (matchFlag?.groups) { + let { prefix, flagText } = matchFlag.groups; + tokens.push({ + type: "flag", + index, + text, + flagText, + flagType: prefix === "-" ? "short" : "long", + }); + continue; + } + tokens.push({ type: "word", index, text }); + } + return tokens; +} + +// deno-lint-ignore no-invalid-regexp +const flagMatch = /^(?--?)(?[^-=\s][^=\s]*)$/; + +// deno-lint-ignore no-invalid-regexp +const setterMatch = /^--(?[^-=\s][^=\s]*)=(?[\s\S]*)$/; diff --git a/lib/tokenizer.ts b/lib/tokenizer.ts new file mode 100644 index 0000000..1f21ee3 --- /dev/null +++ b/lib/tokenizer.ts @@ -0,0 +1,77 @@ +import type { AnyToken } from "./tokenize.ts"; + +export class Tokenizer implements Iterable { + tokens: Iterable; + claimed: Set; + + constructor( + tokens: typeof this.tokens, + claimed: typeof this.claimed = new Set(), + ) { + this.tokens = tokens; + this.claimed = claimed; + } + + claimNext(): Claim { + return this.claimOne(() => true); + } + + claimOne(match: (token: T) => token is S): Claim; + claimOne(match: (token: T) => boolean): Claim; + claimOne(match: (token: T) => boolean): Claim { + for (let token of this) { + if (match(token)) { + return { + tokens: [token], + rest: new Tokenizer(this, new Set([token.index])), + }; + } + } + return { tokens: [], rest: this }; + } + + claimPair(match: (a: T, b: T) => boolean): Claim { + let previous: T | undefined; + for (let token of this) { + if (!previous) { + previous = token; + continue; + } + if (match(previous, token)) { + return { + tokens: [previous, token], + rest: new Tokenizer(this, new Set([previous.index, token.index])), + }; + } + } + return { tokens: [], rest: this }; + } + + claimAll(match: (token: T) => token is S): Claim; + claimAll(match: (token: T) => boolean): Claim; + claimAll(match: (token: T) => boolean): Claim { + let tokens: T[] = []; + let claims = new Set(); + for (let token of this) { + if (match(token)) { + claims.add(token.index); + tokens.push(token); + } + } + let rest = new Tokenizer(this, claims); + return { tokens, rest }; + } + + *[Symbol.iterator](): Generator { + for (let token of this.tokens) { + if (!this.claimed.has(token.index)) { + yield token; + } + } + } +} + +export interface Claim { + tokens: T[]; + rest: Tokenizer; +} diff --git a/lib/types.ts b/lib/types.ts index 80b1a05..b35ea71 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,135 +1,179 @@ +// deno-lint-ignore-file ban-types import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { Literal } from "./tokenize.ts"; +import type { Param } from "./param.ts"; -export type ParseResult = Done | Fail; +export type Issue = StandardSchemaV1.Issue; +export type Schema = StandardSchemaV1; -export interface Done { - ok: true; - value: T; - remainder: Input; +export interface Definition { + readonly name: N; + readonly description?: string; } -export interface Fail { - ok: false; - error: Error; - remainder: Input; +export interface Route< + N extends string, + M extends Method, + T extends object, + C extends readonly AnyRoute[], +> extends Definition { + readonly methods: readonly M[]; + readonly version?: string; + readonly params: { + [K in keyof T]: K extends string ? Param : never; + }; + readonly children: C; } -export interface ParseContext { - progname: string[]; - path: string[]; - commands: Record>>; - args: string[]; - values: { name: string; value: unknown }[]; - envs: { name: string; value: Record }[]; -} +export type ModelOf = R extends + Route ? T + : never; -export interface Parser = ParserInfo> { - description?: string; - aliases?: string[]; - parse(input?: Input, ctx?: ParseContext): ParseResult; - inspect(ctx: ParseContext): Info; - help(input?: Input, ctx?: ParseContext): string; +export interface AnyRoute extends Definition { + readonly methods: readonly Method[]; + readonly version?: string; + readonly params: Readonly>>; + readonly children: readonly AnyRoute[]; } -export type ConfigType

> = P extends Parser - ? T - : never; +export type Method = "help" | "version" | "execute"; +export type MethodsOf = R["methods"][number]; -export type CommandsType

>> = - ConfigType

; +export type Path = readonly string[]; -export type CommandType< - P extends Parser>, - N extends ConfigType

["name"], -> = Extract, { name: N }> extends infer C - ? { [K in keyof C]: C[K] } - : never; +export type Input = { + argv: string[]; +}; -export interface Input { - values?: { - name: string; - value: unknown; - }[]; - envs?: { - name: string; - value: Record; - }[]; - args?: string[]; +export interface Failure { + readonly ok: false; + readonly code: C; } -export interface HelpInfo { - progname: string[]; - args: FieldInfo[]; - opts: FieldInfo[]; - commands: CommandInfo>[]; -} +export type Outcome = T | MethodNotAllowed | UnprocessableContent; -export interface ParserInfo { - type: string; - parser: Parser; - result: ParseResult; - remainder: Input; - help: HelpInfo; -} +export type IntentsOf = IntentsAt; -export interface FieldInfo extends ParserInfo { - type: "field"; - path: string[]; - required: boolean; - argument: boolean; - array: boolean; - aliases?: string[]; - description?: string; - default?: unknown; - boolean: boolean; - source: Source; - sources: Source[]; -} +export type RoutePath = `/${string}`; -export interface CommandInfo> - extends ParserInfo { - type: "command"; - name: T["name"]; - description?: string; - aliases?: string[]; - config: ParserInfo; - commands: Record>>; -} +export type ModelsByRoute = { + readonly [path: RoutePath]: object; +}; -export interface ObjectInfo extends ParserInfo { - type: "object"; - attrs: { - [K in keyof T]: ParserInfo; - }; +export type PathOf = R extends "/" ? [] + : R extends `/${infer Rest}` ? Split + : never; + +export type AnyIntent = + | Help + | Version + | Execute; + +export type ChildIntents< + C extends readonly AnyRoute[], + P extends RoutePath, + T extends ModelsByRoute, +> = C extends readonly [ + infer Head extends AnyRoute, + ...infer Tail extends readonly AnyRoute[], +] ? ( + | IntentsAt, T> + | 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 path: PathOf

; + readonly literals: Iterable; } -export interface ConstantInfo extends ParserInfo { - type: "constant"; - value: T; +export type Help = Intent< + "help", + R, + P +>; + +export type Version = Intent< + "version", + R, + P +>; + +export interface Execute< + R extends AnyRoute, + P extends RoutePath, + Models extends ModelsByRoute, +> extends Intent<"execute", R, P> { + readonly issues: readonly Issue[]; + readonly model: Models[P]; + readonly models: Models; } -export interface CommandsInfo> - extends ParserInfo { - type: "commands"; - commands: { [C in T as C["name"]]: CommandInfo }; +export type Status = + | "method-not-allowed" + | "unprocessable-content"; + +export interface MethodNotAllowed extends Failure<"method-not-allowed"> { + readonly route: string; + readonly definition: AnyRoute; + readonly path: Path; + readonly method: Method; + readonly allowed: readonly Method[]; } -export type Command = - | { name: Name; help: true; text: string } - | { name: Name; help?: false; config: T }; - -export interface Field extends Parser> { - schema: StandardSchemaV1; - required: boolean; - argument: boolean; - array: boolean; - boolean: boolean; - default?: unknown; +export interface UnprocessableContent extends Failure<"unprocessable-content"> { + readonly route: string; + readonly definition: AnyRoute; + readonly path: Path; + readonly issues: Issue[]; } -export type Source = { - issues?: readonly StandardSchemaV1.Issue[]; - value: T; - sourceType: string; - sourceName: string; +type AddModel< + M extends ModelsByRoute, + P extends RoutePath, + T extends object, +> = { + [K in keyof M | P]: K extends P ? T : K extends keyof M ? M[K] : never; }; + +type Split = string extends S ? Path + : S extends `${infer Head}/${infer Tail}` ? [Head, ...Split] + : S extends "" ? [] + : [S]; + +type Append< + A extends RoutePath, + N extends string, +> = A extends "/" ? `/${N}` + : `${A}/${N}`; + +type IntentsAt< + R extends AnyRoute, + P extends RoutePath, + Models extends ModelsByRoute, +> = + | RouteIntents>> + | ChildIntents>>; + +type RouteIntents< + R extends AnyRoute, + P extends RoutePath, + T extends ModelsByRoute, +> = + | Help + | ( + "version" extends MethodsOf ? Version + : never + ) + | ( + "execute" extends MethodsOf ? Execute + : never + ); diff --git a/lib/validate.ts b/lib/validate.ts deleted file mode 100644 index fd7eab2..0000000 --- a/lib/validate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { Source } from "./types.ts"; - -export function validate( - schema: StandardSchemaV1, - value: unknown, -): StandardSchemaV1.Result { - let validation = schema["~standard"].validate(value); - if (validation instanceof Promise) { - throw new Error(`async validation is not supported`); - } - return validation; -} - -export class ValidationError extends Error { - constructor(public sources: Source[]) { - super( - sources.flatMap((source) => { - if (source.issues) { - return [source.issues.map((i) => i.message)]; - } else { - return []; - } - }).join("\n"), - ); - this.name = "ValidationError"; - } -} diff --git a/mod.ts b/mod.ts index 945209d..69984ee 100644 --- a/mod.ts +++ b/mod.ts @@ -1 +1,53 @@ -export * from "./lib/mod.ts"; +export { command } from "./lib/command.ts"; +export type { CommandZero } from "./lib/command.ts"; + +export { description, name } from "./lib/definition.ts"; + +export { option } from "./lib/option.ts"; + +export { param, schema } from "./lib/param.ts"; +export type { Param } from "./lib/param.ts"; + +export { parse } from "./lib/parse.ts"; + +export { printHelp, printVersion } from "./lib/print.ts"; + +export { cli } from "./lib/read.ts"; +export type { + CLIOptions, + CLIRead, + ReadCLI, + Symbol as CLISymbol, +} from "./lib/read.ts"; + +export { executable, route, routes, version } from "./lib/route.ts"; +export type { RouteZero } from "./lib/route.ts"; + +export { toggle } from "./lib/toggle.ts"; + +export type { Literal } from "./lib/tokenize.ts"; + +export type { + AnyIntent, + AnyRoute, + Definition, + Execute, + Help, + Input, + Intent, + IntentsOf, + Issue, + Method, + MethodNotAllowed, + MethodsOf, + ModelOf, + ModelsByRoute, + Outcome, + Path, + PathOf, + Route, + RoutePath, + Schema, + UnprocessableContent, + Version, +} from "./lib/types.ts"; diff --git a/tasks/build-jsr.ts b/tasks/build-jsr.ts index 387d31d..35fea44 100644 --- a/tasks/build-jsr.ts +++ b/tasks/build-jsr.ts @@ -2,7 +2,7 @@ import jsonDeno from "../deno.json" with { type: "json" }; let [version] = Deno.args; if (!version) { - throw new Error("a version argument is required to build the npm package"); + throw new Error("a version argument is required to build the JSR package"); } await Deno.writeTextFile( diff --git a/tasks/build-npm.ts b/tasks/build-npm.ts index f5111ce..3e3d0fb 100644 --- a/tasks/build-npm.ts +++ b/tasks/build-npm.ts @@ -26,8 +26,7 @@ await build({ // package.json properties name: "configliere", version, - description: - "Smart, FP configuration parser that validates all program inputs ahead of time, including config files, environment variables, and command line options using a single schema", + description: "Configuration for programs", license: "MIT", author: "engineering@frontside.com", repository: { diff --git a/test/bind.test.ts b/test/bind.test.ts new file mode 100644 index 0000000..479c4cc --- /dev/null +++ b/test/bind.test.ts @@ -0,0 +1,103 @@ +// deno-lint-ignore-file no-import-prefix no-unversioned-import +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import * as z from "npm:zod"; +import { bind } from "../lib/bind.ts"; +import { name } from "../lib/definition.ts"; +import { param, schema } from "../lib/param.ts"; +import { cli, type Symbol } from "../lib/read.ts"; +import { tokenize } from "../lib/tokenize.ts"; +import { Tokenizer } from "../lib/tokenizer.ts"; + +describe("bind()", () => { + describe("absence", () => { + it("validates undefined after every source is absent", () => { + let { result } = bind({ + param: param(name("port"), schema(z.number())), + tokens: symbols([]), + }); + + expect(result).toMatchObject({ + ok: false, + issues: [{ path: ["port"] }], + }); + }); + + it("lets an optional schema accept an absent value", () => { + let { result } = bind({ + param: param(name("port"), schema(z.number().optional())), + tokens: symbols([]), + }); + + expect(result).toEqual({ + ok: true, + value: undefined, + issues: [], + }); + }); + + it("lets a defaulting schema produce a value from absence", () => { + let { result } = bind({ + param: param(name("port"), schema(z.number().default(9000))), + tokens: symbols([]), + }); + + expect(result).toEqual({ + ok: true, + value: 9000, + issues: [], + }); + }); + }); + + describe("candidates", () => { + it("tries a later decoding after an earlier one fails validation", () => { + let { result } = bind({ + param: param( + name("digits"), + cli(["--digits"]), + schema(z.string()), + ), + tokens: symbols(["--digits", "0012"]), + }); + + expect(result).toEqual({ + ok: true, + value: "0012", + issues: [], + }); + }); + }); + + describe("failed reads", () => { + it("continues from the remainder of a claimed invalid read", () => { + let { result, rest } = bind({ + param: param( + name("port"), + cli(["--port"]), + schema(z.number()), + ), + tokens: symbols(["--port", "--verbose"]), + }); + + expect(result).toMatchObject({ + ok: false, + issues: [{ message: "--port requires a value" }], + }); + expect(texts(rest)).toEqual(["--verbose"]); + }); + }); +}); + +function symbols(argv: string[]): Tokenizer { + let tokens = tokenize(argv).filter((token): token is Symbol => { + return token.type === "flag" || token.type === "setter" || + token.type === "word"; + }); + + return new Tokenizer(tokens); +} + +function texts(tokens: Iterable): string[] { + return Array.from(tokens, (token) => token.text); +} diff --git a/test/boolean.test.ts b/test/boolean.test.ts deleted file mode 100644 index 2e13840..0000000 --- a/test/boolean.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { type } from "arktype"; -import { field } from "../lib/field.ts"; -import { parseNotOk } from "./test-helpers.ts"; - -describe("boolean", () => { - it("is not considered a valid false value if it is undefined", () => { - parseNotOk(field(type("boolean")), {}); - }); -}); diff --git a/test/case.test.ts b/test/case.test.ts deleted file mode 100644 index 476a79f..0000000 --- a/test/case.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { - type EnvCase, - type KebabCase, - toEnvCase, - toKebabCase, -} from "../lib/case.ts"; - -type Equal = (() => T extends A ? 1 : 2) extends - (() => T extends B ? 1 : 2) ? true - : false; - -type Assert = T; - -/* - * type tests - */ -type _clientIdEnv = Assert, "CLIENT_ID">>; -type _clientIdKebab = Assert, "client-id">>; -type _apiResponseKebab = Assert< - Equal, "api-response"> ->; -type _httpServerEnv = Assert, "HTTP_SERVER">>; -type _clientIdToJwtKebab = Assert< - Equal, "client-id-to-jwt"> ->; -type _clientIdToJwtEnv = Assert< - Equal, "CLIENT_ID_TO_JWT"> ->; -type _clientIdWordToJwtEnv = Assert< - Equal, "CLIENT_ID_TO_JWT"> ->; -type _clientIdCamelToJwtKebab = Assert< - Equal, "client-id-to-jwt"> ->; -type _sslConfigKebab = Assert, "ssl-config">>; -type _xmlHttpRequestEnv = Assert< - Equal, "XML_HTTP_REQUEST"> ->; -type _user2FaEnabledKebab = Assert< - Equal, "user-2-fa-enabled"> ->; -type _reallyLongEnv = Assert< - Equal< - EnvCase<"reallyLongButPossiblyHelpful">, - "REALLY_LONG_BUT_POSSIBLY_HELPFUL" - > ->; - -/* - * runtime tests - */ -describe("case conversion", () => { - it("preserves acronym runs when converting to kebab-case", () => { - expect(toKebabCase("clientID")).toEqual("client-id"); - expect(toKebabCase("APIResponse")).toEqual("api-response"); - expect(toKebabCase("HTTPServer")).toEqual("http-server"); - expect(toKebabCase("clientIDToJWT")).toEqual("client-id-to-jwt"); - expect(toKebabCase("clientIDtoJWT")).toEqual("client-id-to-jwt"); - expect(toKebabCase("clientIdToJWT")).toEqual("client-id-to-jwt"); - expect(toKebabCase("SSLConfig")).toEqual("ssl-config"); - expect(toKebabCase("XMLHttpRequest")).toEqual("xml-http-request"); - expect(toKebabCase("user2FAEnabled")).toEqual("user-2-fa-enabled"); - expect(toKebabCase("reallyLongButPossiblyHelpful")).toEqual( - "really-long-but-possibly-helpful", - ); - }); - - it("preserves acronym runs when converting to env-case", () => { - expect(toEnvCase("clientID")).toEqual("CLIENT_ID"); - expect(toEnvCase("APIResponse")).toEqual("API_RESPONSE"); - expect(toEnvCase("HTTPServer")).toEqual("HTTP_SERVER"); - expect(toEnvCase("clientIDToJWT")).toEqual("CLIENT_ID_TO_JWT"); - expect(toEnvCase("clientIDtoJWT")).toEqual("CLIENT_ID_TO_JWT"); - expect(toEnvCase("clientIdToJWT")).toEqual("CLIENT_ID_TO_JWT"); - expect(toEnvCase("SSLConfig")).toEqual("SSL_CONFIG"); - expect(toEnvCase("XMLHttpRequest")).toEqual("XML_HTTP_REQUEST"); - expect(toEnvCase("user2FAEnabled")).toEqual("USER_2_FA_ENABLED"); - expect(toEnvCase("reallyLongButPossiblyHelpful")).toEqual( - "REALLY_LONG_BUT_POSSIBLY_HELPFUL", - ); - }); -}); diff --git a/test/command.test.ts b/test/command.test.ts new file mode 100644 index 0000000..2c8cbed --- /dev/null +++ b/test/command.test.ts @@ -0,0 +1,149 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { type } from "arktype"; +import { command, type CommandZero } from "../lib/command.ts"; +import { name } from "../lib/definition.ts"; +import { option } from "../lib/option.ts"; +import { schema } from "../lib/param.ts"; +import { route, routes, version } from "../lib/route.ts"; +import type { AnyRoute, Method, Route } from "../lib/types.ts"; + +describe("command()", () => { + it("creates an executable route without an explicit executable element", () => { + let result = command(name("simulacrum")); + + expect(result.methods).toEqual(["help", "execute"]); + expectType>(true); + expectType, "help" | "execute">>(true); + }); + + it("materializes a CommandZero before applying the first element", () => { + let result = command(name("simulacrum"), (zero) => { + expectType>>(true); + expect(zero).toEqual({ + name: "simulacrum", + methods: ["help", "execute"], + params: {}, + children: [], + }); + return zero; + }); + + expectType>>(true); + }); + + it("composes the same route elements from left to right", () => { + let auth0 = route(name("auth0")); + let result = command( + name("simulacrum"), + version("1.2.0"), + option(name("port"), schema(type("number"))), + option(name("domain"), schema(type("string"))), + routes(auth0), + ); + + expectType< + Equal, "help" | "execute" | "version"> + >(true); + expectType< + Equal, { port: number; domain: string }> + >(true); + expectType>(true); + }); + + it("infers the exact model across thirty route elements", () => { + let result = command( + name("simulacrum"), + option(name("p1"), schema(type("number"))), + option(name("p2"), schema(type("number"))), + option(name("p3"), schema(type("number"))), + option(name("p4"), schema(type("number"))), + option(name("p5"), schema(type("number"))), + option(name("p6"), schema(type("number"))), + option(name("p7"), schema(type("number"))), + option(name("p8"), schema(type("number"))), + option(name("p9"), schema(type("number"))), + option(name("p10"), schema(type("number"))), + option(name("p11"), schema(type("number"))), + option(name("p12"), schema(type("number"))), + option(name("p13"), schema(type("number"))), + option(name("p14"), schema(type("number"))), + option(name("p15"), schema(type("number"))), + option(name("p16"), schema(type("number"))), + option(name("p17"), schema(type("number"))), + option(name("p18"), schema(type("number"))), + option(name("p19"), schema(type("number"))), + option(name("p20"), schema(type("number"))), + option(name("p21"), schema(type("number"))), + option(name("p22"), schema(type("number"))), + option(name("p23"), schema(type("number"))), + option(name("p24"), schema(type("number"))), + option(name("p25"), schema(type("number"))), + option(name("p26"), schema(type("number"))), + option(name("p27"), schema(type("number"))), + option(name("p28"), schema(type("number"))), + option(name("p29"), schema(type("number"))), + option(name("p30"), schema(type("number"))), + ); + + expectType< + Equal, { + p1: number; + p2: number; + p3: number; + p4: number; + p5: number; + p6: number; + p7: number; + p8: number; + p9: number; + p10: number; + p11: number; + p12: number; + p13: number; + p14: number; + p15: number; + p16: number; + p17: number; + p18: number; + p19: number; + p20: number; + p21: number; + p22: number; + p23: number; + p24: number; + p25: number; + p26: number; + p27: number; + p28: number; + p29: number; + p30: number; + }> + >(true); + expect(result.params.p30.name).toBe("p30"); + }); + + it("does not mutate the definition used to start the command", () => { + let start = name("simulacrum"); + let result = command(start); + + expect(start).toEqual({ name: "simulacrum" }); + expect(result.methods).toEqual(["help", "execute"]); + }); +}); + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) + ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true + : false + : false; + +type Model = R extends + Route ? T + : never; + +type Methods = R["methods"][number]; + +function expectType(_value: T): void { + // Compile-time assertion. +} diff --git a/test/commands.test.ts b/test/commands.test.ts deleted file mode 100644 index ce6dea9..0000000 --- a/test/commands.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { type } from "arktype"; -import { cli, field } from "../lib/field.ts"; -import { object } from "../lib/object.ts"; -import { commands, NoCommandMatchError } from "../lib/commands.ts"; -import type { - Command, - CommandsInfo, - CommandsType, - ObjectInfo, -} from "../lib/types.ts"; -import { format } from "../lib/help.ts"; -import { parseNotOk, parseOk } from "./test-helpers.ts"; -import { createContext } from "../lib/context.ts"; - -describe("commands", () => { - let cli_ = commands({ - run: object({ - host: field(type("string"), cli.argument()), - port: field(type("number"), field.default(3000)), - }), - build: object({ - outDir: field(type("string"), field.default("dist")), - }), - }); - - describe("from cli", () => { - it("dispatches to the matching command", () => { - let value = parseOk(cli_, { args: ["run", "localhost"] }); - expect(value).toEqual({ - name: "run", - config: { host: "localhost", port: 3000 }, - }); - }); - - it("dispatches to another command", () => { - let value = parseOk(cli_, { args: ["build", "--out-dir", "output"] }); - expect(value).toEqual({ name: "build", config: { outDir: "output" } }); - }); - - it("fails when no command matches", () => { - let error = parseNotOk(cli_, { args: ["deploy"] }); - expect(error).toBeInstanceOf(NoCommandMatchError); - expect((error as NoCommandMatchError).available).toEqual([ - "run", - "build", - ]); - }); - }); - - describe("default command", () => { - let withDefault = commands({ - run: object({ - host: field(type("string"), field.default("localhost")), - port: field(type("number"), field.default(3000)), - }), - build: object({ - outDir: field(type("string"), field.default("dist")), - }), - }, { default: "run" }); - - it("uses the default when no command keyword matches", () => { - let value = parseOk(withDefault, { args: ["--port", "9090"] }); - expect(value).toEqual({ - name: "run", - config: { host: "localhost", port: 9090 }, - }); - }); - - it("still dispatches explicit commands normally", () => { - let value = parseOk(withDefault, { - args: ["build", "--out-dir", "output"], - }); - expect(value).toEqual({ name: "build", config: { outDir: "output" } }); - }); - }); - - describe("env vars", () => { - it("passes envs through to the matched command", () => { - let value = parseOk(cli_, { - args: ["run", "localhost"], - envs: [{ name: "env", value: { RUN_PORT: "4000" } }], - }); - expect(value).toEqual({ - name: "run", - config: { host: "localhost", port: 4000 }, - }); - }); - - it("uses acronym-safe env scoping for command names", () => { - let withAcronym = commands({ - syncJWT: object({ - clientID: field(type("string")), - }), - }); - - let value = parseOk(withAcronym, { - args: ["syncJWT"], - envs: [{ - name: "env", - value: { SYNC_JWT_CLIENT_ID: "abc123" }, - }], - }); - - expect(value).toEqual({ - name: "syncJWT", - config: { clientID: "abc123" }, - }); - }); - }); - - describe("aliases", () => { - let withAliases = commands({ - run: object({ - port: field(type("number"), field.default(3000)), - }), - info: { - aliases: ["i"], - ...object({}), - }, - }); - - it("dispatches to a command via its alias", () => { - let value = parseOk(withAliases, { args: ["i"] }); - expect(value).toEqual({ name: "info", config: {} }); - }); - - it("still dispatches by canonical name", () => { - let value = parseOk(withAliases, { args: ["info"] }); - expect(value).toEqual({ name: "info", config: {} }); - }); - - it("accepts metadata-only entries without a parser", () => { - let withMeta = commands({ - run: object({ - port: field(type("number"), field.default(3000)), - }), - info: { description: "Show info", aliases: ["i"] }, - }); - let value = parseOk(withMeta, { args: ["i"] }); - expect(value).toEqual({ name: "info", config: true }); - }); - }); - - describe("descriptions", () => { - let withDescs = commands({ - run: { - description: "Start the dev server", - ...object({ - port: field(type("number"), field.default(3000)), - }), - }, - build: { - description: "Build for production", - ...object({ - outDir: field(type("string"), field.default("dist")), - }), - }, - }); - - it("includes descriptions in help info", () => { - let info = withDescs.inspect(createContext()) as CommandsInfo< - Command - >; - expect(info.commands["run"].description).toBe("Start the dev server"); - expect(info.commands["build"].description).toBe("Build for production"); - }); - - it("renders descriptions in formatted help", () => { - let text = format(withDescs.inspect(createContext()), "myapp"); - expect(text).toMatch(/run\s+Start the dev server/); - expect(text).toMatch(/build\s+Build for production/); - }); - }); - - describe("aliases in help", () => { - let withAliases = commands({ - run: object({}), - info: { - description: "Show information", - aliases: ["i"], - ...object({}), - }, - }); - - it("includes aliases in help info", () => { - let info = withAliases.inspect(createContext()) as CommandsInfo< - Command - >; - expect(info.commands["info"].aliases).toEqual(["i"]); - }); - - it("renders aliases in formatted help", () => { - let text = format(withAliases.inspect(createContext()), "myapp"); - expect(text).toMatch(/info \(i\)\s+Show information/); - }); - }); - - describe("help", () => { - it("lists all commands in help output", () => { - let info = cli_.inspect(createContext()) as CommandsInfo< - Command - >; - expect(Object.keys(info.commands)).toHaveLength(2); - expect(info.commands["run"].name).toBe("run"); - expect(info.commands["build"].name).toBe("build"); - }); - - it("includes each command's config", () => { - let info = cli_.inspect(createContext()) as CommandsInfo< - Command - >; - let runAttrs = (info.commands["run"].config as ObjectInfo< - { host: string; port: number } - >).attrs; - expect(Object.keys(runAttrs)).toContain("host"); - expect(Object.keys(runAttrs)).toContain("port"); - - let buildAttrs = - (info.commands["build"].config as ObjectInfo<{ outDir: string }>).attrs; - expect(Object.keys(buildAttrs)).toContain("outDir"); - }); - - it("shows commands in formatted help", () => { - let text = format(cli_.inspect(createContext()), "myapp"); - expect(text).toMatch(/Usage: myapp /); - expect(text).toMatch(/Commands:/); - expect(text).toMatch(/run/); - expect(text).toMatch(/build/); - }); - }); - - describe("--help flag", () => { - it("intercepts --help after a command name", () => { - let value = parseOk(cli_, { - args: ["run", "--help"], - }) as CommandsType; - expect(value.name).toBe("run"); - expect(value.help).toBe(true); - if (value.help) { - expect(typeof value.text).toBe("string"); - expect(value.text).toMatch(/host/); - expect(value.text).toMatch(/port/); - } - }); - - it("intercepts -h after a command name", () => { - let value = parseOk(cli_, { - args: ["build", "-h"], - }) as CommandsType; - expect(value.name).toBe("build"); - expect(value.help).toBe(true); - if (value.help) { - expect(typeof value.text).toBe("string"); - expect(value.text).toMatch(/out-dir/); - } - }); - - it("shows nested command help", () => { - let nested = commands({ - outer: commands({ - inner: object({ - flag: field(type("boolean"), field.default(false)), - }), - }), - }); - let value = parseOk(nested, { - args: ["outer", "--help"], - }) as CommandsType; - expect(value.name).toBe("outer"); - expect(value.help).toBe(true); - if (value.help) { - expect(value.text).toMatch(/inner/); - } - }); - }); -}); diff --git a/test/constant.test.ts b/test/constant.test.ts deleted file mode 100644 index 59bcbfc..0000000 --- a/test/constant.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { constant } from "../lib/constant.ts"; -import { parseOk } from "./test-helpers.ts"; - -describe("constant", () => { - it("parses a constant", () => { - let value = parseOk(constant("hello world")); - expect(value).toBe("hello world"); - }); -}); diff --git a/test/decode.test.ts b/test/decode.test.ts new file mode 100644 index 0000000..3fe30d0 --- /dev/null +++ b/test/decode.test.ts @@ -0,0 +1,13 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { number, scalar } from "../lib/decode.ts"; + +describe("decoders", () => { + it("offers numeric and text interpretations of a numeral", () => { + expect(scalar("0012")).toEqual([12, "0012"]); + }); + + it("returns no candidate when a number cannot be decoded", () => { + expect(number("twelve")).toEqual([]); + }); +}); diff --git a/test/derivation.test.ts b/test/derivation.test.ts new file mode 100644 index 0000000..c46d7d4 --- /dev/null +++ b/test/derivation.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "@std/testing/bdd"; + +describe("derives()", () => { + describe("checkpoint", () => { + it.skip("returns Next with the values parsed before the checkpoint", () => + undefined); + it.skip("uses the derivation response type as the exact argument to resume()", () => + undefined); + it.skip("does not call the derivation before resume()", () => undefined); + it.skip("does not expose a checkpoint until its preceding input is valid", () => + undefined); + it.skip("is terminal within its current route-element pipeline", () => + undefined); + }); + + describe("extension", () => { + it.skip("accepts a derivation that returns a Route-to-Route extension", () => + undefined); + it.skip("applies the extension at the current point instead of replacing the Route", () => + undefined); + it.skip("preserves options and controls accumulated before the checkpoint", () => + undefined); + it.skip("infers the final config from the extension returned by the derivation", () => + undefined); + it.skip("mounts addresses introduced by an extension relative to its current namespace", () => + undefined); + }); + + describe("resume()", () => { + it.skip("continues parsing the unconsumed tokens without changing their indices", () => + undefined); + it.skip("accepts additional value and environment sources for the next increment", () => + undefined); + it.skip("returns the next Increment or Intent instead of the intermediate Route", () => + undefined); + it.skip("can resume into another dynamically introduced checkpoint", () => + undefined); + it.skip("resolves HELP, VERSION, and EXECUTE against the expanded route graph", () => + undefined); + it.skip("keeps asynchronous loading in caller code between parse() and resume()", () => + undefined); + }); +}); diff --git a/test/field.test.ts b/test/field.test.ts deleted file mode 100644 index 312cf18..0000000 --- a/test/field.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { type } from "arktype"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { field } from "../lib/field.ts"; -import assert from "node:assert"; -import { ValidationError } from "../lib/validate.ts"; -import { parseSync } from "./test-helpers.ts"; -import { createContext } from "../lib/context.ts"; - -describe("field", () => { - describe("from js", () => { - it("can be set from a valid value", () => { - let f = field(type("number")); - let result = parseSync(f, { - values: [{ name: "test", value: 5 }], - }); - assert(result.ok); - expect(result.value).toEqual(5); - let info = f.inspect( - createContext({ values: [{ name: "test", value: 5 }] }), - ); - expect(info.source.issues).toBeUndefined(); - }); - - it("recognizes invalid values", () => { - let result = parseSync(field(type("number")), { - values: [{ name: "test", value: true }], - }); - assert(!result.ok); - assert(result.error instanceof ValidationError, "should be invalid"); - expect(result.error.message).toMatch(/must be a number/); - }); - - it("includes a source for every passed value", () => { - let f = field(type("number")); - let input = { - values: [ - { name: "ausente", value: undefined }, - { name: "invalido", value: "not a number" }, - { name: "valido.2", value: 2 }, - { name: "valido.1", value: 1 }, - ], - }; - let result = parseSync(f, input); - assert(result.ok); - expect(result.value).toEqual(1); - - let info = f.inspect(createContext(input)); - let [none, ausente, invalido, two, one] = info.sources; - expect(none.issues).toBeDefined(); - expect(ausente.issues).toBeDefined(); - expect(invalido.issues).toBeDefined(); - expect(one.issues).not.toBeDefined(); - expect(two.issues).not.toBeDefined(); - }); - - it("can be valid even with no input", () => { - let f = field(type("number|undefined")); - let result = parseSync(f, { values: [] }); - assert(result.ok); - expect(result.value).toBeUndefined(); - expect(f.inspect(createContext({ values: [] })).source.sourceType) - .toEqual("none"); - }); - - it("uses a default value if no source is found", () => { - let f = field(type("number"), field.default(3000)); - let result = parseSync(f, { values: [] }); - assert(result.ok); - expect(result.value).toEqual(3000); - expect(f.inspect(createContext({ values: [] })).source.sourceType) - .toEqual("default"); - }); - - it("uses a falsy default of false", () => { - let f = field(type("boolean"), field.default(false)); - let result = parseSync(f, {}); - assert(result.ok); - expect(result.value).toEqual(false); - expect(f.inspect(createContext({})).source.sourceType).toEqual("default"); - }); - - it("uses a falsy default of 0", () => { - let f = field(type("number"), field.default(0)); - let result = parseSync(f, {}); - assert(result.ok); - expect(result.value).toEqual(0); - expect(f.inspect(createContext({})).source.sourceType).toEqual("default"); - }); - - it("uses a falsy default of empty string", () => { - let f = field(type("string"), field.default("")); - let result = parseSync(f, {}); - assert(result.ok); - expect(result.value).toEqual(""); - expect(f.inspect(createContext({})).source.sourceType).toEqual("default"); - }); - }); - - describe("from env", () => { - it("parses a number", () => { - let f = field(type("number")); - let input = { envs: [{ name: "ENV", value: { "": "5" } }] }; - let result = parseSync(f, input); - assert(result.ok); - expect(result.value).toEqual(5); - expect(f.inspect(createContext(input)).source.sourceType).toEqual("env"); - }); - - it("can parse a boolean", () => { - let config = field(type("boolean")); - let yes = parseSync(config, { - envs: [{ name: "ENV", value: { "": "yes" } }], - }); - assert(yes.ok); - expect(yes.value).toEqual(true); - - let no = parseSync(config, { - envs: [{ name: "ENV", value: { "": "no" } }], - }); - assert(no.ok); - expect(no.value).toEqual(false); - }); - - it("sees everything else as a string", () => { - let result = parseSync(field(type("string")), { - envs: [{ name: "ENV", value: { "": "localhost" } }], - }); - assert(result.ok); - expect(result.value).toEqual("localhost"); - }); - - it.skip("supports custom parsers", () => {}); - }); - - describe("schema-level defaults", () => { - // Simulates a schema that transforms undefined into a default value, - // like Zod's z.boolean().default(false) does via Standard Schema. - let boolWithDefault: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "test", - validate(value) { - if (value === undefined) return { value: false }; - if (value === true || value === false) return { value }; - return { issues: [{ message: "expected boolean" }] }; - }, - }, - }; - - it("applies the schema default when no input is provided", () => { - let result = parseSync(field(boolWithDefault), {}); - assert(result.ok); - expect(result.value).toEqual(false); - }); - - it("uses the provided value over the schema default", () => { - let result = parseSync(field(boolWithDefault), { - values: [{ name: "test", value: true }], - }); - assert(result.ok); - expect(result.value).toEqual(true); - }); - - it("uses the schema-transformed value from object sources", () => { - // A schema that normalizes strings to lowercase - let lower: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "test", - validate(value) { - if (typeof value === "string") { - return { value: value.toLowerCase() }; - } - return { issues: [{ message: "expected string" }] }; - }, - }, - }; - let result = parseSync(field(lower), { - values: [{ name: "test", value: "HELLO" }], - }); - assert(result.ok); - expect(result.value).toEqual("hello"); - }); - }); - - it("includes validation issues in the error when no input is provided", () => { - let result = parseSync(field(type("number")), {}); - assert(!result.ok); - assert(result.error instanceof ValidationError); - expect(result.error.message).toMatch(/must be a number/); - }); -}); diff --git a/test/help.test.ts b/test/help.test.ts deleted file mode 100644 index bfb5b38..0000000 --- a/test/help.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { type } from "arktype"; -import { cli, field } from "../lib/field.ts"; -import { type Attrs, object } from "../lib/object.ts"; -import type { Input } from "../lib/types.ts"; -import { format } from "../lib/help.ts"; -import { createContext } from "../lib/context.ts"; - -function exam(attrs: Attrs, input?: Input): string { - let parser = object(attrs); - return format(parser.inspect(createContext(input)), "configtest"); -} - -describe("help text", () => { - describe("usage", () => { - it("shows usage of positional arguments after their names", () => { - let text = exam({ - source: field(type("string"), cli.argument()), - target: field(type("string"), cli.argument()), - }); - - expect(text).toMatch(/Usage: configtest /); - }); - - it("shows brackets around optional positional arguments", () => { - let text = exam({ - target: field(type("string | undefined"), cli.argument()), - }); - - expect(text).toMatch(/Usage: configtest \[target\]/); - }); - - it("shows ellipsis after positional arguments", () => { - let text = exam({ - target: field(type("string"), cli.argument(), field.array()), - }); - - expect(text).toMatch(/Usage: configtest .../); - }); - - it("shows options if there are options", () => { - let text = exam({ - target: field(type("string"), cli.argument()), - port: field(type("number")), - }); - - expect(text).toMatch(/Usage: configtest \[OPTIONS\] /); - }); - - it("does not show commands if there are no commands", () => { - let text = exam({ - port: field(type("number")), - }); - - expect(text).toMatch(/Usage: configtest \[OPTIONS\]/); - }); - - it("shows nothing but the program name if there are no options or arguments", () => { - let text = exam({}); - - expect(text).toMatch(/Usage: configtest/); - }); - }); - - describe("argument description", () => { - it("shows a listing of all arguments", () => { - let text = exam({ - source: field(type("string"), cli.argument()), - target: field(type("string"), cli.argument()), - }); - - expect(text).toMatch(/Arguments:/); - expect(text).toMatch(//); - expect(text).toMatch(//); - expect(text).not.toMatch(/undefined/); - }); - - it("renders description of arguments if present", () => { - let text = exam({ - source: { - description: "file to copy", - ...field(type("string"), cli.argument()), - }, - target: { - description: "destination of copied file", - ...field(type("string"), cli.argument()), - }, - }); - - expect(text).toMatch(/.* file to copy/); - expect(text).toMatch(/.* destination of/); - }); - - it("shows the default if there is one", () => { - let text = exam({ - port: field(type("number"), field.default(3000), cli.argument()), - }); - - expect(text).toMatch(/.*\[default: 3000]/); - }); - - it("shows the current env source if there is one", () => { - let text = exam( - { - port: field(type("number"), field.default(3000), cli.argument()), - }, - { envs: [{ name: "env", value: { PORT: "3300" } }] }, - ); - - expect(text).toMatch(/.*\[env: PORT=3300]/); - }); - - it("shows the current object source if there is one", () => { - let text = exam( - { - port: field(type("number"), field.default(3000), cli.argument()), - }, - { values: [{ name: "config.json", value: { port: 3500 } }] }, - ); - - expect(text).toMatch(/.*\[config.json: port=3500]/); - }); - }); - - describe("options section", () => { - it("is shown when there are options", () => { - let text = exam({ - port: field(type("number")), - }); - expect(text).toMatch(/Options:/); - }); - it("shows option and aliases for the field", () => { - let text = exam({ - port: { aliases: ["-p"], ...field(type("number")) }, - }); - expect(text).toMatch(/-p, --port /); - }); - it("shows as a switch if the field is boolean", () => { - let text = exam({ - awesome: { aliases: ["-a"], ...field(type("boolean")) }, - }); - expect(text).toMatch(/--awesome/); - expect(text).not.toMatch(//); - }); - it("indicates optional fields", () => { - let text = exam({ - port: field(type("number | undefined")), - }); - expect(text).toMatch(/--port \[PORT\]/); - }); - it("shows an ellipsis on multi-value fields", () => { - let text = exam({ - user: field(type("string[]"), field.array()), - }); - expect(text).toMatch(/--user ... /); - }); - it("displays the description of an option", () => { - let text = exam({ - port: { - description: "port on which to run server", - ...field(type("number")), - }, - }); - expect(text).toMatch(/--port \s+ port on which to run server/); - }); - it("displays the source of an option", () => { - let text = exam({ - port: field(type("number"), field.default(3000)), - }); - expect(text).toMatch(/--port \s+ \[default: 3000\]/); - }); - it("renders derived acronym-safe option and env names", () => { - let text = exam( - { - clientID: field(type("string")), - }, - { envs: [{ name: "env", value: { CLIENT_ID: "abc123" } }] }, - ); - - expect(text).toMatch(/--client-id/); - expect(text).toMatch(/\[env: CLIENT_ID=abc123\]/); - }); - it("does not display the source of an option if it is invalid", () => { - let text = exam( - { - port: field(type("number")), - }, - { args: ["--port", "fnjord"] }, - ); - expect(text).not.toMatch(/fnjord/); - }); - }); -}); diff --git a/test/inject.test.ts b/test/inject.test.ts deleted file mode 100644 index 3d4e12c..0000000 --- a/test/inject.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import assert from "node:assert"; -import { type } from "arktype"; -import { field } from "../lib/field.ts"; -import { object } from "../lib/object.ts"; -import { inject } from "../lib/inject.ts"; -import { createContext } from "../lib/context.ts"; - -describe("inject", () => { - it("returns a resolve function that wraps the factory", () => { - let factory = (_deps: { port: number }) => - object({ - port: field(type("number")), - }); - - let parser = inject(factory); - let result = parser.parse({}); - assert(result.ok); - expect(typeof result.value).toBe("function"); - }); - - it("passes context through as remainder", () => { - let parser = inject((_deps: void) => - object({ - port: field(type("number")), - }) - ); - - let result = parser.parse({ args: ["--port", "8080"] }); - assert(result.ok); - expect(result.remainder.args).toEqual(["--port", "8080"]); - }); - - it("bakes context into the resolved parser", () => { - let parser = inject((_deps: void) => - object({ - port: field(type("number")), - }) - ); - - let result = parser.parse({ args: ["--port", "8080"] }); - assert(result.ok); - - let resolved = result.value(); - let inner = resolved.parse(); - assert(inner.ok); - expect(inner.value).toEqual({ port: 8080 }); - }); - - it("merges caller input on top of baked context", () => { - let parser = inject((_deps: void) => - object({ - port: field(type("number")), - host: field(type("string")), - }) - ); - - let result = parser.parse({ args: ["--port", "8080"] }); - assert(result.ok); - - let resolved = result.value(); - let inner = resolved.parse({ - values: [{ name: "config", value: { host: "localhost" } }], - }); - assert(inner.ok); - expect(inner.value).toEqual({ port: 8080, host: "localhost" }); - }); - - it("provides empty help info", () => { - let parser = inject((_deps: void) => - object({ - port: field(type("number")), - }) - ); - - let info = parser.inspect(createContext()); - expect(info.help.args).toEqual([]); - expect(info.help.opts).toEqual([]); - expect(info.help.commands).toEqual([]); - }); - - it("preserves progname from context", () => { - let parser = inject((_deps: void) => - object({ - port: field(type("number")), - }) - ); - - let info = parser.inspect({ ...createContext(), progname: ["myapp"] }); - expect(info.help.progname).toEqual(["myapp"]); - }); -}); diff --git a/test/lens.test.ts b/test/lens.test.ts deleted file mode 100644 index 1a06e69..0000000 --- a/test/lens.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { lens } from "../lib/lens.ts"; -import { expect } from "@std/expect"; - -describe("lens", () => { - describe("set", () => { - it("sets deeply nested structures", () => { - let result = lens.set(["one", "two", "three"], "Hello World", {}); - expect(result).toEqual({ one: { two: { three: "Hello World" } } }); - }); - }); -}); diff --git a/test/namespace.test.ts b/test/namespace.test.ts new file mode 100644 index 0000000..b359926 --- /dev/null +++ b/test/namespace.test.ts @@ -0,0 +1,6 @@ +import { describe, it } from "@std/testing/bdd"; + +describe("namespace()", () => { + it.skip("infers a nested object model from a nested option address", () => + undefined); +}); diff --git a/test/object.test.ts b/test/object.test.ts deleted file mode 100644 index 188ce91..0000000 --- a/test/object.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, it } from "@std/testing/bdd"; -import { expect } from "@std/expect"; -import { type } from "arktype"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { object } from "../lib/object.ts"; -import { cli, field } from "../lib/field.ts"; -import assert from "node:assert"; -import { ValidationError } from "../lib/validate.ts"; -import { ObjectValidationError } from "../lib/object.ts"; -import { parseNotOk, parseOk, parseSync } from "./test-helpers.ts"; - -describe("object", () => { - let configuration = object({ - port: field(type("number")), - host: field(type("string | undefined")), - }); - - describe("object", () => { - it("parses config as object", () => { - let result = parseOk(configuration, { - values: [{ - name: "configliere.test.ts", - value: { - port: 80, - }, - }], - }); - expect(result).toEqual({ port: 80 }); - }); - it("recognizes bad config", () => { - let error = parseNotOk(configuration, { - values: [{ - name: "app-config.yaml", - value: { - port: true, - }, - }], - }); - - assert(error instanceof ObjectValidationError); - let [{ path, error: fieldError }] = error.fields; - - expect(path).toEqual(["port"]); - assert(fieldError instanceof ValidationError); - let appConfig = fieldError.sources.find((s) => - s.sourceName === "app-config.yaml" - ); - expect(appConfig).toBeDefined(); - }); - - it("accepts multiple object configs, with the last one overriding", () => { - let result = parseOk(configuration, { - values: [{ - name: "one.json", - value: { - port: 80, - host: "localhost", - }, - }, { - name: "two.json", - value: { - port: 8000, - }, - }], - }); - - expect(result).toEqual({ host: "localhost", port: 8000 }); - }); - }); - describe("env", () => { - it("can set config from environment variables", () => { - let result = parseOk(configuration, { - envs: [{ - name: "env", - value: { - PORT: "99", - }, - }], - }); - expect(result.port).toEqual(99); - }); - it("rejects bad string to number conversions", () => { - let error = parseNotOk(configuration, { - envs: [{ - name: "env", - value: { - PORT: "port", - }, - }], - }); - - assert(error instanceof ObjectValidationError); - let [{ path, error: fieldError }] = error.fields; - - expect(path).toEqual(["port"]); - assert(fieldError instanceof ValidationError); - let envSource = fieldError.sources.find((s) => s.sourceType === "env"); - expect(envSource).toBeDefined(); - expect(fieldError.sources[0].issues).toBeDefined(); - }); - - it("handles boolean values", () => { - let result = parseOk( - object({ - enabled: field(type("boolean")), - }), - { - envs: [{ - name: "env", - value: { - ENABLED: "true", - }, - }], - }, - ); - expect(result).toEqual({ enabled: true }); - }); - it("handles multiple env sources", () => { - let result = parseOk(configuration, { - envs: [{ - name: "env", - value: { - HOST: "localhost", - PORT: "8088", - }, - }, { - name: "import.meta.env", - value: { - HOST: "frontside.com", - }, - }], - }); - expect(result.host).toEqual("frontside.com"); - expect(result.port).toEqual(8088); - }); - }); - - describe("from cli", () => { - it("handles string to number conversion", () => { - let result = parseOk(configuration, { - args: ["--port", "80"], - }); - - expect(result.port).toEqual(80); - }); - it("can use option=value format", () => { - let result = parseOk(configuration, { - args: ["--port=80"], - }); - - expect(result.port).toEqual(80); - }); - it("handles boolean switches", () => { - let result = parseOk( - object({ - test: field(type("boolean")), - }), - { args: ["--test"] }, - ); - - expect(result.test).toEqual(true); - }); - it("can negate boolean values", () => { - let result = parseOk( - object({ - test: field(type("boolean")), - }), - { args: ["--no-test"] }, - ); - - expect(result.test).toEqual(false); - }); - it("can have positional arguments", () => { - let config = object({ - host: field(type("string"), cli.argument()), - port: field(type("number"), cli.argument()), - }); - - let result = parseOk(config, { args: ["localhost", "3000"] }); - expect(result.host).toEqual("localhost"); - expect(result.port).toEqual(3000); - }); - it("can be aliased", () => { - let result = parseOk( - object({ - host: { aliases: ["-h"], ...field(type("string")) }, - port: { aliases: ["-p"], ...field(type("number")) }, - }), - { args: ["-h", "localhost", "-p", "3000"] }, - ); - - expect(result).toEqual({ host: "localhost", port: 3000 }); - }); - it("handles a mixture of arguments and options", () => { - let config = object({ - host: field(type("string"), cli.argument()), - port: { aliases: ["-p"], ...field(type("number")) }, - message: field(type("string"), cli.argument()), - }); - - let forward = parseOk(config, { - args: ["localhost", "awesome", "-p", "3000"], - }); - - expect(forward.host).toEqual("localhost"); - expect(forward.message).toEqual("awesome"); - expect(forward.port).toEqual(3000); - - let backward = parseOk(config, { - args: ["-p", "3000", "localhost", "awesome"], - }); - - expect(backward.host).toEqual("localhost"); - expect(backward.message).toEqual("awesome"); - expect(backward.port).toEqual(3000); - - let middle = parseOk(config, { - args: ["localhost", "-p", "3000", "awesome"], - }); - - expect(middle.host).toEqual("localhost"); - expect(middle.message).toEqual("awesome"); - expect(middle.port).toEqual(3000); - }); - it("points out unrecognized options on successful parse", () => { - let result = parseSync( - object({ - port: field(type("number")), - }), - { args: ["--port=3000", "--plort=3000"] }, - ); - - assert(result.ok); - expect(result.remainder.args).toEqual(["--plort=3000"]); - }); - - it("can collect an array of option values", () => { - let result = parseOk( - object({ - user: field(type("string[]"), field.array()), - }), - { args: ["--user", "cowboyd", "--user", "mz"] }, - ); - - expect(result.user).toEqual(["cowboyd", "mz"]); - }); - it("kebab-cases multi-word camelCase options", () => { - let result = parseOk( - object({ - inspectWatchScopes: field(type("boolean")), - }), - { args: ["--inspect-watch-scopes"] }, - ); - - expect(result.inspectWatchScopes).toEqual(true); - }); - it("treats acronym suffixes as a single word in derived names", () => { - let config = object({ - clientID: field(type("string")), - }); - - let cli = parseOk(config, { - args: ["--client-id", "abc123"], - }); - let env = parseOk(config, { - envs: [{ name: "env", value: { CLIENT_ID: "abc123" } }], - }); - - expect(cli.clientID).toEqual("abc123"); - expect(env.clientID).toEqual("abc123"); - }); - it("can collect arrays of argument values", () => { - let result = parseOk( - object({ - user: field(type("string[]"), field.array(), cli.argument()), - }), - { args: ["cowboyd", "mz"] }, - ); - expect(result.user).toEqual(["cowboyd", "mz"]); - }); - }); - - describe("default value", () => { - it("applies schema-level defaults when no input is provided", () => { - let boolWithDefault: StandardSchemaV1 = { - "~standard": { - version: 1, - vendor: "test", - validate(value) { - if (value === undefined) return { value: false }; - if (value === true || value === false) return { value }; - return { issues: [{ message: "expected boolean" }] }; - }, - }, - }; - - let result = parseOk( - object({ - port: field(type("number"), field.default(3000)), - debug: field(boolWithDefault), - }), - {}, - ); - expect(result.port).toEqual(3000); - expect(result.debug).toEqual(false); - }); - }); - - describe("nested", () => { - }); - - describe("with non-consuming sub-parsers", () => { - }); -}); diff --git a/test/parse.test.ts b/test/parse.test.ts new file mode 100644 index 0000000..23c71fe --- /dev/null +++ b/test/parse.test.ts @@ -0,0 +1,591 @@ +import { expect as base, type Expected } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { type } from "arktype"; +import { command } from "../lib/command.ts"; +import { name } from "../lib/definition.ts"; +import { option } from "../lib/option.ts"; +import { schema } from "../lib/param.ts"; +import { parse } from "../lib/parse.ts"; +import { route, routes, version } from "../lib/route.ts"; +import { toggle } from "../lib/toggle.ts"; +import type { AnyRoute, IntentsOf, Route } from "../lib/types.ts"; + +let app = { + ...route( + name("simulacrum"), + version("1.2.0"), + option(name("port"), schema(type("number"))), + ), + children: [ + route(name("auth0")), + { + ...route(name("database")), + children: [route(name("clean"))] as const, + }, + ] as const, +}; + +let tree = command( + name("simulacrum"), + toggle(name("verbose")), + routes( + command( + name("database"), + toggle(name("verbose")), + routes( + command( + name("clean"), + toggle(name("verbose")), + ), + ), + ), + ), +); + +let toggles = command( + name("simulacrum"), + toggle(name("dryRun")), +); + +let fields = command( + name("simulacrum"), + option(name("port"), schema(type("number"))), +); + +let options = command( + name("simulacrum"), + option(name("dryRun"), schema(type("string | undefined"))), +); + +let segments = command( + name("simulacrum"), + option(name("host"), schema(type("string"))), + routes( + command( + name("serve"), + option(name("port"), schema(type("number"))), + ), + ), +); + +describe("parse()", () => { + describe("help", () => { + it("resolves either help flag against the root route", () => { + expect( + $("simulacrum -h"), + ).toHaveRoute("HELP /"); + + expect( + $("simulacrum --help"), + ).toHaveRoute("HELP /"); + }); + + it("resolves help before validating other arguments", () => { + expect( + $("simulacrum --unknown --help"), + ).toHaveRoute("HELP /"); + }); + + it("does not treat help after -- as a control", () => { + let result = exec("simulacrum -- --help"); + + expect(result).toHaveRoute("EXECUTE /"); + }); + + it.skip("accounts for options left unconsumed by help", () => undefined); + }); + + describe("version", () => { + it("resolves either version flag against a versioned route", () => { + expect( + $("simulacrum -v"), + ).toHaveRoute("VERSION /"); + + expect( + $("simulacrum --version"), + ).toHaveRoute("VERSION /"); + }); + + it("resolves version before validating other arguments", () => { + expect( + $("simulacrum --unknown --version"), + ).toHaveRoute("VERSION /"); + }); + + it("rejects version for a route without a version", () => { + let result = plain("simulacrum --version"); + + expect(result).toMatchObject({ + ok: false, + code: "method-not-allowed", + path: [], + method: "version", + allowed: ["help"], + }); + expect(typeof result.route).toBe("string"); + expect(result.route).toBe("/"); + expect(result.definition).toMatchObject({ name: "simulacrum" }); + }); + + it("does not treat version after -- as a control", () => { + let result = exec("simulacrum -- --version"); + + expect(result).toHaveRoute("EXECUTE /"); + }); + }); + + describe("routes", () => { + it("resolves a direct child route", () => { + expect( + $("simulacrum auth0 --help"), + ).toHaveRoute("HELP /auth0"); + }); + it("resolves the deepest matching route", () => { + expect( + $("simulacrum database clean --help"), + ).toHaveRoute("HELP /database/clean"); + }); + it("resolves controls against the deepest matching route", () => { + expect( + $("simulacrum --help database clean"), + ).toHaveRoute("HELP /database/clean"); + }); + it("discovers routes across unresolved parameter tokens", () => { + expect( + $("simulacrum --root value database --db=value clean --help"), + ).toHaveRoute("HELP /database/clean"); + }); + it("stops discovering routes at --", () => { + expect( + $("simulacrum --help database -- clean"), + ).toHaveRoute("HELP /database"); + }); + it("treats child names as route selectors before binding parameters", () => { + expect( + $("simulacrum --target auth0 --help"), + ).toHaveRoute("HELP /auth0"); + }); + }); + + describe("literals", () => { + it("assigns literals to the matching route", () => { + let result = $("simulacrum --help database -- clean --force"); + + expectOk(result); + + expect(result).toHaveRoute("HELP /database"); + expect(Array.from(result.literals, (literal) => literal.text)).toEqual([ + "clean", + "--force", + ]); + }); + + it("keeps literals separate from parameter tokens", () => { + let result = $( + "simulacrum --help --value before -- literal --force", + ); + + expectOk(result); + + expect(Array.from(result.literals, (literal) => literal.text)).toEqual([ + "literal", + "--force", + ]); + }); + }); + + describe("route methods", () => { + it("uses the methods supported by the matching route", () => { + expect( + scoped("simulacrum auth0 --version"), + ).toHaveRoute("VERSION /auth0"); + }); + it("reports the matching route when a method is unsupported", () => { + let result = $("simulacrum database clean --version"); + + expect(result).toMatchObject({ + ok: false, + code: "method-not-allowed", + path: ["database", "clean"], + method: "version", + allowed: ["help"], + }); + expect(typeof result.route).toBe("string"); + expect(result.route).toBe("/database/clean"); + expect(result.definition).toMatchObject({ name: "clean" }); + }); + it("allows an executable route to contain executable children", () => { + expect( + commands("simulacrum database"), + ).toHaveRoute("EXECUTE /database"); + + expect( + commands("simulacrum database clean"), + ).toHaveRoute("EXECUTE /database/clean"); + }); + }); + + describe("binding", () => { + it("binds every route segment into its path-addressed model", () => { + let result = segmented( + "simulacrum --host localhost serve --port 4040", + ); + + expect(result).toHaveModels({ + "/": { host: "localhost" }, + "/serve": { port: 4040 }, + }); + }); + + it("validates required parameters on every matched route", () => { + let result = segmented("simulacrum serve --port 4040"); + + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + path: ["serve"], + issues: [{ path: ["host"] }], + }); + expect(result.route).toBe("/serve"); + expect(result.definition).toMatchObject({ name: "serve" }); + }); + + it("binds an option from a following token", () => { + expect( + configured("simulacrum --port 9001"), + ).toHaveModels({ "/": { port: 9001 } }); + }); + + it("binds an option from a setter", () => { + expect( + configured("simulacrum --port=9001"), + ).toHaveModels({ "/": { port: 9001 } }); + }); + + it("normalizes camel-case option names to kebab-case", () => { + expect( + normalized("simulacrum --dry-run yes"), + ).toHaveModels({ "/": { dryRun: "yes" } }); + + expect( + normalized("simulacrum --dry-run=yes"), + ).toHaveModels({ "/": { dryRun: "yes" } }); + }); + + it("does not retain the camel-case option spelling as an alias", () => { + expect(normalized("simulacrum --dryRun yes")).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [ + { message: `unexpected "--dryRun"` }, + { message: `unexpected "yes"` }, + ], + }); + }); + + it.skip("binds a positional argument", () => { + // let input = cli(command( + // name("simulacrum"), + // argument("input", type("string")), + // )); + // + // expect( + // input("simulacrum input.txt"), + // ).toHaveConfig({ input: "input.txt" }); + }); + + it("reports flags left unconsumed after binding every parameter", () => { + expect( + exec("simulacrum --floop"), + ).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ message: `unexpected "--floop"` }], + }); + }); + + it("reports an invalid option value as a binding error", () => { + expect( + configured("simulacrum --port nope"), + ).toMatchObject({ + ok: false, + code: "unprocessable-content", + issues: [{ path: ["port"] }], + }); + }); + + it("reports a surplus argument as a binding error", () => { + expect( + exec("simulacrum extra"), + ).toMatchObject({ + ok: false, + code: "unprocessable-content", + }); + }); + + it.skip("preserves parameter token order while binding", () => { + // expect( + // $("simulacrum --tag first --tag=second"), + // ).toHaveConfig({ tag: ["first", "second"] }); + }); + + it("binds parameters to the route segment that owns them", () => { + let result = bound( + "simulacrum --verbose database --verbose clean --verbose", + ); + + expect(result).toHaveModels({ + "/": { verbose: true }, + "/database": { verbose: true }, + "/database/clean": { verbose: true }, + }); + }); + + it("binds default, affirmative, and negative toggles", () => { + expect( + toggled("simulacrum"), + ).toHaveModels({ "/": { dryRun: false } }); + + expect( + toggled("simulacrum --dry-run"), + ).toHaveModels({ "/": { dryRun: true } }); + + expect( + toggled("simulacrum --no-dry-run"), + ).toHaveModels({ "/": { dryRun: false } }); + }); + + it("reports several surplus arguments as a binding error", () => { + let result = exec("simulacrum databaes clean"); + + expectUnprocessable(result); + expect(result.issues).toHaveLength(2); + }); + }); + + describe("types", () => { + it("exposes only methods supported by a route", () => { + type Plain = Route<"simulacrum", "help", Empty, []>; + type Versioned = Route< + "simulacrum", + "help" | "version", + Empty, + [] + >; + + expectType["method"], "help">>(true); + expectType< + Equal["method"], "help" | "version"> + >(true); + }); + + it("exposes the exact intents of every reachable route", () => { + type Actual = TargetOf>; + type Expected = + | ["help", "/", []] + | ["version", "/", []] + | ["help", "/auth0", ["auth0"]] + | ["help", "/database", ["database"]] + | ["help", "/database/clean", ["database", "clean"]]; + + expectType>(true); + }); + }); +}); + +const requests = new WeakMap(); +const $ = cli(app); +const bound = cli(tree); +const toggled = cli(toggles); +const configured = cli(fields); +const normalized = cli(options); +const segmented = cli(segments); +const plain = cli(route(name("simulacrum"))); +const scoped = cli({ + ...route(name("simulacrum")), + children: [ + route(name("auth0"), version("2.0.0")), + ] as const, +}); +const commands = cli({ + ...route(name("simulacrum")), + children: [ + { + ...route(name("database")), + methods: ["help", "execute"] as const, + children: [ + { + ...route(name("clean")), + methods: ["help", "execute"] as const, + }, + ] as const, + }, + ] as const, +}); +const exec = cli({ + ...route(name("simulacrum")), + methods: ["help", "execute"] as const, +}); + +interface RouteExpected extends Expected { + toHaveRoute(expected: Target): unknown; + toHaveModels(expected: Models): unknown; +} + +interface Request { + input: string; + root: string; +} + +interface Outcome { + input: string; + root: string; + definition: string; + target: Target; +} + +type Target = `${string} /${string}`; +type Models = Readonly>; +type Empty = Record; +type TargetOf = T extends { + readonly ok: true; + readonly method: infer M; + readonly route: infer R; + readonly path: infer P; +} ? [M, R, P] + : never; + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) + ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true + : false + : false; + +base.extend({ + toHaveRoute(context, expected: Target) { + let outcome = inspect(context.value); + let route = expected.slice(expected.indexOf(" ") + 1); + let leaf = route === "/" + ? outcome?.root + : route.slice(route.lastIndexOf("/") + 1); + let pass = outcome?.target === expected && outcome.definition === leaf; + + return { + pass, + message: () => + outcome + ? `Expected ${ + JSON.stringify(outcome.input) + } to resolve ${expected}, ` + + `but it resolved ${outcome.target} with definition ${ + JSON.stringify(outcome.definition) + }` + : `Expected a request resolving ${expected}, but received no route intent`, + }; + }, + toHaveModels(context, expected: Models) { + let value = context.value; + let models = record(value) && value.ok === true && + value.method === "execute" && record(value.models) + ? value.models + : undefined; + + return { + pass: models !== undefined && context.equal(models, expected), + message: () => + models === undefined + ? `Expected a successful EXECUTE result with models, but received ${ + show( + record(value) + ? { + ok: value.ok, + code: value.code, + path: value.path, + issues: value.issues, + } + : value, + ) + }` + : `Expected models ${show(expected)}, but received ${show(models)}`, + }; + }, +}); + +const expect = base; + +function cli(app: R) { + return (input: string) => { + let [root, ...argv] = input.trim().split(/\s+/); + + if (root !== app.name) { + throw new Error( + `Expected command ${JSON.stringify(app.name)}, received ${ + JSON.stringify(root) + }`, + ); + } + + let result = parse(app, { argv }); + requests.set(result, { input, root }); + return result; + }; +} + +function inspect(value: unknown): Outcome | undefined { + if (!record(value)) { + return; + } + + let request = requests.get(value); + if (!request) { + return; + } + + let { input, root } = request; + let { ok, method, route, definition } = value; + if ( + ok !== true || typeof method !== "string" || typeof route !== "string" || + !route.startsWith("/") || !record(definition) || + typeof definition.name !== "string" + ) { + return; + } + + return { + input, + root, + definition: definition.name, + target: `${method.toUpperCase()} ${route}` as Target, + }; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function show(value: unknown): string { + return Deno.inspect(value, { colors: false, depth: Infinity, sorted: true }); +} + +function expectType(_value: T): void { + // Compile-time assertion. +} + +function expectOk( + result: T, +): asserts result is Extract { + expect(result.ok).toBe(true); +} + +function expectUnprocessable( + result: T, +): asserts result is T & { + readonly ok: false; + readonly code: "unprocessable-content"; + readonly issues: readonly unknown[]; +} { + expect(result).toMatchObject({ + ok: false, + code: "unprocessable-content", + }); +} diff --git a/test/print.test.ts b/test/print.test.ts new file mode 100644 index 0000000..5e0e6ba --- /dev/null +++ b/test/print.test.ts @@ -0,0 +1,136 @@ +/** + * 😵‍💫 This file has been vibe coded 😵‍💫 + */ + +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { command } from "../lib/command.ts"; +import { description, name } from "../lib/definition.ts"; +import { option } from "../lib/option.ts"; +import { parse } from "../lib/parse.ts"; +import { printHelp, printVersion } from "../lib/print.ts"; +import { route, routes, version } from "../lib/route.ts"; +import { toggle } from "../lib/toggle.ts"; + +let app = route( + name("simulacrum"), + description("Run and manage a tree of local service simulators."), + version("1.4.0"), + option( + name("config"), + description("Load configuration from this file before starting."), + ), + toggle( + name("verbose"), + description("Show detailed startup and request diagnostics."), + ), + routes( + route( + name("database"), + description("Inspect and maintain simulator state."), + routes( + command( + name("clean"), + description("Remove generated records and reset database state."), + version("2.0.0"), + option( + name("schema"), + description("Limit cleanup to a single database schema."), + ), + toggle( + name("dryRun"), + description("Show what would be removed without changing data."), + ), + ), + ), + ), + command( + name("serve"), + description("Start every configured simulator."), + option( + name("port"), + description("Listen on this port."), + ), + ), + ), +); + +describe("printHelp()", () => { + it("prints root help with commands, options, controls, and descriptions", () => { + expect(help([])).toBe(`simulacrum 1.4.0 +Run and manage a tree of local service simulators. + +Usage: + simulacrum [OPTIONS] + +Commands: + database Inspect and maintain simulator state. + serve Start every configured simulator. + +Options: + --config Load configuration from this file before starting. + --verbose, --no-verbose Show detailed startup and request diagnostics. + -h, --help Print help + -v, --version Print version`); + }); + + 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. + +Usage: + database clean [OPTIONS] + +Options: + --schema Limit cleanup to a single database schema. + --dry-run, --no-dry-run Show what would be removed without changing data. + -h, --help Print help + -v, --version Print version`); + }); + + it("shows only the immediate commands and controls for an intermediate route", () => { + expect(help(["database"])).toBe(`database +Inspect and maintain simulator state. + +Usage: + database [OPTIONS] + +Commands: + clean Remove generated records and reset database state. + +Options: + -h, --help Print help`); + }); +}); + +describe("printVersion()", () => { + it("prints the root name and version", () => { + expect(release([])).toBe("simulacrum 1.4.0"); + }); + + it("prints a deeply nested route and its own version", () => { + expect(release(["database", "clean"])).toBe("database clean 2.0.0"); + }); +}); + +function help(path: string[]): string { + let result = parse(app, { argv: [...path, "--help"] }); + + expect(result).toMatchObject({ ok: true, method: "help" }); + if (!result.ok || result.method !== "help") { + throw new Error("expected help"); + } + + return printHelp(result); +} + +function release(path: string[]): string { + let result = parse(app, { argv: [...path, "--version"] }); + + expect(result).toMatchObject({ ok: true, method: "version" }); + if (!result.ok || result.method !== "version") { + throw new Error("expected version"); + } + + return printVersion(result); +} diff --git a/test/read.test.ts b/test/read.test.ts new file mode 100644 index 0000000..a3e0891 --- /dev/null +++ b/test/read.test.ts @@ -0,0 +1,36 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { name } from "../lib/definition.ts"; +import { param } from "../lib/param.ts"; +import { cli, type Symbol } from "../lib/read.ts"; +import { tokenize } from "../lib/tokenize.ts"; +import { Tokenizer } from "../lib/tokenizer.ts"; + +describe("CLI reader", () => { + it("claims an incomplete option while reporting its missing value", () => { + let read = param( + name("port"), + cli(["--port"]), + ).cli(symbols(["--port", "--verbose"])); + + expect(read.result).toMatchObject({ + ok: false, + issues: [{ message: "--port requires a value" }], + }); + expect(texts(read.claim.tokens)).toEqual(["--port"]); + expect(texts(read.claim.rest)).toEqual(["--verbose"]); + }); +}); + +function symbols(argv: string[]): Tokenizer { + let tokens = tokenize(argv).filter((token): token is Symbol => { + return token.type === "flag" || token.type === "setter" || + token.type === "word"; + }); + + return new Tokenizer(tokens); +} + +function texts(tokens: Iterable): string[] { + return Array.from(tokens, (token) => token.text); +} diff --git a/test/route.test.ts b/test/route.test.ts new file mode 100644 index 0000000..7a96870 --- /dev/null +++ b/test/route.test.ts @@ -0,0 +1,228 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { type } from "arktype"; +import { description, name } from "../lib/definition.ts"; +import { option } from "../lib/option.ts"; +import { schema } from "../lib/param.ts"; +import { route, type RouteZero, version } from "../lib/route.ts"; +import type { AnyRoute, Method, Route } from "../lib/types.ts"; + +describe("route() types", () => { + it("preserves the literal route name through each element", () => { + let result = route( + name("simulacrum"), + option(name("port"), schema(type("number"))), + option(name("domain"), schema(type("string"))), + ); + + expectType>(true); + }); + + it("starts with help as its only supported method", () => { + let result = route(name("simulacrum")); + + expectType, "help">>(true); + }); + + it("materializes a RouteZero before applying the first element", () => { + let result = route(name("simulacrum"), (zero) => { + expectType>>(true); + expect(zero).toEqual({ + name: "simulacrum", + methods: ["help"], + params: {}, + children: [], + }); + return zero; + }); + + expectType>>(true); + }); + + it("accepts shared definition elements in the route pipeline", () => { + let result = route( + name("simulacrum"), + description("manage service simulators"), + ); + + expect(result.description).toBe("manage service simulators"); + expectType>>(true); + }); + + it("preserves supported methods while adding options", () => { + let result = route( + name("simulacrum"), + option(name("port"), schema(type("number"))), + ); + + expectType, "help">>(true); + }); + + it("adds version to the supported methods", () => { + let result = route( + name("simulacrum"), + option(name("port"), schema(type("number"))), + version("1.2.0"), + ); + + expectType, "help" | "version">>(true); + expectType, { port: number }>>(true); + }); + + it("infers a plain object model from option schema outputs", () => { + let result = route( + name("simulacrum"), + option(name("port"), schema(type("number"))), + ); + + expectType, { port: number }>>(true); + }); + + it("preserves exact option keys and values across several elements", () => { + let result = route( + name("simulacrum"), + option(name("port"), schema(type("number"))), + option(name("domain"), schema(type("string"))), + ); + + expectType< + Equal, { port: number; domain: string }> + >(true); + }); + + it("preserves exact children while adding an option", () => { + let auth0 = route(name("auth0")); + let parent = { + ...route(name("simulacrum")), + children: [auth0] as const, + }; + let result = option(name("port"), schema(type("number")))(parent); + + expectType< + Equal + >(true); + expectType, { port: number }>>(true); + }); + + it("rejects a first value that is not a Definition", () => { + check(() => { + // @ts-expect-error route() must start with a Route. + route("simulacrum"); + }); + }); + + it("rejects adjacent elements whose output and input do not align", () => { + let start = name("simulacrum"); + let count = (_value: typeof start): number => 1; + let label = (_value: string): string => "done"; + + check(() => { + // @ts-expect-error label cannot consume the number returned by count. + route(start, count, label); + }); + }); + + it("allows an explicit final element to transform a Route into another type", () => { + let result = route( + name("simulacrum"), + (value) => ({ type: "bloop" as const, route: value }), + ); + + expectType>(true); + expectType>(true); + }); + + it("preserves inference through the longest supported pipeline", () => { + let result = route( + name("simulacrum"), + option(name("one"), schema(type("string"))), + option(name("two"), schema(type("number"))), + option(name("three"), schema(type("boolean"))), + option(name("four"), schema(type("string"))), + option(name("five"), schema(type("number"))), + option(name("six"), schema(type("boolean"))), + option(name("seven"), schema(type("string"))), + option(name("eight"), schema(type("number"))), + option(name("nine"), schema(type("boolean"))), + option(name("ten"), schema(type("string"))), + option(name("eleven"), schema(type("number"))), + option(name("twelve"), schema(type("boolean"))), + option(name("thirteen"), schema(type("string"))), + option(name("fourteen"), schema(type("number"))), + option(name("fifteen"), schema(type("boolean"))), + option(name("sixteen"), schema(type("string"))), + option(name("seventeen"), schema(type("number"))), + option(name("eighteen"), schema(type("boolean"))), + option(name("nineteen"), schema(type("string"))), + option(name("twenty"), schema(type("number"))), + option(name("twentyOne"), schema(type("boolean"))), + option(name("twentyTwo"), schema(type("string"))), + option(name("twentyThree"), schema(type("number"))), + option(name("twentyFour"), schema(type("boolean"))), + option(name("twentyFive"), schema(type("string"))), + option(name("twentySix"), schema(type("number"))), + option(name("twentySeven"), schema(type("boolean"))), + option(name("twentyEight"), schema(type("string"))), + option(name("twentyNine"), schema(type("number"))), + option(name("thirty"), schema(type("boolean"))), + ); + + expectType< + Equal< + Model, + { + one: string; + two: number; + three: boolean; + four: string; + five: number; + six: boolean; + seven: string; + eight: number; + nine: boolean; + ten: string; + eleven: number; + twelve: boolean; + thirteen: string; + fourteen: number; + fifteen: boolean; + sixteen: string; + seventeen: number; + eighteen: boolean; + nineteen: string; + twenty: number; + twentyOne: boolean; + twentyTwo: string; + twentyThree: number; + twentyFour: boolean; + twentyFive: string; + twentySix: number; + twentySeven: boolean; + twentyEight: string; + twentyNine: number; + thirty: boolean; + } + > + >(true); + }); +}); + +type Equal = (() => T extends L ? 1 : 2) extends + (() => T extends R ? 1 : 2) + ? (() => T extends R ? 1 : 2) extends (() => T extends L ? 1 : 2) ? true + : false + : false; + +type Model = R extends + Route ? T + : never; + +type Methods = R["methods"][number]; + +function expectType(_value: T): void { + // Compile-time assertion. +} + +function check(_body: () => void): void { + // Compile the callback without executing it. +} diff --git a/test/test-helpers.ts b/test/test-helpers.ts deleted file mode 100644 index 3b4563a..0000000 --- a/test/test-helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert"; -import type { Done, Fail, Input, Parser } from "../lib/types.ts"; - -export function parseOk( - parser: Parser, - input: Input = {}, -): V { - let result = parseSync(parser, input); - assert(result.ok, "expected parse to succeed, but failed"); - return result.value; -} - -export function parseNotOk( - parser: Parser, - input: Input, -): Error { - let result = parseSync(parser, input); - assert(!result.ok, "expected parse to fail, but succeeded"); - return result.error; -} - -export function parseSync( - parser: Parser, - input: Input = {}, -): Done | Fail { - return parser.parse(input); -} diff --git a/test/tokenize.test.ts b/test/tokenize.test.ts new file mode 100644 index 0000000..3e8d1a3 --- /dev/null +++ b/test/tokenize.test.ts @@ -0,0 +1,109 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { tokenize } from "../lib/tokenize.ts"; + +describe("tokenize()", () => { + it("classifies argv without assigning semantic meaning", () => { + expect( + tokenize(["-h", "--help", "-aux", "--port=9000", "database"]), + ).toEqual([ + { + type: "flag", + flagType: "short", + index: 0, + text: "-h", + flagText: "h", + }, + { + type: "flag", + flagType: "long", + index: 1, + text: "--help", + flagText: "help", + }, + { + type: "flag", + flagType: "short", + index: 2, + text: "-aux", + flagText: "aux", + }, + { + type: "setter", + index: 3, + text: "--port=9000", + nameText: "port", + valueText: "9000", + }, + { + type: "word", + index: 4, + text: "database", + }, + ]); + }); + + it("keeps a stacked short flag as one token", () => { + expect(tokenize(["-aux"])).toEqual([ + { + type: "flag", + flagType: "short", + index: 0, + text: "-aux", + flagText: "aux", + }, + ]); + }); + + it("splits setters at the first equals sign", () => { + expect(tokenize(["--url=https://example.test?a=b", "--name="])).toEqual([ + { + type: "setter", + index: 0, + text: "--url=https://example.test?a=b", + nameText: "url", + valueText: "https://example.test?a=b", + }, + { + type: "setter", + index: 1, + text: "--name=", + nameText: "name", + valueText: "", + }, + ]); + }); + + it("treats every argument after -- as a literal", () => { + expect(tokenize(["--help", "--", "--version", "-V", "--port=9000"])) + .toEqual([ + { + type: "flag", + flagType: "long", + index: 0, + text: "--help", + flagText: "help", + }, + { + type: "separator", + index: 1, + text: "--", + }, + { + type: "literal", + index: 2, + text: "--version", + }, + { + type: "literal", + index: 3, + text: "-V", + }, + { + type: "literal", + index: 4, + text: "--port=9000", + }, + ]); + }); +}); diff --git a/test/tokenizer.test.ts b/test/tokenizer.test.ts new file mode 100644 index 0000000..d92007c --- /dev/null +++ b/test/tokenizer.test.ts @@ -0,0 +1,62 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; +import { type AnyToken, tokenize } from "../lib/tokenize.ts"; +import { Tokenizer } from "../lib/tokenizer.ts"; + +describe("Tokenizer", () => { + it("returns matching tokens and removes them from rest", () => { + let source = tokenize(["-h", "database", "--verbose", "--port=9000"]); + let tokenizer = new Tokenizer(source); + + let claim = tokenizer.claimAll((token) => token.type === "flag"); + + expect(indices(claim.tokens)).toEqual([0, 2]); + expect(indices(claim.rest)).toEqual([1, 3]); + }); + + it("does not change the tokenizer that was claimed", () => { + let source = tokenize(["-h", "database", "--port=9000"]); + let tokenizer = new Tokenizer(source); + + tokenizer.claimAll((token) => token.type === "flag"); + + expect(indices(tokenizer)).toEqual([0, 1, 2]); + }); + + it("claims incrementally from the remaining tokens", () => { + let source = tokenize([ + "-h", + "database", + "--verbose", + "--port=9000", + ]); + let tokenizer = new Tokenizer(source); + + let flags = tokenizer.claimAll((token) => token.type === "flag"); + let setters = flags.rest.claimAll((token) => token.type === "setter"); + let words = setters.rest.claimAll((token) => token.type === "word"); + + expect(indices(flags.tokens)).toEqual([0, 2]); + expect(indices(setters.tokens)).toEqual([3]); + expect(indices(words.tokens)).toEqual([1]); + expect(indices(words.rest)).toEqual([]); + }); + + it("does not test already claimed tokens again", () => { + let source = tokenize(["-h", "database", "--port=9000"]); + let tokenizer = new Tokenizer(source); + let first = tokenizer.claimAll((token) => token.index === 0); + let tested: number[] = []; + + first.rest.claimAll((token) => { + tested.push(token.index); + return false; + }); + + expect(tested).toEqual([1, 2]); + }); +}); + +function indices(tokens: Iterable): number[] { + return Array.from(tokens, (token) => token.index); +}