@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.
- package/LICENSE +22 -0
- package/lib/ast.d.ts +341 -0
- package/lib/ast.d.ts.map +1 -0
- package/lib/ast.js +9 -0
- package/lib/ast.js.map +1 -0
- package/lib/checker.d.ts +17 -0
- package/lib/checker.d.ts.map +1 -0
- package/lib/checker.js +525 -0
- package/lib/checker.js.map +1 -0
- package/lib/diagnostics.d.ts +46 -0
- package/lib/diagnostics.d.ts.map +1 -0
- package/lib/diagnostics.js +77 -0
- package/lib/diagnostics.js.map +1 -0
- package/lib/emit/lua.d.ts +19 -0
- package/lib/emit/lua.d.ts.map +1 -0
- package/lib/emit/lua.js +451 -0
- package/lib/emit/lua.js.map +1 -0
- package/lib/emit/ts.d.ts +42 -0
- package/lib/emit/ts.d.ts.map +1 -0
- package/lib/emit/ts.js +950 -0
- package/lib/emit/ts.js.map +1 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +54 -0
- package/lib/index.js.map +1 -0
- package/lib/lexer.d.ts +10 -0
- package/lib/lexer.d.ts.map +1 -0
- package/lib/lexer.js +282 -0
- package/lib/lexer.js.map +1 -0
- package/lib/parser.d.ts +11 -0
- package/lib/parser.d.ts.map +1 -0
- package/lib/parser.js +1138 -0
- package/lib/parser.js.map +1 -0
- package/lib/token.d.ts +32 -0
- package/lib/token.d.ts.map +1 -0
- package/lib/token.js +18 -0
- package/lib/token.js.map +1 -0
- package/lib/types.d.ts +63 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +177 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +25 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +47 -0
- package/lib/version.js.map +1 -0
- package/package.json +10 -3
- package/src/ast.ts +467 -0
- package/src/checker.ts +554 -0
- package/src/diagnostics.ts +100 -0
- package/src/emit/lua.ts +484 -0
- package/src/emit/ts.ts +1024 -0
- package/src/index.ts +24 -0
- package/src/lexer.ts +301 -0
- package/src/parser.ts +1257 -0
- package/src/token.ts +34 -0
- package/src/types.ts +218 -0
- package/src/version.ts +44 -0
- 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
|
+
}
|