@starpak/lang 0.0.0-stage → 0.1.3

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 (58) hide show
  1. package/LICENSE +22 -0
  2. package/lib/ast.d.ts +341 -0
  3. package/lib/ast.d.ts.map +1 -0
  4. package/lib/ast.js +9 -0
  5. package/lib/ast.js.map +1 -0
  6. package/lib/checker.d.ts +17 -0
  7. package/lib/checker.d.ts.map +1 -0
  8. package/lib/checker.js +525 -0
  9. package/lib/checker.js.map +1 -0
  10. package/lib/diagnostics.d.ts +46 -0
  11. package/lib/diagnostics.d.ts.map +1 -0
  12. package/lib/diagnostics.js +77 -0
  13. package/lib/diagnostics.js.map +1 -0
  14. package/lib/emit/lua.d.ts +19 -0
  15. package/lib/emit/lua.d.ts.map +1 -0
  16. package/lib/emit/lua.js +451 -0
  17. package/lib/emit/lua.js.map +1 -0
  18. package/lib/emit/ts.d.ts +42 -0
  19. package/lib/emit/ts.d.ts.map +1 -0
  20. package/lib/emit/ts.js +950 -0
  21. package/lib/emit/ts.js.map +1 -0
  22. package/lib/index.d.ts +18 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +54 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/lexer.d.ts +10 -0
  27. package/lib/lexer.d.ts.map +1 -0
  28. package/lib/lexer.js +282 -0
  29. package/lib/lexer.js.map +1 -0
  30. package/lib/parser.d.ts +11 -0
  31. package/lib/parser.d.ts.map +1 -0
  32. package/lib/parser.js +1138 -0
  33. package/lib/parser.js.map +1 -0
  34. package/lib/token.d.ts +32 -0
  35. package/lib/token.d.ts.map +1 -0
  36. package/lib/token.js +18 -0
  37. package/lib/token.js.map +1 -0
  38. package/lib/types.d.ts +63 -0
  39. package/lib/types.d.ts.map +1 -0
  40. package/lib/types.js +177 -0
  41. package/lib/types.js.map +1 -0
  42. package/lib/version.d.ts +25 -0
  43. package/lib/version.d.ts.map +1 -0
  44. package/lib/version.js +47 -0
  45. package/lib/version.js.map +1 -0
  46. package/package.json +10 -3
  47. package/src/ast.ts +467 -0
  48. package/src/checker.ts +554 -0
  49. package/src/diagnostics.ts +100 -0
  50. package/src/emit/lua.ts +484 -0
  51. package/src/emit/ts.ts +1024 -0
  52. package/src/index.ts +24 -0
  53. package/src/lexer.ts +301 -0
  54. package/src/parser.ts +1257 -0
  55. package/src/token.ts +34 -0
  56. package/src/types.ts +218 -0
  57. package/src/version.ts +44 -0
  58. package/README.md +0 -3
