@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.
- package/README.md +68 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +35 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/errors/types.d.ts +9 -0
- package/dist/errors/types.d.ts.map +1 -0
- package/dist/errors/types.js +5 -0
- package/dist/errors/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +200 -0
- package/dist/index.js.map +1 -0
- package/dist/interpreter.d.ts +35 -0
- package/dist/interpreter.d.ts.map +1 -0
- package/dist/interpreter.js +305 -0
- package/dist/interpreter.js.map +1 -0
- package/dist/lexer/chars.d.ts +6 -0
- package/dist/lexer/chars.d.ts.map +1 -0
- package/dist/lexer/chars.js +6 -0
- package/dist/lexer/chars.js.map +1 -0
- package/dist/lexer/cursor.d.ts +16 -0
- package/dist/lexer/cursor.d.ts.map +1 -0
- package/dist/lexer/cursor.js +43 -0
- package/dist/lexer/cursor.js.map +1 -0
- package/dist/lexer/index.d.ts +21 -0
- package/dist/lexer/index.d.ts.map +1 -0
- package/dist/lexer/index.js +163 -0
- package/dist/lexer/index.js.map +1 -0
- package/dist/lexer/keywords.d.ts +5 -0
- package/dist/lexer/keywords.d.ts.map +1 -0
- package/dist/lexer/keywords.js +39 -0
- package/dist/lexer/keywords.js.map +1 -0
- package/dist/lexer/token.d.ts +20 -0
- package/dist/lexer/token.d.ts.map +1 -0
- package/dist/lexer/token.js +2 -0
- package/dist/lexer/token.js.map +1 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +17 -0
- package/dist/lib.js.map +1 -0
- package/dist/parser/ast.d.ts +128 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +2 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/expr.d.ts +4 -0
- package/dist/parser/expr.d.ts.map +1 -0
- package/dist/parser/expr.js +277 -0
- package/dist/parser/expr.js.map +1 -0
- package/dist/parser/index.d.ts +12 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +40 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/printer.d.ts +7 -0
- package/dist/parser/printer.d.ts.map +1 -0
- package/dist/parser/printer.js +130 -0
- package/dist/parser/printer.js.map +1 -0
- package/dist/parser/stmt.d.ts +7 -0
- package/dist/parser/stmt.d.ts.map +1 -0
- package/dist/parser/stmt.js +153 -0
- package/dist/parser/stmt.js.map +1 -0
- package/dist/parser/token-stream.d.ts +19 -0
- package/dist/parser/token-stream.d.ts.map +1 -0
- package/dist/parser/token-stream.js +111 -0
- package/dist/parser/token-stream.js.map +1 -0
- package/dist/parser/type-expr.d.ts +5 -0
- package/dist/parser/type-expr.d.ts.map +1 -0
- package/dist/parser/type-expr.js +54 -0
- package/dist/parser/type-expr.js.map +1 -0
- package/dist/parser/typechecker.d.ts +9 -0
- package/dist/parser/typechecker.d.ts.map +1 -0
- package/dist/parser/typechecker.js +446 -0
- package/dist/parser/typechecker.js.map +1 -0
- package/dist/parser/typed-ast.d.ts +130 -0
- package/dist/parser/typed-ast.d.ts.map +1 -0
- package/dist/parser/typed-ast.js +2 -0
- package/dist/parser/typed-ast.js.map +1 -0
- package/dist/parser/typed-printer.d.ts +3 -0
- package/dist/parser/typed-printer.d.ts.map +1 -0
- package/dist/parser/typed-printer.js +91 -0
- package/dist/parser/typed-printer.js.map +1 -0
- package/dist/types/types.d.ts +28 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/types.js +48 -0
- package/dist/types/types.js.map +1 -0
- package/package.json +70 -0
- package/src/errors/index.ts +38 -0
- package/src/errors/lexical.yml +16 -0
- package/src/errors/name.yml +11 -0
- package/src/errors/syntactic.yml +66 -0
- package/src/errors/typechecker.yml +61 -0
- package/src/errors/types.ts +13 -0
- package/src/index.ts +213 -0
- package/src/interpreter.ts +332 -0
- package/src/lexer/chars.ts +12 -0
- package/src/lexer/cursor.ts +51 -0
- package/src/lexer/index.ts +174 -0
- package/src/lexer/keywords.ts +43 -0
- package/src/lexer/token.ts +62 -0
- package/src/lib.ts +33 -0
- package/src/parser/ast.ts +64 -0
- package/src/parser/expr.ts +313 -0
- package/src/parser/index.ts +50 -0
- package/src/parser/printer.ts +146 -0
- package/src/parser/stmt.ts +176 -0
- package/src/parser/token-stream.ts +121 -0
- package/src/parser/type-expr.ts +63 -0
- package/src/parser/typechecker.ts +406 -0
- package/src/parser/typed-ast.ts +63 -0
- package/src/parser/typed-printer.ts +105 -0
- 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
|
+
}
|