@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/emit/ts.ts ADDED
@@ -0,0 +1,1024 @@
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
+ * - `fs` named imports stay async when the binding is awaited, so
15
+ * `import { readFile } from "fs"` + `await readFile(...)` works. Imports that
16
+ * are never awaited keep the historical synchronous binding (hello.spk
17
+ * compatibility); `import { readFileSync }` is always explicit.
18
+ */
19
+
20
+ import type {
21
+ Assign,
22
+ Block,
23
+ Call,
24
+ ClassDecl,
25
+ Decorator,
26
+ Expr,
27
+ ExportDecl,
28
+ FnDecl,
29
+ ForIn,
30
+ ForNumeric,
31
+ FunctionExpr,
32
+ Ident,
33
+ ImportDecl,
34
+ ImportForm,
35
+ Index,
36
+ MatchStmt,
37
+ Param,
38
+ Pattern,
39
+ Program,
40
+ Statement,
41
+ StringLit,
42
+ Table,
43
+ TablePattern,
44
+ TryStmt,
45
+ TypeAliasDecl,
46
+ TypeAnn,
47
+ VarDecl,
48
+ } from '../ast'
49
+ import { versionBanner } from '../version'
50
+
51
+ export interface EmitOptions {
52
+ /** Emit the inlined runtime shim. Defaults to true. */
53
+ runtime?: boolean
54
+ /** Header comment; set to an empty string to omit it. */
55
+ banner?: string
56
+ /** Indentation unit. Defaults to two spaces. */
57
+ indent?: string
58
+ /**
59
+ * Optional artifact name. When `version` is set (and `banner` is not), the
60
+ * banner becomes `// spak:<name>:<version>`, matching spm's build banners.
61
+ */
62
+ name?: string
63
+ /**
64
+ * Artifact version, 3 or 4 numeric segments (`1.0.0` / `1.0.0.0`). Parsed by
65
+ * the same rules as spm's `parseVersion`; invalid values throw.
66
+ */
67
+ version?: string
68
+ }
69
+
70
+ /** Lua-style methods that are backed by the runtime shim. */
71
+ const RUNTIME_METHODS = new Set(['push', 'pop', 'sort', 'split', 'len'])
72
+
73
+ /** Node builtins whose Spak `fs` surface uses the sync/async compat shim. */
74
+ const FS_SOURCES = new Set(['fs', 'node:fs'])
75
+ /** Node `fs` functions that also exist as `*Sync` and `fs/promises` variants. */
76
+ const FS_SYNC = new Set([
77
+ 'access', 'appendFile', 'chmod', 'chown', 'close', 'copyFile', 'cp',
78
+ 'exists', 'fchmod', 'fchown', 'fstat', 'fsync', 'ftruncate', 'futimes',
79
+ 'glob', 'lchmod', 'lchown', 'link', 'lstat', 'lutimes', 'mkdir', 'mkdtemp',
80
+ 'open', 'opendir', 'read', 'readFile', 'readdir', 'readlink', 'readv',
81
+ 'realpath', 'rename', 'rm', 'rmdir', 'stat', 'statfs', 'symlink',
82
+ 'truncate', 'unlink', 'utimes', 'write', 'writeFile', 'writev',
83
+ ])
84
+
85
+ const RUNTIME = `// spak runtime (inlined helpers, no external dependencies)
86
+ const __spak = {
87
+ len(x) {
88
+ if (x === null || x === undefined) return 0
89
+ if (typeof x === "string" || Array.isArray(x)) return x.length
90
+ if (x instanceof Map || x instanceof Set) return x.size
91
+ if (typeof x === "object") return Object.keys(x).length
92
+ return 0
93
+ },
94
+ range(from, to, inclusive = true) {
95
+ const out = []
96
+ const step = from <= to ? 1 : -1
97
+ const end = inclusive ? to : to - step
98
+ for (let i = from; step > 0 ? i <= end : i >= end; i += step) out.push(i)
99
+ return out
100
+ },
101
+ slice(x, from, to, inclusive = true) {
102
+ // 1-based, end-inclusive bounds. Empty ranges (1..0, 3..2) must be empty,
103
+ // so clamp instead of letting a negative end mean "from the tail".
104
+ const length = typeof x === "string" || Array.isArray(x) ? x.length : 0
105
+ const rawStart = Number(from) - 1
106
+ const rawEnd = inclusive ? Number(to) : Number(to) - 1
107
+ const start = Math.max(0, Number.isFinite(rawStart) ? rawStart : 0)
108
+ const end = Number.isFinite(rawEnd) ? rawEnd : -1
109
+ if (end < start || start >= length) return x.slice(0, 0)
110
+ return x.slice(start, Math.min(end, length))
111
+ },
112
+ idx(x, i) {
113
+ // Lua-style 1-based indexing for sequences; plain maps keep their keys.
114
+ if (Array.isArray(x) || typeof x === "string") return x[Number(i) - 1]
115
+ return x[i]
116
+ },
117
+ split(s, sep) {
118
+ return String(s).split(sep)
119
+ },
120
+ push(t, v) {
121
+ if (Array.isArray(t)) t.push(v)
122
+ else if (typeof t.length === "number") t[t.length] = v
123
+ else t[Object.keys(t).length] = v
124
+ return t
125
+ },
126
+ pop(t) {
127
+ if (Array.isArray(t)) return t.pop()
128
+ const keys = Object.keys(t)
129
+ if (!keys.length) return null
130
+ const key = keys[keys.length - 1]
131
+ const value = t[key]
132
+ delete t[key]
133
+ return value
134
+ },
135
+ sort(t, cmp) {
136
+ if (!cmp) {
137
+ t.sort()
138
+ return t
139
+ }
140
+ t.sort((a, b) => {
141
+ const result = cmp(a, b)
142
+ if (typeof result === "number") return result
143
+ if (result) return -1
144
+ return cmp(b, a) ? 1 : 0
145
+ })
146
+ return t
147
+ },
148
+ entries(t) {
149
+ if (t instanceof Map) return Array.from(t.entries())
150
+ if (t instanceof Set) return Array.from(t.entries())
151
+ if (Array.isArray(t) || typeof t === "string") return Array.from(t.entries())
152
+ return Object.entries(t)
153
+ },
154
+ iter(t) {
155
+ if (t === null || t === undefined) return []
156
+ if (typeof t === "string" || Array.isArray(t)) return t
157
+ if (t instanceof Map || t instanceof Set) return t
158
+ if (typeof t[Symbol.iterator] === "function") return t
159
+ return Object.keys(t)
160
+ }
161
+ }
162
+ function len(x) { return __spak.len(x) }
163
+ function range(from, to) { return __spak.range(from, to, true) }
164
+ function print(...args) { console.log(...args) }
165
+ function tostring(x) { return String(x) }
166
+ `
167
+
168
+ class Emitter {
169
+ private lines: string[] = []
170
+ private level = 0
171
+ private scopes: Array<Set<string>> = [new Set()]
172
+ private temp = 0
173
+ /** Identifiers used directly under `await`; drives the async `fs` surface. */
174
+ private awaited = new Set<string>()
175
+ private readonly options: EmitOptions
176
+
177
+ constructor(options: EmitOptions = {}) {
178
+ this.options = options
179
+ }
180
+
181
+ private get unit(): string {
182
+ return this.options.indent ?? ' '
183
+ }
184
+
185
+ private pad(extra = 0): string {
186
+ return this.unit.repeat(Math.max(0, this.level + extra))
187
+ }
188
+
189
+ private line(text = ''): void {
190
+ this.lines.push(text ? this.pad() + text : '')
191
+ }
192
+
193
+ private withScope(fn: () => void): void {
194
+ this.scopes.push(new Set())
195
+ this.level++
196
+ try {
197
+ fn()
198
+ } finally {
199
+ this.level--
200
+ this.scopes.pop()
201
+ }
202
+ }
203
+
204
+ private declare(name: string): void {
205
+ this.scopes[this.scopes.length - 1].add(name)
206
+ }
207
+
208
+ private declared(name: string): boolean {
209
+ return this.scopes.some(scope => scope.has(name))
210
+ }
211
+
212
+ /* ---------------------------------------------------------------- */
213
+ /* Program */
214
+ /* ---------------------------------------------------------------- */
215
+
216
+ emitProgram(program: Program): string {
217
+ this.awaited = collectAwaitedIdentifiers(program)
218
+ for (const statement of program.body) this.emitStatement(statement)
219
+ const chunks: string[] = []
220
+ const defaultBanner = this.options.version
221
+ ? versionBanner(this.options.name ?? 'spak', this.options.version)
222
+ : '// Generated by Spak emitTypeScript'
223
+ const banner = this.options.banner ?? defaultBanner
224
+ if (banner) chunks.push(banner)
225
+ if (this.options.runtime !== false) chunks.push(RUNTIME.replace(/\n$/, ''))
226
+ chunks.push(this.lines.join('\n'))
227
+ return chunks.filter(Boolean).join('\n\n') + '\n'
228
+ }
229
+
230
+ private emitStatement(statement: Statement, exported = false): void {
231
+ switch (statement.kind) {
232
+ case 'VarDecl':
233
+ this.emitVarDecl(statement, exported)
234
+ return
235
+ case 'Assign':
236
+ this.emitAssign(statement)
237
+ return
238
+ case 'FnDecl':
239
+ this.emitFnDecl(statement, exported)
240
+ return
241
+ case 'ClassDecl':
242
+ this.emitClassDecl(statement, exported)
243
+ return
244
+ case 'IfStmt':
245
+ this.emitIf(statement)
246
+ return
247
+ case 'UnlessStmt':
248
+ this.line(`if (!(${this.expr(statement.cond)})) {`)
249
+ this.withScope(() => this.emitBlock(statement.body))
250
+ this.line('}')
251
+ return
252
+ case 'WhileStmt':
253
+ this.line(`while (${this.expr(statement.cond)}) {`)
254
+ this.withScope(() => this.emitBlock(statement.body))
255
+ this.line('}')
256
+ return
257
+ case 'RepeatStmt':
258
+ this.line('do {')
259
+ this.withScope(() => this.emitBlock(statement.body))
260
+ this.line(`} while (!(${this.expr(statement.cond)}));`)
261
+ return
262
+ case 'ForNumeric':
263
+ this.emitForNumeric(statement)
264
+ return
265
+ case 'ForIn':
266
+ this.emitForIn(statement)
267
+ return
268
+ case 'ReturnStmt': {
269
+ if (statement.values.length === 0) this.line('return;')
270
+ else if (statement.values.length === 1) this.line(`return ${this.expr(statement.values[0])};`)
271
+ else this.line(`return [${statement.values.map(value => this.expr(value)).join(', ')}];`)
272
+ return
273
+ }
274
+ case 'BreakStmt':
275
+ this.line('break;')
276
+ return
277
+ case 'ContinueStmt':
278
+ this.line('continue;')
279
+ return
280
+ case 'MatchStmt':
281
+ this.emitMatch(statement)
282
+ return
283
+ case 'TryStmt':
284
+ this.emitTry(statement)
285
+ return
286
+ case 'ImportDecl':
287
+ this.emitImport(statement)
288
+ return
289
+ case 'ExportDecl':
290
+ this.emitStatement((statement as ExportDecl).declaration, true)
291
+ return
292
+ case 'ExportDefault':
293
+ this.line(`export default ${this.expr(statement.expr)};`)
294
+ return
295
+ case 'TypeAliasDecl':
296
+ this.emitTypeAlias(statement, exported)
297
+ return
298
+ case 'ThrowStmt':
299
+ this.line(`throw ${this.expr(statement.value)};`)
300
+ return
301
+ case 'SpawnStmt': {
302
+ const value = this.expr(statement.value)
303
+ if (statement.value.kind === 'FunctionExpr') this.line(`void Promise.resolve((${value})());`)
304
+ else this.line(`void Promise.resolve(${value});`)
305
+ return
306
+ }
307
+ case 'ExprStmt':
308
+ this.line(`${this.expr(statement.expr)};`)
309
+ return
310
+ default:
311
+ this.line(`// unsupported statement: ${(statement as Statement).kind}`)
312
+ }
313
+ }
314
+
315
+ private emitBlock(block: Block | undefined): void {
316
+ if (!block) return
317
+ for (const statement of block.body) this.emitStatement(statement)
318
+ }
319
+
320
+ /** Comments only: used for positions that cannot be wrapped at runtime. */
321
+ private emitDecorators(decorators: Decorator[] | undefined): void {
322
+ if (!decorators?.length) return
323
+ for (const decorator of decorators) {
324
+ this.line(`// @${decorator.name}${this.decoratorArgs(decorator)}`)
325
+ }
326
+ }
327
+
328
+ private decoratorArgs(decorator: Decorator): string {
329
+ const args = decorator.args.map(arg => this.expr(arg)).join(', ')
330
+ return args ? `(${args})` : ''
331
+ }
332
+
333
+ /** Decorator as a callable expression: `memo`, `route("GET", "/x")`. */
334
+ private decoratorCall(decorator: Decorator): string {
335
+ return decorator.args.length
336
+ ? `${decorator.name}(${decorator.args.map(arg => this.expr(arg)).join(', ')})`
337
+ : decorator.name
338
+ }
339
+
340
+ /**
341
+ * Emits executable decorator wrappers (`target = dec(target)`), innermost
342
+ * decorator first, matching the docs' `@decorator fn` => `f = decorator(f)`.
343
+ */
344
+ private applyDecorators(target: string, decorators: Decorator[] | undefined): void {
345
+ if (!decorators?.length) return
346
+ for (const decorator of [...decorators].reverse()) {
347
+ this.line(`${target} = ${this.decoratorCall(decorator)}(${target});`)
348
+ }
349
+ }
350
+
351
+ /* ---------------------------------------------------------------- */
352
+ /* Declarations */
353
+ /* ---------------------------------------------------------------- */
354
+
355
+ private emitVarDecl(node: VarDecl, exported: boolean): void {
356
+ if (node.doc) this.emitDoc(node.doc)
357
+ this.emitDecorators(node.decorators)
358
+
359
+ const pairs = node.names.map((entry, index) => {
360
+ const value = node.values[index]
361
+ return { name: entry.name, value: value ? this.expr(value) : undefined }
362
+ })
363
+
364
+ if (node.scope === 'global') {
365
+ for (const pair of pairs) {
366
+ const value = pair.value ?? 'undefined'
367
+ this.line(`globalThis.${pair.name} = ${value};`)
368
+ if (exported) this.line(`export const ${pair.name} = globalThis.${pair.name};`)
369
+ }
370
+ return
371
+ }
372
+
373
+ const declaredHere = pairs.some(pair => this.declared(pair.name))
374
+ if (!declaredHere) {
375
+ const keyword = node.scope === 'const' ? 'const' : 'let'
376
+ const prefix = exported ? 'export ' : ''
377
+ const assign = pairs.map(pair => pair.value === undefined ? pair.name : `${pair.name} = ${pair.value}`)
378
+ this.line(`${prefix}${keyword} ${assign.join(', ')};`)
379
+ for (const pair of pairs) this.declare(pair.name)
380
+ return
381
+ }
382
+
383
+ for (const pair of pairs) {
384
+ if (!this.declared(pair.name)) {
385
+ const keyword = node.scope === 'const' ? 'const' : 'let'
386
+ this.declare(pair.name)
387
+ this.line(pair.value === undefined ? `${keyword} ${pair.name};` : `${keyword} ${pair.name} = ${pair.value};`)
388
+ } else if (pair.value !== undefined) {
389
+ this.line(`${pair.name} = ${pair.value};`)
390
+ }
391
+ }
392
+ }
393
+
394
+ private emitDoc(doc: string): void {
395
+ const lines = doc.split(/\r?\n/)
396
+ if (lines.length === 1) {
397
+ this.line(`/** ${lines[0]} */`)
398
+ return
399
+ }
400
+ this.line('/**')
401
+ for (const text of lines) this.line(` * ${text}`)
402
+ this.line(' */')
403
+ }
404
+
405
+ private emitAssign(node: Assign): void {
406
+ const values = node.values
407
+ node.targets.forEach((target, index) => {
408
+ const value = values[index] ? this.expr(values[index]) : 'undefined'
409
+ if (this.isPattern(target)) {
410
+ this.emitPatternAssign(target, value)
411
+ return
412
+ }
413
+ this.line(`${this.expr(target)} = ${value};`)
414
+ })
415
+ }
416
+
417
+ private isPattern(target: Expr | Pattern): target is Pattern {
418
+ return target.kind === 'TablePattern' || target.kind === 'ArrayPattern'
419
+ }
420
+
421
+ private emitPatternAssign(pattern: Pattern, value: string): void {
422
+ if (pattern.kind === 'ArrayPattern') {
423
+ const names = pattern.elements
424
+ this.line(`let [${names.join(', ')}] = ${value};`)
425
+ for (const name of names) this.declare(name)
426
+ return
427
+ }
428
+ const table = pattern as TablePattern
429
+ const entries = table.entries.map((entry) => {
430
+ const target = entry.target ?? entry.key
431
+ if (entry.default) return `${entry.key}: ${target} = ${this.expr(entry.default)}`
432
+ return entry.target ? `${entry.key}: ${target}` : entry.key
433
+ })
434
+ this.line(`let { ${entries.join(', ')} } = ${value};`)
435
+ for (const entry of table.entries) this.declare(entry.target ?? entry.key)
436
+ }
437
+
438
+ private emitFnDecl(node: FnDecl, exported: boolean): void {
439
+ if (node.doc) this.emitDoc(node.doc)
440
+ this.emitDecorators(node.decorators)
441
+
442
+ if (node.receiver) {
443
+ const fn = this.renderFunction(node)
444
+ const target = `${node.receiver}.${propertyName(node.name)}`
445
+ this.line(`${target} = ${fn};`)
446
+ this.applyDecorators(target, node.decorators)
447
+ return
448
+ }
449
+
450
+ this.declare(node.name)
451
+ const prefix = exported ? 'export ' : ''
452
+ this.line(`${prefix}${node.async ? 'async ' : ''}function ${node.name}(${this.emitParams(node.params)}) {`)
453
+ this.withScope(() => {
454
+ this.declareParams(node.params)
455
+ this.emitFunctionBody(node)
456
+ })
457
+ this.line('}')
458
+ this.applyDecorators(node.name, node.decorators)
459
+ }
460
+
461
+ private emitClassDecl(node: ClassDecl, exported: boolean): void {
462
+ if (node.doc) this.emitDoc(node.doc)
463
+ this.emitDecorators(node.decorators)
464
+ this.declare(node.name)
465
+
466
+ const prefix = exported ? 'export ' : ''
467
+ const parent = node.superClass ? ` extends ${node.superClass}` : ''
468
+ this.line(`${prefix}class ${node.name}${parent} {`)
469
+ const methodWrappers: Array<{ target: string, decorators: Decorator[] }> = []
470
+ this.withScope(() => {
471
+ for (const member of node.members) {
472
+ if (member.kind === 'VarDecl') {
473
+ if (member.doc) this.emitDoc(member.doc)
474
+ this.emitDecorators(member.decorators)
475
+ member.names.forEach((entry, index) => {
476
+ const value = member.values[index]
477
+ this.line(`${entry.name} = ${value ? this.expr(value) : 'undefined'};`)
478
+ this.declare(entry.name)
479
+ })
480
+ continue
481
+ }
482
+ this.emitClassMethod(member)
483
+ if (member.decorators.length) {
484
+ if (member.name === 'init') {
485
+ // Constructors cannot be reassigned after the class is built.
486
+ this.emitDecorators(member.decorators)
487
+ } else {
488
+ methodWrappers.push({
489
+ target: `${node.name}.prototype.${propertyName(member.name)}`,
490
+ decorators: member.decorators,
491
+ })
492
+ }
493
+ }
494
+ }
495
+ })
496
+ this.line('}')
497
+ for (const wrapper of methodWrappers) {
498
+ this.emitDecorators(wrapper.decorators)
499
+ this.applyDecorators(wrapper.target, wrapper.decorators)
500
+ }
501
+ this.applyDecorators(node.name, node.decorators)
502
+ }
503
+
504
+ private emitClassMethod(node: FnDecl): void {
505
+ if (node.doc) this.emitDoc(node.doc)
506
+ const name = node.name === 'init' ? 'constructor' : node.name
507
+ const asyncKeyword = node.async && name !== 'constructor' ? 'async ' : ''
508
+ this.line(`${asyncKeyword}${name}(${this.emitParams(node.params)}) {`)
509
+ this.withScope(() => {
510
+ this.declareParams(node.params)
511
+ this.emitFunctionBody(node)
512
+ })
513
+ this.line('}')
514
+ }
515
+
516
+ private emitFunctionBody(node: FnDecl | FunctionExpr): void {
517
+ if (node.exprBody) {
518
+ this.line(`return ${this.expr(node.exprBody)};`)
519
+ return
520
+ }
521
+ this.emitBlock(node.body)
522
+ }
523
+
524
+ private declareParams(params: Param[]): void {
525
+ for (const param of params) this.declare(rawParamName(param))
526
+ }
527
+
528
+ private emitParams(params: Param[]): string {
529
+ return params.map((param) => {
530
+ if (param.rest) return `...${rawParamName(param)}`
531
+ const value = param.default ? ` = ${this.expr(param.default)}` : ''
532
+ return `${param.name}${value}`
533
+ }).join(', ')
534
+ }
535
+
536
+ private renderFunction(node: FnDecl | FunctionExpr): string {
537
+ const sub = new Emitter(this.options)
538
+ sub.scopes = [new Set()]
539
+ const params = sub.emitParams(node.params)
540
+ sub.withScope(() => {
541
+ sub.declareParams(node.params)
542
+ sub.emitFunctionBody(node)
543
+ })
544
+ const body = sub.lines.map(text => (text ? this.pad() + text : text)).join('\n')
545
+ const asyncKeyword = node.async ? 'async ' : ''
546
+ return `${asyncKeyword}function (${params}) {\n${body}\n${this.pad()}}`
547
+ }
548
+
549
+ private emitTypeAlias(node: TypeAliasDecl, exported: boolean): void {
550
+ if (node.doc) this.emitDoc(node.doc)
551
+ this.line(`${exported ? 'export ' : ''}type ${node.name} = ${typeAnn(node.type)};`)
552
+ }
553
+
554
+ /* ---------------------------------------------------------------- */
555
+ /* Control flow */
556
+ /* ---------------------------------------------------------------- */
557
+
558
+ private emitIf(node: Extract<Statement, { kind: 'IfStmt' }>): void {
559
+ this.line(`if (${this.expr(node.cond)}) {`)
560
+ this.withScope(() => this.emitBlock(node.then))
561
+ for (const branch of node.elifs) {
562
+ this.line(`} else if (${this.expr(branch.cond)}) {`)
563
+ this.withScope(() => this.emitBlock(branch.body))
564
+ }
565
+ if (node.else) {
566
+ this.line('} else {')
567
+ this.withScope(() => this.emitBlock(node.else))
568
+ }
569
+ this.line('}')
570
+ }
571
+
572
+ private emitForNumeric(node: ForNumeric): void {
573
+ const from = this.expr(node.from)
574
+ const to = this.expr(node.to)
575
+ const step = node.step ? this.expr(node.step) : '1'
576
+ const descending = isNegative(node.step)
577
+ const compare = descending ? '>=' : '<='
578
+ this.line(`for (let ${node.varName} = ${from}; ${node.varName} ${compare} ${to}; ${node.varName} += ${step}) {`)
579
+ this.withScope(() => {
580
+ this.declare(node.varName)
581
+ this.emitBlock(node.body)
582
+ })
583
+ this.line('}')
584
+ }
585
+
586
+ private emitForIn(node: ForIn): void {
587
+ let source: string
588
+ if (node.iter.kind === 'Range') {
589
+ source = `__spak.range(${this.expr(node.iter.from)}, ${this.expr(node.iter.to)}, ${node.iter.inclusive ? 'true' : 'false'})`
590
+ } else {
591
+ source = this.expr(node.iter)
592
+ }
593
+
594
+ if (node.vars.length > 1) {
595
+ this.line(`for (const [${node.vars.join(', ')}] of __spak.entries(${source})) {`)
596
+ } else {
597
+ this.line(`for (const ${node.vars[0]} of ${source}) {`)
598
+ }
599
+ this.withScope(() => {
600
+ for (const name of node.vars) this.declare(name)
601
+ this.emitBlock(node.body)
602
+ })
603
+ this.line('}')
604
+ }
605
+
606
+ private emitMatch(node: MatchStmt): void {
607
+ const subject = `__match${++this.temp}`
608
+ this.line(`const ${subject} = ${this.expr(node.subject)};`)
609
+
610
+ node.arms.forEach((arm, index) => {
611
+ const bindings: string[] = []
612
+ const conditions = arm.patterns.map((pattern) => {
613
+ if (pattern.kind === 'Ident') {
614
+ bindings.push(pattern.name)
615
+ return 'true'
616
+ }
617
+ return this.matchCondition(pattern, subject)
618
+ })
619
+ let condition = conditions.length ? conditions.join(' || ') : 'true'
620
+ if (arm.guard) condition = `(${condition} && (${this.expr(arm.guard)}))`
621
+ for (const name of bindings) condition = `((${name}) => ${condition})(${subject})`
622
+
623
+ this.line(`${index === 0 ? 'if' : '} else if'} (${condition}) {`)
624
+ this.withScope(() => {
625
+ for (const name of bindings) {
626
+ this.line(`const ${name} = ${subject};`)
627
+ this.declare(name)
628
+ }
629
+ this.emitBlock(arm.body)
630
+ })
631
+ })
632
+
633
+ if (node.elseBody) {
634
+ const hasArms = node.arms.length > 0
635
+ this.line(hasArms ? '} else {' : 'if (true) {')
636
+ this.withScope(() => this.emitBlock(node.elseBody))
637
+ }
638
+
639
+ if (node.arms.length || node.elseBody) this.line('}')
640
+ }
641
+
642
+ private matchCondition(pattern: Expr, subject: string): string {
643
+ if (pattern.kind === 'Range') {
644
+ const from = this.expr(pattern.from)
645
+ const to = this.expr(pattern.to)
646
+ const op = pattern.inclusive ? '<=' : '<'
647
+ return `(${subject} >= ${from} && ${subject} ${op} ${to})`
648
+ }
649
+ return `${subject} === ${this.expr(pattern)}`
650
+ }
651
+
652
+ private emitTry(node: TryStmt): void {
653
+ this.line('try {')
654
+ this.withScope(() => this.emitBlock(node.body))
655
+ if (node.catchBody) {
656
+ this.line(node.catchVar ? `} catch (${node.catchVar}) {` : '} catch {')
657
+ this.withScope(() => {
658
+ if (node.catchVar) this.declare(node.catchVar)
659
+ this.emitBlock(node.catchBody)
660
+ })
661
+ }
662
+ if (node.finallyBody) {
663
+ this.line('} finally {')
664
+ this.withScope(() => this.emitBlock(node.finallyBody))
665
+ }
666
+ this.line('}')
667
+ }
668
+
669
+ /* ---------------------------------------------------------------- */
670
+ /* Modules */
671
+ /* ---------------------------------------------------------------- */
672
+
673
+ private emitImport(node: ImportDecl): void {
674
+ if (node.doc) this.emitDoc(node.doc)
675
+ if (FS_SOURCES.has(node.source)) {
676
+ this.emitFsImport(node)
677
+ return
678
+ }
679
+ const source = JSON.stringify(node.source)
680
+ const form = node.form ?? inferImportForm(node)
681
+ if (node.clauses.length === 0 || form === 'side-effect') {
682
+ this.line(`import ${source};`)
683
+ return
684
+ }
685
+ if (form === 'namespace' || node.clauses[0]?.name === '*') {
686
+ const clause = node.clauses[0]
687
+ this.line(`import * as ${clause.alias ?? 'ns'} from ${source};`)
688
+ this.declare(clause.alias ?? 'ns')
689
+ return
690
+ }
691
+ if (form === 'default' && node.clauses.length === 1) {
692
+ const clause = node.clauses[0]
693
+ this.line(`import ${clause.name} from ${source};`)
694
+ this.declare(clause.name)
695
+ return
696
+ }
697
+ const specifiers = node.clauses.map(clause => clause.alias ? `${clause.name} as ${clause.alias}` : clause.name)
698
+ this.line(`import { ${specifiers.join(', ')} } from ${source};`)
699
+ for (const clause of node.clauses) this.declare(clause.alias ?? clause.name)
700
+ }
701
+
702
+ /**
703
+ * Spak's `fs` keeps the Lua-flavoured synchronous surface for backwards
704
+ * compatibility (`for name in readdir(dir)` in hello.spk), but named imports
705
+ * that are awaited bind to `node:fs/promises` instead:
706
+ *
707
+ * import { readFile } from "fs"
708
+ * text = await readFile("a.txt") -> node:fs/promises
709
+ *
710
+ * Synchronous access is explicit via `import { readFileSync } from "fs"`.
711
+ */
712
+ private emitFsImport(node: ImportDecl): void {
713
+ const form = node.form ?? inferImportForm(node)
714
+ if (node.clauses.length === 0 || form === 'side-effect') {
715
+ this.line('import "node:fs";')
716
+ return
717
+ }
718
+
719
+ const namespace = `__fs${++this.temp}`
720
+
721
+ if (form === 'namespace' || node.clauses[0]?.name === '*') {
722
+ const clause = node.clauses[0]
723
+ const alias = clause.alias ?? 'fs'
724
+ this.line(`import * as ${namespace} from "node:fs";`)
725
+ this.line(`const ${alias} = ${namespace};`)
726
+ this.declare(alias)
727
+ return
728
+ }
729
+
730
+ // `import fs from "fs"` historically exposed the whole namespace.
731
+ if (form === 'default' && node.clauses.length === 1) {
732
+ const clause = node.clauses[0]
733
+ this.line(`import * as ${namespace} from "node:fs";`)
734
+ this.line(`const ${clause.name} = ${namespace};`)
735
+ this.declare(clause.name)
736
+ return
737
+ }
738
+
739
+ const bindings: string[] = []
740
+ const asyncSpecifiers: string[] = []
741
+ for (const clause of node.clauses) {
742
+ if (clause.name === '*') {
743
+ bindings.push(`const ${clause.alias ?? 'fs'} = ${namespace};`)
744
+ this.declare(clause.alias ?? 'fs')
745
+ continue
746
+ }
747
+ const local = clause.alias ?? clause.name
748
+ const member = fsMember(clause.name, this.awaited.has(local))
749
+ if (member.namespace === 'async') {
750
+ asyncSpecifiers.push(clause.alias ? `${clause.name} as ${clause.alias}` : clause.name)
751
+ this.declare(local)
752
+ continue
753
+ }
754
+ if (member.renamed) {
755
+ bindings.push(`// fs compat: never awaited, bound to ${member.name}; use import { ${member.name} } for an explicit sync API`)
756
+ }
757
+ bindings.push(`const ${local} = ${namespace}.${member.name};`)
758
+ this.declare(local)
759
+ }
760
+
761
+ if (bindings.length) this.line(`import * as ${namespace} from "node:fs";`)
762
+ if (asyncSpecifiers.length) this.line(`import { ${asyncSpecifiers.join(', ')} } from "node:fs/promises";`)
763
+ for (const binding of bindings) this.line(binding)
764
+ }
765
+
766
+ /* ---------------------------------------------------------------- */
767
+ /* Expressions */
768
+ /* ---------------------------------------------------------------- */
769
+
770
+ private expr(node: Expr): string {
771
+ switch (node.kind) {
772
+ case 'NumberLit':
773
+ return Number.isFinite(node.value) ? String(node.value) : '0'
774
+ case 'StringLit':
775
+ return this.string(node)
776
+ case 'BoolLit':
777
+ return node.value ? 'true' : 'false'
778
+ case 'NilLit':
779
+ return 'null'
780
+ case 'Ident':
781
+ return (node as Ident).name
782
+ case 'SelfExpr':
783
+ return 'this'
784
+ case 'Member':
785
+ return `${this.expr(node.object)}${node.optional ? '?.' : '.'}${propertyName(node.property)}`
786
+ case 'Index':
787
+ return this.index(node)
788
+ case 'Call':
789
+ return this.call(node)
790
+ case 'Binary':
791
+ return this.binary(node)
792
+ case 'Unary':
793
+ if (node.op === 'not') return `(!(${this.expr(node.argument)}))`
794
+ if (node.op === '#') return `__spak.len(${this.expr(node.argument)})`
795
+ return `(-(${this.expr(node.argument)}))`
796
+ case 'Table':
797
+ return this.table(node)
798
+ case 'FunctionExpr':
799
+ return this.renderFunction(node)
800
+ case 'Range':
801
+ return `__spak.range(${this.expr(node.from)}, ${this.expr(node.to)}, ${node.inclusive ? 'true' : 'false'})`
802
+ case 'AwaitExpr':
803
+ return `(await ${this.expr(node.argument)})`
804
+ case 'Spread':
805
+ return node.argument ? `...${this.expr(node.argument)}` : '...[]'
806
+ default:
807
+ return 'undefined'
808
+ }
809
+ }
810
+
811
+ private string(node: StringLit): string {
812
+ if (node.fstring && node.parts) {
813
+ const body = node.parts.map((part) => {
814
+ if (typeof part === 'string') return escapeTemplate(part)
815
+ return `\${${this.expr(part)}}`
816
+ }).join('')
817
+ return `\`${body}\``
818
+ }
819
+ return JSON.stringify(node.value)
820
+ }
821
+
822
+ private index(node: Index): string {
823
+ const object = this.expr(node.object)
824
+ if (node.index.kind === 'Range') {
825
+ const range = node.index
826
+ // `list[1..n]` is the common case: a plain 0-based slice. Everything
827
+ // else (including `..<` and empty `1..0`) goes through the clamped
828
+ // runtime helper so an off-by-one end can never wrap from the tail.
829
+ if (range.from.kind === 'NumberLit' && range.from.value === 1 && range.inclusive) {
830
+ return `${object}.slice(0, ${this.expr(range.to)})`
831
+ }
832
+ return `__spak.slice(${object}, ${this.expr(range.from)}, ${this.expr(range.to)}, ${range.inclusive ? 'true' : 'false'})`
833
+ }
834
+ return `__spak.idx(${object}, ${this.expr(node.index)})`
835
+ }
836
+
837
+ private call(node: Call, prepend: Expr[] = []): string {
838
+ const args = [...prepend, ...node.args].map(arg => this.expr(arg)).join(', ')
839
+
840
+ if (node.callee.kind === 'Member') {
841
+ const member = node.callee
842
+ const object = this.expr(member.object)
843
+ if (!node.isMethod && member.property === 'new') {
844
+ return `new ${object}(${args})`
845
+ }
846
+ if (node.isMethod && RUNTIME_METHODS.has(member.property)) {
847
+ const prefix = args ? `${object}, ${args}` : object
848
+ return `__spak.${member.property}(${prefix})`
849
+ }
850
+ const access = `${object}${member.optional ? '?.' : '.'}${propertyName(member.property)}`
851
+ return `${access}(${args})`
852
+ }
853
+
854
+ if (node.callee.kind === 'Ident') {
855
+ if (node.callee.name === 'len') return `__spak.len(${args})`
856
+ if (node.callee.name === 'range') return `__spak.range(${args})`
857
+ if (node.callee.name === 'pairs' || node.callee.name === 'ipairs') return `__spak.entries(${args})`
858
+ }
859
+
860
+ return `${this.expr(node.callee)}(${args})`
861
+ }
862
+
863
+ private binary(node: Extract<Expr, { kind: 'Binary' }>): string {
864
+ const left = this.expr(node.left)
865
+ const right = this.expr(node.right)
866
+
867
+ if (node.op === '|>') {
868
+ if (node.right.kind === 'Call') return this.call(node.right, [node.left])
869
+ return `${this.expr(node.right)}(${left})`
870
+ }
871
+ if (node.op === '//') return `Math.floor((${left}) / (${right}))`
872
+ if (node.op === '..') return `(String(${left}) + String(${right}))`
873
+
874
+ const operators: Record<string, string> = {
875
+ and: '&&',
876
+ or: '||',
877
+ '==': '===',
878
+ '~=': '!==',
879
+ '^': '**',
880
+ }
881
+ const operator = operators[node.op] ?? node.op
882
+ return `(${left} ${operator} ${right})`
883
+ }
884
+
885
+ private table(node: Table): string {
886
+ if (node.fields.length === 0) return '[]'
887
+ const hasKeys = node.fields.some(field => field.key)
888
+
889
+ if (!hasKeys) {
890
+ return `[${node.fields.map(field => this.expr(field.value)).join(', ')}]`
891
+ }
892
+
893
+ const parts: string[] = []
894
+ let position = 0
895
+ for (const field of node.fields) {
896
+ if (field.key?.kind === 'Ident') {
897
+ parts.push(`${propertyName(field.key.name)}: ${this.expr(field.value)}`)
898
+ continue
899
+ }
900
+ if (field.key) {
901
+ parts.push(`[${this.expr(field.key)}]: ${this.expr(field.value)}`)
902
+ continue
903
+ }
904
+ parts.push(`${position}: ${this.expr(field.value)}`)
905
+ position++
906
+ }
907
+ return `{ ${parts.join(', ')} }`
908
+ }
909
+ }
910
+
911
+ /* ------------------------------------------------------------------ */
912
+ /* Helpers */
913
+ /* ------------------------------------------------------------------ */
914
+
915
+ /** Falls back to the historical clause-shape inference for hand-built ASTs. */
916
+ function inferImportForm(node: ImportDecl): ImportForm {
917
+ if (!node.clauses.length) return 'side-effect'
918
+ if (node.clauses.length > 1) return 'named'
919
+ if (node.clauses[0].name === '*') return 'namespace'
920
+ return 'default'
921
+ }
922
+
923
+ interface FsMember {
924
+ /** Property read from the `node:fs` / `node:fs/promises` namespace. */
925
+ name: string
926
+ namespace: 'sync' | 'async'
927
+ /** True when a non-awaited function was rewritten to its `*Sync` twin. */
928
+ renamed: boolean
929
+ }
930
+
931
+ /**
932
+ * Resolves how an `fs` named import is bound:
933
+ * - `readFileSync` -> `readFileSync` on `node:fs` (explicit sync)
934
+ * - `readFile` awaited -> `readFile` on `node:fs/promises`
935
+ * - `readFile` not awaited -> `readFileSync` on `node:fs` (compat)
936
+ */
937
+ function fsMember(name: string, awaited: boolean): FsMember {
938
+ if (name.endsWith('Sync') || !FS_SYNC.has(name)) {
939
+ return { name, namespace: 'sync', renamed: false }
940
+ }
941
+ if (awaited) return { name, namespace: 'async', renamed: false }
942
+ return { name: `${name}Sync`, namespace: 'sync', renamed: true }
943
+ }
944
+
945
+ /** Collects identifiers used directly under `await` (`await readFile(...)`). */
946
+ function collectAwaitedIdentifiers(program: Program): Set<string> {
947
+ const names = new Set<string>()
948
+
949
+ // Every identifier inside the awaited expression is a candidate; only fs
950
+ // names are consulted later, so unrelated identifiers are harmless.
951
+ const collectFrom = (node: unknown): void => {
952
+ if (!node || typeof node !== 'object') return
953
+ if (Array.isArray(node)) {
954
+ for (const item of node) collectFrom(item)
955
+ return
956
+ }
957
+ const candidate = node as { kind?: string, name?: string }
958
+ if (candidate.kind === 'Ident' && candidate.name) names.add(candidate.name)
959
+ for (const child of Object.values(node)) collectFrom(child)
960
+ }
961
+
962
+ const visit = (value: unknown): void => {
963
+ if (!value || typeof value !== 'object') return
964
+ if (Array.isArray(value)) {
965
+ for (const item of value) visit(item)
966
+ return
967
+ }
968
+ const node = value as { kind?: string, argument?: unknown }
969
+ if (node.kind === 'AwaitExpr') collectFrom(node.argument)
970
+ for (const child of Object.values(value)) visit(child)
971
+ }
972
+
973
+ visit(program)
974
+ return names
975
+ }
976
+
977
+ function rawParamName(param: Param): string {
978
+ return param.rest ? (param.name === '...' ? 'args' : param.name) : param.name
979
+ }
980
+
981
+ function propertyName(name: string): string {
982
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name)
983
+ }
984
+
985
+ function isNegative(expr: Expr | undefined): boolean {
986
+ if (!expr) return false
987
+ if (expr.kind === 'NumberLit') return expr.value < 0
988
+ return expr.kind === 'Unary' && expr.op === '-' && expr.argument.kind === 'NumberLit'
989
+ }
990
+
991
+ function escapeTemplate(text: string): string {
992
+ return text.replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
993
+ }
994
+
995
+ /** Converts a Spak type annotation into TypeScript syntax (types only). */
996
+ export function typeAnn(node: TypeAnn): string {
997
+ switch (node.kind) {
998
+ case 'named':
999
+ return node.name === 'nil' ? 'null' : node.name
1000
+ case 'array': {
1001
+ const element = node.element.kind === 'union' || node.element.kind === 'fn'
1002
+ ? `(${typeAnn(node.element)})`
1003
+ : typeAnn(node.element)
1004
+ return `${element}[]`
1005
+ }
1006
+ case 'dict':
1007
+ return `Record<${typeAnn(node.key)}, ${typeAnn(node.value)}>`
1008
+ case 'record':
1009
+ return `{ ${node.fields.map(field => `${propertyName(field.name)}: ${typeAnn(field.type)}`).join(', ')} }`
1010
+ case 'union':
1011
+ return node.types.map(typeAnn).join(' | ')
1012
+ case 'fn':
1013
+ return `(${node.params.map(typeAnn).join(', ')}) => ${typeAnn(node.returns)}`
1014
+ case 'optional':
1015
+ return `${typeAnn(node.type)} | null`
1016
+ default:
1017
+ return 'unknown'
1018
+ }
1019
+ }
1020
+
1021
+ /** Lowers a Spak program to an executable ESM TypeScript module. */
1022
+ export function emitTypeScript(program: Program, options: EmitOptions = {}): string {
1023
+ return new Emitter(options).emitProgram(program)
1024
+ }