@starpak/lang 0.0.0-stage → 0.1.2

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 (53) hide show
  1. package/LICENSE +22 -0
  2. package/lib/ast.d.ts +334 -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 +28 -0
  19. package/lib/emit/ts.d.ts.map +1 -0
  20. package/lib/emit/ts.js +803 -0
  21. package/lib/emit/ts.js.map +1 -0
  22. package/lib/index.d.ts +17 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +49 -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 +1133 -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/package.json +10 -3
  43. package/src/ast.ts +459 -0
  44. package/src/checker.ts +554 -0
  45. package/src/diagnostics.ts +100 -0
  46. package/src/emit/lua.ts +484 -0
  47. package/src/emit/ts.ts +861 -0
  48. package/src/index.ts +21 -0
  49. package/src/lexer.ts +301 -0
  50. package/src/parser.ts +1251 -0
  51. package/src/token.ts +34 -0
  52. package/src/types.ts +218 -0
  53. package/README.md +0 -3
package/src/emit/ts.ts ADDED
@@ -0,0 +1,861 @@
1
+ /**
2
+ * Spak Lang - TypeScript emitter
3
+ *
4
+ * Lowers a checked Spak `Program` into standalone ESM TypeScript that can be
5
+ * executed directly with `node --import tsx <file>`.
6
+ *
7
+ * Design notes:
8
+ * - Types are erased from runtime positions; `type` aliases are kept.
9
+ * - The output embeds a tiny runtime shim (`// spak runtime`) so generated
10
+ * files do not depend on any external package.
11
+ * - Spak's Lua-flavoured method calls (`list:sort`, `s:split`, ...) lower to
12
+ * calls on that shim; unknown methods keep normal JS method syntax.
13
+ * - Slices are 1-based and inclusive: `list[1..n]` -> `list.slice(0, n)`.
14
+ */
15
+
16
+ import type {
17
+ Assign,
18
+ Block,
19
+ Call,
20
+ ClassDecl,
21
+ Decorator,
22
+ Expr,
23
+ ExportDecl,
24
+ FnDecl,
25
+ ForIn,
26
+ ForNumeric,
27
+ FunctionExpr,
28
+ Ident,
29
+ ImportDecl,
30
+ Index,
31
+ MatchStmt,
32
+ Param,
33
+ Pattern,
34
+ Program,
35
+ Statement,
36
+ StringLit,
37
+ Table,
38
+ TablePattern,
39
+ TryStmt,
40
+ TypeAliasDecl,
41
+ TypeAnn,
42
+ VarDecl,
43
+ } from '../ast'
44
+
45
+ export interface EmitOptions {
46
+ /** Emit the inlined runtime shim. Defaults to true. */
47
+ runtime?: boolean
48
+ /** Header comment; set to an empty string to omit it. */
49
+ banner?: string
50
+ /** Indentation unit. Defaults to two spaces. */
51
+ indent?: string
52
+ }
53
+
54
+ /** Lua-style methods that are backed by the runtime shim. */
55
+ const RUNTIME_METHODS = new Set(['push', 'pop', 'sort', 'split', 'len'])
56
+
57
+ /** Node builtins whose Spak `fs` surface is synchronous. */
58
+ const FS_SOURCES = new Set(['fs', 'node:fs'])
59
+ const FS_SYNC = new Set([
60
+ 'access', 'appendFile', 'chmod', 'chown', 'close', 'copyFile', 'cp',
61
+ 'exists', 'fchmod', 'fchown', 'fstat', 'fsync', 'ftruncate', 'futimes',
62
+ 'glob', 'lchmod', 'lchown', 'link', 'lstat', 'lutimes', 'mkdir', 'mkdtemp',
63
+ 'open', 'opendir', 'read', 'readFile', 'readdir', 'readlink', 'readv',
64
+ 'realpath', 'rename', 'rm', 'rmdir', 'stat', 'statfs', 'symlink',
65
+ 'truncate', 'unlink', 'utimes', 'write', 'writeFile', 'writev',
66
+ ])
67
+
68
+ const RUNTIME = `// spak runtime (inlined helpers, no external dependencies)
69
+ const __spak = {
70
+ len(x) {
71
+ if (x === null || x === undefined) return 0
72
+ if (typeof x === "string" || Array.isArray(x)) return x.length
73
+ if (x instanceof Map || x instanceof Set) return x.size
74
+ if (typeof x === "object") return Object.keys(x).length
75
+ return 0
76
+ },
77
+ range(from, to, inclusive = true) {
78
+ const out = []
79
+ const step = from <= to ? 1 : -1
80
+ const end = inclusive ? to : to - step
81
+ for (let i = from; step > 0 ? i <= end : i >= end; i += step) out.push(i)
82
+ return out
83
+ },
84
+ slice(x, from, to, inclusive = true) {
85
+ const start = Number(from) - 1
86
+ const end = inclusive ? Number(to) : Number(to) - 1
87
+ return x.slice(start, end)
88
+ },
89
+ idx(x, i) {
90
+ // Lua-style 1-based indexing for sequences; plain maps keep their keys.
91
+ if (Array.isArray(x) || typeof x === "string") return x[Number(i) - 1]
92
+ return x[i]
93
+ },
94
+ split(s, sep) {
95
+ return String(s).split(sep)
96
+ },
97
+ push(t, v) {
98
+ if (Array.isArray(t)) t.push(v)
99
+ else if (typeof t.length === "number") t[t.length] = v
100
+ else t[Object.keys(t).length] = v
101
+ return t
102
+ },
103
+ pop(t) {
104
+ if (Array.isArray(t)) return t.pop()
105
+ const keys = Object.keys(t)
106
+ if (!keys.length) return null
107
+ const key = keys[keys.length - 1]
108
+ const value = t[key]
109
+ delete t[key]
110
+ return value
111
+ },
112
+ sort(t, cmp) {
113
+ if (!cmp) {
114
+ t.sort()
115
+ return t
116
+ }
117
+ t.sort((a, b) => {
118
+ const result = cmp(a, b)
119
+ if (typeof result === "number") return result
120
+ if (result) return -1
121
+ return cmp(b, a) ? 1 : 0
122
+ })
123
+ return t
124
+ },
125
+ entries(t) {
126
+ if (t instanceof Map) return Array.from(t.entries())
127
+ if (t instanceof Set) return Array.from(t.entries())
128
+ if (Array.isArray(t) || typeof t === "string") return Array.from(t.entries())
129
+ return Object.entries(t)
130
+ },
131
+ iter(t) {
132
+ if (t === null || t === undefined) return []
133
+ if (typeof t === "string" || Array.isArray(t)) return t
134
+ if (t instanceof Map || t instanceof Set) return t
135
+ if (typeof t[Symbol.iterator] === "function") return t
136
+ return Object.keys(t)
137
+ }
138
+ }
139
+ function len(x) { return __spak.len(x) }
140
+ function range(from, to) { return __spak.range(from, to, true) }
141
+ function print(...args) { console.log(...args) }
142
+ function tostring(x) { return String(x) }
143
+ `
144
+
145
+ class Emitter {
146
+ private lines: string[] = []
147
+ private level = 0
148
+ private scopes: Array<Set<string>> = [new Set()]
149
+ private temp = 0
150
+ private readonly options: EmitOptions
151
+
152
+ constructor(options: EmitOptions = {}) {
153
+ this.options = options
154
+ }
155
+
156
+ private get unit(): string {
157
+ return this.options.indent ?? ' '
158
+ }
159
+
160
+ private pad(extra = 0): string {
161
+ return this.unit.repeat(Math.max(0, this.level + extra))
162
+ }
163
+
164
+ private line(text = ''): void {
165
+ this.lines.push(text ? this.pad() + text : '')
166
+ }
167
+
168
+ private withScope(fn: () => void): void {
169
+ this.scopes.push(new Set())
170
+ this.level++
171
+ try {
172
+ fn()
173
+ } finally {
174
+ this.level--
175
+ this.scopes.pop()
176
+ }
177
+ }
178
+
179
+ private declare(name: string): void {
180
+ this.scopes[this.scopes.length - 1].add(name)
181
+ }
182
+
183
+ private declared(name: string): boolean {
184
+ return this.scopes.some(scope => scope.has(name))
185
+ }
186
+
187
+ /* ---------------------------------------------------------------- */
188
+ /* Program */
189
+ /* ---------------------------------------------------------------- */
190
+
191
+ emitProgram(program: Program): string {
192
+ for (const statement of program.body) this.emitStatement(statement)
193
+ const chunks: string[] = []
194
+ const banner = this.options.banner ?? '// Generated by Spak emitTypeScript'
195
+ if (banner) chunks.push(banner)
196
+ if (this.options.runtime !== false) chunks.push(RUNTIME.replace(/\n$/, ''))
197
+ chunks.push(this.lines.join('\n'))
198
+ return chunks.filter(Boolean).join('\n\n') + '\n'
199
+ }
200
+
201
+ private emitStatement(statement: Statement, exported = false): void {
202
+ switch (statement.kind) {
203
+ case 'VarDecl':
204
+ this.emitVarDecl(statement, exported)
205
+ return
206
+ case 'Assign':
207
+ this.emitAssign(statement)
208
+ return
209
+ case 'FnDecl':
210
+ this.emitFnDecl(statement, exported)
211
+ return
212
+ case 'ClassDecl':
213
+ this.emitClassDecl(statement, exported)
214
+ return
215
+ case 'IfStmt':
216
+ this.emitIf(statement)
217
+ return
218
+ case 'UnlessStmt':
219
+ this.line(`if (!(${this.expr(statement.cond)})) {`)
220
+ this.withScope(() => this.emitBlock(statement.body))
221
+ this.line('}')
222
+ return
223
+ case 'WhileStmt':
224
+ this.line(`while (${this.expr(statement.cond)}) {`)
225
+ this.withScope(() => this.emitBlock(statement.body))
226
+ this.line('}')
227
+ return
228
+ case 'RepeatStmt':
229
+ this.line('do {')
230
+ this.withScope(() => this.emitBlock(statement.body))
231
+ this.line(`} while (!(${this.expr(statement.cond)}));`)
232
+ return
233
+ case 'ForNumeric':
234
+ this.emitForNumeric(statement)
235
+ return
236
+ case 'ForIn':
237
+ this.emitForIn(statement)
238
+ return
239
+ case 'ReturnStmt': {
240
+ if (statement.values.length === 0) this.line('return;')
241
+ else if (statement.values.length === 1) this.line(`return ${this.expr(statement.values[0])};`)
242
+ else this.line(`return [${statement.values.map(value => this.expr(value)).join(', ')}];`)
243
+ return
244
+ }
245
+ case 'BreakStmt':
246
+ this.line('break;')
247
+ return
248
+ case 'ContinueStmt':
249
+ this.line('continue;')
250
+ return
251
+ case 'MatchStmt':
252
+ this.emitMatch(statement)
253
+ return
254
+ case 'TryStmt':
255
+ this.emitTry(statement)
256
+ return
257
+ case 'ImportDecl':
258
+ this.emitImport(statement)
259
+ return
260
+ case 'ExportDecl':
261
+ this.emitStatement((statement as ExportDecl).declaration, true)
262
+ return
263
+ case 'ExportDefault':
264
+ this.line(`export default ${this.expr(statement.expr)};`)
265
+ return
266
+ case 'TypeAliasDecl':
267
+ this.emitTypeAlias(statement, exported)
268
+ return
269
+ case 'ThrowStmt':
270
+ this.line(`throw ${this.expr(statement.value)};`)
271
+ return
272
+ case 'SpawnStmt': {
273
+ const value = this.expr(statement.value)
274
+ if (statement.value.kind === 'FunctionExpr') this.line(`void Promise.resolve((${value})());`)
275
+ else this.line(`void Promise.resolve(${value});`)
276
+ return
277
+ }
278
+ case 'ExprStmt':
279
+ this.line(`${this.expr(statement.expr)};`)
280
+ return
281
+ default:
282
+ this.line(`// unsupported statement: ${(statement as Statement).kind}`)
283
+ }
284
+ }
285
+
286
+ private emitBlock(block: Block | undefined): void {
287
+ if (!block) return
288
+ for (const statement of block.body) this.emitStatement(statement)
289
+ }
290
+
291
+ private emitDecorators(decorators: Decorator[] | undefined): void {
292
+ if (!decorators?.length) return
293
+ for (const decorator of decorators) {
294
+ const args = decorator.args.map(arg => this.expr(arg)).join(', ')
295
+ this.line(`// @${decorator.name}${args ? `(${args})` : ''}`)
296
+ }
297
+ }
298
+
299
+ /* ---------------------------------------------------------------- */
300
+ /* Declarations */
301
+ /* ---------------------------------------------------------------- */
302
+
303
+ private emitVarDecl(node: VarDecl, exported: boolean): void {
304
+ if (node.doc) this.emitDoc(node.doc)
305
+ this.emitDecorators(node.decorators)
306
+
307
+ const pairs = node.names.map((entry, index) => {
308
+ const value = node.values[index]
309
+ return { name: entry.name, value: value ? this.expr(value) : undefined }
310
+ })
311
+
312
+ if (node.scope === 'global') {
313
+ for (const pair of pairs) {
314
+ const value = pair.value ?? 'undefined'
315
+ this.line(`globalThis.${pair.name} = ${value};`)
316
+ if (exported) this.line(`export const ${pair.name} = globalThis.${pair.name};`)
317
+ }
318
+ return
319
+ }
320
+
321
+ const declaredHere = pairs.some(pair => this.declared(pair.name))
322
+ if (!declaredHere) {
323
+ const keyword = node.scope === 'const' ? 'const' : 'let'
324
+ const prefix = exported ? 'export ' : ''
325
+ const assign = pairs.map(pair => pair.value === undefined ? pair.name : `${pair.name} = ${pair.value}`)
326
+ this.line(`${prefix}${keyword} ${assign.join(', ')};`)
327
+ for (const pair of pairs) this.declare(pair.name)
328
+ return
329
+ }
330
+
331
+ for (const pair of pairs) {
332
+ if (!this.declared(pair.name)) {
333
+ const keyword = node.scope === 'const' ? 'const' : 'let'
334
+ this.declare(pair.name)
335
+ this.line(pair.value === undefined ? `${keyword} ${pair.name};` : `${keyword} ${pair.name} = ${pair.value};`)
336
+ } else if (pair.value !== undefined) {
337
+ this.line(`${pair.name} = ${pair.value};`)
338
+ }
339
+ }
340
+ }
341
+
342
+ private emitDoc(doc: string): void {
343
+ const lines = doc.split(/\r?\n/)
344
+ if (lines.length === 1) {
345
+ this.line(`/** ${lines[0]} */`)
346
+ return
347
+ }
348
+ this.line('/**')
349
+ for (const text of lines) this.line(` * ${text}`)
350
+ this.line(' */')
351
+ }
352
+
353
+ private emitAssign(node: Assign): void {
354
+ const values = node.values
355
+ node.targets.forEach((target, index) => {
356
+ const value = values[index] ? this.expr(values[index]) : 'undefined'
357
+ if (this.isPattern(target)) {
358
+ this.emitPatternAssign(target, value)
359
+ return
360
+ }
361
+ this.line(`${this.expr(target)} = ${value};`)
362
+ })
363
+ }
364
+
365
+ private isPattern(target: Expr | Pattern): target is Pattern {
366
+ return target.kind === 'TablePattern' || target.kind === 'ArrayPattern'
367
+ }
368
+
369
+ private emitPatternAssign(pattern: Pattern, value: string): void {
370
+ if (pattern.kind === 'ArrayPattern') {
371
+ const names = pattern.elements
372
+ this.line(`let [${names.join(', ')}] = ${value};`)
373
+ for (const name of names) this.declare(name)
374
+ return
375
+ }
376
+ const table = pattern as TablePattern
377
+ const entries = table.entries.map((entry) => {
378
+ const target = entry.target ?? entry.key
379
+ if (entry.default) return `${entry.key}: ${target} = ${this.expr(entry.default)}`
380
+ return entry.target ? `${entry.key}: ${target}` : entry.key
381
+ })
382
+ this.line(`let { ${entries.join(', ')} } = ${value};`)
383
+ for (const entry of table.entries) this.declare(entry.target ?? entry.key)
384
+ }
385
+
386
+ private emitFnDecl(node: FnDecl, exported: boolean): void {
387
+ if (node.doc) this.emitDoc(node.doc)
388
+ this.emitDecorators(node.decorators)
389
+
390
+ if (node.receiver) {
391
+ const fn = this.renderFunction(node)
392
+ this.line(`${node.receiver}.${node.name} = ${fn};`)
393
+ return
394
+ }
395
+
396
+ this.declare(node.name)
397
+ const prefix = exported ? 'export ' : ''
398
+ this.line(`${prefix}${node.async ? 'async ' : ''}function ${node.name}(${this.emitParams(node.params)}) {`)
399
+ this.withScope(() => {
400
+ this.declareParams(node.params)
401
+ this.emitFunctionBody(node)
402
+ })
403
+ this.line('}')
404
+ }
405
+
406
+ private emitClassDecl(node: ClassDecl, exported: boolean): void {
407
+ if (node.doc) this.emitDoc(node.doc)
408
+ this.emitDecorators(node.decorators)
409
+ this.declare(node.name)
410
+
411
+ const prefix = exported ? 'export ' : ''
412
+ const parent = node.superClass ? ` extends ${node.superClass}` : ''
413
+ this.line(`${prefix}class ${node.name}${parent} {`)
414
+ this.withScope(() => {
415
+ for (const member of node.members) {
416
+ if (member.kind === 'VarDecl') {
417
+ if (member.doc) this.emitDoc(member.doc)
418
+ this.emitDecorators(member.decorators)
419
+ member.names.forEach((entry, index) => {
420
+ const value = member.values[index]
421
+ this.line(`${entry.name} = ${value ? this.expr(value) : 'undefined'};`)
422
+ this.declare(entry.name)
423
+ })
424
+ continue
425
+ }
426
+ this.emitClassMethod(member)
427
+ }
428
+ })
429
+ this.line('}')
430
+ }
431
+
432
+ private emitClassMethod(node: FnDecl): void {
433
+ if (node.doc) this.emitDoc(node.doc)
434
+ this.emitDecorators(node.decorators)
435
+ const name = node.name === 'init' ? 'constructor' : node.name
436
+ const asyncKeyword = node.async && name !== 'constructor' ? 'async ' : ''
437
+ this.line(`${asyncKeyword}${name}(${this.emitParams(node.params)}) {`)
438
+ this.withScope(() => {
439
+ this.declareParams(node.params)
440
+ this.emitFunctionBody(node)
441
+ })
442
+ this.line('}')
443
+ }
444
+
445
+ private emitFunctionBody(node: FnDecl | FunctionExpr): void {
446
+ if (node.exprBody) {
447
+ this.line(`return ${this.expr(node.exprBody)};`)
448
+ return
449
+ }
450
+ this.emitBlock(node.body)
451
+ }
452
+
453
+ private declareParams(params: Param[]): void {
454
+ for (const param of params) this.declare(rawParamName(param))
455
+ }
456
+
457
+ private emitParams(params: Param[]): string {
458
+ return params.map((param) => {
459
+ if (param.rest) return `...${rawParamName(param)}`
460
+ const value = param.default ? ` = ${this.expr(param.default)}` : ''
461
+ return `${param.name}${value}`
462
+ }).join(', ')
463
+ }
464
+
465
+ private renderFunction(node: FnDecl | FunctionExpr): string {
466
+ const sub = new Emitter(this.options)
467
+ sub.scopes = [new Set()]
468
+ const params = sub.emitParams(node.params)
469
+ sub.withScope(() => {
470
+ sub.declareParams(node.params)
471
+ sub.emitFunctionBody(node)
472
+ })
473
+ const body = sub.lines.map(text => (text ? this.pad() + text : text)).join('\n')
474
+ const asyncKeyword = node.async ? 'async ' : ''
475
+ return `${asyncKeyword}function (${params}) {\n${body}\n${this.pad()}}`
476
+ }
477
+
478
+ private emitTypeAlias(node: TypeAliasDecl, exported: boolean): void {
479
+ if (node.doc) this.emitDoc(node.doc)
480
+ this.line(`${exported ? 'export ' : ''}type ${node.name} = ${typeAnn(node.type)};`)
481
+ }
482
+
483
+ /* ---------------------------------------------------------------- */
484
+ /* Control flow */
485
+ /* ---------------------------------------------------------------- */
486
+
487
+ private emitIf(node: Extract<Statement, { kind: 'IfStmt' }>): void {
488
+ this.line(`if (${this.expr(node.cond)}) {`)
489
+ this.withScope(() => this.emitBlock(node.then))
490
+ for (const branch of node.elifs) {
491
+ this.line(`} else if (${this.expr(branch.cond)}) {`)
492
+ this.withScope(() => this.emitBlock(branch.body))
493
+ }
494
+ if (node.else) {
495
+ this.line('} else {')
496
+ this.withScope(() => this.emitBlock(node.else))
497
+ }
498
+ this.line('}')
499
+ }
500
+
501
+ private emitForNumeric(node: ForNumeric): void {
502
+ const from = this.expr(node.from)
503
+ const to = this.expr(node.to)
504
+ const step = node.step ? this.expr(node.step) : '1'
505
+ const descending = isNegative(node.step)
506
+ const compare = descending ? '>=' : '<='
507
+ this.line(`for (let ${node.varName} = ${from}; ${node.varName} ${compare} ${to}; ${node.varName} += ${step}) {`)
508
+ this.withScope(() => {
509
+ this.declare(node.varName)
510
+ this.emitBlock(node.body)
511
+ })
512
+ this.line('}')
513
+ }
514
+
515
+ private emitForIn(node: ForIn): void {
516
+ let source: string
517
+ if (node.iter.kind === 'Range') {
518
+ source = `__spak.range(${this.expr(node.iter.from)}, ${this.expr(node.iter.to)}, ${node.iter.inclusive ? 'true' : 'false'})`
519
+ } else {
520
+ source = this.expr(node.iter)
521
+ }
522
+
523
+ if (node.vars.length > 1) {
524
+ this.line(`for (const [${node.vars.join(', ')}] of __spak.entries(${source})) {`)
525
+ } else {
526
+ this.line(`for (const ${node.vars[0]} of ${source}) {`)
527
+ }
528
+ this.withScope(() => {
529
+ for (const name of node.vars) this.declare(name)
530
+ this.emitBlock(node.body)
531
+ })
532
+ this.line('}')
533
+ }
534
+
535
+ private emitMatch(node: MatchStmt): void {
536
+ const subject = `__match${++this.temp}`
537
+ this.line(`const ${subject} = ${this.expr(node.subject)};`)
538
+
539
+ node.arms.forEach((arm, index) => {
540
+ const bindings: string[] = []
541
+ const conditions = arm.patterns.map((pattern) => {
542
+ if (pattern.kind === 'Ident') {
543
+ bindings.push(pattern.name)
544
+ return 'true'
545
+ }
546
+ return this.matchCondition(pattern, subject)
547
+ })
548
+ let condition = conditions.length ? conditions.join(' || ') : 'true'
549
+ if (arm.guard) condition = `(${condition} && (${this.expr(arm.guard)}))`
550
+ for (const name of bindings) condition = `((${name}) => ${condition})(${subject})`
551
+
552
+ this.line(`${index === 0 ? 'if' : '} else if'} (${condition}) {`)
553
+ this.withScope(() => {
554
+ for (const name of bindings) {
555
+ this.line(`const ${name} = ${subject};`)
556
+ this.declare(name)
557
+ }
558
+ this.emitBlock(arm.body)
559
+ })
560
+ })
561
+
562
+ if (node.elseBody) {
563
+ const hasArms = node.arms.length > 0
564
+ this.line(hasArms ? '} else {' : 'if (true) {')
565
+ this.withScope(() => this.emitBlock(node.elseBody))
566
+ }
567
+
568
+ if (node.arms.length || node.elseBody) this.line('}')
569
+ }
570
+
571
+ private matchCondition(pattern: Expr, subject: string): string {
572
+ if (pattern.kind === 'Range') {
573
+ const from = this.expr(pattern.from)
574
+ const to = this.expr(pattern.to)
575
+ const op = pattern.inclusive ? '<=' : '<'
576
+ return `(${subject} >= ${from} && ${subject} ${op} ${to})`
577
+ }
578
+ return `${subject} === ${this.expr(pattern)}`
579
+ }
580
+
581
+ private emitTry(node: TryStmt): void {
582
+ this.line('try {')
583
+ this.withScope(() => this.emitBlock(node.body))
584
+ if (node.catchBody) {
585
+ this.line(node.catchVar ? `} catch (${node.catchVar}) {` : '} catch {')
586
+ this.withScope(() => {
587
+ if (node.catchVar) this.declare(node.catchVar)
588
+ this.emitBlock(node.catchBody)
589
+ })
590
+ }
591
+ if (node.finallyBody) {
592
+ this.line('} finally {')
593
+ this.withScope(() => this.emitBlock(node.finallyBody))
594
+ }
595
+ this.line('}')
596
+ }
597
+
598
+ /* ---------------------------------------------------------------- */
599
+ /* Modules */
600
+ /* ---------------------------------------------------------------- */
601
+
602
+ private emitImport(node: ImportDecl): void {
603
+ if (node.doc) this.emitDoc(node.doc)
604
+ if (FS_SOURCES.has(node.source)) {
605
+ this.emitFsImport(node)
606
+ return
607
+ }
608
+ const source = JSON.stringify(node.source)
609
+ if (node.clauses.length === 0) {
610
+ this.line(`import ${source};`)
611
+ return
612
+ }
613
+ if (node.clauses.length === 1) {
614
+ const clause = node.clauses[0]
615
+ if (clause.name === '*') {
616
+ this.line(`import * as ${clause.alias ?? 'ns'} from ${source};`)
617
+ return
618
+ }
619
+ if (!clause.alias) {
620
+ this.line(`import ${clause.name} from ${source};`)
621
+ this.declare(clause.name)
622
+ return
623
+ }
624
+ this.line(`import { ${clause.name} as ${clause.alias} } from ${source};`)
625
+ this.declare(clause.alias)
626
+ return
627
+ }
628
+ const specifiers = node.clauses.map(clause => clause.alias ? `${clause.name} as ${clause.alias}` : clause.name)
629
+ this.line(`import { ${specifiers.join(', ')} } from ${source};`)
630
+ for (const clause of node.clauses) this.declare(clause.alias ?? clause.name)
631
+ }
632
+
633
+ /**
634
+ * Spak's `fs` is synchronous (`for name in readdir(dir)`), so imports from
635
+ * `fs` are bound to the `*Sync` variants through a namespace import.
636
+ */
637
+ private emitFsImport(node: ImportDecl): void {
638
+ const namespace = `__fs${++this.temp}`
639
+ this.line(`import * as ${namespace} from "node:fs";`)
640
+ if (node.clauses.length === 0) return
641
+
642
+ if (node.clauses.length === 1) {
643
+ const clause = node.clauses[0]
644
+ if (clause.name === '*') {
645
+ this.line(`const ${clause.alias ?? 'fs'} = ${namespace};`)
646
+ return
647
+ }
648
+ if (!clause.alias && !FS_SYNC.has(clause.name)) {
649
+ this.line(`const ${clause.name} = ${namespace};`)
650
+ this.declare(clause.name)
651
+ return
652
+ }
653
+ }
654
+
655
+ for (const clause of node.clauses) {
656
+ if (clause.name === '*') {
657
+ this.line(`const ${clause.alias ?? 'fs'} = ${namespace};`)
658
+ continue
659
+ }
660
+ const local = clause.alias ?? clause.name
661
+ const member = FS_SYNC.has(clause.name) ? `${clause.name}Sync` : clause.name
662
+ this.line(`const ${local} = ${namespace}.${member};`)
663
+ this.declare(local)
664
+ }
665
+ }
666
+
667
+ /* ---------------------------------------------------------------- */
668
+ /* Expressions */
669
+ /* ---------------------------------------------------------------- */
670
+
671
+ private expr(node: Expr): string {
672
+ switch (node.kind) {
673
+ case 'NumberLit':
674
+ return Number.isFinite(node.value) ? String(node.value) : '0'
675
+ case 'StringLit':
676
+ return this.string(node)
677
+ case 'BoolLit':
678
+ return node.value ? 'true' : 'false'
679
+ case 'NilLit':
680
+ return 'null'
681
+ case 'Ident':
682
+ return (node as Ident).name
683
+ case 'SelfExpr':
684
+ return 'this'
685
+ case 'Member':
686
+ return `${this.expr(node.object)}${node.optional ? '?.' : '.'}${propertyName(node.property)}`
687
+ case 'Index':
688
+ return this.index(node)
689
+ case 'Call':
690
+ return this.call(node)
691
+ case 'Binary':
692
+ return this.binary(node)
693
+ case 'Unary':
694
+ if (node.op === 'not') return `(!(${this.expr(node.argument)}))`
695
+ if (node.op === '#') return `__spak.len(${this.expr(node.argument)})`
696
+ return `(-(${this.expr(node.argument)}))`
697
+ case 'Table':
698
+ return this.table(node)
699
+ case 'FunctionExpr':
700
+ return this.renderFunction(node)
701
+ case 'Range':
702
+ return `__spak.range(${this.expr(node.from)}, ${this.expr(node.to)}, ${node.inclusive ? 'true' : 'false'})`
703
+ case 'AwaitExpr':
704
+ return `(await ${this.expr(node.argument)})`
705
+ case 'Spread':
706
+ return node.argument ? `...${this.expr(node.argument)}` : '...[]'
707
+ default:
708
+ return 'undefined'
709
+ }
710
+ }
711
+
712
+ private string(node: StringLit): string {
713
+ if (node.fstring && node.parts) {
714
+ const body = node.parts.map((part) => {
715
+ if (typeof part === 'string') return escapeTemplate(part)
716
+ return `\${${this.expr(part)}}`
717
+ }).join('')
718
+ return `\`${body}\``
719
+ }
720
+ return JSON.stringify(node.value)
721
+ }
722
+
723
+ private index(node: Index): string {
724
+ const object = this.expr(node.object)
725
+ if (node.index.kind === 'Range') {
726
+ const range = node.index
727
+ if (range.from.kind === 'NumberLit' && range.from.value === 1) {
728
+ if (range.inclusive) return `${object}.slice(0, ${this.expr(range.to)})`
729
+ return `${object}.slice(0, (${this.expr(range.to)}) - 1)`
730
+ }
731
+ return `__spak.slice(${object}, ${this.expr(range.from)}, ${this.expr(range.to)}, ${range.inclusive ? 'true' : 'false'})`
732
+ }
733
+ return `__spak.idx(${object}, ${this.expr(node.index)})`
734
+ }
735
+
736
+ private call(node: Call, prepend: Expr[] = []): string {
737
+ const args = [...prepend, ...node.args].map(arg => this.expr(arg)).join(', ')
738
+
739
+ if (node.callee.kind === 'Member') {
740
+ const member = node.callee
741
+ const object = this.expr(member.object)
742
+ if (!node.isMethod && member.property === 'new') {
743
+ return `new ${object}(${args})`
744
+ }
745
+ if (node.isMethod && RUNTIME_METHODS.has(member.property)) {
746
+ const prefix = args ? `${object}, ${args}` : object
747
+ return `__spak.${member.property}(${prefix})`
748
+ }
749
+ const access = `${object}${member.optional ? '?.' : '.'}${propertyName(member.property)}`
750
+ return `${access}(${args})`
751
+ }
752
+
753
+ if (node.callee.kind === 'Ident') {
754
+ if (node.callee.name === 'len') return `__spak.len(${args})`
755
+ if (node.callee.name === 'range') return `__spak.range(${args})`
756
+ if (node.callee.name === 'pairs' || node.callee.name === 'ipairs') return `__spak.entries(${args})`
757
+ }
758
+
759
+ return `${this.expr(node.callee)}(${args})`
760
+ }
761
+
762
+ private binary(node: Extract<Expr, { kind: 'Binary' }>): string {
763
+ const left = this.expr(node.left)
764
+ const right = this.expr(node.right)
765
+
766
+ if (node.op === '|>') {
767
+ if (node.right.kind === 'Call') return this.call(node.right, [node.left])
768
+ return `${this.expr(node.right)}(${left})`
769
+ }
770
+ if (node.op === '//') return `Math.floor((${left}) / (${right}))`
771
+ if (node.op === '..') return `(String(${left}) + String(${right}))`
772
+
773
+ const operators: Record<string, string> = {
774
+ and: '&&',
775
+ or: '||',
776
+ '==': '===',
777
+ '~=': '!==',
778
+ '^': '**',
779
+ }
780
+ const operator = operators[node.op] ?? node.op
781
+ return `(${left} ${operator} ${right})`
782
+ }
783
+
784
+ private table(node: Table): string {
785
+ if (node.fields.length === 0) return '[]'
786
+ const hasKeys = node.fields.some(field => field.key)
787
+
788
+ if (!hasKeys) {
789
+ return `[${node.fields.map(field => this.expr(field.value)).join(', ')}]`
790
+ }
791
+
792
+ const parts: string[] = []
793
+ let position = 0
794
+ for (const field of node.fields) {
795
+ if (field.key?.kind === 'Ident') {
796
+ parts.push(`${propertyName(field.key.name)}: ${this.expr(field.value)}`)
797
+ continue
798
+ }
799
+ if (field.key) {
800
+ parts.push(`[${this.expr(field.key)}]: ${this.expr(field.value)}`)
801
+ continue
802
+ }
803
+ parts.push(`${position}: ${this.expr(field.value)}`)
804
+ position++
805
+ }
806
+ return `{ ${parts.join(', ')} }`
807
+ }
808
+ }
809
+
810
+ /* ------------------------------------------------------------------ */
811
+ /* Helpers */
812
+ /* ------------------------------------------------------------------ */
813
+
814
+ function rawParamName(param: Param): string {
815
+ return param.rest ? (param.name === '...' ? 'args' : param.name) : param.name
816
+ }
817
+
818
+ function propertyName(name: string): string {
819
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name)
820
+ }
821
+
822
+ function isNegative(expr: Expr | undefined): boolean {
823
+ if (!expr) return false
824
+ if (expr.kind === 'NumberLit') return expr.value < 0
825
+ return expr.kind === 'Unary' && expr.op === '-' && expr.argument.kind === 'NumberLit'
826
+ }
827
+
828
+ function escapeTemplate(text: string): string {
829
+ return text.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
830
+ }
831
+
832
+ /** Converts a Spak type annotation into TypeScript syntax (types only). */
833
+ export function typeAnn(node: TypeAnn): string {
834
+ switch (node.kind) {
835
+ case 'named':
836
+ return node.name === 'nil' ? 'null' : node.name
837
+ case 'array': {
838
+ const element = node.element.kind === 'union' || node.element.kind === 'fn'
839
+ ? `(${typeAnn(node.element)})`
840
+ : typeAnn(node.element)
841
+ return `${element}[]`
842
+ }
843
+ case 'dict':
844
+ return `Record<${typeAnn(node.key)}, ${typeAnn(node.value)}>`
845
+ case 'record':
846
+ return `{ ${node.fields.map(field => `${propertyName(field.name)}: ${typeAnn(field.type)}`).join(', ')} }`
847
+ case 'union':
848
+ return node.types.map(typeAnn).join(' | ')
849
+ case 'fn':
850
+ return `(${node.params.map(typeAnn).join(', ')}) => ${typeAnn(node.returns)}`
851
+ case 'optional':
852
+ return `${typeAnn(node.type)} | null`
853
+ default:
854
+ return 'unknown'
855
+ }
856
+ }
857
+
858
+ /** Lowers a Spak program to an executable ESM TypeScript module. */
859
+ export function emitTypeScript(program: Program, options: EmitOptions = {}): string {
860
+ return new Emitter(options).emitProgram(program)
861
+ }