@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/lexer.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Synax lexer — turns raw source text into a flat stream of tokens.
3
+ *
4
+ * Synax has no semicolons, so a NEWLINE token marks the end of a statement.
5
+ * The lexer therefore emits one NEWLINE per line break instead of collapsing
6
+ * runs of them; blank lines produce extra NEWLINE tokens and it is up to the
7
+ * parser to skip them.
8
+ *
9
+ * Line breaks that do NOT produce a NEWLINE token:
10
+ * - line breaks inside a `/# ... #/` multi-line comment
11
+ * - (a `#` single-line comment stops *before* its line break, so that break
12
+ * still yields a NEWLINE — which is what we want for statement separators)
13
+ *
14
+ * NOTE: `Token` / `TokenType` / `LexerError` live here for now. They can move to
15
+ * `src/ast.ts` once the AST node types are defined (see the TODO there).
16
+ */
17
+
18
+ /** Every distinct kind of token Synax can produce. */
19
+ export const TokenType = {
20
+ // Keywords — each is its own token type.
21
+ SET: "SET",
22
+ PRINT: "PRINT",
23
+ IF: "IF",
24
+ ELSE: "ELSE",
25
+ END: "END",
26
+ FN: "FN",
27
+ FOR: "FOR",
28
+ IN: "IN",
29
+
30
+ // Literals.
31
+ NUMBER: "NUMBER",
32
+ STRING: "STRING",
33
+
34
+ // Identifier.
35
+ IDENT: "IDENT",
36
+
37
+ // Operators.
38
+ EQUALS: "EQUALS",
39
+ PLUS: "PLUS",
40
+ MINUS: "MINUS",
41
+ STAR: "STAR",
42
+ SLASH: "SLASH",
43
+ EQEQ: "EQEQ",
44
+ BANGEQ: "BANGEQ",
45
+ GT: "GT",
46
+ LT: "LT",
47
+ GTEQ: "GTEQ",
48
+ LTEQ: "LTEQ",
49
+ DOTDOT: "DOTDOT",
50
+
51
+ // Punctuation.
52
+ LPAREN: "LPAREN",
53
+ RPAREN: "RPAREN",
54
+ COMMA: "COMMA",
55
+
56
+ // Structural.
57
+ NEWLINE: "NEWLINE",
58
+ EOF: "EOF",
59
+ } as const;
60
+
61
+ export type TokenType = (typeof TokenType)[keyof typeof TokenType];
62
+
63
+ export interface Token {
64
+ readonly type: TokenType;
65
+ /**
66
+ * The raw source text this token was scanned from. The only exception is the
67
+ * EOF sentinel, whose lexeme is `""`.
68
+ */
69
+ readonly lexeme: string;
70
+ /**
71
+ * Decoded literal value, present only on NUMBER and STRING tokens: the
72
+ * numeric value, and the string's contents with escapes already applied.
73
+ */
74
+ readonly value?: string | number;
75
+ /** 1-based line the token starts on. */
76
+ readonly line: number;
77
+ /** 1-based column of the token's first character. */
78
+ readonly column: number;
79
+ }
80
+
81
+ /** Raised for malformed input. `line` and `column` are 1-based. */
82
+ export class LexerError extends Error {
83
+ readonly line: number;
84
+ readonly column: number;
85
+ /** How many characters the error spans; used to underline the source. */
86
+ readonly length: number;
87
+ /** The message on its own, without the appended `(line N)` context. */
88
+ readonly reason: string;
89
+
90
+ constructor(reason: string, line: number, column: number, length = 1) {
91
+ super(`${reason} (line ${line})`);
92
+ this.name = "LexerError";
93
+ this.reason = reason;
94
+ this.line = line;
95
+ this.column = column;
96
+ this.length = length;
97
+ }
98
+ }
99
+
100
+ const KEYWORDS: ReadonlyMap<string, TokenType> = new Map([
101
+ ["set", TokenType.SET],
102
+ ["print", TokenType.PRINT],
103
+ ["if", TokenType.IF],
104
+ ["else", TokenType.ELSE],
105
+ ["end", TokenType.END],
106
+ ["fn", TokenType.FN],
107
+ ["for", TokenType.FOR],
108
+ ["in", TokenType.IN],
109
+ ]);
110
+
111
+ /**
112
+ * Two-character operators. These are matched BEFORE the single-character table
113
+ * (maximal munch), so `>=` cannot degrade into `>` followed by `=`.
114
+ */
115
+ const TWO_CHAR_OPERATORS: ReadonlyMap<string, TokenType> = new Map([
116
+ ["==", TokenType.EQEQ],
117
+ ["!=", TokenType.BANGEQ],
118
+ [">=", TokenType.GTEQ],
119
+ ["<=", TokenType.LTEQ],
120
+ ["..", TokenType.DOTDOT],
121
+ ]);
122
+
123
+ /**
124
+ * Single-character tokens. Note the deliberate omissions: there is no `.`
125
+ * (only `..` exists), no `!` (only `!=`), and `#` / `/#` never reach here
126
+ * because comments are handled first.
127
+ */
128
+ const ONE_CHAR_TOKENS: ReadonlyMap<string, TokenType> = new Map([
129
+ ["=", TokenType.EQUALS],
130
+ ["+", TokenType.PLUS],
131
+ ["-", TokenType.MINUS],
132
+ ["*", TokenType.STAR],
133
+ ["/", TokenType.SLASH],
134
+ [">", TokenType.GT],
135
+ ["<", TokenType.LT],
136
+ ["(", TokenType.LPAREN],
137
+ [")", TokenType.RPAREN],
138
+ [",", TokenType.COMMA],
139
+ ]);
140
+
141
+ /** Escape sequences understood inside a double-quoted string. */
142
+ const ESCAPES: ReadonlyMap<string, string> = new Map([
143
+ ["n", "\n"],
144
+ ["t", "\t"],
145
+ ['"', '"'],
146
+ ["\\", "\\"],
147
+ ]);
148
+
149
+ function isDigit(char: string | undefined): boolean {
150
+ return char !== undefined && char >= "0" && char <= "9";
151
+ }
152
+
153
+ function isLetter(char: string | undefined): boolean {
154
+ return (
155
+ char !== undefined &&
156
+ ((char >= "a" && char <= "z") || (char >= "A" && char <= "Z"))
157
+ );
158
+ }
159
+
160
+ function isIdentifierStart(char: string | undefined): boolean {
161
+ return isLetter(char) || char === "_";
162
+ }
163
+
164
+ function isIdentifierPart(char: string | undefined): boolean {
165
+ return isIdentifierStart(char) || isDigit(char);
166
+ }
167
+
168
+ export class Lexer {
169
+ /** The Synax source text being scanned. */
170
+ readonly source: string;
171
+
172
+ private index = 0;
173
+ private line = 1;
174
+ /** Index of the first character on the current line, for column math. */
175
+ private lineStart = 0;
176
+
177
+ constructor(source: string) {
178
+ this.source = source;
179
+ }
180
+
181
+ /** 1-based column of the cursor. */
182
+ private get column(): number {
183
+ return this.index - this.lineStart + 1;
184
+ }
185
+
186
+ /**
187
+ * Scan the whole source and return its tokens, always ending with exactly one
188
+ * EOF token.
189
+ *
190
+ * @throws {LexerError} on an unexpected character, an unterminated string, an
191
+ * unrecognised escape sequence, or an unterminated multi-line comment.
192
+ */
193
+ tokenize(): Token[] {
194
+ // Reset so a single Lexer instance can be tokenized more than once.
195
+ this.index = 0;
196
+ this.line = 1;
197
+ this.lineStart = 0;
198
+
199
+ const tokens: Token[] = [];
200
+
201
+ while (true) {
202
+ const char = this.source[this.index];
203
+ if (char === undefined) break;
204
+
205
+ // Horizontal whitespace. `\r` is dropped so CRLF files behave exactly
206
+ // like LF ones: one NEWLINE per line break.
207
+ if (char === " " || char === "\t" || char === "\r") {
208
+ this.index++;
209
+ continue;
210
+ }
211
+
212
+ if (char === "\n") {
213
+ tokens.push({
214
+ type: TokenType.NEWLINE,
215
+ lexeme: "\n",
216
+ line: this.line,
217
+ column: this.column,
218
+ });
219
+ this.index++;
220
+ this.line++;
221
+ this.lineStart = this.index;
222
+ continue;
223
+ }
224
+
225
+ if (char === "#") {
226
+ this.skipLineComment();
227
+ continue;
228
+ }
229
+
230
+ if (char === "/" && this.source[this.index + 1] === "#") {
231
+ this.skipBlockComment();
232
+ continue;
233
+ }
234
+
235
+ if (char === '"') {
236
+ tokens.push(this.readString());
237
+ continue;
238
+ }
239
+
240
+ if (isDigit(char)) {
241
+ tokens.push(this.readNumber());
242
+ continue;
243
+ }
244
+
245
+ if (isIdentifierStart(char)) {
246
+ tokens.push(this.readIdentifier());
247
+ continue;
248
+ }
249
+
250
+ const operator = this.readOperator();
251
+ if (operator !== undefined) {
252
+ tokens.push(operator);
253
+ continue;
254
+ }
255
+
256
+ throw new LexerError(
257
+ `Unexpected character ${JSON.stringify(char)}`,
258
+ this.line,
259
+ this.column,
260
+ );
261
+ }
262
+
263
+ tokens.push({
264
+ type: TokenType.EOF,
265
+ lexeme: "",
266
+ line: this.line,
267
+ column: this.column,
268
+ });
269
+ return tokens;
270
+ }
271
+
272
+ /** Consume `#` and everything up to — but not including — the line break. */
273
+ private skipLineComment(): void {
274
+ this.index++; // the `#`
275
+
276
+ while (true) {
277
+ const char = this.source[this.index];
278
+ if (char === undefined || char === "\n") return;
279
+ this.index++;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Consume `/#` through the matching `#/`. Line breaks inside are counted but
285
+ * never emitted as NEWLINE tokens. Nesting is not supported.
286
+ */
287
+ private skipBlockComment(): void {
288
+ const startLine = this.line;
289
+ const startColumn = this.column;
290
+ this.index += 2; // the `/#`
291
+
292
+ while (true) {
293
+ const char = this.source[this.index];
294
+
295
+ if (char === undefined) {
296
+ throw new LexerError(
297
+ "Unterminated multi-line comment",
298
+ startLine,
299
+ startColumn,
300
+ 2,
301
+ );
302
+ }
303
+
304
+ if (char === "#" && this.source[this.index + 1] === "/") {
305
+ this.index += 2;
306
+ return;
307
+ }
308
+
309
+ if (char === "\n") {
310
+ this.line++;
311
+ this.index++;
312
+ this.lineStart = this.index;
313
+ continue;
314
+ }
315
+
316
+ this.index++;
317
+ }
318
+ }
319
+
320
+ /** Scan a double-quoted string, decoding escapes. */
321
+ private readString(): Token {
322
+ const line = this.line;
323
+ const column = this.column;
324
+ const start = this.index;
325
+ this.index++; // the opening quote
326
+
327
+ let value = "";
328
+
329
+ while (true) {
330
+ const char = this.source[this.index];
331
+
332
+ // A string may not span lines, so a line break here means the closing
333
+ // quote is missing.
334
+ if (char === undefined || char === "\n") {
335
+ throw new LexerError("Unterminated string", line, column);
336
+ }
337
+
338
+ if (char === '"') {
339
+ this.index++; // the closing quote
340
+ const lexeme = this.source.slice(start, this.index);
341
+ return { type: TokenType.STRING, lexeme, value, line, column };
342
+ }
343
+
344
+ if (char === "\\") {
345
+ const escaped = this.source[this.index + 1];
346
+ const decoded = escaped === undefined ? undefined : ESCAPES.get(escaped);
347
+
348
+ if (decoded === undefined) {
349
+ throw new LexerError(
350
+ `Invalid escape sequence \\${escaped ?? ""}`,
351
+ this.line,
352
+ this.column,
353
+ 2,
354
+ );
355
+ }
356
+
357
+ value += decoded;
358
+ this.index += 2;
359
+ continue;
360
+ }
361
+
362
+ value += char;
363
+ this.index++;
364
+ }
365
+ }
366
+
367
+ /** Scan an integer or float literal. No leading dots, no exponents. */
368
+ private readNumber(): Token {
369
+ const line = this.line;
370
+ const column = this.column;
371
+ const start = this.index;
372
+
373
+ while (isDigit(this.source[this.index])) this.index++;
374
+
375
+ // Only treat the `.` as a decimal point when a digit follows it, so `1..5`
376
+ // lexes as NUMBER(1) DOTDOT NUMBER(5) rather than NUMBER(1.) + junk.
377
+ if (
378
+ this.source[this.index] === "." &&
379
+ isDigit(this.source[this.index + 1])
380
+ ) {
381
+ this.index++;
382
+ while (isDigit(this.source[this.index])) this.index++;
383
+ }
384
+
385
+ const lexeme = this.source.slice(start, this.index);
386
+ return {
387
+ type: TokenType.NUMBER,
388
+ lexeme,
389
+ value: Number(lexeme),
390
+ line,
391
+ column,
392
+ };
393
+ }
394
+
395
+ /** Scan an identifier, then reclassify it if it is a keyword. */
396
+ private readIdentifier(): Token {
397
+ const line = this.line;
398
+ const column = this.column;
399
+ const start = this.index;
400
+
401
+ while (isIdentifierPart(this.source[this.index])) this.index++;
402
+
403
+ const lexeme = this.source.slice(start, this.index);
404
+ return {
405
+ type: KEYWORDS.get(lexeme) ?? TokenType.IDENT,
406
+ lexeme,
407
+ line,
408
+ column,
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Match the longest operator at the cursor, or `undefined` if the character
414
+ * starts no known operator.
415
+ */
416
+ private readOperator(): Token | undefined {
417
+ const line = this.line;
418
+ const column = this.column;
419
+
420
+ // Maximal munch: try two characters before one, so `>=`, `==`, `!=`, `<=`
421
+ // and `..` win over their one-character prefixes.
422
+ const two = this.source.slice(this.index, this.index + 2);
423
+ const twoType = TWO_CHAR_OPERATORS.get(two);
424
+ if (twoType !== undefined) {
425
+ this.index += 2;
426
+ return { type: twoType, lexeme: two, line, column };
427
+ }
428
+
429
+ const one = this.source[this.index];
430
+ if (one === undefined) return undefined;
431
+
432
+ const oneType = ONE_CHAR_TOKENS.get(one);
433
+ if (oneType === undefined) return undefined;
434
+
435
+ this.index += 1;
436
+ return { type: oneType, lexeme: one, line, column };
437
+ }
438
+ }