Skip to content
Merged
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
68 changes: 52 additions & 16 deletions docs/binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,8 @@ argv: -c app.json auth0 --port 9001
view: [---] |--- hidden ---|
```

Nothing in the current phase claims `auth0`, so the horizon remains unchanged
after a complete parameter sweep. CLI binding has reached a fixed point and must
stop.
Nothing in the current phase offers a claim for `auth0`. CLI binding has reached
a fixed point and must stop.

If `resume()` introduces an `auth0` route, route search can now claim index 2 as
its selector. The child receives `--port 9001`. Without the horizon, a parent
Expand Down Expand Up @@ -157,8 +156,32 @@ moves to `auth0`:
horizon
```

No parameter claims it, so the horizon is stable and CLI binding stops. This is
a fixed-point calculation rather than an ordinary left-to-right cursor.
No parameter offers a claim for it, so CLI binding stops. This is a fixed-point
calculation rather than an ordinary left-to-right cursor.

### Competing claims

Every pending reader inspects the same immutable view before any claim is
committed. If several readers offer claims, the claim beginning earliest in the
original argv wins. Exact ties preserve parameter declaration order.

For example:

```text
argv: --port 9000 input.txt
option: [-----------]
argument: [--]
```

The option wins because its claim begins at `--port`; the positional reader
cannot steal `9000`. On the next step, the argument claims `input.txt`. Two
positional readers instead offer the same word, so their declaration order
decides which one receives it.

This arbitration comes entirely from proposed claims. Readers carry no
option-versus-argument tag, and custom readers participate in exactly the same
protocol. Failed reads compete normally, ensuring an invalid option still owns
the tokens it captured.

Route search always runs before phase binding. A currently discoverable route
therefore beats an option value. A route introduced only after the current
Expand Down Expand Up @@ -251,23 +274,33 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding {
pending.delete(param.name);
}

// Positional source: expand safe lookahead to a fixed point.
// Positional source: arbitrate claims until no reader can make progress.
while (true) {
let before = firstWord(rest.tokens, segment.range);
let horizon = firstWord(rest.tokens, segment.range);
let offer;

for (let param of pending.values()) {
let view = rest.tokens.view({
range: segment.range,
through: before?.index,
through: horizon?.index,
});
let read = param.cli.read(view);

accept(param, fromCLI({ param, view, rest }));
if (read.result.ok && !read.result.value.exists) {
continue;
}

let index = earliest(read.claim.tokens);
if (!offer || index < offer.index) {
offer = { param, read, index };
}
}

let after = firstWord(rest.tokens, segment.range);
if (after?.index === before?.index) {
if (!offer) {
break;
}

accept(offer.param, fromRead(offer.param, offer.read, rest));
}

// Stable address sources: array order is precedence.
Expand Down Expand Up @@ -298,12 +331,12 @@ function bindPhase(options: BindPhaseOptions): PhaseBinding {

The source-outer address loop makes precedence explicit. Deleting a parameter
from `pending` on every existing attempt prevents fallback after invalid input.
Comparing the first remaining word before and after a sweep expresses the fixed
point directly and removes `has()` and `next()` cursor bookkeeping.
Recomputing proposals after each committed claim advances the horizon without
letting one reader observe another reader's speculative remainder.

When the final word is consumed, the changed before/after comparison causes one
additional unbounded sweep. That sweep exposes trailing flags or setters. With
no remaining words before or after it, the next comparison terminates.
When the final word is consumed, the absent horizon creates an unbounded view,
which exposes trailing flags or setters. The loop terminates when no reader
offers another claim.

## Parser integration

Expand Down Expand Up @@ -353,6 +386,9 @@ The design should be established with observable tests for these cases:
- Consuming the horizon exposes the next word and retries previously absent
parameters.
- A stable horizon leaves the entire suffix untouched.
- A named option claim outranks an overlapping positional claim without reader
metadata.
- Positional readers retain declaration order when they offer the same word.
- CLI overrides Values even when its option is not visible until a later sweep.
- Env overrides Values when that is the declared source order.
- Invalid CLI blocks valid Env and Values.
Expand Down
91 changes: 91 additions & 0 deletions lib/argument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { type Param, param } from "./param.ts";
import {
brand,
type Check,
type Fold,
type ParamElement,
type Unary,
} from "./pipeline.ts";
import type { CLIRead, ReadCLI } from "./read.ts";
import type { Word } from "./tokenize.ts";
import type { AnyRoute, Definition } from "./types.ts";

export function argument<
const N extends string,
const E extends readonly Unary[],
>(
named: Definition<N>,
...elements: E & Check<Param<N, unknown>, E>
): ElementOf<N, Fold<Param<N, unknown>, E>> {
const added = elements.reduce<unknown>(
(value, element) => element(value as never),
param(named, positional),
) as Param<string, unknown>;

return brand<ElementOf<N, Fold<Param<N, unknown>, E>>>(
(route: AnyRoute) => {
let phases = [...route.phases];
let phase = phases.pop()!;
phases.push({
...phase,
params: {
...phase.params,
[added.name]: added,
},
});

return {
...route,
phases,
};
},
);
}

type ValueOf<P> = P extends Param<string, infer T> ? T : never;

type ElementOf<N extends string, P> = P extends Param<N, unknown>
? ParamElement<N, ValueOf<P>>
: never;

function positional<P extends Param<string, unknown>>(param: P): P {
return {
...param,
cli: {
read,
syntax: {
type: "argument",
label: `<${param.name.toUpperCase()}>`,
},
},
};
}

const read: ReadCLI = (tokens): CLIRead => {
let claim = tokens.claimOne((token): token is Word => token.type === "word");
let [word] = claim.tokens;

return word
? {
claim,
result: {
ok: true,
value: { exists: true, value: word.text },
issues: [],
},
}
: nothing(tokens);
};

function nothing(tokens: Parameters<ReadCLI>[0]): CLIRead {
let claim = tokens.claimAll(() => false);

return {
claim,
result: {
ok: true,
value: { exists: false },
issues: [],
},
};
}
Loading
Loading