@user-synax/synax 0.0.1

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/src/parser.ts ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Synax parser — turns the token stream from `src/lexer.ts` into an AST
3
+ * (`src/ast.ts`) via straightforward recursive descent.
4
+ *
5
+ * Grammar (SPEC.md §4), one method per rule:
6
+ *
7
+ * statement := varDecl | printStmt | ifStmt | fnDecl | forStmt | exprStmt
8
+ * varDecl := "set" IDENT "=" expression
9
+ * printStmt := "print" expression
10
+ * ifStmt := "if" expression statement* ("else" statement*)? "end"
11
+ * fnDecl := "fn" IDENT "(" paramList? ")" statement* "end"
12
+ * forStmt := "for" IDENT "in" expression ".." expression statement* "end"
13
+ * exprStmt := expression
14
+ * expression := comparison
15
+ * comparison := term ((">=" | "<=" | ">" | "<" | "==" | "!=") term)*
16
+ * term := factor (("+" | "-") factor)*
17
+ * factor := primary (("*" | "/") primary)*
18
+ * primary := NUMBER | STRING | IDENT | "(" expression ")" | functionCall
19
+ *
20
+ * Precedence is lowest-to-highest as written, all left-associative (SPEC.md §5).
21
+ *
22
+ * Since Synax has no semicolons, NEWLINE is the statement separator. Blank lines
23
+ * produce runs of NEWLINE tokens; those are skipped rather than becoming empty
24
+ * statements.
25
+ */
26
+ import type { Token } from "./lexer";
27
+ import { TokenType } from "./lexer";
28
+ import type {
29
+ BinaryOperator,
30
+ Expression,
31
+ FnDecl,
32
+ ForStmt,
33
+ IfStmt,
34
+ PrintStmt,
35
+ Program,
36
+ Statement,
37
+ VarDecl,
38
+ } from "./ast";
39
+
40
+ /**
41
+ * Raised for malformed token streams. `line` and `column` are 1-based.
42
+ * Mirrors `LexerError` so diagnostics can be rendered the same way.
43
+ */
44
+ export class ParserError extends Error {
45
+ readonly line: number;
46
+ readonly column: number;
47
+ /** How many characters the error spans; used to underline the source. */
48
+ readonly length: number;
49
+ /** The message on its own, without the appended `(line N)` context. */
50
+ readonly reason: string;
51
+
52
+ constructor(reason: string, line: number, column: number, length = 1) {
53
+ super(`${reason} (line ${line})`);
54
+ this.name = "ParserError";
55
+ this.reason = reason;
56
+ this.line = line;
57
+ this.column = column;
58
+ this.length = length;
59
+ }
60
+ }
61
+
62
+ /** Operator token types, grouped by precedence level (loosest first). */
63
+ const COMPARISON_OPERATORS: ReadonlyMap<TokenType, BinaryOperator> = new Map([
64
+ [TokenType.GTEQ, ">="],
65
+ [TokenType.LTEQ, "<="],
66
+ [TokenType.GT, ">"],
67
+ [TokenType.LT, "<"],
68
+ [TokenType.EQEQ, "=="],
69
+ [TokenType.BANGEQ, "!="],
70
+ ]);
71
+
72
+ const ADDITIVE_OPERATORS: ReadonlyMap<TokenType, BinaryOperator> = new Map([
73
+ [TokenType.PLUS, "+"],
74
+ [TokenType.MINUS, "-"],
75
+ ]);
76
+
77
+ const MULTIPLICATIVE_OPERATORS: ReadonlyMap<TokenType, BinaryOperator> = new Map([
78
+ [TokenType.STAR, "*"],
79
+ [TokenType.SLASH, "/"],
80
+ ]);
81
+
82
+ /**
83
+ * Token types that end a statement block. `if` and `for`/`fn` bodies stop at
84
+ * `end`; `if` then-bodies also stop at `else`.
85
+ */
86
+ const END: readonly TokenType[] = [TokenType.END];
87
+ const ELSE_OR_END: readonly TokenType[] = [TokenType.ELSE, TokenType.END];
88
+
89
+ /** Human-readable token name for error messages. */
90
+ function describe(token: Token): string {
91
+ switch (token.type) {
92
+ case TokenType.EOF:
93
+ return "end of input";
94
+ case TokenType.NEWLINE:
95
+ return "end of line";
96
+ default:
97
+ return `'${token.lexeme}'`;
98
+ }
99
+ }
100
+
101
+ export class Parser {
102
+ /** The tokens being parsed; expected to end with an EOF sentinel. */
103
+ readonly tokens: readonly Token[];
104
+
105
+ private index = 0;
106
+
107
+ constructor(tokens: readonly Token[] = []) {
108
+ this.tokens = tokens;
109
+ }
110
+
111
+ /**
112
+ * Parse the whole token stream into a `Program`.
113
+ *
114
+ * Consumes every token up to and including EOF; if any token is left over the
115
+ * parser has a bug and throws rather than silently stopping.
116
+ *
117
+ * @throws {ParserError} on an unexpected or malformed token sequence.
118
+ */
119
+ parse(): Program {
120
+ this.index = 0;
121
+
122
+ const first = this.peek();
123
+ const body = this.parseStatements([]);
124
+
125
+ const token = this.peek();
126
+ if (token.type !== TokenType.EOF) {
127
+ throw this.error(token, `Unexpected ${describe(token)} after program`);
128
+ }
129
+
130
+ return { kind: "Program", body, line: first.line };
131
+ }
132
+
133
+ // -------------------------------------------------------------------------
134
+ // Statements
135
+ // -------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Parse statements until EOF or one of `terminators`, skipping blank lines.
139
+ * The terminator itself is left unconsumed for the caller to handle.
140
+ */
141
+ private parseStatements(terminators: readonly TokenType[]): Statement[] {
142
+ const statements: Statement[] = [];
143
+
144
+ while (true) {
145
+ this.skipNewlines();
146
+
147
+ const token = this.peek();
148
+ if (token.type === TokenType.EOF || terminators.includes(token.type)) {
149
+ return statements;
150
+ }
151
+
152
+ statements.push(this.parseStatement());
153
+
154
+ // A statement must be followed by a line break, EOF, or the end of the
155
+ // block. Otherwise two statements have been squashed onto one line, which
156
+ // Synax has no way to express without semicolons.
157
+ const next = this.peek();
158
+ if (
159
+ next.type !== TokenType.NEWLINE &&
160
+ next.type !== TokenType.EOF &&
161
+ !terminators.includes(next.type)
162
+ ) {
163
+ throw this.error(
164
+ next,
165
+ `Expected end of statement but found ${describe(next)}`,
166
+ );
167
+ }
168
+ }
169
+ }
170
+
171
+ private parseStatement(): Statement {
172
+ switch (this.peek().type) {
173
+ case TokenType.SET:
174
+ return this.parseVarDecl();
175
+ case TokenType.PRINT:
176
+ return this.parsePrintStmt();
177
+ case TokenType.IF:
178
+ return this.parseIfStmt();
179
+ case TokenType.FN:
180
+ return this.parseFnDecl();
181
+ case TokenType.FOR:
182
+ return this.parseForStmt();
183
+ default:
184
+ return this.parseExprStmt();
185
+ }
186
+ }
187
+
188
+ /** varDecl := "set" IDENT "=" expression */
189
+ private parseVarDecl(): VarDecl {
190
+ const keyword = this.expect(TokenType.SET, "'set'");
191
+ const name = this.expect(TokenType.IDENT, "a variable name after 'set'");
192
+ this.expect(TokenType.EQUALS, "'=' after the variable name");
193
+ const value = this.parseExpression();
194
+
195
+ return { kind: "VarDecl", name: name.lexeme, value, line: keyword.line };
196
+ }
197
+
198
+ /** printStmt := "print" expression */
199
+ private parsePrintStmt(): PrintStmt {
200
+ const keyword = this.expect(TokenType.PRINT, "'print'");
201
+ const argument = this.parseExpression();
202
+
203
+ return { kind: "PrintStmt", argument, line: keyword.line };
204
+ }
205
+
206
+ /** ifStmt := "if" expression statement* ("else" statement*)? "end" */
207
+ private parseIfStmt(): IfStmt {
208
+ const keyword = this.expect(TokenType.IF, "'if'");
209
+ const condition = this.parseExpression();
210
+
211
+ const thenBranch = this.parseStatements(ELSE_OR_END);
212
+
213
+ let elseBranch: Statement[] | null = null;
214
+ if (this.peek().type === TokenType.ELSE) {
215
+ this.advance();
216
+ elseBranch = this.parseStatements(END);
217
+ }
218
+
219
+ this.expect(TokenType.END, "'end' to close the 'if'");
220
+
221
+ return { kind: "IfStmt", condition, thenBranch, elseBranch, line: keyword.line };
222
+ }
223
+
224
+ /** fnDecl := "fn" IDENT "(" paramList? ")" statement* "end" */
225
+ private parseFnDecl(): FnDecl {
226
+ const keyword = this.expect(TokenType.FN, "'fn'");
227
+ const name = this.expect(TokenType.IDENT, "a function name after 'fn'");
228
+ this.expect(TokenType.LPAREN, "'(' after the function name");
229
+
230
+ const params: string[] = [];
231
+ if (this.peek().type !== TokenType.RPAREN) {
232
+ params.push(this.expect(TokenType.IDENT, "a parameter name").lexeme);
233
+ while (this.peek().type === TokenType.COMMA) {
234
+ this.advance();
235
+ params.push(this.expect(TokenType.IDENT, "a parameter name").lexeme);
236
+ }
237
+ }
238
+
239
+ this.expect(TokenType.RPAREN, "')' after the parameter list");
240
+
241
+ const body = this.parseStatements(END);
242
+ this.expect(TokenType.END, "'end' to close the function body");
243
+
244
+ return { kind: "FnDecl", name: name.lexeme, params, body, line: keyword.line };
245
+ }
246
+
247
+ /** forStmt := "for" IDENT "in" expression ".." expression statement* "end" */
248
+ private parseForStmt(): ForStmt {
249
+ const keyword = this.expect(TokenType.FOR, "'for'");
250
+ const variable = this.expect(TokenType.IDENT, "a loop variable after 'for'");
251
+ this.expect(TokenType.IN, "'in' after the loop variable");
252
+ const start = this.parseExpression();
253
+ this.expect(TokenType.DOTDOT, "'..' between the range bounds");
254
+ const end = this.parseExpression();
255
+
256
+ const body = this.parseStatements(END);
257
+ this.expect(TokenType.END, "'end' to close the 'for' loop");
258
+
259
+ return {
260
+ kind: "ForStmt",
261
+ variable: variable.lexeme,
262
+ start,
263
+ end,
264
+ body,
265
+ line: keyword.line,
266
+ };
267
+ }
268
+
269
+ /** exprStmt := expression */
270
+ private parseExprStmt(): Statement {
271
+ const expression = this.parseExpression();
272
+ return { kind: "ExprStmt", expression, line: expression.line };
273
+ }
274
+
275
+ // -------------------------------------------------------------------------
276
+ // Expressions
277
+ // -------------------------------------------------------------------------
278
+
279
+ /** expression := comparison */
280
+ private parseExpression(): Expression {
281
+ return this.parseComparison();
282
+ }
283
+
284
+ /** comparison := term (comparisonOp term)* — left-associative, loosest. */
285
+ private parseComparison(): Expression {
286
+ return this.parseBinary(
287
+ () => this.parseTerm(),
288
+ COMPARISON_OPERATORS,
289
+ );
290
+ }
291
+
292
+ /** term := factor (("+" | "-") factor)* */
293
+ private parseTerm(): Expression {
294
+ return this.parseBinary(
295
+ () => this.parseFactor(),
296
+ ADDITIVE_OPERATORS,
297
+ );
298
+ }
299
+
300
+ /** factor := primary (("*" | "/") primary)* — tightest binary level. */
301
+ private parseFactor(): Expression {
302
+ return this.parseBinary(
303
+ () => this.parsePrimary(),
304
+ MULTIPLICATIVE_OPERATORS,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Shared loop for the left-associative binary levels: parse one `operand`,
310
+ * then keep folding `operator operand` into the left-hand side.
311
+ */
312
+ private parseBinary(
313
+ operand: () => Expression,
314
+ operators: ReadonlyMap<TokenType, BinaryOperator>,
315
+ ): Expression {
316
+ let left = operand();
317
+
318
+ while (true) {
319
+ const operator = operators.get(this.peek().type);
320
+ if (operator === undefined) return left;
321
+
322
+ this.advance();
323
+ const right = operand();
324
+ left = { kind: "BinaryExpr", operator, left, right, line: left.line };
325
+ }
326
+ }
327
+
328
+ /**
329
+ * primary := NUMBER | STRING | IDENT | "(" expression ")" | functionCall
330
+ */
331
+ private parsePrimary(): Expression {
332
+ const token = this.peek();
333
+
334
+ switch (token.type) {
335
+ case TokenType.NUMBER: {
336
+ this.advance();
337
+ const value =
338
+ typeof token.value === "number" ? token.value : Number(token.lexeme);
339
+ return { kind: "NumberLiteral", value, line: token.line };
340
+ }
341
+
342
+ case TokenType.STRING: {
343
+ this.advance();
344
+ const value =
345
+ typeof token.value === "string"
346
+ ? token.value
347
+ : token.lexeme.slice(1, -1);
348
+ return { kind: "StringLiteral", value, line: token.line };
349
+ }
350
+
351
+ case TokenType.IDENT: {
352
+ this.advance();
353
+ // An identifier followed by `(` is a function call (grammar: functionCall).
354
+ if (this.peek().type === TokenType.LPAREN) {
355
+ return this.finishFunctionCall(token);
356
+ }
357
+ return { kind: "Identifier", name: token.lexeme, line: token.line };
358
+ }
359
+
360
+ case TokenType.LPAREN: {
361
+ // Grouping only affects the tree's shape through precedence, so it does
362
+ // not need its own node.
363
+ this.advance();
364
+ const expression = this.parseExpression();
365
+ this.expect(TokenType.RPAREN, "')' to close the group");
366
+ return expression;
367
+ }
368
+
369
+ default:
370
+ throw this.error(token, `Expected an expression but found ${describe(token)}`);
371
+ }
372
+ }
373
+
374
+ /** functionCall := IDENT "(" argList? ")" — the callee identifier is already consumed. */
375
+ private finishFunctionCall(callee: Token): Expression {
376
+ this.expect(TokenType.LPAREN, "'('");
377
+
378
+ const args: Expression[] = [];
379
+ if (this.peek().type !== TokenType.RPAREN) {
380
+ args.push(this.parseExpression());
381
+ while (this.peek().type === TokenType.COMMA) {
382
+ this.advance();
383
+ args.push(this.parseExpression());
384
+ }
385
+ }
386
+
387
+ this.expect(TokenType.RPAREN, "')' to close the argument list");
388
+
389
+ return {
390
+ kind: "FunctionCall",
391
+ callee: callee.lexeme,
392
+ args,
393
+ line: callee.line,
394
+ };
395
+ }
396
+
397
+ // -------------------------------------------------------------------------
398
+ // Token helpers
399
+ // -------------------------------------------------------------------------
400
+
401
+ /** The token `offset` positions ahead of the cursor (default: the cursor). */
402
+ private peek(offset = 0): Token {
403
+ const token = this.tokens[this.index + offset];
404
+ if (token === undefined) {
405
+ // Unreachable: `Lexer.tokenize()` always appends an EOF sentinel, and we
406
+ // never advance past it without first observing it.
407
+ throw new Error("Parser ran past the end of the token stream");
408
+ }
409
+ return token;
410
+ }
411
+
412
+ private advance(): Token {
413
+ const token = this.peek();
414
+ this.index++;
415
+ return token;
416
+ }
417
+
418
+ /** Consume a token of `type`, or throw naming what was expected. */
419
+ private expect(type: TokenType, description: string): Token {
420
+ const token = this.peek();
421
+ if (token.type !== type) {
422
+ throw this.error(token, `Expected ${description} but found ${describe(token)}`);
423
+ }
424
+ this.index++;
425
+ return token;
426
+ }
427
+
428
+ private skipNewlines(): void {
429
+ while (this.peek().type === TokenType.NEWLINE) this.advance();
430
+ }
431
+
432
+ /** Build an error positioned at `token`, spanning its whole lexeme. */
433
+ private error(token: Token, message: string): ParserError {
434
+ return new ParserError(
435
+ message,
436
+ token.line,
437
+ token.column,
438
+ Math.max(token.lexeme.length, 1),
439
+ );
440
+ }
441
+ }
package/synax.d.ts ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Type declarations for the `synax` package.
3
+ *
4
+ * Written by hand because the project has no TypeScript dependency (zero
5
+ * runtime deps, and Bun's bundler does not emit `.d.ts`), but kept faithful to
6
+ * `src/`. If you change the public surface in `src/`, update this file to match.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Tokens
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export declare const TokenType: {
14
+ SET: "SET";
15
+ PRINT: "PRINT";
16
+ IF: "IF";
17
+ ELSE: "ELSE";
18
+ END: "END";
19
+ FN: "FN";
20
+ FOR: "FOR";
21
+ IN: "IN";
22
+ NUMBER: "NUMBER";
23
+ STRING: "STRING";
24
+ IDENT: "IDENT";
25
+ EQUALS: "EQUALS";
26
+ PLUS: "PLUS";
27
+ MINUS: "MINUS";
28
+ STAR: "STAR";
29
+ SLASH: "SLASH";
30
+ EQEQ: "EQEQ";
31
+ BANGEQ: "BANGEQ";
32
+ GT: "GT";
33
+ LT: "LT";
34
+ GTEQ: "GTEQ";
35
+ LTEQ: "LTEQ";
36
+ DOTDOT: "DOTDOT";
37
+ LPAREN: "LPAREN";
38
+ RPAREN: "RPAREN";
39
+ COMMA: "COMMA";
40
+ NEWLINE: "NEWLINE";
41
+ EOF: "EOF";
42
+ };
43
+
44
+ export type TokenType = (typeof TokenType)[keyof typeof TokenType];
45
+
46
+ export interface Token {
47
+ readonly type: TokenType;
48
+ /** The raw source text this token was scanned from (`""` for EOF). */
49
+ readonly lexeme: string;
50
+ /** Decoded value for NUMBER (number) and STRING (string) tokens. */
51
+ readonly value?: string | number;
52
+ /** 1-based line the token starts on. */
53
+ readonly line: number;
54
+ /** 1-based column of the token's first character. */
55
+ readonly column: number;
56
+ }
57
+
58
+ /** Raised for malformed input. `line` and `column` are 1-based. */
59
+ export declare class LexerError extends Error {
60
+ readonly line: number;
61
+ readonly column: number;
62
+ /** How many characters the error spans. */
63
+ readonly length: number;
64
+ /** The message without the appended `(line N)` context. */
65
+ readonly reason: string;
66
+ constructor(reason: string, line: number, column: number, length?: number);
67
+ }
68
+
69
+ export declare class ParserError extends Error {
70
+ readonly line: number;
71
+ readonly column: number;
72
+ readonly length: number;
73
+ readonly reason: string;
74
+ constructor(reason: string, line: number, column: number, length?: number);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // AST
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export type BinaryOperator =
82
+ | "=="
83
+ | "!="
84
+ | ">"
85
+ | "<"
86
+ | ">="
87
+ | "<="
88
+ | "+"
89
+ | "-"
90
+ | "*"
91
+ | "/";
92
+
93
+ export interface NumberLiteral {
94
+ readonly kind: "NumberLiteral";
95
+ readonly value: number;
96
+ readonly line: number;
97
+ }
98
+
99
+ export interface StringLiteral {
100
+ readonly kind: "StringLiteral";
101
+ readonly value: string;
102
+ readonly line: number;
103
+ }
104
+
105
+ export interface Identifier {
106
+ readonly kind: "Identifier";
107
+ readonly name: string;
108
+ readonly line: number;
109
+ }
110
+
111
+ export interface BinaryExpr {
112
+ readonly kind: "BinaryExpr";
113
+ readonly operator: BinaryOperator;
114
+ readonly left: Expression;
115
+ readonly right: Expression;
116
+ readonly line: number;
117
+ }
118
+
119
+ export interface FunctionCall {
120
+ readonly kind: "FunctionCall";
121
+ readonly callee: string;
122
+ readonly args: Expression[];
123
+ readonly line: number;
124
+ }
125
+
126
+ export type Expression =
127
+ | NumberLiteral
128
+ | StringLiteral
129
+ | Identifier
130
+ | BinaryExpr
131
+ | FunctionCall;
132
+
133
+ export interface VarDecl {
134
+ readonly kind: "VarDecl";
135
+ readonly name: string;
136
+ readonly value: Expression;
137
+ readonly line: number;
138
+ }
139
+
140
+ export interface PrintStmt {
141
+ readonly kind: "PrintStmt";
142
+ readonly argument: Expression;
143
+ readonly line: number;
144
+ }
145
+
146
+ export interface IfStmt {
147
+ readonly kind: "IfStmt";
148
+ readonly condition: Expression;
149
+ readonly thenBranch: Statement[];
150
+ readonly elseBranch: Statement[] | null;
151
+ readonly line: number;
152
+ }
153
+
154
+ export interface FnDecl {
155
+ readonly kind: "FnDecl";
156
+ readonly name: string;
157
+ readonly params: string[];
158
+ readonly body: Statement[];
159
+ readonly line: number;
160
+ }
161
+
162
+ export interface ForStmt {
163
+ readonly kind: "ForStmt";
164
+ readonly variable: string;
165
+ readonly start: Expression;
166
+ readonly end: Expression;
167
+ readonly body: Statement[];
168
+ readonly line: number;
169
+ }
170
+
171
+ export interface ExprStmt {
172
+ readonly kind: "ExprStmt";
173
+ readonly expression: Expression;
174
+ readonly line: number;
175
+ }
176
+
177
+ export type Statement =
178
+ | VarDecl
179
+ | PrintStmt
180
+ | IfStmt
181
+ | FnDecl
182
+ | ForStmt
183
+ | ExprStmt;
184
+
185
+ export interface Program {
186
+ readonly kind: "Program";
187
+ readonly body: Statement[];
188
+ readonly line: number;
189
+ }
190
+
191
+ export type Node = Program | Statement | Expression;
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // API
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export declare class Lexer {
198
+ /** The Synax source text being scanned. */
199
+ readonly source: string;
200
+ constructor(source: string);
201
+ /** Scan the whole source; the result ends with exactly one EOF token. */
202
+ tokenize(): Token[];
203
+ }
204
+
205
+ export declare class Parser {
206
+ /** The tokens being parsed; expected to end with an EOF sentinel. */
207
+ readonly tokens: readonly Token[];
208
+ constructor(tokens: readonly Token[]);
209
+ /** Parse the whole token stream into a `Program`. */
210
+ parse(): Program;
211
+ }
212
+
213
+ /**
214
+ * Translate a parsed `Program` into JavaScript source (no trailing newline;
215
+ * `""` for an empty program).
216
+ */
217
+ export declare function generate(program: Program): string;
218
+
219
+ /** A positioned error, ready to render. All positions are 1-based. */
220
+ export interface Diagnostic {
221
+ readonly file: string;
222
+ readonly line: number;
223
+ readonly column: number;
224
+ /** Characters to underline; at least 1. */
225
+ readonly length: number;
226
+ readonly reason: string;
227
+ }
228
+
229
+ /**
230
+ * Render `diagnostic` against `source` as a `file:line:column` header plus a
231
+ * code frame underlining the offending characters.
232
+ */
233
+ export declare function formatDiagnostic(
234
+ diagnostic: Diagnostic,
235
+ source: string,
236
+ ): string;