Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 30 additions & 5 deletions lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,33 +165,58 @@ function command<T, const Name extends string>(
};
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: [] });
// Run inner parser first with all args. If --help/-h survives
// in the inner parser's remainder (no descendant consumed it),
// produce help for this command. This preserves nested command
// routing: a descendant commands() parser dispatches first and
// its command() handles --help at the correct level.
let config = inner.inspect(cmdCtx);

let innerRemainder = config.result.remainder?.args ?? [];
let dashDashIndex = innerRemainder.indexOf("--");
let searchEnd = dashDashIndex === -1
? innerRemainder.length
: dashDashIndex;
let helpPosition = -1;
for (let i = 0; i < searchEnd; i++) {
if (
innerRemainder[i] === "--help" || innerRemainder[i] === "-h"
) {
helpPosition = i;
break;
}
}

if (helpPosition !== -1) {
let help = {
...config.help,
progname: [...ctx.progname, name],
opts: [...config.help.opts, helpOpt],
};
let text = format({ ...config, help });
let helpArgs = [
...innerRemainder.slice(0, helpPosition),
...innerRemainder.slice(helpPosition + 1),
];
let helpRemainder = { ...remainder, args: helpArgs };
return {
type: "command",
parser,
result: {
ok: true as const,
value: { name, help: true as const, text } as Command<T, Name>,
remainder,
remainder: helpRemainder,
},
name,
description: inner.description,
aliases: inner.aliases,
config,
commands: {},
help,
remainder,
remainder: helpRemainder,
};
}

