@starpak/lang 0.1.2 → 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/lib/ast.d.ts +7 -0
- package/lib/ast.d.ts.map +1 -1
- package/lib/emit/ts.d.ts +14 -0
- package/lib/emit/ts.d.ts.map +1 -1
- package/lib/emit/ts.js +190 -43
- package/lib/emit/ts.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -1
- package/lib/index.js.map +1 -1
- package/lib/parser.d.ts.map +1 -1
- package/lib/parser.js +6 -1
- package/lib/parser.js.map +1 -1
- 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 +1 -1
- package/src/ast.ts +8 -0
- package/src/emit/ts.ts +205 -42
- package/src/index.ts +3 -0
- package/src/parser.ts +7 -1
- package/src/version.ts +44 -0
package/src/emit/ts.ts
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* - Spak's Lua-flavoured method calls (`list:sort`, `s:split`, ...) lower to
|
|
12
12
|
* calls on that shim; unknown methods keep normal JS method syntax.
|
|
13
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.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import type {
|
|
@@ -27,6 +31,7 @@ import type {
|
|
|
27
31
|
FunctionExpr,
|
|
28
32
|
Ident,
|
|
29
33
|
ImportDecl,
|
|
34
|
+
ImportForm,
|
|
30
35
|
Index,
|
|
31
36
|
MatchStmt,
|
|
32
37
|
Param,
|
|
@@ -41,6 +46,7 @@ import type {
|
|
|
41
46
|
TypeAnn,
|
|
42
47
|
VarDecl,
|
|
43
48
|
} from '../ast'
|
|
49
|
+
import { versionBanner } from '../version'
|
|
44
50
|
|
|
45
51
|
export interface EmitOptions {
|
|
46
52
|
/** Emit the inlined runtime shim. Defaults to true. */
|
|
@@ -49,13 +55,24 @@ export interface EmitOptions {
|
|
|
49
55
|
banner?: string
|
|
50
56
|
/** Indentation unit. Defaults to two spaces. */
|
|
51
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
|
|
52
68
|
}
|
|
53
69
|
|
|
54
70
|
/** Lua-style methods that are backed by the runtime shim. */
|
|
55
71
|
const RUNTIME_METHODS = new Set(['push', 'pop', 'sort', 'split', 'len'])
|
|
56
72
|
|
|
57
|
-
/** Node builtins whose Spak `fs` surface
|
|
73
|
+
/** Node builtins whose Spak `fs` surface uses the sync/async compat shim. */
|
|
58
74
|
const FS_SOURCES = new Set(['fs', 'node:fs'])
|
|
75
|
+
/** Node `fs` functions that also exist as `*Sync` and `fs/promises` variants. */
|
|
59
76
|
const FS_SYNC = new Set([
|
|
60
77
|
'access', 'appendFile', 'chmod', 'chown', 'close', 'copyFile', 'cp',
|
|
61
78
|
'exists', 'fchmod', 'fchown', 'fstat', 'fsync', 'ftruncate', 'futimes',
|
|
@@ -82,9 +99,15 @@ const __spak = {
|
|
|
82
99
|
return out
|
|
83
100
|
},
|
|
84
101
|
slice(x, from, to, inclusive = true) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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))
|
|
88
111
|
},
|
|
89
112
|
idx(x, i) {
|
|
90
113
|
// Lua-style 1-based indexing for sequences; plain maps keep their keys.
|
|
@@ -147,6 +170,8 @@ class Emitter {
|
|
|
147
170
|
private level = 0
|
|
148
171
|
private scopes: Array<Set<string>> = [new Set()]
|
|
149
172
|
private temp = 0
|
|
173
|
+
/** Identifiers used directly under `await`; drives the async `fs` surface. */
|
|
174
|
+
private awaited = new Set<string>()
|
|
150
175
|
private readonly options: EmitOptions
|
|
151
176
|
|
|
152
177
|
constructor(options: EmitOptions = {}) {
|
|
@@ -189,9 +214,13 @@ class Emitter {
|
|
|
189
214
|
/* ---------------------------------------------------------------- */
|
|
190
215
|
|
|
191
216
|
emitProgram(program: Program): string {
|
|
217
|
+
this.awaited = collectAwaitedIdentifiers(program)
|
|
192
218
|
for (const statement of program.body) this.emitStatement(statement)
|
|
193
219
|
const chunks: string[] = []
|
|
194
|
-
const
|
|
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
|
|
195
224
|
if (banner) chunks.push(banner)
|
|
196
225
|
if (this.options.runtime !== false) chunks.push(RUNTIME.replace(/\n$/, ''))
|
|
197
226
|
chunks.push(this.lines.join('\n'))
|
|
@@ -288,11 +317,34 @@ class Emitter {
|
|
|
288
317
|
for (const statement of block.body) this.emitStatement(statement)
|
|
289
318
|
}
|
|
290
319
|
|
|
320
|
+
/** Comments only: used for positions that cannot be wrapped at runtime. */
|
|
291
321
|
private emitDecorators(decorators: Decorator[] | undefined): void {
|
|
292
322
|
if (!decorators?.length) return
|
|
293
323
|
for (const decorator of decorators) {
|
|
294
|
-
|
|
295
|
-
|
|
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});`)
|
|
296
348
|
}
|
|
297
349
|
}
|
|
298
350
|
|
|
@@ -389,7 +441,9 @@ class Emitter {
|
|
|
389
441
|
|
|
390
442
|
if (node.receiver) {
|
|
391
443
|
const fn = this.renderFunction(node)
|
|
392
|
-
|
|
444
|
+
const target = `${node.receiver}.${propertyName(node.name)}`
|
|
445
|
+
this.line(`${target} = ${fn};`)
|
|
446
|
+
this.applyDecorators(target, node.decorators)
|
|
393
447
|
return
|
|
394
448
|
}
|
|
395
449
|
|
|
@@ -401,6 +455,7 @@ class Emitter {
|
|
|
401
455
|
this.emitFunctionBody(node)
|
|
402
456
|
})
|
|
403
457
|
this.line('}')
|
|
458
|
+
this.applyDecorators(node.name, node.decorators)
|
|
404
459
|
}
|
|
405
460
|
|
|
406
461
|
private emitClassDecl(node: ClassDecl, exported: boolean): void {
|
|
@@ -411,6 +466,7 @@ class Emitter {
|
|
|
411
466
|
const prefix = exported ? 'export ' : ''
|
|
412
467
|
const parent = node.superClass ? ` extends ${node.superClass}` : ''
|
|
413
468
|
this.line(`${prefix}class ${node.name}${parent} {`)
|
|
469
|
+
const methodWrappers: Array<{ target: string, decorators: Decorator[] }> = []
|
|
414
470
|
this.withScope(() => {
|
|
415
471
|
for (const member of node.members) {
|
|
416
472
|
if (member.kind === 'VarDecl') {
|
|
@@ -424,14 +480,29 @@ class Emitter {
|
|
|
424
480
|
continue
|
|
425
481
|
}
|
|
426
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
|
+
}
|
|
427
494
|
}
|
|
428
495
|
})
|
|
429
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)
|
|
430
502
|
}
|
|
431
503
|
|
|
432
504
|
private emitClassMethod(node: FnDecl): void {
|
|
433
505
|
if (node.doc) this.emitDoc(node.doc)
|
|
434
|
-
this.emitDecorators(node.decorators)
|
|
435
506
|
const name = node.name === 'init' ? 'constructor' : node.name
|
|
436
507
|
const asyncKeyword = node.async && name !== 'constructor' ? 'async ' : ''
|
|
437
508
|
this.line(`${asyncKeyword}${name}(${this.emitParams(node.params)}) {`)
|
|
@@ -606,23 +677,21 @@ class Emitter {
|
|
|
606
677
|
return
|
|
607
678
|
}
|
|
608
679
|
const source = JSON.stringify(node.source)
|
|
609
|
-
|
|
680
|
+
const form = node.form ?? inferImportForm(node)
|
|
681
|
+
if (node.clauses.length === 0 || form === 'side-effect') {
|
|
610
682
|
this.line(`import ${source};`)
|
|
611
683
|
return
|
|
612
684
|
}
|
|
613
|
-
if (node.clauses
|
|
685
|
+
if (form === 'namespace' || node.clauses[0]?.name === '*') {
|
|
614
686
|
const clause = node.clauses[0]
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
this.line(`import { ${clause.name} as ${clause.alias} } from ${source};`)
|
|
625
|
-
this.declare(clause.alias)
|
|
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)
|
|
626
695
|
return
|
|
627
696
|
}
|
|
628
697
|
const specifiers = node.clauses.map(clause => clause.alias ? `${clause.name} as ${clause.alias}` : clause.name)
|
|
@@ -631,37 +700,67 @@ class Emitter {
|
|
|
631
700
|
}
|
|
632
701
|
|
|
633
702
|
/**
|
|
634
|
-
* Spak's `fs`
|
|
635
|
-
* `
|
|
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"`.
|
|
636
711
|
*/
|
|
637
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
|
+
|
|
638
719
|
const namespace = `__fs${++this.temp}`
|
|
639
|
-
this.line(`import * as ${namespace} from "node:fs";`)
|
|
640
|
-
if (node.clauses.length === 0) return
|
|
641
720
|
|
|
642
|
-
if (node.clauses
|
|
721
|
+
if (form === 'namespace' || node.clauses[0]?.name === '*') {
|
|
643
722
|
const clause = node.clauses[0]
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
653
737
|
}
|
|
654
738
|
|
|
739
|
+
const bindings: string[] = []
|
|
740
|
+
const asyncSpecifiers: string[] = []
|
|
655
741
|
for (const clause of node.clauses) {
|
|
656
742
|
if (clause.name === '*') {
|
|
657
|
-
|
|
743
|
+
bindings.push(`const ${clause.alias ?? 'fs'} = ${namespace};`)
|
|
744
|
+
this.declare(clause.alias ?? 'fs')
|
|
658
745
|
continue
|
|
659
746
|
}
|
|
660
747
|
const local = clause.alias ?? clause.name
|
|
661
|
-
const member =
|
|
662
|
-
|
|
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};`)
|
|
663
758
|
this.declare(local)
|
|
664
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)
|
|
665
764
|
}
|
|
666
765
|
|
|
667
766
|
/* ---------------------------------------------------------------- */
|
|
@@ -724,9 +823,11 @@ class Emitter {
|
|
|
724
823
|
const object = this.expr(node.object)
|
|
725
824
|
if (node.index.kind === 'Range') {
|
|
726
825
|
const range = node.index
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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)})`
|
|
730
831
|
}
|
|
731
832
|
return `__spak.slice(${object}, ${this.expr(range.from)}, ${this.expr(range.to)}, ${range.inclusive ? 'true' : 'false'})`
|
|
732
833
|
}
|
|
@@ -811,6 +912,68 @@ class Emitter {
|
|
|
811
912
|
/* Helpers */
|
|
812
913
|
/* ------------------------------------------------------------------ */
|
|
813
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
|
+
|
|
814
977
|
function rawParamName(param: Param): string {
|
|
815
978
|
return param.rest ? (param.name === '...' ? 'args' : param.name) : param.name
|
|
816
979
|
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { emitTypeScript, typeAnn } from './emit/ts'
|
|
|
13
13
|
export type { EmitOptions } from './emit/ts'
|
|
14
14
|
export { emitLua } from './emit/lua'
|
|
15
15
|
export type { EmitLuaOptions } from './emit/lua'
|
|
16
|
+
export {
|
|
17
|
+
VERSION_PATTERN, isValidVersion, parseVersion, versionBanner,
|
|
18
|
+
} from './version'
|
|
16
19
|
export {
|
|
17
20
|
ANY, NIL, NUMBER, STRING, BOOLEAN,
|
|
18
21
|
fromTypeAnn, union, isNil, withoutNil, isAssignable,
|
package/src/parser.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
IfStmt,
|
|
29
29
|
ImportClause,
|
|
30
30
|
ImportDecl,
|
|
31
|
+
ImportForm,
|
|
31
32
|
Index,
|
|
32
33
|
MatchArm,
|
|
33
34
|
MatchStmt,
|
|
@@ -687,9 +688,11 @@ class Parser {
|
|
|
687
688
|
const start = this.expectKeyword('import')
|
|
688
689
|
const clauses: ImportClause[] = []
|
|
689
690
|
let source: string
|
|
691
|
+
let form: ImportForm
|
|
690
692
|
|
|
691
693
|
if (this.at(TokenType.String)) {
|
|
692
694
|
source = this.next().value
|
|
695
|
+
form = 'side-effect'
|
|
693
696
|
} else if (this.matchOperator('{')) {
|
|
694
697
|
this.skipNewlines()
|
|
695
698
|
while (!this.atOperator('}')) {
|
|
@@ -706,12 +709,14 @@ class Parser {
|
|
|
706
709
|
this.expect(TokenType.Operator, '}', 'Expected "}"')
|
|
707
710
|
this.expectKeyword('from')
|
|
708
711
|
source = this.expect(TokenType.String, undefined, 'Expected module path').value
|
|
712
|
+
form = 'named'
|
|
709
713
|
} else if (this.matchOperator('*')) {
|
|
710
714
|
this.expectKeyword('as')
|
|
711
715
|
const alias = this.expect(TokenType.Identifier, undefined, 'Expected namespace alias')
|
|
712
716
|
clauses.push({ name: '*', alias: alias.value })
|
|
713
717
|
this.expectKeyword('from')
|
|
714
718
|
source = this.expect(TokenType.String, undefined, 'Expected module path').value
|
|
719
|
+
form = 'namespace'
|
|
715
720
|
} else {
|
|
716
721
|
const name = this.expect(TokenType.Identifier, undefined, 'Expected import name')
|
|
717
722
|
const clause: ImportClause = { name: name.value }
|
|
@@ -721,9 +726,10 @@ class Parser {
|
|
|
721
726
|
clauses.push(clause)
|
|
722
727
|
this.expectKeyword('from')
|
|
723
728
|
source = this.expect(TokenType.String, undefined, 'Expected module path').value
|
|
729
|
+
form = 'default'
|
|
724
730
|
}
|
|
725
731
|
|
|
726
|
-
return { kind: 'ImportDecl', clauses, source, loc: this.loc(start) }
|
|
732
|
+
return { kind: 'ImportDecl', clauses, source, form, loc: this.loc(start) }
|
|
727
733
|
}
|
|
728
734
|
|
|
729
735
|
private parseExport(): Statement {
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spak Lang - artifact version parsing
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the rules spm uses for `--version` / manifest versions so emitted
|
|
5
|
+
* comments and spm artifacts accept exactly the same 3- or 4-segment values:
|
|
6
|
+
* `major.minor.patch` or `major.minor.patch.build`.
|
|
7
|
+
*
|
|
8
|
+
* This module intentionally has no dependency on spm; spm keeps its own
|
|
9
|
+
* validation (`packages/spm/src/version.ts`) and is not modified here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 3 or 4 numeric segments, nothing else. */
|
|
13
|
+
export const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:\.\d+)?$/
|
|
14
|
+
|
|
15
|
+
/** True when `input` is a valid 3- or 4-segment Spak version. */
|
|
16
|
+
export function isValidVersion(input: unknown): input is string {
|
|
17
|
+
return typeof input === 'string' && VERSION_PATTERN.test(input)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a version and return it unchanged (trimmed).
|
|
22
|
+
* Throws a plain `Error` for empty / malformed / path-like input.
|
|
23
|
+
*/
|
|
24
|
+
export function parseVersion(input: unknown): string {
|
|
25
|
+
const value = typeof input === 'string' ? input.trim() : ''
|
|
26
|
+
if (!value) {
|
|
27
|
+
throw new Error('版本号不能为空(格式:major.minor.patch 或 major.minor.patch.build,如 1.0.0 / 1.0.0.0)')
|
|
28
|
+
}
|
|
29
|
+
if (/[/\\\0]/.test(value) || value.includes('..')) {
|
|
30
|
+
throw new Error(`非法版本号 "${value}":不能包含 /、\\、.. 或空字符`)
|
|
31
|
+
}
|
|
32
|
+
if (!VERSION_PATTERN.test(value)) {
|
|
33
|
+
throw new Error(`非法版本号 "${value}":需为 3 段或 4 段纯数字(如 1.0.0 或 1.0.0.0)`)
|
|
34
|
+
}
|
|
35
|
+
return value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Builds the banner comment used by spm build/dev (`// spak:<name>:<version>`).
|
|
40
|
+
* Both 3- and 4-segment versions are preserved verbatim after validation.
|
|
41
|
+
*/
|
|
42
|
+
export function versionBanner(name: string, version: unknown): string {
|
|
43
|
+
return `// spak:${name}:${parseVersion(version)}`
|
|
44
|
+
}
|