@ascent-lang/dev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +68 -0
  2. package/dist/errors/index.d.ts +4 -0
  3. package/dist/errors/index.d.ts.map +1 -0
  4. package/dist/errors/index.js +35 -0
  5. package/dist/errors/index.js.map +1 -0
  6. package/dist/errors/types.d.ts +9 -0
  7. package/dist/errors/types.d.ts.map +1 -0
  8. package/dist/errors/types.js +5 -0
  9. package/dist/errors/types.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +200 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/interpreter.d.ts +35 -0
  15. package/dist/interpreter.d.ts.map +1 -0
  16. package/dist/interpreter.js +305 -0
  17. package/dist/interpreter.js.map +1 -0
  18. package/dist/lexer/chars.d.ts +6 -0
  19. package/dist/lexer/chars.d.ts.map +1 -0
  20. package/dist/lexer/chars.js +6 -0
  21. package/dist/lexer/chars.js.map +1 -0
  22. package/dist/lexer/cursor.d.ts +16 -0
  23. package/dist/lexer/cursor.d.ts.map +1 -0
  24. package/dist/lexer/cursor.js +43 -0
  25. package/dist/lexer/cursor.js.map +1 -0
  26. package/dist/lexer/index.d.ts +21 -0
  27. package/dist/lexer/index.d.ts.map +1 -0
  28. package/dist/lexer/index.js +163 -0
  29. package/dist/lexer/index.js.map +1 -0
  30. package/dist/lexer/keywords.d.ts +5 -0
  31. package/dist/lexer/keywords.d.ts.map +1 -0
  32. package/dist/lexer/keywords.js +39 -0
  33. package/dist/lexer/keywords.js.map +1 -0
  34. package/dist/lexer/token.d.ts +20 -0
  35. package/dist/lexer/token.d.ts.map +1 -0
  36. package/dist/lexer/token.js +2 -0
  37. package/dist/lexer/token.js.map +1 -0
  38. package/dist/lib.d.ts +14 -0
  39. package/dist/lib.d.ts.map +1 -0
  40. package/dist/lib.js +17 -0
  41. package/dist/lib.js.map +1 -0
  42. package/dist/parser/ast.d.ts +128 -0
  43. package/dist/parser/ast.d.ts.map +1 -0
  44. package/dist/parser/ast.js +2 -0
  45. package/dist/parser/ast.js.map +1 -0
  46. package/dist/parser/expr.d.ts +4 -0
  47. package/dist/parser/expr.d.ts.map +1 -0
  48. package/dist/parser/expr.js +277 -0
  49. package/dist/parser/expr.js.map +1 -0
  50. package/dist/parser/index.d.ts +12 -0
  51. package/dist/parser/index.d.ts.map +1 -0
  52. package/dist/parser/index.js +40 -0
  53. package/dist/parser/index.js.map +1 -0
  54. package/dist/parser/printer.d.ts +7 -0
  55. package/dist/parser/printer.d.ts.map +1 -0
  56. package/dist/parser/printer.js +130 -0
  57. package/dist/parser/printer.js.map +1 -0
  58. package/dist/parser/stmt.d.ts +7 -0
  59. package/dist/parser/stmt.d.ts.map +1 -0
  60. package/dist/parser/stmt.js +153 -0
  61. package/dist/parser/stmt.js.map +1 -0
  62. package/dist/parser/token-stream.d.ts +19 -0
  63. package/dist/parser/token-stream.d.ts.map +1 -0
  64. package/dist/parser/token-stream.js +111 -0
  65. package/dist/parser/token-stream.js.map +1 -0
  66. package/dist/parser/type-expr.d.ts +5 -0
  67. package/dist/parser/type-expr.d.ts.map +1 -0
  68. package/dist/parser/type-expr.js +54 -0
  69. package/dist/parser/type-expr.js.map +1 -0
  70. package/dist/parser/typechecker.d.ts +9 -0
  71. package/dist/parser/typechecker.d.ts.map +1 -0
  72. package/dist/parser/typechecker.js +446 -0
  73. package/dist/parser/typechecker.js.map +1 -0
  74. package/dist/parser/typed-ast.d.ts +130 -0
  75. package/dist/parser/typed-ast.d.ts.map +1 -0
  76. package/dist/parser/typed-ast.js +2 -0
  77. package/dist/parser/typed-ast.js.map +1 -0
  78. package/dist/parser/typed-printer.d.ts +3 -0
  79. package/dist/parser/typed-printer.d.ts.map +1 -0
  80. package/dist/parser/typed-printer.js +91 -0
  81. package/dist/parser/typed-printer.js.map +1 -0
  82. package/dist/types/types.d.ts +28 -0
  83. package/dist/types/types.d.ts.map +1 -0
  84. package/dist/types/types.js +48 -0
  85. package/dist/types/types.js.map +1 -0
  86. package/package.json +70 -0
  87. package/src/errors/index.ts +38 -0
  88. package/src/errors/lexical.yml +16 -0
  89. package/src/errors/name.yml +11 -0
  90. package/src/errors/syntactic.yml +66 -0
  91. package/src/errors/typechecker.yml +61 -0
  92. package/src/errors/types.ts +13 -0
  93. package/src/index.ts +213 -0
  94. package/src/interpreter.ts +332 -0
  95. package/src/lexer/chars.ts +12 -0
  96. package/src/lexer/cursor.ts +51 -0
  97. package/src/lexer/index.ts +174 -0
  98. package/src/lexer/keywords.ts +43 -0
  99. package/src/lexer/token.ts +62 -0
  100. package/src/lib.ts +33 -0
  101. package/src/parser/ast.ts +64 -0
  102. package/src/parser/expr.ts +313 -0
  103. package/src/parser/index.ts +50 -0
  104. package/src/parser/printer.ts +146 -0
  105. package/src/parser/stmt.ts +176 -0
  106. package/src/parser/token-stream.ts +121 -0
  107. package/src/parser/type-expr.ts +63 -0
  108. package/src/parser/typechecker.ts +406 -0
  109. package/src/parser/typed-ast.ts +63 -0
  110. package/src/parser/typed-printer.ts +105 -0
  111. package/src/types/types.ts +65 -0
