Skip to content

Commit d93f74f

Browse files
committed
feat: add lexer helpers (lexeme/trivia)
1 parent c4a909b commit d93f74f

5 files changed

Lines changed: 217 additions & 0 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ if (!result.success) console.error(formatErrorStack(result));
115115
If you want the deeper explanations (recursion patterns, `createLanguage`, error
116116
handling, `cut` vs `context`, and `any` vs `furthest`), see `docs/guide.md`.
117117

118+
The guide also covers the optional lexer layer (`lexeme`, `symbol`, `keyword`,
119+
`createLexer`) for trivia/comments.
120+
118121
## License
119122

120123
MIT © [Claudiu Ceia](https://github.com/ClaudiuCeia)

docs/guide.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,77 @@ if (res.success) {
217217
console.log(formatTraceTable(tr.rows()));
218218
}
219219
```
220+
221+
## Lexer layer (optional)
222+
223+
For larger grammars, it's common to separate "lexing" (whitespace/comments and
224+
token-like units) from higher-level structure, otherwise every rule ends up
225+
sprinkling `space()`/`regex()`/`optional(...)`.
226+
227+
This repo includes a minimal lexer layer that consumes trailing trivia and drops
228+
it from the output.
229+
230+
Exports are in `src/lexer.ts`:
231+
232+
- `defaultTrivia()` skips whitespace plus `//` and `/* ... */` comments
233+
- `lexeme(p, trivia?)` runs `p` and then consumes trailing trivia
234+
- `symbol("...")` is `lexeme(str("..."))`
235+
- `keyword("if")` like `symbol`, but enforces an identifier boundary (won't
236+
match `ifx`)
237+
- `createLexer({ trivia? })` builds a small helper object around your trivia
238+
policy
239+
240+
Example:
241+
242+
```ts
243+
import { any, createLexer, map, seq } from "@claudiu-ceia/combine";
244+
import { eof, int } from "@claudiu-ceia/combine";
245+
246+
const L = createLexer();
247+
248+
const expr = any(
249+
L.lexeme(int()),
250+
map(seq(L.symbol("("), L.lexeme(int()), L.symbol(")")), ([, n]) => n),
251+
);
252+
253+
const program = seq(expr, eof());
254+
```
255+
256+
### With `createLanguageThis`
257+
258+
The lexer layer and `createLanguageThis` are complementary: the lexer keeps
259+
trivia handling out of your productions, while `createLanguageThis` handles
260+
mutual recursion without worrying about declaration order.
261+
262+
```ts
263+
import {
264+
any,
265+
createLanguageThis,
266+
createLexer,
267+
map,
268+
seq,
269+
} from "@claudiu-ceia/combine";
270+
import { eof, int, regex } from "@claudiu-ceia/combine";
271+
272+
const Lx = createLexer();
273+
274+
const Lang = createLanguageThis({
275+
Ident() {
276+
return Lx.lexeme(regex(/[a-zA-Z_][a-zA-Z0-9_]*/, "identifier"));
277+
},
278+
Atom() {
279+
return any(
280+
Lx.lexeme(int()),
281+
this.Ident,
282+
map(seq(Lx.symbol("("), this.Expr, Lx.symbol(")")), ([, e]) => e),
283+
);
284+
},
285+
Expr() {
286+
return this.Atom;
287+
},
288+
Program() {
289+
// Parse leading trivia once at the entry point.
290+
return map(seq(Lx.trivia, this.Expr, eof()), ([, e]) => e);
291+
},
292+
});
293+
```

mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./src/parsers.ts";
44
export * from "./src/utility.ts";
55
export * from "./src/language.ts";
66
export * from "./src/perf.ts";
7+
export * from "./src/lexer.ts";

src/lexer.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { any, not, seq, skipMany, surrounded } from "./combinators.ts";
2+
import type { Parser } from "./Parser.ts";
3+
import { regex, space, str } from "./parsers.ts";
4+
import { map } from "./utility.ts";
5+
6+
export type TriviaParser = Parser<null>;
7+
8+
/**
9+
* Match and skip a line comment: `// ...` until (but not including) `\n`.
10+
*/
11+
export const lineComment = (): Parser<null> => {
12+
return map(regex(/\/\/[^\n]*/, "line comment"), () => null);
13+
};
14+
15+
/**
16+
* Match and skip a block comment (non-greedy).
17+
*/
18+
export const blockComment = (): Parser<null> => {
19+
return map(regex(/\/\*[\s\S]*?\*\//, "block comment"), () => null);
20+
};
21+
22+
/**
23+
* Default "trivia" parser: whitespace and line/block comments.
24+
*
25+
* Designed to be used with `lexeme(...)` so most parsers don't need to handle
26+
* trivia explicitly.
27+
*/
28+
export const defaultTrivia = (): TriviaParser => {
29+
const piece = any(space(), lineComment(), blockComment());
30+
return skipMany(piece);
31+
};
32+
33+
/**
34+
* Parse `p` and then consume trailing trivia.
35+
*/
36+
export const lexeme = <T>(
37+
p: Parser<T>,
38+
trivia: TriviaParser = defaultTrivia(),
39+
): Parser<T> => {
40+
return map(seq(p, trivia), ([v]) => v);
41+
};
42+
43+
/**
44+
* Parse a fixed string token and consume trailing trivia.
45+
*/
46+
export const symbol = (
47+
s: string,
48+
trivia: TriviaParser = defaultTrivia(),
49+
): Parser<string> => {
50+
return lexeme(str(s), trivia);
51+
};
52+
53+
const identContinueChar = (): Parser<string> => {
54+
return regex(/[a-zA-Z0-9_]/, "identifier char");
55+
};
56+
57+
/**
58+
* Parse a keyword and consume trailing trivia.
59+
*
60+
* Ensures the keyword is not immediately followed by an identifier character.
61+
*/
62+
export const keyword = (
63+
s: string,
64+
trivia: TriviaParser = defaultTrivia(),
65+
): Parser<string> => {
66+
return lexeme(
67+
map(seq(str(s), not(identContinueChar())), ([kw]) => kw),
68+
trivia,
69+
);
70+
};
71+
72+
export type Lexer = Readonly<{
73+
trivia: TriviaParser;
74+
lexeme: <T>(p: Parser<T>) => Parser<T>;
75+
symbol: (s: string) => Parser<string>;
76+
keyword: (s: string) => Parser<string>;
77+
parens: <T>(p: Parser<T>) => Parser<T>;
78+
}>;
79+
80+
/**
81+
* Create a small "lexer layer" around a trivia parser.
82+
*
83+
* This keeps grammars readable by centralizing whitespace/comment handling.
84+
*/
85+
export const createLexer = (opts?: { trivia?: TriviaParser }): Lexer => {
86+
const trivia = opts?.trivia ?? defaultTrivia();
87+
return {
88+
trivia,
89+
lexeme: <T>(p: Parser<T>): Parser<T> => lexeme(p, trivia),
90+
symbol: (s: string): Parser<string> => symbol(s, trivia),
91+
keyword: (s: string): Parser<string> => keyword(s, trivia),
92+
parens: <T>(p: Parser<T>): Parser<T> =>
93+
surrounded(symbol("(", trivia), p, symbol(")", trivia)),
94+
};
95+
};

tests/lexer.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { assertEquals } from "@std/assert";
2+
import {
3+
createLexer,
4+
defaultTrivia,
5+
keyword,
6+
lexeme,
7+
symbol,
8+
} from "../src/lexer.ts";
9+
import { eof, int, str } from "../src/parsers.ts";
10+
import { seq } from "../src/combinators.ts";
11+
import { map } from "../src/utility.ts";
12+
13+
Deno.test("lexeme consumes trailing whitespace", () => {
14+
const p = seq(lexeme(str("a")), str("b"), eof());
15+
const res = p({ text: "a b", index: 0 });
16+
assertEquals(res.success, true);
17+
});
18+
19+
Deno.test("defaultTrivia consumes line and block comments", () => {
20+
const p = seq(
21+
symbol("a", defaultTrivia()),
22+
symbol("b", defaultTrivia()),
23+
eof(),
24+
);
25+
const res = p({ text: "a // hi\n /* ok */ b", index: 0 });
26+
assertEquals(res.success, true);
27+
});
28+
29+
Deno.test("keyword enforces identifier boundary", () => {
30+
const p = seq(keyword("if"), eof());
31+
assertEquals(p({ text: "if", index: 0 }).success, true);
32+
assertEquals(p({ text: "ifx", index: 0 }).success, false);
33+
});
34+
35+
Deno.test("createLexer provides a consistent trivia policy", () => {
36+
const L = createLexer();
37+
const p = map(
38+
seq(L.symbol("("), L.lexeme(int()), L.symbol(")"), eof()),
39+
([, n]) => n,
40+
);
41+
const res = p({ text: "( 12 /*x*/ )", index: 0 });
42+
assertEquals(res.success, true);
43+
if (res.success) assertEquals(res.value, 12);
44+
});

0 commit comments

Comments
 (0)