package/src/parser.ts ADDED
@@ -0,0 +1,1257 @@
1
+ /**
2
+ * Spak Lang - Recursive descent parser
3
+ *
4
+ * Produces an AST (`Program`) from Spak source. The grammar follows
5
+ * docs/syntax/spak-lang.md.
6
+ */
7
+
8
+ import type {
9
+ ArrayPattern,
10
+ Assign,
11
+ AwaitExpr,
12
+ Binary,
13
+ Block,
14
+ BreakStmt,
15
+ Call,
16
+ ClassDecl,
17
+ ContinueStmt,
18
+ Decorator,
19
+ ElifBranch,
20
+ Expr,
21
+ ExportDecl,
22
+ ExportDefault,
23
+ FnDecl,
24
+ ForIn,
25
+ ForNumeric,
26
+ FunctionExpr,
27
+ Ident,
28
+ IfStmt,
29
+ ImportClause,
30
+ ImportDecl,
31
+ ImportForm,
32
+ Index,
33
+ MatchArm,
34
+ MatchStmt,
35
+ Member,
36
+ Param,
37
+ Pattern,
38
+ Position,
39
+ Program,
40
+ Range,
41
+ RepeatStmt,
42
+ ReturnStmt,
43
+ SpawnStmt,
44
+ Spread,
45
+ Statement,
46
+ StringLit,
47
+ Table,
48
+ TableField,
49
+ TablePattern,
50
+ TablePatternEntry,
51
+ ThrowStmt,
52
+ TryStmt,
53
+ TypeAliasDecl,
54
+ TypeAnn,
55
+ UnlessStmt,
56
+ VarDecl,
57
+ VarName,
58
+ WhileStmt,
59
+ } from './ast'
60
+ import { tokenize } from './lexer'
61
+ import { Token, TokenType } from './token'
62
+
63
+ /**
64
+ * Binary operator precedence, low to high. `^` is right-associative.
65
+ */
66
+ const BINARY_PRECEDENCE: Record<string, number> = {
67
+ 'or': 1,
68
+ 'and': 2,
69
+ '==': 3,
70
+ '~=': 3,
71
+ '<': 3,
72
+ '<=': 3,
73
+ '>': 3,
74
+ '>=': 3,
75
+ '|>': 4,
76
+ '??': 5,
77
+ '..': 6,
78
+ '+': 7,
79
+ '-': 7,
80
+ '*': 8,
81
+ '/': 8,
82
+ '//': 8,
83
+ '%': 8,
84
+ '^': 10,
85
+ }
86
+
87
+ const UNARY_PRECEDENCE = 9
88
+
89
+ const BLOCK_END_KEYWORDS = [
90
+ 'end', 'else', 'elseif', 'when', 'until', 'catch', 'finally',
91
+ ]
92
+
93
+ const PRIMITIVE_TYPE_NAMES = new Set([
94
+ 'any', 'nil', 'number', 'string', 'boolean', 'table', 'function',
95
+ ])
96
+
97
+ /** Statement kinds that may carry a `---` doc comment. */
98
+ const DOC_STATEMENTS = new Set([
99
+ 'FnDecl', 'ClassDecl', 'VarDecl', 'TypeAliasDecl', 'ExportDecl', 'ImportDecl',
100
+ ])
101
+
102
+ function isNameToken(token: Token): boolean {
103
+ return token.type === TokenType.Identifier || token.type === TokenType.Keyword
104
+ }
105
+
106
+ class Parser {
107
+ private tokens: Token[]
108
+ private pos = 0
109
+
110
+ constructor(source: string) {
111
+ this.tokens = tokenize(source)
112
+ }
113
+
114
+ /* ---------------------------------------------------------------- */
115
+ /* Token helpers */
116
+ /* ---------------------------------------------------------------- */
117
+
118
+ private peek(offset = 0): Token {
119
+ const index = this.pos + offset
120
+ return this.tokens[index < this.tokens.length ? index : this.tokens.length - 1]
121
+ }
122
+
123
+ private next(): Token {
124
+ const token = this.peek()
125
+ if (this.pos < this.tokens.length - 1) this.pos++
126
+ return token
127
+ }
128
+
129
+ private at(type: TokenType, value?: string): boolean {
130
+ const token = this.peek()
131
+ return token.type === type && (value === undefined || token.value === value)
132
+ }
133
+
134
+ private atKeyword(value: string): boolean {
135
+ return this.at(TokenType.Keyword, value)
136
+ }
137
+
138
+ private atOperator(value: string): boolean {
139
+ return this.at(TokenType.Operator, value)
140
+ }
141
+
142
+ private match(type: TokenType, value?: string): boolean {
143
+ if (!this.at(type, value)) return false
144
+ this.next()
145
+ return true
146
+ }
147
+
148
+ private matchKeyword(value: string): boolean {
149
+ return this.match(TokenType.Keyword, value)
150
+ }
151
+
152
+ private matchOperator(value: string): boolean {
153
+ return this.match(TokenType.Operator, value)
154
+ }
155
+
156
+ private expect(type: TokenType, value?: string, message?: string): Token {
157
+ if (this.at(type, value)) return this.next()
158
+ const token = this.peek()
159
+ throw this.error(message ?? `Expected ${value ?? type} but got "${token.value}"`, token)
160
+ }
161
+
162
+ private expectKeyword(value: string): Token {
163
+ return this.expect(TokenType.Keyword, value, `Expected "${value}"`)
164
+ }
165
+
166
+ private expectName(message: string): Token {
167
+ if (isNameToken(this.peek())) return this.next()
168
+ throw this.error(message)
169
+ }
170
+
171
+ private error(message: string, token: Token = this.peek()): SyntaxError {
172
+ return new SyntaxError(`${message} at ${token.line}:${token.column}`)
173
+ }
174
+
175
+ private loc(token: Token): Position {
176
+ return { line: token.line, column: token.column }
177
+ }
178
+
179
+ private skipNewlines(): void {
180
+ while (this.at(TokenType.Newline)) this.next()
181
+ }
182
+
183
+ private skipSeparators(): void {
184
+ while (this.at(TokenType.Newline) || this.at(TokenType.Semicolon)) this.next()
185
+ }
186
+
187
+ private isBlockEnd(): boolean {
188
+ const token = this.peek()
189
+ return token.type === TokenType.Keyword && BLOCK_END_KEYWORDS.includes(token.value)
190
+ }
191
+
192
+ private isStatementEnd(): boolean {
193
+ const token = this.peek()
194
+ return token.type === TokenType.EOF
195
+ || token.type === TokenType.Newline
196
+ || token.type === TokenType.Semicolon
197
+ || this.isBlockEnd()
198
+ }
199
+
200
+ /* ---------------------------------------------------------------- */
201
+ /* Program & statements */
202
+ /* ---------------------------------------------------------------- */
203
+
204
+ parseProgram(): Program {
205
+ const start = this.peek()
206
+ const body: Statement[] = []
207
+ this.skipSeparators()
208
+ while (!this.at(TokenType.EOF)) {
209
+ const before = this.pos
210
+ body.push(this.parseStatement())
211
+ if (this.pos === before) throw this.error('Unexpected token')
212
+ this.skipSeparators()
213
+ }
214
+ return { kind: 'Program', body, loc: this.loc(start) }
215
+ }
216
+
217
+ private parseStatement(): Statement {
218
+ const doc = this.peek().doc
219
+ const statement = this.parseStatementCore()
220
+ if (doc && DOC_STATEMENTS.has(statement.kind)) {
221
+ ;(statement as FnDecl | ClassDecl | VarDecl | TypeAliasDecl | ExportDecl | ImportDecl).doc = doc
222
+ }
223
+ return statement
224
+ }
225
+
226
+ private parseStatementCore(): Statement {
227
+ const token = this.peek()
228
+
229
+ if (token.type === TokenType.Operator && token.value === '@') {
230
+ return this.parseDecoratedDeclaration()
231
+ }
232
+
233
+ if (token.type === TokenType.Keyword) {
234
+ switch (token.value) {
235
+ case 'global':
236
+ return this.parseVarDeclaration('global')
237
+ case 'const':
238
+ return this.parseVarDeclaration('const')
239
+ case 'import':
240
+ return this.parseImport()
241
+ case 'export':
242
+ return this.parseExport()
243
+ case 'type':
244
+ return this.parseTypeAlias()
245
+ case 'fn':
246
+ return this.parseFnDecl([], false, false)
247
+ case 'class':
248
+ return this.parseClassDecl([])
249
+ case 'async':
250
+ if (this.peek(1).type === TokenType.Keyword && this.peek(1).value === 'fn') {
251
+ this.next()
252
+ return this.parseFnDecl([], true, false)
253
+ }
254
+ break
255
+ case 'throw':
256
+ return this.parseThrow()
257
+ case 'spawn':
258
+ return this.parseSpawn()
259
+ case 'if':
260
+ return this.parseIf()
261
+ case 'unless':
262
+ return this.parseUnless()
263
+ case 'while':
264
+ return this.parseWhile()
265
+ case 'repeat':
266
+ return this.parseRepeat()
267
+ case 'for':
268
+ return this.parseFor()
269
+ case 'return':
270
+ return this.parseReturn()
271
+ case 'break': {
272
+ const keyword = this.next()
273
+ return { kind: 'BreakStmt', loc: this.loc(keyword) } as BreakStmt
274
+ }
275
+ case 'continue': {
276
+ const keyword = this.next()
277
+ return { kind: 'ContinueStmt', loc: this.loc(keyword) } as ContinueStmt
278
+ }
279
+ case 'match':
280
+ return this.parseMatch()
281
+ case 'try':
282
+ return this.parseTry()
283
+ }
284
+ }
285
+
286
+ return this.parseSimpleStatement()
287
+ }
288
+
289
+ private parseBlock(terminators: string[]): Block {
290
+ const start = this.peek()
291
+ const body: Statement[] = []
292
+ this.skipSeparators()
293
+ while (!this.at(TokenType.EOF)) {
294
+ const token = this.peek()
295
+ if (token.type === TokenType.Keyword && terminators.includes(token.value)) break
296
+ const before = this.pos
297
+ body.push(this.parseStatement())
298
+ if (this.pos === before) throw this.error('Unexpected token')
299
+ this.skipSeparators()
300
+ }
301
+ return { kind: 'Block', body, loc: this.loc(start) }
302
+ }
303
+
304
+ private parseVarDeclaration(scope: 'local' | 'global' | 'const'): VarDecl {
305
+ const keyword = this.next()
306
+ const names: VarName[] = []
307
+ do {
308
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected variable name')
309
+ const entry: VarName = { name: name.value }
310
+ if (this.matchOperator(':')) entry.type = this.parseType()
311
+ names.push(entry)
312
+ } while (this.matchOperator(','))
313
+
314
+ const values = this.matchOperator('=') ? this.parseExprList() : []
315
+ return { kind: 'VarDecl', names, values, scope, loc: this.loc(keyword) }
316
+ }
317
+
318
+ private parseSimpleStatement(): Statement {
319
+ const start = this.peek()
320
+
321
+ // Destructuring assignment: `{ x, y } = p` / `[a, b] = list`.
322
+ if (this.atOperator('{') || this.atOperator('[')) {
323
+ const saved = this.pos
324
+ try {
325
+ const target = this.parsePattern()
326
+ if (this.matchOperator('=')) {
327
+ const values = this.parseExprList()
328
+ const node: Assign = { kind: 'Assign', targets: [target], values, loc: this.loc(start) }
329
+ return node
330
+ }
331
+ } catch {
332
+ // Not a pattern; rewind and parse it as an expression instead.
333
+ }
334
+ this.pos = saved
335
+ }
336
+
337
+ const first = this.parseExpression()
338
+
339
+ // Typed declaration without a keyword: x: number = 1
340
+ if (first.kind === 'Ident' && this.atOperator(':')) {
341
+ this.next()
342
+ const type = this.parseType()
343
+ const name: VarName = { name: first.name, type }
344
+ const values = this.matchOperator('=') ? this.parseExprList() : []
345
+ return { kind: 'VarDecl', names: [name], values, scope: 'local', loc: first.loc }
346
+ }
347
+
348
+ const targets: Array<Expr | Pattern> = [first]
349
+ while (this.matchOperator(',')) targets.push(this.parseExpression())
350
+
351
+ if (this.matchOperator('=')) {
352
+ const values = this.parseExprList()
353
+ // Assignment is declaration: plain identifiers create local bindings.
354
+ if (targets.every((target) => target.kind === 'Ident')) {
355
+ const names: VarName[] = targets.map((target) => ({ name: (target as Ident).name }))
356
+ return { kind: 'VarDecl', names, values, scope: 'local', loc: this.loc(start) }
357
+ }
358
+ return { kind: 'Assign', targets, values, loc: this.loc(start) } as Assign
359
+ }
360
+
361
+ if (targets.length > 1) throw this.error('Expected "=" in assignment')
362
+ return { kind: 'ExprStmt', expr: first, loc: this.loc(start) }
363
+ }
364
+
365
+ private parsePattern(): Pattern {
366
+ if (this.atOperator('{')) return this.parseTablePattern()
367
+ if (this.atOperator('[')) return this.parseArrayPattern()
368
+ throw this.error('Expected destructuring pattern')
369
+ }
370
+
371
+ private parseTablePattern(): TablePattern {
372
+ const start = this.expect(TokenType.Operator, '{')
373
+ const entries: TablePatternEntry[] = []
374
+ this.skipNewlines()
375
+
376
+ while (!this.atOperator('}')) {
377
+ const key = this.expectName('Expected field name in table pattern')
378
+ const entry: TablePatternEntry = { key: key.value }
379
+ if (this.matchOperator('=')) {
380
+ const value = this.parseExpression()
381
+ // `{ a = first }` renames, any other value is a default.
382
+ if (value.kind === 'Ident') entry.target = (value as Ident).name
383
+ else entry.default = value
384
+ }
385
+ entries.push(entry)
386
+ this.skipNewlines()
387
+ if (!this.matchOperator(',')) break
388
+ this.skipNewlines()
389
+ }
390
+
391
+ this.expect(TokenType.Operator, '}', 'Expected "}"')
392
+ return { kind: 'TablePattern', entries, loc: this.loc(start) }
393
+ }
394
+
395
+ private parseArrayPattern(): ArrayPattern {
396
+ const start = this.expect(TokenType.Operator, '[')
397
+ const elements: string[] = []
398
+ this.skipNewlines()
399
+
400
+ while (!this.atOperator(']')) {
401
+ const name = this.expectName('Expected name in array pattern')
402
+ elements.push(name.value)
403
+ this.skipNewlines()
404
+ if (!this.matchOperator(',')) break
405
+ this.skipNewlines()
406
+ }
407
+
408
+ this.expect(TokenType.Operator, ']', 'Expected "]"')
409
+ return { kind: 'ArrayPattern', elements, loc: this.loc(start) }
410
+ }
411
+
412
+ private parseFnDecl(decorators: Decorator[], isAsync: boolean, isMethod: boolean): FnDecl {
413
+ const start = this.expectKeyword('fn')
414
+ let name = this.expect(TokenType.Identifier, undefined, 'Expected function name').value
415
+ let receiver: string | undefined
416
+ if (this.matchOperator(':')) {
417
+ receiver = name
418
+ name = this.expect(TokenType.Identifier, undefined, 'Expected method name').value
419
+ }
420
+
421
+ const params = this.parseParams()
422
+ const returnType = this.matchOperator(':') ? this.parseType() : undefined
423
+
424
+ let body: Block | undefined
425
+ let exprBody: Expr | undefined
426
+ if (this.matchOperator('=')) {
427
+ exprBody = this.parseExpression()
428
+ } else {
429
+ body = this.parseBlock(['end'])
430
+ this.expectKeyword('end')
431
+ }
432
+
433
+ const decl: FnDecl = {
434
+ kind: 'FnDecl',
435
+ name,
436
+ params,
437
+ returnType,
438
+ body,
439
+ exprBody,
440
+ isMethod: isMethod || !!receiver,
441
+ decorators,
442
+ loc: this.loc(start),
443
+ }
444
+ if (receiver) decl.receiver = receiver
445
+ if (isAsync) decl.async = true
446
+ return decl
447
+ }
448
+
449
+ private parseParams(): Param[] {
450
+ this.expect(TokenType.Operator, '(', 'Expected "("')
451
+ const params: Param[] = []
452
+ this.skipNewlines()
453
+
454
+ while (!this.atOperator(')')) {
455
+ if (this.matchOperator('...')) {
456
+ params.push({ name: '...', rest: true })
457
+ this.skipNewlines()
458
+ break
459
+ }
460
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected parameter name')
461
+ const param: Param = { name: name.value }
462
+ if (this.matchOperator(':')) param.type = this.parseType()
463
+ if (this.matchOperator('=')) param.default = this.parseExpression()
464
+ params.push(param)
465
+ this.skipNewlines()
466
+ if (!this.matchOperator(',')) break
467
+ this.skipNewlines()
468
+ }
469
+
470
+ this.expect(TokenType.Operator, ')', 'Expected ")"')
471
+ return params
472
+ }
473
+
474
+ private parseDecorators(): Decorator[] {
475
+ const decorators: Decorator[] = []
476
+ while (this.atOperator('@')) {
477
+ const at = this.next()
478
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected decorator name').value
479
+ const args = this.atOperator('(') ? this.parseArguments() : []
480
+ decorators.push({ kind: 'Decorator', name, args, loc: this.loc(at) })
481
+ this.skipNewlines()
482
+ }
483
+ return decorators
484
+ }
485
+
486
+ private parseDecoratedDeclaration(): Statement {
487
+ const decorators = this.parseDecorators()
488
+ if (this.atKeyword('class')) return this.parseClassDecl(decorators)
489
+ if (this.atKeyword('async') && this.peek(1).value === 'fn') {
490
+ this.next()
491
+ return this.parseFnDecl(decorators, true, false)
492
+ }
493
+ if (this.atKeyword('fn')) return this.parseFnDecl(decorators, false, false)
494
+ throw this.error('Expected "fn" or "class" after decorator')
495
+ }
496
+
497
+ private parseClassDecl(decorators: Decorator[]): ClassDecl {
498
+ const start = this.expectKeyword('class')
499
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected class name').value
500
+ const superClass = this.matchKeyword('extends')
501
+ ? this.expect(TokenType.Identifier, undefined, 'Expected super class name').value
502
+ : undefined
503
+
504
+ const members: Array<FnDecl | VarDecl> = []
505
+ this.skipSeparators()
506
+ while (!this.atKeyword('end') && !this.at(TokenType.EOF)) {
507
+ const doc = this.peek().doc
508
+ let member: FnDecl | VarDecl
509
+ if (this.atOperator('@')) {
510
+ member = this.parseClassMember(this.parseDecorators())
511
+ } else {
512
+ member = this.parseClassMember([])
513
+ }
514
+ if (doc) member.doc = doc
515
+ members.push(member)
516
+ this.skipSeparators()
517
+ }
518
+
519
+ this.expectKeyword('end')
520
+ return { kind: 'ClassDecl', name, superClass, members, decorators, loc: this.loc(start) }
521
+ }
522
+
523
+ /** Parses one class body member, with decorators already consumed. */
524
+ private parseClassMember(decorators: Decorator[]): FnDecl | VarDecl {
525
+ if (this.atKeyword('async') && this.peek(1).value === 'fn') {
526
+ this.next()
527
+ return this.parseFnDecl(decorators, true, true)
528
+ }
529
+ if (this.atKeyword('fn')) return this.parseFnDecl(decorators, false, true)
530
+ return this.parseField(decorators)
531
+ }
532
+
533
+ private parseField(decorators: Decorator[] = []): VarDecl {
534
+ const name = this.expectName('Expected field name')
535
+ const entry: VarName = { name: name.value }
536
+ if (this.matchOperator(':')) entry.type = this.parseType()
537
+ const values = this.matchOperator('=') ? [this.parseExpression()] : []
538
+ const decl: VarDecl = { kind: 'VarDecl', names: [entry], values, scope: 'local', loc: this.loc(name) }
539
+ if (decorators.length) decl.decorators = decorators
540
+ return decl
541
+ }
542
+
543
+ private parseIf(): IfStmt {
544
+ const start = this.expectKeyword('if')
545
+ const cond = this.parseExpression()
546
+ this.expectKeyword('then')
547
+ const then = this.parseBlock(['elseif', 'else', 'end'])
548
+
549
+ const elifs: ElifBranch[] = []
550
+ while (this.matchKeyword('elseif')) {
551
+ const elifCond = this.parseExpression()
552
+ this.expectKeyword('then')
553
+ const body = this.parseBlock(['elseif', 'else', 'end'])
554
+ elifs.push({ cond: elifCond, body })
555
+ }
556
+
557
+ const elseBody = this.matchKeyword('else') ? this.parseBlock(['end']) : undefined
558
+ this.expectKeyword('end')
559
+ return { kind: 'IfStmt', cond, then, elifs, else: elseBody, loc: this.loc(start) }
560
+ }
561
+
562
+ private parseUnless(): UnlessStmt {
563
+ const start = this.expectKeyword('unless')
564
+ const cond = this.parseExpression()
565
+ this.expectKeyword('do')
566
+ const body = this.parseBlock(['end'])
567
+ this.expectKeyword('end')
568
+ return { kind: 'UnlessStmt', cond, body, loc: this.loc(start) }
569
+ }
570
+
571
+ private parseWhile(): WhileStmt {
572
+ const start = this.expectKeyword('while')
573
+ const cond = this.parseExpression()
574
+ this.expectKeyword('do')
575
+ const body = this.parseBlock(['end'])
576
+ this.expectKeyword('end')
577
+ return { kind: 'WhileStmt', cond, body, loc: this.loc(start) }
578
+ }
579
+
580
+ private parseRepeat(): RepeatStmt {
581
+ const start = this.expectKeyword('repeat')
582
+ const body = this.parseBlock(['until'])
583
+ this.expectKeyword('until')
584
+ const cond = this.parseExpression()
585
+ return { kind: 'RepeatStmt', body, cond, loc: this.loc(start) }
586
+ }
587
+
588
+ private parseFor(): Statement {
589
+ const start = this.expectKeyword('for')
590
+ const first = this.expect(TokenType.Identifier, undefined, 'Expected loop variable')
591
+ const vars = [first.value]
592
+ while (this.atOperator(',')) {
593
+ this.next()
594
+ vars.push(this.expect(TokenType.Identifier, undefined, 'Expected loop variable').value)
595
+ }
596
+
597
+ if (this.matchOperator('=')) {
598
+ if (vars.length > 1) throw this.error('Numeric for accepts a single variable')
599
+ const from = this.parseExpression()
600
+ this.expect(TokenType.Operator, ',', 'Expected "," in numeric for')
601
+ const to = this.parseExpression()
602
+ const step = this.matchOperator(',') ? this.parseExpression() : undefined
603
+ this.expectKeyword('do')
604
+ const body = this.parseBlock(['end'])
605
+ this.expectKeyword('end')
606
+ const node: ForNumeric = {
607
+ kind: 'ForNumeric',
608
+ varName: first.value,
609
+ from,
610
+ to,
611
+ step,
612
+ inclusive: true,
613
+ body,
614
+ loc: this.loc(start),
615
+ }
616
+ return node
617
+ }
618
+
619
+ this.expectKeyword('in')
620
+ const iter = this.parseRangeExpr()
621
+ this.expectKeyword('do')
622
+ const body = this.parseBlock(['end'])
623
+ this.expectKeyword('end')
624
+ const node: ForIn = { kind: 'ForIn', vars, iter, body, loc: this.loc(start) }
625
+ return node
626
+ }
627
+
628
+ private parseReturn(): ReturnStmt {
629
+ const start = this.expectKeyword('return')
630
+ const values = this.isStatementEnd() ? [] : this.parseExprList()
631
+ return { kind: 'ReturnStmt', values, loc: this.loc(start) }
632
+ }
633
+
634
+ private parseThrow(): ThrowStmt {
635
+ const start = this.expectKeyword('throw')
636
+ if (this.isStatementEnd()) throw this.error('Expected expression after "throw"')
637
+ return { kind: 'ThrowStmt', value: this.parseExpression(), loc: this.loc(start) }
638
+ }
639
+
640
+ private parseSpawn(): SpawnStmt {
641
+ const start = this.expectKeyword('spawn')
642
+ if (this.isStatementEnd()) throw this.error('Expected expression after "spawn"')
643
+ return { kind: 'SpawnStmt', value: this.parseExpression(), loc: this.loc(start) }
644
+ }
645
+
646
+ private parseMatch(): MatchStmt {
647
+ const start = this.expectKeyword('match')
648
+ const subject = this.parseExpression()
649
+ this.skipSeparators()
650
+
651
+ const arms: MatchArm[] = []
652
+ while (this.atKeyword('when')) {
653
+ const when = this.next()
654
+ const patterns = this.parseExprList()
655
+ const guard = this.matchKeyword('if') ? this.parseExpression() : undefined
656
+ this.expectKeyword('then')
657
+ const body = this.parseBlock(['when', 'else', 'end'])
658
+ arms.push({ patterns, guard, body, loc: this.loc(when) })
659
+ this.skipSeparators()
660
+ }
661
+
662
+ const elseBody = this.matchKeyword('else') ? this.parseBlock(['end']) : undefined
663
+ this.expectKeyword('end')
664
+ return { kind: 'MatchStmt', subject, arms, elseBody, loc: this.loc(start) }
665
+ }
666
+
667
+ private parseTry(): TryStmt {
668
+ const start = this.expectKeyword('try')
669
+ const body = this.parseBlock(['catch', 'finally', 'end'])
670
+
671
+ let catchVar: string | undefined
672
+ let catchType: TypeAnn | undefined
673
+ let catchBody: Block | undefined
674
+ if (this.matchKeyword('catch')) {
675
+ if (this.at(TokenType.Identifier)) {
676
+ catchVar = this.next().value
677
+ if (this.matchOperator(':')) catchType = this.parseType()
678
+ }
679
+ catchBody = this.parseBlock(['finally', 'end'])
680
+ }
681
+
682
+ const finallyBody = this.matchKeyword('finally') ? this.parseBlock(['end']) : undefined
683
+ this.expectKeyword('end')
684
+ return { kind: 'TryStmt', body, catchVar, catchType, catchBody, finallyBody, loc: this.loc(start) }
685
+ }
686
+
687
+ private parseImport(): ImportDecl {
688
+ const start = this.expectKeyword('import')
689
+ const clauses: ImportClause[] = []
690
+ let source: string
691
+ let form: ImportForm
692
+
693
+ if (this.at(TokenType.String)) {
694
+ source = this.next().value
695
+ form = 'side-effect'
696
+ } else if (this.matchOperator('{')) {
697
+ this.skipNewlines()
698
+ while (!this.atOperator('}')) {
699
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected import name')
700
+ const clause: ImportClause = { name: name.value }
701
+ if (this.matchKeyword('as')) {
702
+ clause.alias = this.expect(TokenType.Identifier, undefined, 'Expected alias').value
703
+ }
704
+ clauses.push(clause)
705
+ this.skipNewlines()
706
+ if (!this.matchOperator(',')) break
707
+ this.skipNewlines()
708
+ }
709
+ this.expect(TokenType.Operator, '}', 'Expected "}"')
710
+ this.expectKeyword('from')
711
+ source = this.expect(TokenType.String, undefined, 'Expected module path').value
712
+ form = 'named'
713
+ } else if (this.matchOperator('*')) {
714
+ this.expectKeyword('as')
715
+ const alias = this.expect(TokenType.Identifier, undefined, 'Expected namespace alias')
716
+ clauses.push({ name: '*', alias: alias.value })
717
+ this.expectKeyword('from')
718
+ source = this.expect(TokenType.String, undefined, 'Expected module path').value
719
+ form = 'namespace'
720
+ } else {
721
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected import name')
722
+ const clause: ImportClause = { name: name.value }
723
+ if (this.matchKeyword('as')) {
724
+ clause.alias = this.expect(TokenType.Identifier, undefined, 'Expected alias').value
725
+ }
726
+ clauses.push(clause)
727
+ this.expectKeyword('from')
728
+ source = this.expect(TokenType.String, undefined, 'Expected module path').value
729
+ form = 'default'
730
+ }
731
+
732
+ return { kind: 'ImportDecl', clauses, source, form, loc: this.loc(start) }
733
+ }
734
+
735
+ private parseExport(): Statement {
736
+ const start = this.expectKeyword('export')
737
+ if (this.matchKeyword('default')) {
738
+ const expr = this.parseExpression()
739
+ const node: ExportDefault = { kind: 'ExportDefault', expr, loc: this.loc(start) }
740
+ return node
741
+ }
742
+
743
+ let declaration: Statement
744
+ if (this.atKeyword('const')) declaration = this.parseVarDeclaration('const')
745
+ else if (this.atKeyword('global')) declaration = this.parseVarDeclaration('global')
746
+ else if (this.atKeyword('fn')) declaration = this.parseFnDecl([], false, false)
747
+ else if (this.atKeyword('class')) declaration = this.parseClassDecl([])
748
+ else if (this.atKeyword('type')) declaration = this.parseTypeAlias()
749
+ else if (this.atKeyword('async') && this.peek(1).value === 'fn') {
750
+ this.next()
751
+ declaration = this.parseFnDecl([], true, false)
752
+ } else {
753
+ throw this.error('Expected declaration after "export"')
754
+ }
755
+
756
+ if (declaration.kind === 'VarDecl') declaration.export = true
757
+ const node: ExportDecl = { kind: 'ExportDecl', declaration, loc: this.loc(start) }
758
+ return node
759
+ }
760
+
761
+ private parseTypeAlias(): TypeAliasDecl {
762
+ const start = this.expectKeyword('type')
763
+ const name = this.expect(TokenType.Identifier, undefined, 'Expected type name').value
764
+ this.expect(TokenType.Operator, '=', 'Expected "=" in type alias')
765
+ const type = this.parseType()
766
+ return { kind: 'TypeAliasDecl', name, type, loc: this.loc(start) }
767
+ }
768
+
769
+ /* ---------------------------------------------------------------- */
770
+ /* Types */
771
+ /* ---------------------------------------------------------------- */
772
+
773
+ private parseType(): TypeAnn {
774
+ const start = this.peek()
775
+ const types = [this.parseTypePostfix()]
776
+ while (this.matchOperator('|')) types.push(this.parseTypePostfix())
777
+ if (types.length === 1) return types[0]
778
+ return { kind: 'union', types, loc: this.loc(start) }
779
+ }
780
+
781
+ private parseTypePostfix(): TypeAnn {
782
+ let type = this.parseTypePrimary()
783
+ while (this.matchOperator('?')) {
784
+ type = { kind: 'optional', type, inner: type, loc: type.loc }
785
+ }
786
+ return type
787
+ }
788
+
789
+ private parseTypePrimary(): TypeAnn {
790
+ const token = this.peek()
791
+
792
+ if (this.matchOperator('(')) {
793
+ const params: TypeAnn[] = []
794
+ this.skipNewlines()
795
+ while (!this.atOperator(')')) {
796
+ params.push(this.parseType())
797
+ this.skipNewlines()
798
+ if (!this.matchOperator(',')) break
799
+ this.skipNewlines()
800
+ }
801
+ this.expect(TokenType.Operator, ')', 'Expected ")"')
802
+ this.expect(TokenType.Operator, '->', 'Expected "->" in function type')
803
+ const returns = this.parseType()
804
+ return { kind: 'fn', params, returns, loc: this.loc(token) }
805
+ }
806
+
807
+ if (this.matchOperator('[')) {
808
+ const element = this.parseType()
809
+ this.expect(TokenType.Operator, ']', 'Expected "]"')
810
+ return { kind: 'array', element, loc: this.loc(token) }
811
+ }
812
+
813
+ if (this.matchOperator('{')) {
814
+ this.skipNewlines()
815
+ const entries: Array<{ name: string; type: TypeAnn }> = []
816
+ while (!this.atOperator('}')) {
817
+ const member = this.expect(TokenType.Identifier, undefined, 'Expected type member name')
818
+ this.expect(TokenType.Operator, ':', 'Expected ":" in type')
819
+ const type = this.parseType()
820
+ entries.push({ name: member.value, type })
821
+ this.skipNewlines()
822
+ if (!this.matchOperator(',')) break
823
+ this.skipNewlines()
824
+ }
825
+ this.expect(TokenType.Operator, '}', 'Expected "}"')
826
+
827
+ if (entries.length === 1 && PRIMITIVE_TYPE_NAMES.has(entries[0].name)) {
828
+ return {
829
+ kind: 'dict',
830
+ key: { kind: 'named', name: entries[0].name, loc: this.loc(token) },
831
+ value: entries[0].type,
832
+ loc: this.loc(token),
833
+ }
834
+ }
835
+ return { kind: 'record', fields: entries, loc: this.loc(token) }
836
+ }
837
+
838
+ if (token.type === TokenType.Identifier || this.atKeyword('nil')) {
839
+ this.next()
840
+ return { kind: 'named', name: token.value, loc: this.loc(token) }
841
+ }
842
+
843
+ throw this.error('Expected type annotation')
844
+ }
845
+
846
+ /* ---------------------------------------------------------------- */
847
+ /* Expressions */
848
+ /* ---------------------------------------------------------------- */
849
+
850
+ private parseExprList(): Expr[] {
851
+ const list = [this.parseExpression()]
852
+ while (this.matchOperator(',')) list.push(this.parseExpression())
853
+ return list
854
+ }
855
+
856
+ private parseExpression(minPrecedence = 1): Expr {
857
+ let left = this.parseUnary()
858
+
859
+ while (true) {
860
+ const token = this.peek()
861
+ const op = token.value
862
+ const precedence = BINARY_PRECEDENCE[op]
863
+ if (precedence === undefined || precedence < minPrecedence) break
864
+
865
+ const isOperator = token.type === TokenType.Operator
866
+ || (token.type === TokenType.Keyword && (op === 'and' || op === 'or'))
867
+ if (!isOperator) break
868
+
869
+ this.next()
870
+ const right = this.parseExpression(op === '^' ? precedence : precedence + 1)
871
+ const binary: Binary = { kind: 'Binary', op, left, right, loc: left.loc }
872
+ left = binary
873
+ }
874
+
875
+ return left
876
+ }
877
+
878
+ private parseUnary(): Expr {
879
+ const token = this.peek()
880
+ if (token.type === TokenType.Keyword && token.value === 'not') {
881
+ this.next()
882
+ return {
883
+ kind: 'Unary',
884
+ op: 'not',
885
+ argument: this.parseExpression(UNARY_PRECEDENCE),
886
+ loc: this.loc(token),
887
+ }
888
+ }
889
+ if (token.type === TokenType.Keyword && token.value === 'await') {
890
+ this.next()
891
+ const node: AwaitExpr = {
892
+ kind: 'AwaitExpr',
893
+ argument: this.parseExpression(UNARY_PRECEDENCE),
894
+ loc: this.loc(token),
895
+ }
896
+ return node
897
+ }
898
+ if (token.type === TokenType.Operator && (token.value === '-' || token.value === '#')) {
899
+ this.next()
900
+ return {
901
+ kind: 'Unary',
902
+ op: token.value,
903
+ argument: this.parseExpression(UNARY_PRECEDENCE),
904
+ loc: this.loc(token),
905
+ }
906
+ }
907
+ return this.parsePostfix()
908
+ }
909
+
910
+ private parsePostfix(): Expr {
911
+ let expr = this.parsePrimary()
912
+
913
+ while (true) {
914
+ const token = this.peek()
915
+
916
+ if (token.type === TokenType.Operator && token.value === '(') {
917
+ const args = this.parseArguments()
918
+ const call: Call = { kind: 'Call', callee: expr, args, loc: expr.loc }
919
+ expr = call
920
+ continue
921
+ }
922
+
923
+ if (token.type === TokenType.Operator && (token.value === '.' || token.value === '?.')) {
924
+ this.next()
925
+ const property = this.expectName('Expected property name')
926
+ const member: Member = {
927
+ kind: 'Member',
928
+ object: expr,
929
+ property: property.value,
930
+ loc: expr.loc,
931
+ }
932
+ if (token.value === '?.') member.optional = true
933
+ expr = member
934
+ continue
935
+ }
936
+
937
+ if (token.type === TokenType.Operator && token.value === '[') {
938
+ this.next()
939
+ this.skipNewlines()
940
+ const index = this.parseRangeExpr()
941
+ this.skipNewlines()
942
+ this.expect(TokenType.Operator, ']', 'Expected "]"')
943
+ const node: Index = { kind: 'Index', object: expr, index, loc: expr.loc }
944
+ if (index.kind === 'Range') node.isRange = true
945
+ expr = node
946
+ continue
947
+ }
948
+
949
+ // Method call: a:b(args). A bare `:` is a type annotation instead.
950
+ if (
951
+ token.type === TokenType.Operator && token.value === ':'
952
+ && isNameToken(this.peek(1))
953
+ && this.peek(2).type === TokenType.Operator && this.peek(2).value === '('
954
+ ) {
955
+ this.next()
956
+ const property = this.next()
957
+ const member: Member = {
958
+ kind: 'Member',
959
+ object: expr,
960
+ property: property.value,
961
+ loc: expr.loc,
962
+ }
963
+ const args = this.parseArguments()
964
+ const call: Call = { kind: 'Call', callee: member, args, isMethod: true, loc: expr.loc }
965
+ expr = call
966
+ continue
967
+ }
968
+
969
+ break
970
+ }
971
+
972
+ return expr
973
+ }
974
+
975
+ private parseArguments(): Expr[] {
976
+ this.expect(TokenType.Operator, '(', 'Expected "("')
977
+ const args: Expr[] = []
978
+ this.skipNewlines()
979
+ while (!this.atOperator(')')) {
980
+ if (this.atOperator('...')) {
981
+ const spreadToken = this.next()
982
+ const spread: Spread = { kind: 'Spread', loc: this.loc(spreadToken) }
983
+ if (!this.atOperator(')') && !this.atOperator(',')) spread.argument = this.parseExpression()
984
+ args.push(spread)
985
+ } else {
986
+ args.push(this.parseExpression())
987
+ }
988
+ this.skipNewlines()
989
+ if (!this.matchOperator(',')) break
990
+ this.skipNewlines()
991
+ }
992
+ this.expect(TokenType.Operator, ')', 'Expected ")"')
993
+ return args
994
+ }
995
+
996
+ private parsePrimary(): Expr {
997
+ const token = this.peek()
998
+
999
+ switch (token.type) {
1000
+ case TokenType.Number: {
1001
+ this.next()
1002
+ return {
1003
+ kind: 'NumberLit',
1004
+ value: Number(token.value.replace(/_/g, '')),
1005
+ loc: this.loc(token),
1006
+ }
1007
+ }
1008
+ case TokenType.String: {
1009
+ this.next()
1010
+ const literal: StringLit = { kind: 'StringLit', value: token.value, loc: this.loc(token) }
1011
+ if (token.value.includes('\n')) literal.multiline = true
1012
+ return literal
1013
+ }
1014
+ case TokenType.FString: {
1015
+ this.next()
1016
+ const literal: StringLit = {
1017
+ kind: 'StringLit',
1018
+ value: token.value,
1019
+ fstring: true,
1020
+ parts: parseFStringParts(token),
1021
+ loc: this.loc(token),
1022
+ }
1023
+ if (token.value.includes('\n')) literal.multiline = true
1024
+ return literal
1025
+ }
1026
+ case TokenType.Identifier: {
1027
+ this.next()
1028
+ const ident: Ident = { kind: 'Ident', name: token.value, loc: this.loc(token) }
1029
+ return ident
1030
+ }
1031
+ case TokenType.Keyword:
1032
+ return this.parseKeywordPrimary(token)
1033
+ case TokenType.Operator:
1034
+ if (token.value === '{') return this.parseTable()
1035
+ if (token.value === '(') {
1036
+ this.next()
1037
+ const expr = this.parseExpression()
1038
+ this.expect(TokenType.Operator, ')', 'Expected ")"')
1039
+ return expr
1040
+ }
1041
+ break
1042
+ }
1043
+
1044
+ throw this.error(`Unexpected token "${token.value}"`)
1045
+ }
1046
+
1047
+ private parseKeywordPrimary(token: Token): Expr {
1048
+ switch (token.value) {
1049
+ case 'true':
1050
+ case 'false':
1051
+ this.next()
1052
+ return { kind: 'BoolLit', value: token.value === 'true', loc: this.loc(token) }
1053
+ case 'nil':
1054
+ this.next()
1055
+ return { kind: 'NilLit', loc: this.loc(token) }
1056
+ case 'self':
1057
+ this.next()
1058
+ return { kind: 'SelfExpr', loc: this.loc(token) }
1059
+ case 'fn':
1060
+ return this.parseFunctionExpr(false)
1061
+ case 'async':
1062
+ if (this.peek(1).type === TokenType.Keyword && this.peek(1).value === 'fn') {
1063
+ this.next()
1064
+ return this.parseFunctionExpr(true)
1065
+ }
1066
+ break
1067
+ }
1068
+ throw this.error(`Unexpected keyword "${token.value}"`)
1069
+ }
1070
+
1071
+ private parseFunctionExpr(isAsync: boolean): FunctionExpr {
1072
+ const start = this.expectKeyword('fn')
1073
+ const params = this.parseParams()
1074
+ const returnType = this.matchOperator(':') ? this.parseType() : undefined
1075
+
1076
+ let body: Block | undefined
1077
+ let exprBody: Expr | undefined
1078
+ if (this.matchOperator('->')) {
1079
+ exprBody = this.parseExpression()
1080
+ } else {
1081
+ body = this.parseBlock(['end'])
1082
+ this.expectKeyword('end')
1083
+ }
1084
+
1085
+ const node: FunctionExpr = {
1086
+ kind: 'FunctionExpr',
1087
+ params,
1088
+ returnType,
1089
+ body,
1090
+ exprBody,
1091
+ loc: this.loc(start),
1092
+ }
1093
+ if (isAsync) node.async = true
1094
+ return node
1095
+ }
1096
+
1097
+ private parseTable(): Table {
1098
+ const start = this.expect(TokenType.Operator, '{')
1099
+ const fields: TableField[] = []
1100
+
1101
+ while (true) {
1102
+ this.skipNewlines()
1103
+ if (this.atOperator('}')) break
1104
+
1105
+ if (this.matchOperator('[')) {
1106
+ const key = this.parseExpression()
1107
+ this.expect(TokenType.Operator, ']', 'Expected "]"')
1108
+ this.expect(TokenType.Operator, '=', 'Expected "="')
1109
+ const value = this.parseExpression()
1110
+ fields.push({ key, value })
1111
+ } else if (
1112
+ isNameToken(this.peek())
1113
+ && this.peek(1).type === TokenType.Operator
1114
+ && this.peek(1).value === '='
1115
+ ) {
1116
+ const key = this.next()
1117
+ this.next()
1118
+ const value = this.parseExpression()
1119
+ const keyNode: Ident = { kind: 'Ident', name: key.value, loc: this.loc(key) }
1120
+ fields.push({ key: keyNode, value })
1121
+ } else if (this.peek().type === TokenType.Identifier && this.isTableFieldEnd(this.peek(1))) {
1122
+ const shorthand = this.next()
1123
+ const ident: Ident = { kind: 'Ident', name: shorthand.value, loc: this.loc(shorthand) }
1124
+ fields.push({ key: ident, value: ident })
1125
+ } else {
1126
+ fields.push({ value: this.parseExpression() })
1127
+ }
1128
+
1129
+ this.skipNewlines()
1130
+ if (this.matchOperator(',') || this.match(TokenType.Semicolon)) continue
1131
+ if (this.atOperator('}')) break
1132
+ }
1133
+
1134
+ this.expect(TokenType.Operator, '}', 'Expected "}"')
1135
+ return { kind: 'Table', fields, loc: this.loc(start) }
1136
+ }
1137
+
1138
+ private isTableFieldEnd(token: Token): boolean {
1139
+ return token.type === TokenType.Newline
1140
+ || token.type === TokenType.Semicolon
1141
+ || token.type === TokenType.EOF
1142
+ || (token.type === TokenType.Operator && (token.value === ',' || token.value === '}'))
1143
+ }
1144
+
1145
+ /**
1146
+ * Range is only meaningful in `for ... in` heads and slices, where `..`
1147
+ * must not be treated as string concatenation.
1148
+ */
1149
+ private parseRangeExpr(): Expr {
1150
+ const start = this.peek()
1151
+ const expr = this.parseExpression()
1152
+
1153
+ if (expr.kind === 'Binary' && expr.op === '..') {
1154
+ const range: Range = {
1155
+ kind: 'Range',
1156
+ from: expr.left,
1157
+ to: expr.right,
1158
+ inclusive: true,
1159
+ loc: expr.loc,
1160
+ }
1161
+ return range
1162
+ }
1163
+
1164
+ if (this.atOperator('..') || this.atOperator('..<')) {
1165
+ const inclusive = this.next().value === '..'
1166
+ const to = this.parseExpression()
1167
+ const range: Range = { kind: 'Range', from: expr, to, inclusive, loc: this.loc(start) }
1168
+ return range
1169
+ }
1170
+
1171
+ return expr
1172
+ }
1173
+
1174
+ /** Parses a complete standalone expression (used by f-string interpolation). */
1175
+ parseExpressionOnly(): Expr {
1176
+ const expr = this.parseExpression()
1177
+ if (!this.at(TokenType.EOF)) throw this.error('Unexpected token in expression')
1178
+ return expr
1179
+ }
1180
+ }
1181
+
1182
+ /** Splits an f-string body into literal text and parsed interpolation expressions. */
1183
+ function parseFStringParts(token: Token): Array<string | Expr> {
1184
+ const raw = token.value
1185
+ const parts: Array<string | Expr> = []
1186
+ let text = ''
1187
+ let index = 0
1188
+
1189
+ while (index < raw.length) {
1190
+ const ch = raw[index]
1191
+ if (ch === '{' && raw[index + 1] === '{') {
1192
+ text += '{'
1193
+ index += 2
1194
+ continue
1195
+ }
1196
+ if (ch === '}' && raw[index + 1] === '}') {
1197
+ text += '}'
1198
+ index += 2
1199
+ continue
1200
+ }
1201
+ if (ch === '{') {
1202
+ const close = findInterpolationEnd(raw, index + 1)
1203
+ if (close < 0) {
1204
+ throw new SyntaxError(
1205
+ `Unterminated f-string interpolation (line ${token.line}, column ${token.column})`,
1206
+ )
1207
+ }
1208
+ if (text) {
1209
+ parts.push(text)
1210
+ text = ''
1211
+ }
1212
+ parts.push(new Parser(raw.slice(index + 1, close)).parseExpressionOnly())
1213
+ index = close + 1
1214
+ continue
1215
+ }
1216
+ text += ch
1217
+ index++
1218
+ }
1219
+
1220
+ if (text) parts.push(text)
1221
+ return parts
1222
+ }
1223
+
1224
+ /** Finds the `}` closing an interpolation, skipping nested braces and strings. */
1225
+ function findInterpolationEnd(raw: string, from: number): number {
1226
+ let depth = 1
1227
+ let quote = ''
1228
+
1229
+ for (let i = from; i < raw.length; i++) {
1230
+ const ch = raw[i]
1231
+ if (quote) {
1232
+ if (ch === '\\') i++
1233
+ else if (ch === quote) quote = ''
1234
+ continue
1235
+ }
1236
+ if (ch === '"' || ch === "'") {
1237
+ quote = ch
1238
+ continue
1239
+ }
1240
+ if (ch === '{') depth++
1241
+ else if (ch === '}') {
1242
+ depth--
1243
+ if (depth === 0) return i
1244
+ }
1245
+ }
1246
+
1247
+ return -1
1248
+ }
1249
+
1250
+ export function parse(source: string): Program {
1251
+ return new Parser(source).parseProgram()
1252
+ }
1253
+
1254
+ /** Parses a single expression; mainly useful for tooling and f-string chunks. */
1255
+ export function parseExpression(source: string): Expr {
1256
+ return new Parser(source).parseExpressionOnly()
1257
+ }