@@ -0,0 +1,174 @@
1
+ import type { Position, Span, Marker } from './token.js';
2
+ import type { Token, TokenKind } from './token.js';
3
+ import { isDigit, isAlpha, isAlphaNum, isWhitespace } from './chars.js';
4
+ import { Cursor } from './cursor.js';
5
+ import { resolveWord } from './keywords.js';
6
+
7
+ export interface LexResult {
8
+ tokens: Token[];
9
+ errorMarkers: Marker[];
10
+ }
11
+
12
+ export class Lexer {
13
+ private c: Cursor;
14
+ private errorMarkers: Marker[] = [];
15
+
16
+ public constructor(src: string) {
17
+ this.c = new Cursor(src);
18
+ }
19
+
20
+ private token(kind: TokenKind, start: Position): Token {
21
+ return { kind, value: this.c.slice(start), span: this.c.spanFrom(start) };
22
+ }
23
+
24
+ private error(code: string, span: Span): Token {
25
+ this.errorMarkers.push({ code, span });
26
+ return { kind: 'ERROR', value: '', span };
27
+ }
28
+
29
+ private consumeWhile(pred: (ch: string) => boolean): void {
30
+ while (pred(this.c.peek())) {
31
+ this.c.advance();
32
+ }
33
+ }
34
+
35
+ private skipWhitespace(): void {
36
+ this.consumeWhile(isWhitespace);
37
+ }
38
+
39
+ private readWord(): Token {
40
+ const start = this.c.mark();
41
+ const firstCh = this.c.peek();
42
+ this.consumeWhile(isAlphaNum);
43
+ const kind = resolveWord(this.c.slice(start), firstCh);
44
+ return kind !== null ? this.token(kind, start) : this.error('L0001', this.c.spanFrom(start));
45
+ }
46
+
47
+ private readString(start: Position): Token {
48
+ let value = '';
49
+ while (true) {
50
+ const ch = this.c.peek();
51
+ if (ch === '\0' || ch === '\n') {
52
+ return this.error('L0003', this.c.spanFrom(start));
53
+ }
54
+ if (ch === '"') {
55
+ this.c.advance(); // consume closing '"'
56
+ return { kind: 'STR_LIT', value, span: this.c.spanFrom(start) };
57
+ }
58
+ if (ch === '\\') {
59
+ this.c.advance(); // consume '\'
60
+ const esc = this.c.peek();
61
+ this.c.advance(); // consume escape char
62
+ switch (esc) {
63
+ case 'n': value += '\n'; break;
64
+ case 't': value += '\t'; break;
65
+ case 'r': value += '\r'; break;
66
+ case '"': value += '"'; break;
67
+ case '\\': value += '\\'; break;
68
+ default: return this.error('L0001', this.c.spanFrom(start));
69
+ }
70
+ } else {
71
+ value += ch;
72
+ this.c.advance();
73
+ }
74
+ }
75
+ }
76
+
77
+ private readNumber(): Token {
78
+ const start = this.c.mark();
79
+ this.consumeWhile(isDigit);
80
+
81
+ // Float: only consume the dot when a digit follows — this keeps '3.method()'
82
+ // valid in later sections where '.' is the member-access operator.
83
+ let kind: TokenKind = 'INT_LIT';
84
+ if (this.c.peek() === '.' && isDigit(this.c.peek(1))) {
85
+ this.c.advance(); // '.'
86
+ this.consumeWhile(isDigit);
87
+ kind = 'FLOAT_LIT';
88
+ }
89
+
90
+ // A number may not be glued to a letter: 123abc / 1.5x are one malformed
91
+ // token, not a number followed by a name.
92
+ if (isAlpha(this.c.peek())) {
93
+ this.consumeWhile(isAlphaNum);
94
+ return this.error('L0002', this.c.spanFrom(start));
95
+ }
96
+
97
+ return this.token(kind, start);
98
+ }
99
+
100
+ private nextToken(): Token {
101
+ this.skipWhitespace();
102
+
103
+ if (this.c.atEnd()) {
104
+ const start = this.c.mark();
105
+ return this.token('EOF', start);
106
+ }
107
+
108
+ const ch = this.c.peek();
109
+
110
+ if (isDigit(ch)) {
111
+ return this.readNumber();
112
+ }
113
+ if (isAlpha(ch)) {
114
+ return this.readWord();
115
+ }
116
+
117
+ // A leading-dot float like .5 looks like a number attempt, so L0002 is
118
+ // more helpful than L0001 ("unexpected character").
119
+ if (ch === '.' && isDigit(this.c.peek(1))) {
120
+ const start = this.c.mark();
121
+ this.c.advance(); // '.'
122
+ this.consumeWhile(isDigit);
123
+ return this.error('L0002', this.c.spanFrom(start));
124
+ }
125
+
126
+ const start = this.c.mark();
127
+ this.c.advance();
128
+
129
+ switch (ch) {
130
+ case '+': return this.token('PLUS', start);
131
+ case '-': return this.token('MINUS', start);
132
+ case '*': return this.token('STAR', start);
133
+ case '/': return this.token('SLASH', start);
134
+ case '=':
135
+ if (this.c.match('=')) return this.token('EQ_EQ', start);
136
+ return this.token('EQUALS', start);
137
+ case '!':
138
+ // Bare '!' has no meaning — negation is the word 'not' (§5), so
139
+ // only '!=' is a valid token starting with '!'.
140
+ if (this.c.match('=')) return this.token('BANG_EQ', start);
141
+ return this.error('L0001', this.c.spanFrom(start));
142
+ case '<':
143
+ if (this.c.match('=')) return this.token('LT_EQ', start);
144
+ return this.token('LT', start);
145
+ case '>':
146
+ if (this.c.match('=')) return this.token('GT_EQ', start);
147
+ return this.token('GT', start);
148
+ case '"': return this.readString(start);
149
+ case '.': return this.token('DOT', start);
150
+ case ':': return this.token('COLON', start);
151
+ case ',': return this.token('COMMA', start);
152
+ case ';': return this.token('SEMICOLON', start);
153
+ case '(': return this.token('LPAREN', start);
154
+ case ')': return this.token('RPAREN', start);
155
+ case '{': return this.token('LBRACE', start);
156
+ case '}': return this.token('RBRACE', start);
157
+ case '[': return this.token('LBRACKET', start);
158
+ case ']': return this.token('RBRACKET', start);
159
+ default: return this.error('L0001', this.c.spanFrom(start));
160
+ }
161
+ }
162
+
163
+ public tokenize(): LexResult {
164
+ const tokens: Token[] = [];
165
+ while (true) {
166
+ const tok = this.nextToken();
167
+ tokens.push(tok);
168
+ if (tok.kind === 'EOF') {
169
+ break;
170
+ }
171
+ }
172
+ return { tokens, errorMarkers: this.errorMarkers };
173
+ }
174
+ }
@@ -0,0 +1,43 @@
1
+ import type { TokenKind } from './token.js';
2
+
3
+ export const KEYWORDS: Record<string, TokenKind> = {
4
+ div: 'KW_DIV',
5
+ mod: 'KW_MOD',
6
+ fix: 'KW_FIX',
7
+ mut: 'KW_MUT',
8
+ if: 'KW_IF',
9
+ else: 'KW_ELSE',
10
+ while: 'KW_WHILE',
11
+ args: 'KW_ARGS',
12
+ };
13
+
14
+ // Built-in constructors: uppercase names that are part of the language
15
+ // core but are not keywords — they are non-shadowable constructor names
16
+ // (True, False, None) that happen to be built in rather than user-defined.
17
+ export const CONSTRUCTORS: Record<string, TokenKind> = {
18
+ True: 'BOOL_LIT',
19
+ False: 'BOOL_LIT',
20
+ None: 'NONE_LIT',
21
+ Done: 'DONE_LIT',
22
+ };
23
+
24
+ // Built-in type names: recognised as TYPE_NAME tokens so the parser can
25
+ // use them in type annotations (e.g. args declarations). Other uppercase
26
+ // names that are neither constructors nor type names are still L0001.
27
+ const BUILTIN_TYPES: Record<string, TokenKind> = {
28
+ Int: 'TYPE_NAME',
29
+ Float: 'TYPE_NAME',
30
+ Bool: 'TYPE_NAME',
31
+ String: 'TYPE_NAME',
32
+ List: 'TYPE_NAME',
33
+ };
34
+
35
+ // Returns the token kind for a scanned word, or null for an unrecognised
36
+ // uppercase name (the caller must emit L0001). Lowercase words that are not
37
+ // keywords resolve to SLOT rather than null.
38
+ export function resolveWord(value: string, firstCh: string): TokenKind | null {
39
+ if (firstCh >= 'A' && firstCh <= 'Z') {
40
+ return CONSTRUCTORS[value] ?? BUILTIN_TYPES[value] ?? null;
41
+ }
42
+ return KEYWORDS[value] ?? 'SLOT';
43
+ }
@@ -0,0 +1,62 @@
1
+ export type TokenKind =
2
+ | 'INT_LIT' // a sequence of decimal digits: 0, 42, 1000
3
+ | 'FLOAT_LIT' // a decimal number with a dot: 0.5, 3.14, 1.0
4
+ | 'STR_LIT' // a double-quoted string: "hello"
5
+ | 'BOOL_LIT' // True or False
6
+ | 'NONE_LIT' // None
7
+ | 'DONE_LIT' // Done — the unit constructor
8
+ | 'SLOT' // a lowercase-starting identifier — a binding name
9
+ | 'PLUS' // '+'
10
+ | 'MINUS' // '-'
11
+ | 'STAR' // '*'
12
+ | 'SLASH' // '/', always real division — yields a Float
13
+ | 'KW_DIV' // the keyword div — Int-only floor division
14
+ | 'KW_MOD' // the keyword mod — Int-only floored modulo
15
+ | 'KW_FIX' // the keyword fix — declares a fixed slot
16
+ | 'KW_MUT' // the keyword mut — declares a mutable slot
17
+ | 'KW_IF' // the keyword if — starts a conditional expression
18
+ | 'KW_ELSE' // the keyword else — the alternative branch of an if
19
+ | 'KW_WHILE' // the keyword while — starts a condition loop
20
+ | 'KW_ARGS' // the keyword args — declares the program's typed inputs
21
+ | 'TYPE_NAME' // a built-in type name: Int, Float, Bool, String
22
+ | 'COLON' // ':' — separates a name from its type annotation
23
+ | 'EQUALS' // '=' — used in slot declarations and updates
24
+ | 'EQ_EQ' // '==' — structural equality
25
+ | 'BANG_EQ' // '!=' — structural inequality
26
+ | 'LT' // '<'
27
+ | 'LT_EQ' // '<='
28
+ | 'GT' // '>'
29
+ | 'GT_EQ' // '>='
30
+ | 'DOT' // '.' — method call operator
31
+ | 'COMMA' // ','
32
+ | 'SEMICOLON' // ';' — statement terminator
33
+ | 'LPAREN' // '('
34
+ | 'RPAREN' // ')'
35
+ | 'LBRACE' // '{'
36
+ | 'RBRACE' // '}'
37
+ | 'LBRACKET' // '[' — list literal open / index open
38
+ | 'RBRACKET' // ']' — list literal close / index close
39
+ | 'ERROR' // a character or run the lexer couldn't recognise
40
+ | 'EOF'; // the sentinel that marks the end of source
41
+
42
+ export interface Position {
43
+ offset: number; // 0-based index into the source string
44
+ line: number; // 1-based
45
+ column: number; // 1-based
46
+ }
47
+
48
+ export interface Span {
49
+ start: Position;
50
+ end: Position; // exclusive — points one past the last character
51
+ }
52
+
53
+ export interface Marker {
54
+ code: string;
55
+ span: Span;
56
+ }
57
+
58
+ export interface Token {
59
+ kind: TokenKind;
60
+ value: string;
61
+ span: Span;
62
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Public programmatic API for the Ascent language toolchain.
2
+ // The `ascent` CLI (see index.ts) is the primary entry point, but the
3
+ // individual stages are re-exported here so tools can embed the pipeline:
4
+ //
5
+ // const { tokens, errorMarkers } = new Lexer(src).tokenize();
6
+ // const { program } = new Parser(tokens).parse();
7
+ // const { typedProgram } = typecheck(program!);
8
+ // const result = executeProgram(typedProgram!, new Environment());
9
+
10
+ export { Lexer } from './lexer/index.js';
11
+ export type { LexResult } from './lexer/index.js';
12
+
13
+ export { Parser } from './parser/index.js';
14
+ export type { ParseResult } from './parser/index.js';
15
+
16
+ export { typecheck } from './parser/typechecker.js';
17
+ export type { TypeCheckResult } from './parser/typechecker.js';
18
+
19
+ export {
20
+ Environment,
21
+ evaluateExpr,
22
+ executeStmt,
23
+ executeProgram,
24
+ } from './interpreter.js';
25
+ export type { RuntimeValue, AssignResult } from './interpreter.js';
26
+
27
+ export { formatExpr, formatStmt, formatValue } from './parser/printer.js';
28
+ export { formatTypedStmt } from './parser/typed-printer.js';
29
+
30
+ export * from './types/types.js';
31
+
32
+ export { ERRORS, byCode } from './errors/index.js';
33
+ export type { ErrorEntry, Category } from './errors/types.js';
@@ -0,0 +1,64 @@
1
+ import type { Span } from '../lexer/token.js';
2
+
3
+ // TypeExpr is the AST node for a type written in source code.
4
+ // It carries span information so the type checker can point at it in errors.
5
+ export type TypeExpr =
6
+ | { kind: 'TypeName'; name: 'Int' | 'Float' | 'Bool' | 'String'; span: Span }
7
+ | { kind: 'ListType'; elem: TypeExpr; span: Span };
8
+
9
+ export type Literal = (
10
+ | { kind: 'literal'; valueType: 'Int'; value: bigint; span: Span }
11
+ | { kind: 'literal'; valueType: 'Float'; value: number; span: Span }
12
+ | { kind: 'literal'; valueType: 'Bool'; value: boolean; span: Span }
13
+ | { kind: 'literal'; valueType: 'String'; value: string; span: Span }
14
+ | { kind: 'literal'; valueType: 'None'; span: Span }
15
+ | { kind: 'literal'; valueType: 'Done'; span: Span }
16
+ );
17
+
18
+ export type UnaryOp = '-';
19
+ export type ArithmeticOp = '+' | '-' | '*' | '/' | 'div' | 'mod';
20
+ export type ComparisonOp = '==' | '!=' | '<' | '<=' | '>' | '>=';
21
+ export type BinaryOp = ArithmeticOp | ComparisonOp;
22
+
23
+ // A block is itself an expression — it yields the value of its last
24
+ // statement, or Done when empty (the '{}' unit value).
25
+ export type Block = { kind: 'block'; stmts: Statement[]; span: Span };
26
+
27
+ // 'else if' is sugar: the else branch is either a block or another
28
+ // If, never a separate grammar rule.
29
+ export type If = {
30
+ kind: 'if';
31
+ cond: Expr;
32
+ then: Block;
33
+ else: Block | If | null;
34
+ span: Span;
35
+ };
36
+
37
+ export type Expr = (
38
+ | Literal
39
+ | { kind: 'slot'; name: string; span: Span }
40
+ | { kind: 'call'; callee: string; args: Expr[]; span: Span }
41
+ | { kind: 'methodCall'; receiver: Expr; method: string; args: Expr[]; span: Span }
42
+ | { kind: 'list'; elements: Expr[]; span: Span }
43
+ | { kind: 'index'; list: Expr; index: Expr; span: Span }
44
+ | { kind: 'unary'; op: UnaryOp; operand: Expr; span: Span }
45
+ | { kind: 'binary'; op: BinaryOp; left: Expr; right: Expr; span: Span }
46
+ | Block
47
+ | If
48
+ );
49
+
50
+ // Unlike 'if', 'while' is a statement, not an expression — a loop has
51
+ // no single meaningful result (zero iterations has no last value to
52
+ // give), so it always yields Done rather than forcing a fake one.
53
+ export type Statement = (
54
+ | { kind: 'fix'; name: string; typeAnnotation: TypeExpr | null; init: Expr; span: Span }
55
+ | { kind: 'mut'; name: string; typeAnnotation: TypeExpr | null; init: Expr; span: Span }
56
+ | { kind: 'assign'; name: string; value: Expr; span: Span }
57
+ | { kind: 'expr'; expr: Expr; span: Span }
58
+ | { kind: 'while'; cond: Expr; body: Block; span: Span }
59
+ );
60
+
61
+ export type ArgType = 'Int' | 'Float' | 'Bool' | 'String';
62
+ export type ArgDef = { name: string; type: ArgType };
63
+
64
+ export type Program = { args: ArgDef[]; stmts: Statement[] };
@@ -0,0 +1,313 @@
1
+ import type { Token, TokenKind } from '../lexer/token.js';
2
+ import type { Expr, BinaryOp, UnaryOp } from './ast.js';
3
+ import type { TokenStream } from './token-stream.js';
4
+ import { parseBlock, parseIf } from './stmt.js';
5
+
6
+ // ---- Pratt parsing ----------------------------------------------------
7
+ //
8
+ // A Pratt parser recognises an expression as an atom (a "nud" — null
9
+ // denotation, a value that doesn't look left at anything) optionally
10
+ // followed by a chain of infix or postfix operators (each a "led" —
11
+ // left denotation, because it combines the value already parsed with
12
+ // whatever comes after it).
13
+ //
14
+ // Every operator — prefix, infix, or postfix — has a binding power: a
15
+ // number saying how tightly it grabs its operands. Higher binds
16
+ // tighter — that's what encodes precedence: '*' outbinds '+', so
17
+ // `1 + 2 * 3` parses as `1 + (2 * 3)`, not `(1 + 2) * 3`. The parsing
18
+ // loop only accepts an operator when its binding power is at least
19
+ // `minBp` — the "how tight does the *caller* need things bound"
20
+ // threshold passed down on each recursive call.
21
+ //
22
+ // This ladder is the single source of truth for what binds tighter
23
+ // than what: postfix (`.method()`, `[index]`) binds tightest, then
24
+ // unary '-', then '*'/'/'/'div'/'mod', then '+'/'-', then the
25
+ // comparisons, loosest. Every table below is keyed off these numbers
26
+ // instead of inlining its own.
27
+ const BP = {
28
+ COMPARISON: 1,
29
+ ADDITIVE: 2,
30
+ MULTIPLICATIVE: 3,
31
+ UNARY: 4,
32
+ POSTFIX: 4,
33
+ } as const;
34
+
35
+ // Every binary operator this parser knows about has one row in this
36
+ // table. Adding the next one means adding a row, never touching the
37
+ // loop below. '*', '/', 'div' and 'mod' all share a binding power —
38
+ // they're the same precedence tier — so all four outbind '+', which
39
+ // in turn outbinds the comparisons — `1 + 2 < 3 * 4` groups as
40
+ // `(1 + 2) < (3 * 4)`. Comparisons are also marked `assoc: 'none'`:
41
+ // unlike '+' or '*', two of them can never sit side by side
42
+ // (`a < b < c` is rejected, not silently grouped one way or the other).
43
+ const INFIX_OPS: Partial<Record<TokenKind, { op: BinaryOp; bp: number; assoc: 'left' | 'none' }>> = {
44
+ EQ_EQ: { op: '==', bp: BP.COMPARISON, assoc: 'none' },
45
+ BANG_EQ: { op: '!=', bp: BP.COMPARISON, assoc: 'none' },
46
+ LT: { op: '<', bp: BP.COMPARISON, assoc: 'none' },
47
+ LT_EQ: { op: '<=', bp: BP.COMPARISON, assoc: 'none' },
48
+ GT: { op: '>', bp: BP.COMPARISON, assoc: 'none' },
49
+ GT_EQ: { op: '>=', bp: BP.COMPARISON, assoc: 'none' },
50
+ PLUS: { op: '+', bp: BP.ADDITIVE, assoc: 'left' },
51
+ MINUS: { op: '-', bp: BP.ADDITIVE, assoc: 'left' },
52
+ STAR: { op: '*', bp: BP.MULTIPLICATIVE, assoc: 'left' },
53
+ SLASH: { op: '/', bp: BP.MULTIPLICATIVE, assoc: 'left' },
54
+ KW_DIV: { op: 'div', bp: BP.MULTIPLICATIVE, assoc: 'left' },
55
+ KW_MOD: { op: 'mod', bp: BP.MULTIPLICATIVE, assoc: 'left' },
56
+ };
57
+
58
+ // Postfix table — dot-calls and indexing are "led" operators exactly
59
+ // like the binary ones above (they look left at the value already
60
+ // parsed), so they're declared here and dispatched on in the loop
61
+ // below instead of being special-cased ahead of the INFIX_OPS lookup.
62
+ // Both bind at POSTFIX — tighter than unary or any binary operator —
63
+ // which is why `-a.b()[0]` parses as `-(a.b()[0])`.
64
+ const POSTFIX_OPS: Partial<Record<TokenKind, { bp: number }>> = {
65
+ DOT: { bp: BP.POSTFIX },
66
+ LBRACKET: { bp: BP.POSTFIX },
67
+ };
68
+
69
+ // Prefix table — unary '-' is the Pratt parser's other operator kind
70
+ // (a "nud" that still takes an operand, parsed in parseAtom below).
71
+ // Only one entry today, but its binding power is declared here rather
72
+ // than inlined at the call site.
73
+ const PREFIX_OPS: Partial<Record<TokenKind, { op: UnaryOp; bp: number }>> = {
74
+ MINUS: { op: '-', bp: BP.UNARY },
75
+ };
76
+
77
+ export function parseExpr(ts: TokenStream, minBp = 0): Expr | null {
78
+ let left = parseAtom(ts);
79
+ if (left === null) {
80
+ return null;
81
+ }
82
+
83
+ // Tracks whether a non-associative (comparison-tier) operator has
84
+ // already been consumed at this call's level — a second one directly
85
+ // beside it (`a < b < c`) is a chain, not a grouping choice, so it's
86
+ // rejected here rather than silently parsed left- or right-first.
87
+ let chained = false;
88
+
89
+ while (true) {
90
+ const kind = ts.peek().kind;
91
+
92
+ // Postfix: expr.method(args) or expr[index] — dispatched by table
93
+ // rather than special-cased ahead of the INFIX_OPS lookup below.
94
+ const postfix = POSTFIX_OPS[kind];
95
+ if (postfix !== undefined) {
96
+ if (postfix.bp < minBp) break;
97
+ left = kind === 'DOT' ? parseMethodCall(ts, left) : parseIndex(ts, left);
98
+ if (left === null) return null;
99
+ continue;
100
+ }
101
+
102
+ const infix = INFIX_OPS[kind];
103
+ if (infix === undefined || infix.bp < minBp) {
104
+ break;
105
+ }
106
+
107
+ if (infix.assoc === 'none') {
108
+ if (chained) {
109
+ ts.report('S0008', ts.peek().span);
110
+ return null;
111
+ }
112
+ chained = true;
113
+ }
114
+
115
+ ts.advance(); // consume the operator
116
+
117
+ // Left-associativity — `1 + 2 + 3` must parse as `(1 + 2) + 3`,
118
+ // not `1 + (2 + 3)`. Parsing the right-hand side with `bp + 1`
119
+ // (instead of `bp`) is what enforces that: it stops a second '+'
120
+ // from being absorbed into the *right* operand, forcing it to
121
+ // instead be picked up by the loop one level up. Non-associative
122
+ // operators reuse the same `bp + 1` call — it still keeps looser
123
+ // operators out of the right operand, and the `chained` check
124
+ // above is what stops them from reappearing at this level.
125
+ const right = parseExpr(ts, infix.bp + 1);
126
+ if (right === null) {
127
+ return null;
128
+ }
129
+
130
+ left = {
131
+ kind: 'binary',
132
+ op: infix.op,
133
+ left,
134
+ right,
135
+ span: { start: left.span.start, end: right.span.end }
136
+ };
137
+ }
138
+
139
+ return left;
140
+ }
141
+
142
+ // 'receiver.method(args)' — DOT already confirmed on lookahead by the
143
+ // Pratt loop; this consumes it through the closing ')'.
144
+ function parseMethodCall(ts: TokenStream, receiver: Expr): Expr | null {
145
+ ts.advance(); // consume '.'
146
+
147
+ const methodTok = ts.peek();
148
+ if (methodTok.kind !== 'SLOT') {
149
+ ts.report('S0012', methodTok.span);
150
+ return null;
151
+ }
152
+ ts.advance(); // consume method name
153
+
154
+ if (ts.peek().kind !== 'LPAREN') {
155
+ ts.report('S0001', ts.peek().span);
156
+ return null;
157
+ }
158
+ ts.advance(); // consume '('
159
+
160
+ const parsed = ts.parseSeparated(() => parseExpr(ts), 'COMMA', 'RPAREN', 'S0001');
161
+ if (parsed === null) return null;
162
+
163
+ return {
164
+ kind: 'methodCall',
165
+ receiver,
166
+ method: methodTok.value,
167
+ args: parsed.items,
168
+ span: { start: receiver.span.start, end: parsed.close.span.end },
169
+ };
170
+ }
171
+
172
+ // 'list[index]' — LBRACKET already confirmed on lookahead by the Pratt loop.
173
+ function parseIndex(ts: TokenStream, list: Expr): Expr | null {
174
+ ts.advance(); // consume '['
175
+
176
+ const index = parseExpr(ts);
177
+ if (index === null) return null;
178
+
179
+ const rbracket = ts.expect('RBRACKET', 'S0013');
180
+ if (rbracket === null) return null;
181
+
182
+ return {
183
+ kind: 'index',
184
+ list,
185
+ index,
186
+ span: { start: list.span.start, end: rbracket.span.end },
187
+ };
188
+ }
189
+
190
+ // parseAtom parses the smallest possible expression: a single literal
191
+ // that doesn't depend on any operator. This is the Pratt parser's nud —
192
+ // every future one (parenthesized groups, unary '-', identifiers) is
193
+ // just another case added here, never a change to the loop above.
194
+ function parseAtom(ts: TokenStream): Expr | null {
195
+ const tok = ts.peek();
196
+
197
+ if (tok.kind === 'INT_LIT') {
198
+ ts.advance();
199
+ return {
200
+ kind: 'literal',
201
+ valueType: 'Int',
202
+ value: BigInt(tok.value),
203
+ span: tok.span
204
+ };
205
+ }
206
+
207
+ if (tok.kind === 'FLOAT_LIT') {
208
+ ts.advance();
209
+ return {
210
+ kind: 'literal',
211
+ valueType: 'Float',
212
+ value: parseFloat(tok.value),
213
+ span: tok.span
214
+ };
215
+ }
216
+
217
+ if (tok.kind === 'STR_LIT') {
218
+ ts.advance();
219
+ return { kind: 'literal', valueType: 'String', value: tok.value, span: tok.span };
220
+ }
221
+
222
+ if (tok.kind === 'BOOL_LIT') {
223
+ ts.advance();
224
+ return {
225
+ kind: 'literal',
226
+ valueType: 'Bool',
227
+ value: tok.value === 'True',
228
+ span: tok.span
229
+ };
230
+ }
231
+
232
+ if (tok.kind === 'NONE_LIT') {
233
+ ts.advance();
234
+ return { kind: 'literal', valueType: 'None', span: tok.span };
235
+ }
236
+
237
+ if (tok.kind === 'DONE_LIT') {
238
+ ts.advance();
239
+ return { kind: 'literal', valueType: 'Done', span: tok.span };
240
+ }
241
+
242
+ if (tok.kind === 'SLOT') {
243
+ ts.advance();
244
+ if (ts.peek().kind === 'LPAREN') {
245
+ return parseCall(ts, tok);
246
+ }
247
+ return { kind: 'slot', name: tok.value, span: tok.span };
248
+ }
249
+
250
+ if (tok.kind === 'LPAREN') {
251
+ ts.advance();
252
+ const inner = parseExpr(ts);
253
+ if (inner === null) {
254
+ return null;
255
+ }
256
+ const closing = ts.peek();
257
+ if (closing.kind !== 'RPAREN') {
258
+ ts.report('S0001', closing.span);
259
+ return null;
260
+ }
261
+ ts.advance(); // consume ')'
262
+ return inner;
263
+ }
264
+
265
+ const prefix = PREFIX_OPS[tok.kind];
266
+ if (prefix !== undefined) {
267
+ const start = tok.span.start;
268
+ ts.advance();
269
+ const operand = parseExpr(ts, prefix.bp);
270
+ if (operand === null) {
271
+ return null;
272
+ }
273
+ return { kind: 'unary', op: prefix.op, operand, span: { start, end: operand.span.end } };
274
+ }
275
+
276
+ if (tok.kind === 'LBRACKET') {
277
+ return parseList(ts);
278
+ }
279
+
280
+ if (tok.kind === 'LBRACE') {
281
+ return parseBlock(ts);
282
+ }
283
+
284
+ if (tok.kind === 'KW_IF') {
285
+ return parseIf(ts);
286
+ }
287
+
288
+ ts.report('S0002', tok.span);
289
+ return null;
290
+ }
291
+
292
+ // 'name(arg, arg, …)' — callee token already consumed by parseAtom.
293
+ function parseCall(ts: TokenStream, callee: Token): Expr | null {
294
+ ts.advance(); // consume '('
295
+ const parsed = ts.parseSeparated(() => parseExpr(ts), 'COMMA', 'RPAREN', 'S0001');
296
+ if (parsed === null) return null;
297
+
298
+ return {
299
+ kind: 'call',
300
+ callee: callee.value,
301
+ args: parsed.items,
302
+ span: { start: callee.span.start, end: parsed.close.span.end },
303
+ };
304
+ }
305
+
306
+ // '[' expr, expr, … ']' — list literal. Already peeked '[' in parseAtom.
307
+ function parseList(ts: TokenStream): Expr | null {
308
+ const openTok = ts.advance(); // consume '['
309
+ const parsed = ts.parseSeparated(() => parseExpr(ts), 'COMMA', 'RBRACKET', 'S0013');
310
+ if (parsed === null) return null;
311
+
312
+ return { kind: 'list', elements: parsed.items, span: { start: openTok.span.start, end: parsed.close.span.end } };
313
+ }