Skip to content

Commit b8dfcf3

Browse files
committed
feat(nondeterministic): add recognizeAt and subpath exports
- add src/nondeterministic.ts with recognizeAt/allMatches/furthestAll\n- re-export allMatches/furthestAll from combinators for compatibility\n- add deno.json exports for /nondeterministic and /perf\n- document nondeterministic module in README\n- add tests
1 parent a414904 commit b8dfcf3

6 files changed

Lines changed: 282 additions & 108 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ compose them into a grammar.
1515
import { seq, str } from "jsr:@claudiu-ceia/combine@^0.2.6";
1616
```
1717

18+
Subpath imports are also supported:
19+
20+
```ts
21+
import { recognizeAt } from "jsr:@claudiu-ceia/combine/nondeterministic";
22+
import { createTracer } from "jsr:@claudiu-ceia/combine/perf";
23+
```
24+
1825
### Node (npm)
1926

2027
```sh
@@ -105,6 +112,19 @@ import { formatErrorStack } from "@claudiu-ceia/combine";
105112
if (!result.success) console.error(formatErrorStack(result));
106113
```
107114

115+
## Nondeterministic Recognizers
116+
117+
Most combinators are deterministic: they return a single success or failure. For
118+
tokenizer-like use cases where you want _multiple_ simultaneous matches at the
119+
same input position, use the nondeterministic/recognizer module:
120+
121+
```ts
122+
import { recognizeAt } from "jsr:@claudiu-ceia/combine/nondeterministic";
123+
```
124+
125+
These combinators can return multiple successes; you must decide how (or
126+
whether) to advance the cursor.
127+
108128
## More Examples
109129

110130
- `tests/` has the most coverage and real usage patterns

deno.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
{
22
"name": "@claudiu-ceia/combine",
33
"version": "0.2.7",
4-
"exports": "./mod.ts",
4+
"exports": {
5+
".": "./mod.ts",
6+
"./nondeterministic": "./src/nondeterministic.ts",
7+
"./perf": "./src/perf.ts"
8+
},
59
"publish": {
610
"exclude": ["bench/", "tests/", "npm/"]
711
},

mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./src/Parser.ts";
22
export * from "./src/combinators.ts";
3+
export * from "./src/nondeterministic.ts";
34
export * from "./src/parsers.ts";
45
export * from "./src/utility.ts";
56
export * from "./src/language.ts";

src/combinators.ts

Lines changed: 1 addition & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -232,113 +232,7 @@ export const furthest = <T>(...parsers: Parser<T>[]): Parser<T> => {
232232
*
233233
* Fatal errors are propagated immediately.
234234
*/
235-
export const furthestAll = <T extends [...Parser<unknown>[]]>(
236-
...parsers: [...T]
237-
): Parser<ArrayUnion<UnwrapParsers<T>>[]> => {
238-
return (ctx) => {
239-
if (parsers.length === 0) {
240-
return failure(
241-
ctx,
242-
"furthestAll: expected at least one parser",
243-
);
244-
}
245-
246-
let bestIndex = -1;
247-
let bestCtx: Context | undefined;
248-
let bestValues: ArrayUnion<UnwrapParsers<T>>[] = [];
249-
250-
let furthestFailure: Failure | undefined;
251-
252-
for (const parser of parsers) {
253-
const res = parser(ctx) as Result<ArrayUnion<UnwrapParsers<T>>>;
254-
255-
// Fatal errors propagate immediately - no backtracking
256-
if (!res.success && isFatal(res)) {
257-
return res;
258-
}
259-
260-
if (res.success) {
261-
const idx = res.ctx.index;
262-
if (idx > bestIndex) {
263-
bestIndex = idx;
264-
bestCtx = res.ctx;
265-
bestValues = [res.value];
266-
} else if (idx === bestIndex) {
267-
bestValues.push(res.value);
268-
}
269-
continue;
270-
}
271-
272-
if (!furthestFailure || furthestFailure.ctx.index < res.ctx.index) {
273-
furthestFailure = res;
274-
}
275-
}
276-
277-
if (bestCtx) {
278-
return success(bestCtx, bestValues);
279-
}
280-
281-
assert(furthestFailure);
282-
return furthestFailure;
283-
};
284-
};
285-
286-
/**
287-
* Try all parsers and collect all successful matches (even if they consume
288-
* different amounts of input). The returned context advances to the furthest
289-
* successful match (highest index), so parsing can continue from the longest
290-
* match.
291-
*
292-
* If none match, returns the failure result of the parser that consumed the
293-
* most input.
294-
*
295-
* Fatal errors are propagated immediately.
296-
*/
297-
export const allMatches = <T extends [...Parser<unknown>[]]>(
298-
...parsers: [...T]
299-
): Parser<ArrayUnion<UnwrapParsers<T>>[]> => {
300-
return (ctx) => {
301-
if (parsers.length === 0) {
302-
return failure(
303-
ctx,
304-
"allMatches: expected at least one parser",
305-
);
306-
}
307-
308-
let bestSuccessCtx: Context | undefined;
309-
const values: ArrayUnion<UnwrapParsers<T>>[] = [];
310-
311-
let furthestFailure: Failure | undefined;
312-
313-
for (const parser of parsers) {
314-
const res = parser(ctx) as Result<ArrayUnion<UnwrapParsers<T>>>;
315-
316-
// Fatal errors propagate immediately - no backtracking
317-
if (!res.success && isFatal(res)) {
318-
return res;
319-
}
320-
321-
if (res.success) {
322-
values.push(res.value);
323-
if (!bestSuccessCtx || bestSuccessCtx.index < res.ctx.index) {
324-
bestSuccessCtx = res.ctx;
325-
}
326-
continue;
327-
}
328-
329-
if (!furthestFailure || furthestFailure.ctx.index < res.ctx.index) {
330-
furthestFailure = res;
331-
}
332-
}
333-
334-
if (bestSuccessCtx) {
335-
return success(bestSuccessCtx, values);
336-
}
337-
338-
assert(furthestFailure);
339-
return furthestFailure;
340-
};
341-
};
235+
export { allMatches, furthestAll } from "./nondeterministic.ts";
342236

343237
/**
344238
* Try a parser. If it matches, return it, otherwise return a `null`

src/nondeterministic.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { assert } from "./internal_assert.ts";
2+
import {
3+
type Context,
4+
type Failure,
5+
failure,
6+
isFatal,
7+
type Parser,
8+
type Result,
9+
success,
10+
} from "./Parser.ts";
11+
12+
/**
13+
* Nondeterministic combinators.
14+
*
15+
* These are closer to "recognizers" / tokenizers: they can return multiple
16+
* successful alternatives for the same input position.
17+
*
18+
* Important:
19+
* - Do not use these inside `many(...)` unless you are explicitly advancing
20+
* the cursor yourself (otherwise you can create non-terminating loops).
21+
* - When multiple matches exist, you must decide how (or whether) to advance.
22+
*/
23+
24+
/**
25+
* A single recognized match: a value and the context where that value ends.
26+
*/
27+
export type Recognition<T> = Readonly<{
28+
value: T;
29+
ctx: Context;
30+
}>;
31+
32+
type UnwrapParser<T> = T extends Parser<infer U> ? U : T;
33+
34+
type UnwrapParsers<T extends [...unknown[]]> = T extends [
35+
infer Head,
36+
...infer Tail,
37+
] ? [UnwrapParser<Head>, ...UnwrapParsers<Tail>]
38+
: [];
39+
40+
type ArrayUnion<T extends unknown[]> = T extends [infer Head, ...infer Tail]
41+
? Head | ArrayUnion<Tail>
42+
: never;
43+
44+
/**
45+
* Run all parsers at the same input position and return every successful match.
46+
*
47+
* - On success: returns an array of recognitions (value + end ctx) and does NOT
48+
* advance the returned success ctx (it stays at the starting ctx).
49+
* - If no parser matches: returns the failure of the parser that got furthest.
50+
* - Fatal failures propagate immediately (no backtracking).
51+
*
52+
* Matches are returned sorted by descending end index (longest match first),
53+
* stable for ties (preserves parser order).
54+
*/
55+
export const recognizeAt = <T extends [...Parser<unknown>[]]>(
56+
...parsers: [...T]
57+
): Parser<Recognition<ArrayUnion<UnwrapParsers<T>>>[]> => {
58+
return (ctx) => {
59+
if (parsers.length === 0) {
60+
return failure(ctx, "recognizeAt: expected at least one parser");
61+
}
62+
63+
const matches: Recognition<ArrayUnion<UnwrapParsers<T>>>[] = [];
64+
let furthestFailure: Failure | undefined;
65+
66+
for (const parser of parsers) {
67+
const res = parser(ctx) as Result<ArrayUnion<UnwrapParsers<T>>>;
68+
69+
if (!res.success && isFatal(res)) return res;
70+
71+
if (res.success) {
72+
matches.push({ value: res.value, ctx: res.ctx });
73+
continue;
74+
}
75+
76+
if (!furthestFailure || furthestFailure.ctx.index < res.ctx.index) {
77+
furthestFailure = res;
78+
}
79+
}
80+
81+
if (matches.length === 0) {
82+
assert(furthestFailure);
83+
return furthestFailure;
84+
}
85+
86+
// Longest match first; stable for equal indexes.
87+
const sorted = matches
88+
.map((m, i) => ({ m, i }))
89+
.sort((a, b) => (b.m.ctx.index - a.m.ctx.index) || (a.i - b.i))
90+
.map((x) => x.m);
91+
92+
return success(ctx, sorted);
93+
};
94+
};
95+
96+
/**
97+
* Try all parsers and collect all successful matches that consume the most
98+
* input, then return them as an array and advance to that furthest position.
99+
*
100+
* If none match, returns the failure result of the parser that consumed the
101+
* most input.
102+
*
103+
* Fatal errors are propagated immediately.
104+
*/
105+
export const furthestAll = <T extends [...Parser<unknown>[]]>(
106+
...parsers: [...T]
107+
): Parser<ArrayUnion<UnwrapParsers<T>>[]> => {
108+
return (ctx) => {
109+
if (parsers.length === 0) {
110+
return failure(
111+
ctx,
112+
"furthestAll: expected at least one parser",
113+
);
114+
}
115+
116+
let bestIndex = -1;
117+
let bestCtx: Context | undefined;
118+
let bestValues: ArrayUnion<UnwrapParsers<T>>[] = [];
119+
120+
let furthestFailure: Failure | undefined;
121+
122+
for (const parser of parsers) {
123+
const res = parser(ctx) as Result<ArrayUnion<UnwrapParsers<T>>>;
124+
125+
// Fatal errors propagate immediately - no backtracking
126+
if (!res.success && isFatal(res)) {
127+
return res;
128+
}
129+
130+
if (res.success) {
131+
const idx = res.ctx.index;
132+
if (idx > bestIndex) {
133+
bestIndex = idx;
134+
bestCtx = res.ctx;
135+
bestValues = [res.value];
136+
} else if (idx === bestIndex) {
137+
bestValues.push(res.value);
138+
}
139+
continue;
140+
}
141+
142+
if (!furthestFailure || furthestFailure.ctx.index < res.ctx.index) {
143+
furthestFailure = res;
144+
}
145+
}
146+
147+
if (bestCtx) {
148+
return success(bestCtx, bestValues);
149+
}
150+
151+
assert(furthestFailure);
152+
return furthestFailure;
153+
};
154+
};
155+
156+
/**
157+
* Try all parsers and collect all successful matches (even if they consume
158+
* different amounts of input). The returned context advances to the furthest
159+
* successful match (highest index), so parsing can continue from the longest
160+
* match.
161+
*
162+
* If none match, returns the failure result of the parser that consumed the
163+
* most input.
164+
*
165+
* Fatal errors are propagated immediately.
166+
*/
167+
export const allMatches = <T extends [...Parser<unknown>[]]>(
168+
...parsers: [...T]
169+
): Parser<ArrayUnion<UnwrapParsers<T>>[]> => {
170+
return (ctx) => {
171+
if (parsers.length === 0) {
172+
return failure(
173+
ctx,
174+
"allMatches: expected at least one parser",
175+
);
176+
}
177+
178+
let bestSuccessCtx: Context | undefined;
179+
const values: ArrayUnion<UnwrapParsers<T>>[] = [];
180+
181+
let furthestFailure: Failure | undefined;
182+
183+
for (const parser of parsers) {
184+
const res = parser(ctx) as Result<ArrayUnion<UnwrapParsers<T>>>;
185+
186+
// Fatal errors propagate immediately - no backtracking
187+
if (!res.success && isFatal(res)) {
188+
return res;
189+
}
190+
191+
if (res.success) {
192+
values.push(res.value);
193+
if (!bestSuccessCtx || bestSuccessCtx.index < res.ctx.index) {
194+
bestSuccessCtx = res.ctx;
195+
}
196+
continue;
197+
}
198+
199+
if (!furthestFailure || furthestFailure.ctx.index < res.ctx.index) {
200+
furthestFailure = res;
201+
}
202+
}
203+
204+
if (bestSuccessCtx) {
205+
return success(bestSuccessCtx, values);
206+
}
207+
208+
assert(furthestFailure);
209+
return furthestFailure;
210+
};
211+
};

0 commit comments

Comments
 (0)