Skip to content

Commit 0329b23

Browse files
authored
Merge pull request #22 from thefrontside/project-tom-hagen-dynamic
Project Tom Hagen: add typed incremental route expansion
2 parents 6c82188 + 2af8a67 commit 0329b23

25 files changed

Lines changed: 4871 additions & 351 deletions

‎AGENTS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ Strongly prefer one-word variable and function names (e.g. `dir` not `tmpDir`,
2525

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

2937
### Runtime and tooling
3038

‎deno.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
"@std/testing/bdd": "jsr:@std/testing@^1.0.19/bdd",
99
"arktype": "npm:arktype@^2.2.3",
1010
"dnt": "jsr:@deno/dnt@0.42.3",
11-
"@standard-schema/spec": "npm:@standard-schema/spec@^1.1.0"
11+
"@standard-schema/spec": "npm:@standard-schema/spec@^1.1.0",
12+
"zod": "npm:zod@^4.4.3"
1213
},
1314
"lint": {
14-
"rules": { "exclude": ["prefer-const"] }
15+
"rules": { "exclude": ["ban-types", "prefer-const"] }
1516
},
1617
"tasks": {
1718
"test": "deno test",

‎deno.lock‎

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/bind.ts‎

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,27 @@ import type { Param } from "./param.ts";
22
import type { Symbol } from "./read.ts";
33
import type { Result } from "./result.ts";
44
import type { Flag, Setter, Word } from "./tokenize.ts";
5-
import type { Tokenizer } from "./tokenizer.ts";
6-
import type { Issue } from "./types.ts";
5+
import { Tokenizer } from "./tokenizer.ts";
6+
import type { AnyPhase, Issue } from "./types.ts";
77

88
export interface Binding<T> {
99
rest: Tokenizer<Symbol>;
1010
result: Result<T>;
1111
}
1212

13+
export interface PhaseBinding {
14+
rest: Tokenizer<Symbol>;
15+
model: Record<string, unknown>;
16+
issues: Issue[];
17+
valid: boolean;
18+
cursor?: number;
19+
}
20+
21+
export interface PhaseSegment {
22+
readonly tokens: Iterable<Symbol>;
23+
readonly cursor?: number;
24+
}
25+
1326
export function bind<T, P extends Param<string, T>>(options: {
1427
param: P;
1528
tokens: Tokenizer<Flag | Setter | Word>;
@@ -68,6 +81,79 @@ export function bind<T, P extends Param<string, T>>(options: {
6881
};
6982
}
7083

84+
export function bindPhase(options: {
85+
phase: AnyPhase;
86+
segment: PhaseSegment;
87+
tokens: Tokenizer<Symbol>;
88+
}): PhaseBinding {
89+
let { phase, segment } = options;
90+
let tokens = options.tokens;
91+
let params = Object.values(phase.params) as Param<string, unknown>[];
92+
let members = new Set(Array.from(segment.tokens, (token) => token.index));
93+
let cursor = segment.cursor;
94+
let results = new Map<string, Result<unknown>>();
95+
let settled = new Set<string>();
96+
97+
while (true) {
98+
for (let param of params) {
99+
if (settled.has(param.name)) {
100+
continue;
101+
}
102+
103+
let visible = Array.from(tokens).filter((token) => {
104+
return members.has(token.index) &&
105+
(cursor === undefined || token.index <= cursor);
106+
});
107+
let binding = bind({
108+
param,
109+
tokens: new Tokenizer(visible),
110+
});
111+
let remaining = new Set(
112+
Array.from(binding.rest, (token) => token.index),
113+
);
114+
let claimed = new Set(
115+
visible
116+
.filter((token) => !remaining.has(token.index))
117+
.map((token) => token.index),
118+
);
119+
120+
results.set(param.name, binding.result);
121+
122+
if (claimed.size > 0) {
123+
tokens = tokens.claimAll((token) => claimed.has(token.index)).rest;
124+
settled.add(param.name);
125+
}
126+
}
127+
128+
if (cursor === undefined || has(tokens, members, cursor)) {
129+
break;
130+
}
131+
132+
cursor = next(tokens, members, cursor);
133+
}
134+
135+
let model: Record<string, unknown> = {};
136+
let issues: Issue[] = [];
137+
let valid = true;
138+
139+
for (let param of params) {
140+
let result = results.get(param.name);
141+
if (!result) {
142+
continue;
143+
}
144+
145+
issues.push(...result.issues ?? []);
146+
147+
if (result.ok) {
148+
model[param.name] = result.value;
149+
} else {
150+
valid = false;
151+
}
152+
}
153+
154+
return { rest: tokens, model, issues, valid, cursor };
155+
}
156+
71157
function validate<T, P extends Param<string, T>>(
72158
param: P,
73159
value: unknown,
@@ -87,7 +173,11 @@ function validate<T, P extends Param<string, T>>(
87173
if (validated.issues) {
88174
return {
89175
ok: false,
90-
issues: validated.issues.map((i) => ({ ...i, path })),
176+
issues: validated.issues.map((issue) => ({
177+
...issue,
178+
message: issue.message,
179+
path,
180+
})),
91181
};
92182
} else {
93183
return {
@@ -97,3 +187,30 @@ function validate<T, P extends Param<string, T>>(
97187
};
98188
}
99189
}
190+
191+
function has(
192+
tokens: Tokenizer<Symbol>,
193+
members: Set<number>,
194+
index: number,
195+
): boolean {
196+
for (let token of tokens) {
197+
if (members.has(token.index) && token.index === index) {
198+
return true;
199+
}
200+
}
201+
return false;
202+
}
203+
204+
function next(
205+
tokens: Tokenizer<Symbol>,
206+
members: Set<number>,
207+
index: number,
208+
): number | undefined {
209+
for (let token of tokens) {
210+
if (
211+
members.has(token.index) && token.index > index && token.type === "word"
212+
) {
213+
return token.index;
214+
}
215+
}
216+
}

‎lib/command.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// deno-lint-ignore-file ban-types
2+
import { extend } from "./extend.ts";
23
import { executable, route } from "./route.ts";
3-
import type { Definition, Route } from "./types.ts";
4+
import type { Definition, Done, Route } from "./types.ts";
45

56
export type CommandZero<N extends string = string> = Route<
67
N,
78
"help" | "execute",
89
{},
9-
[]
10+
[],
11+
[Done<{}, []>]
1012
>;
1113

1214
export function command<const N extends string>(
@@ -1094,8 +1096,5 @@ export function command(
10941096
start: Definition<string>,
10951097
...elements: readonly ((value: never) => unknown)[]
10961098
): unknown {
1097-
return elements.reduce<unknown>(
1098-
(value, element) => element(value as never),
1099-
route(start, executable()),
1100-
);
1099+
return extend(elements)(route(start, executable()));
11011100
}

‎lib/dynamic.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type {
2+
AnyPhase,
3+
AnyPhases,
4+
AnyRoute,
5+
ChildrenOf,
6+
MethodsOf,
7+
ModelOf,
8+
Phase,
9+
Route,
10+
} from "./types.ts";
11+
12+
export function dynamic<
13+
Before extends AnyRoute,
14+
Requirement,
15+
After extends AnyRoute,
16+
>(
17+
extension: (requires: Requirement) => (input: Seed<Before>) => After,
18+
): (input: Before) => Conjoin<Before, After, Requirement> {
19+
return (route) => {
20+
let phases = [...route.phases];
21+
let phase = phases.pop()!;
22+
23+
phases.push({
24+
...phase,
25+
resolver: extension,
26+
});
27+
phases.push({
28+
params: {},
29+
routes: [],
30+
});
31+
32+
return { ...route, phases } as unknown as Conjoin<
33+
Before,
34+
After,
35+
Requirement
36+
>;
37+
};
38+
}
39+
40+
export type PhaseOf<R extends AnyRoute> = R["phases"][0];
41+
export type PhasesOf<R extends AnyRoute> = R["phases"];
42+
43+
export type Seed<R extends AnyRoute> = Route<
44+
R["name"],
45+
MethodsOf<R>,
46+
ModelOf<R>,
47+
ChildrenOf<R>,
48+
readonly [Phase<{}, [], never>]
49+
>;
50+
51+
type Conjoin<
52+
A extends AnyRoute,
53+
B extends AnyRoute,
54+
Requirement,
55+
> = Route<
56+
B["name"],
57+
MethodsOf<B>,
58+
ModelOf<B>,
59+
ChildrenOf<B>,
60+
ConjoinPhases<A, B, Requirement>
61+
>;
62+
63+
export type ConjoinPhases<
64+
A extends AnyRoute,
65+
B extends AnyRoute,
66+
Requirement,
67+
> = ConcatPhases<
68+
ContinueLast<PhasesOf<A>, Requirement>,
69+
PhasesOf<B>
70+
>;
71+
72+
type ContinueLast<
73+
P extends AnyPhases,
74+
Requirement,
75+
> = P extends readonly [infer Only extends AnyPhase]
76+
? readonly [WithRequirement<Only, Requirement>]
77+
: P extends readonly [
78+
infer Head extends AnyPhase,
79+
...infer Middle extends readonly AnyPhase[],
80+
infer Last extends AnyPhase,
81+
] ? readonly [
82+
Head,
83+
...Middle,
84+
WithRequirement<Last, Requirement>,
85+
]
86+
: never;
87+
88+
type ConcatPhases<
89+
A extends AnyPhases,
90+
B extends AnyPhases,
91+
> = A extends readonly [
92+
infer AHead extends AnyPhase,
93+
...infer ATail extends AnyPhase[],
94+
] ? B extends readonly [
95+
infer BHead extends AnyPhase,
96+
...infer BTail extends AnyPhase[],
97+
] ? readonly [
98+
AHead,
99+
...ATail,
100+
BHead,
101+
...BTail,
102+
]
103+
: never
104+
: never;
105+
106+
type WithRequirement<
107+
P extends AnyPhase,
108+
Requirement,
109+
> = P extends Phase<infer Model, infer Children, never>
110+
? Phase<Model, Children, Requirement>
111+
: never;

0 commit comments

Comments
 (0)