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
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ Strongly prefer one-word variable and function names (e.g. `dir` not `tmpDir`,

- Framework: `@std/testing/bdd` (`describe`/`it`) with `@std/expect`
- Tests live in `test/` and mirror the source layout.
- Runtime parser tests assert only observable parse outcomes, such as
increments, intents, models, routes, and issues. Do not inspect definition
internals such as phases, parameters, or children, even when publicly
accessible.
- Do not test implementation mechanics such as resolver call counts, timing, or
traversal order.
- When replacing an internal assertion, preserve its behavioral contract with an
outcome-based test rather than dropping the coverage.

### Runtime and tooling

Expand Down
5 changes: 3 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
"@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"
"@standard-schema/spec": "npm:@standard-schema/spec@^1.1.0",
"zod": "npm:zod@^4.4.3"
},
"lint": {
"rules": { "exclude": ["prefer-const"] }
"rules": { "exclude": ["ban-types", "prefer-const"] }
},
"tasks": {
"test": "deno test",
Expand Down
6 changes: 4 additions & 2 deletions deno.lock

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

123 changes: 120 additions & 3 deletions lib/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ 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";
import { Tokenizer } from "./tokenizer.ts";
import type { AnyPhase, Issue } from "./types.ts";

export interface Binding<T> {
rest: Tokenizer<Symbol>;
result: Result<T>;
}

export interface PhaseBinding {
rest: Tokenizer<Symbol>;
model: Record<string, unknown>;
issues: Issue[];
valid: boolean;
cursor?: number;
}

export interface PhaseSegment {
readonly tokens: Iterable<Symbol>;
readonly cursor?: number;
}

export function bind<T, P extends Param<string, T>>(options: {
param: P;
tokens: Tokenizer<Flag | Setter | Word>;
Expand Down Expand Up @@ -68,6 +81,79 @@ export function bind<T, P extends Param<string, T>>(options: {
};
}

export function bindPhase(options: {
phase: AnyPhase;
segment: PhaseSegment;
tokens: Tokenizer<Symbol>;
}): PhaseBinding {
let { phase, segment } = options;
let tokens = options.tokens;
let params = Object.values(phase.params) as Param<string, unknown>[];
let members = new Set(Array.from(segment.tokens, (token) => token.index));
let cursor = segment.cursor;
let results = new Map<string, Result<unknown>>();
let settled = new Set<string>();

while (true) {
for (let param of params) {
if (settled.has(param.name)) {
continue;
}

let visible = Array.from(tokens).filter((token) => {
return members.has(token.index) &&
(cursor === undefined || token.index <= cursor);
});
let binding = bind({
param,
tokens: new Tokenizer(visible),
});
let remaining = new Set(
Array.from(binding.rest, (token) => token.index),
);
let claimed = new Set(
visible
.filter((token) => !remaining.has(token.index))
.map((token) => token.index),
);

results.set(param.name, binding.result);

if (claimed.size > 0) {
tokens = tokens.claimAll((token) => claimed.has(token.index)).rest;
settled.add(param.name);
}
}

if (cursor === undefined || has(tokens, members, cursor)) {
break;
}

cursor = next(tokens, members, cursor);
}

let model: Record<string, unknown> = {};
let issues: Issue[] = [];
let valid = true;

for (let param of params) {
let result = results.get(param.name);
if (!result) {
continue;
}

issues.push(...result.issues ?? []);

if (result.ok) {
model[param.name] = result.value;
} else {
valid = false;
}
}

return { rest: tokens, model, issues, valid, cursor };
}

function validate<T, P extends Param<string, T>>(
param: P,
value: unknown,
Expand All @@ -87,7 +173,11 @@ function validate<T, P extends Param<string, T>>(
if (validated.issues) {
return {
ok: false,
issues: validated.issues.map((i) => ({ ...i, path })),
issues: validated.issues.map((issue) => ({
...issue,
message: issue.message,
path,
})),
};
} else {
return {
Expand All @@ -97,3 +187,30 @@ function validate<T, P extends Param<string, T>>(
};
}
}

function has(
tokens: Tokenizer<Symbol>,
members: Set<number>,
index: number,
): boolean {
for (let token of tokens) {
if (members.has(token.index) && token.index === index) {
return true;
}
}
return false;
}

function next(
tokens: Tokenizer<Symbol>,
members: Set<number>,
index: number,
): number | undefined {
for (let token of tokens) {
if (
members.has(token.index) && token.index > index && token.type === "word"
) {
return token.index;
}
}
}
11 changes: 5 additions & 6 deletions lib/command.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// deno-lint-ignore-file ban-types
import { extend } from "./extend.ts";
import { executable, route } from "./route.ts";
import type { Definition, Route } from "./types.ts";
import type { Definition, Done, Route } from "./types.ts";

export type CommandZero<N extends string = string> = Route<
N,
"help" | "execute",
{},
[]
[],
[Done<{}, []>]
>;

export function command<const N extends string>(
Expand Down Expand Up @@ -1094,8 +1096,5 @@ export function command(
start: Definition<string>,
...elements: readonly ((value: never) => unknown)[]
): unknown {
return elements.reduce<unknown>(
(value, element) => element(value as never),
route(start, executable()),
);
return extend(elements)(route(start, executable()));
}
111 changes: 111 additions & 0 deletions lib/dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type {
AnyPhase,
AnyPhases,
AnyRoute,
ChildrenOf,
MethodsOf,
ModelOf,
Phase,
Route,
} from "./types.ts";

export function dynamic<
Before extends AnyRoute,
Requirement,
After extends AnyRoute,
>(
extension: (requires: Requirement) => (input: Seed<Before>) => After,
): (input: Before) => Conjoin<Before, After, Requirement> {
return (route) => {
let phases = [...route.phases];
let phase = phases.pop()!;

phases.push({
...phase,
resolver: extension,
});
phases.push({
params: {},
routes: [],
});

return { ...route, phases } as unknown as Conjoin<
Before,
After,
Requirement
>;
};
}

export type PhaseOf<R extends AnyRoute> = R["phases"][0];
export type PhasesOf<R extends AnyRoute> = R["phases"];

export type Seed<R extends AnyRoute> = Route<
R["name"],
MethodsOf<R>,
ModelOf<R>,
ChildrenOf<R>,
readonly [Phase<{}, [], never>]
>;

type Conjoin<
A extends AnyRoute,
B extends AnyRoute,
Requirement,
> = Route<
B["name"],
MethodsOf<B>,
ModelOf<B>,
ChildrenOf<B>,
ConjoinPhases<A, B, Requirement>
>;

export type ConjoinPhases<
A extends AnyRoute,
B extends AnyRoute,
Requirement,
> = ConcatPhases<
ContinueLast<PhasesOf<A>, Requirement>,
PhasesOf<B>
>;

type ContinueLast<
P extends AnyPhases,
Requirement,
> = P extends readonly [infer Only extends AnyPhase]
? readonly [WithRequirement<Only, Requirement>]
: P extends readonly [
infer Head extends AnyPhase,
...infer Middle extends readonly AnyPhase[],
infer Last extends AnyPhase,
] ? readonly [
Head,
...Middle,
WithRequirement<Last, Requirement>,
]
: never;

type ConcatPhases<
A extends AnyPhases,
B extends AnyPhases,
> = A extends readonly [
infer AHead extends AnyPhase,
...infer ATail extends AnyPhase[],
] ? B extends readonly [
infer BHead extends AnyPhase,
...infer BTail extends AnyPhase[],
] ? readonly [
AHead,
...ATail,
BHead,
...BTail,
]
: never
: never;

type WithRequirement<
P extends AnyPhase,
Requirement,
> = P extends Phase<infer Model, infer Children, never>
? Phase<Model, Children, Requirement>
: never;
Loading
Loading