let config = inner.inspect(cmdCtx);
let result = config.result.ok
? {
ok: true as const,
Expand Down
23 changes: 18 additions & 5 deletions lib/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,16 @@ function scopeInput<V>(

if (args.length > 0) {
let matched = new Set<keyof V>();
let prev: string[] | undefined;
while (
args.length > 0 && (prev === undefined || args.length < prev.length)
) {
prev = args;
let skipped: string[] = [];

while (args.length > 0) {
if (args[0] === "--") {
skipped.push(...args);
args = [];
break;
}

let consumed = false;
for (let [key, parser] of entries) {
let f = asField(parser);
if (!f) continue;
Expand Down Expand Up @@ -153,10 +158,18 @@ function scopeInput<V>(
}
matched.add(key);
args = match.remainder;
consumed = true;
break;
}
}

if (!consumed) {
skipped.push(args[0]);
args = args.slice(1);
}
}

args = skipped;
}

let scoped = new Map<
Expand Down
50 changes: 49 additions & 1 deletion lib/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,54 @@ export function program<T>(
let remainder = { args: ctx.args, values: ctx.values, envs: ctx.envs };
let main = config.inspect(rootCtx);

// Post-parse: if help/version was not detected at args[0],
// check if it survived into the config parser's remainder.
// When a sub-parser (command) handles --help, it removes the token
// from its result remainder, so it won't appear here.
let resultRemainder = main.result.remainder;
if (!help && !ver) {
let remainderArgs = resultRemainder?.args ?? [];
let dashDashIndex = remainderArgs.indexOf("--");
let searchEnd = dashDashIndex === -1
? remainderArgs.length
: dashDashIndex;

for (let i = 0; i < searchEnd; i++) {
if (remainderArgs[i] === "--help" || remainderArgs[i] === "-h") {
help = true;
resultRemainder = {
...resultRemainder,
args: [
...remainderArgs.slice(0, i),
...remainderArgs.slice(i + 1),
],
};
break;
}
}

if (!help && version) {
let versionArgs = resultRemainder?.args ?? [];
let versionDashDash = versionArgs.indexOf("--");
let versionSearchEnd = versionDashDash === -1
? versionArgs.length
: versionDashDash;
for (let i = 0; i < versionSearchEnd; i++) {
if (versionArgs[i] === "--version" || versionArgs[i] === "-v") {
ver = version;
resultRemainder = {
...resultRemainder,
args: [
...versionArgs.slice(0, i),
...versionArgs.slice(i + 1),
],
};
break;
}
}
}
}

let value: Program<T> = {
...(help ? { help: true } : {}),
...(ver ? { version: ver } : {}),
Expand All @@ -65,7 +113,7 @@ export function program<T>(
? {
ok: true as const,
value,
remainder: main.result.ok ? main.result.remainder : remainder,
remainder: resultRemainder,
}
: main.result;

Expand Down
62 changes: 60 additions & 2 deletions specs/configliere-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ provide CLI entry-point behavior.
**Parse result.** The outcome of parsing: `Done<T>` (success with typed value)
or `Fail` (failure with error). Both carry a `remainder` of unconsumed input.

**Remainder.** The unconsumed portion of input after a parser has consumed what
it recognizes. Remainder flows upward from child to parent.
**Remainder.** The unconsumed portion of input after a parser has attempted to
match what it recognizes. "Did not consume" means "did not match," not "did not
reach due to early termination." Remainder flows upward from child to parent.

**Source.** A record of where a field's value came from:
`{ sourceName, sourceType, value, issues }`. Source types include `"none"`,
Expand Down Expand Up @@ -420,6 +421,11 @@ scoping itself does not change precedence — it changes which values are visibl
- MUST scope CLI args by matching against child field paths (via `scopeInput`).
- MUST scope config-file values by extracting each child's key from value
objects.
- MUST parse known options interspersed with unrecognized tokens. When no field
matcher claims a token, skip it, preserve it in remainder, and continue
matching. `--` terminates option processing; `--` and all subsequent tokens go
to remainder as-is. Unrecognized tokens appear in remainder in original
relative order.

**Type expectation:**
`object({ port: field(type("number")), host: field(type("string")) })` MUST
Expand Down Expand Up @@ -454,11 +460,34 @@ progname, and delegation to a config parser.

- MUST screen for `--help`/`-h` and `--version`/`-v` before delegating to the
config parser.
- MUST also detect `--help`/`-h` and `--version`/`-v` in the config parser's
remainder after delegation, for tokens that no sub-parser handled. When a
sub-parser (e.g., `command()`) handles `--help`, it removes the token from its
result remainder, so it will not be detected again at the program level.
- `--help`/`-h` and `--version`/`-v` MUST be detected regardless of position in
argv (before `--`). Detection includes consumption: the handled token MUST NOT
appear in `result.remainder.args`.
- MUST set `progname: [name]` in the root context.
- MUST produce `Parser<Program<T>>` where `T` is the config parser's value type.
- MUST merge preamble options (help, version) into the help output alongside the
config parser's help.

> **Subcommand help routing.** When `program()` wraps `commands()`,
> subcommand-targeted help (e.g., `myapp dev --help`) is handled by `command()`,
> not by `program()`. `command()` runs the inner parser first with all args; if
> `--help` survives in the inner parser's remainder (no descendant consumed it),
> `command()` produces help for that command. `program()` only catches
> help/version tokens that survive into the config parser's remainder — i.e.,
> tokens that no sub-parser handled.

> **Provisional: Help-validation interaction (Decision B).** When `--help` is
> detected alongside missing required fields, `command()` runs the inner parser
> with all args; whether the inner parse succeeds or fails, the command still
> returns `ok: true` with `help: true` if `--help` is found in remainder. At the
> program level, the `(main.result.ok || help || ver)` guard ensures `ok: true`
> when help is detected. Whether finer-grained help-mode validation suppression
> is needed is deferred.

### 8.5 `inject(fn)`

**Role:** Defers a parse phase behind a dependency. The factory function `fn`
Expand Down Expand Up @@ -765,6 +794,15 @@ key. Fields use path for CLI option key derivation and env-var key mapping.
When both parsers are known at construction time, all help is available before
any phase executes. This is the simplest case and MUST be fully supported.

### 12.1.1 Interspersed extraction guarantee

A phase-1 parser MUST extract all known flags regardless of their position
relative to tokens that belong to later phases. Because `object()` uses
interspersed parsing (§8.2), a bootstrap parser defining `--config` but not a
positional `<suite>` will successfully extract `--config` from
`run ./suite --config app.json`, passing `run` and `./suite` through as
remainder for phase-2.

### 12.2 Dependency-generated phases

`inject((dep: D) => Parser<T>)` represents a phase whose parser depends on
Expand Down Expand Up @@ -1211,6 +1249,26 @@ Tests MUST verify that provided extraction helpers (currently `ConfigType`,
instances. [Lock down contract — for whatever helpers exist at the time.] If the
helper set evolves, tests MUST cover whatever helpers are provided.

### 18.11 Interspersed parsing and help detection

- `object()` interspersed parsing: flags before, after, and interleaved with
unknown positionals; all-consume and no-consume cases [Verify current]
- `--` terminates option processing [Lock down contract]
- Remainder preserves original token order for unrecognized tokens [Lock down
contract]
- `--help`/`-h` position-insensitive at both program and command level [Lock
down contract]
- Subcommand help shape preserved: `config.help === true`, `config.text` present
[Lock down contract]
- Handled help/version tokens do not appear in `result.remainder.args` [Lock
down contract]
- Contextual args consumed during help are not in result remainder [Lock down
contract]
- Phased parsing: phase-1 extracts known flags regardless of position [Verify
current]
- Help-validation interaction: `--help` with missing required fields
(observation, provisional) [Verify current]

---

_This specification is derived from reverse-engineering research across 13
Expand Down
40 changes: 40 additions & 0 deletions test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,5 +253,45 @@ describe("commands", () => {
expect(value.text).toMatch(/inner/);
}
});

it("routes --help to nested inner command", () => {
let nested = commands({
outer: commands({
inner: object({
flag: field(type("boolean"), field.default(false)),
}),
}),
});
let result = parseOk(nested, {
args: ["outer", "inner", "--help"],
});
expect(result.name).toBe("outer");
let inner = (result as unknown as {
config: { name: string; help: boolean; text: string };
}).config;
expect(inner.name).toBe("inner");
expect(inner.help).toBe(true);
expect(inner.text).toMatch(/flag/);
});

it("routes non-first --help to nested inner command", () => {
let nested = commands({
outer: commands({
inner: object({
port: field(type("number"), field.default(3000)),
}),
}),
});
let result = parseOk(nested, {
args: ["outer", "inner", "--port", "4000", "--help"],
});
expect(result.name).toBe("outer");
let inner = (result as unknown as {
config: { name: string; help: boolean; text: string };
}).config;
expect(inner.name).toBe("inner");
expect(inner.help).toBe(true);
expect(inner.text).toMatch(/port/);
});
});
});
Loading
Loading