@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/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 is synchronous. */
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
- const start = Number(from) - 1
86
- const end = inclusive ? Number(to) : Number(to) - 1
87
- return x.slice(start, end)
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 banner = this.options.banner ?? '// Generated by Spak emitTypeScript'
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
- const args = decorator.args.map(arg => this.expr(arg)).join(', ')
295
- this.line(`// @${decorator.name}${args ? `(${args})` : ''}`)
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
- this.line(`${node.receiver}.${node.name} = ${fn};`)
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
- if (node.clauses.length === 0) {
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.length === 1) {
685
+ if (form === 'namespace' || node.clauses[0]?.name === '*') {
614
686
  const clause = node.clauses[0]
615
- if (clause.name === '*') {
616
- this.line(`import * as ${clause.alias ?? 'ns'} from ${source};`)
617
- return
618
- }
619
- if (!clause.alias) {
620
- this.line(`import ${clause.name} from ${source};`)
621
- this.declare(clause.name)
622
- return
623
- }
624
- this.line(`import { ${clause.name} as ${clause.alias} } from ${source};`)
625
- this.declare(clause.alias)
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` is synchronous (`for name in readdir(dir)`), so imports from
635
- * `fs` are bound to the `*Sync` variants through a namespace import.
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.length === 1) {
721
+ if (form === 'namespace' || node.clauses[0]?.name === '*') {
643
722
  const clause = node.clauses[0]
644
- if (clause.name === '*') {
645
- this.line(`const ${clause.alias ?? 'fs'} = ${namespace};`)
646
- return
647
- }
648
- if (!clause.alias && !FS_SYNC.has(clause.name)) {
649
- this.line(`const ${clause.name} = ${namespace};`)
650
- this.declare(clause.name)
651
- return
652
- }
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
- this.line(`const ${clause.alias ?? 'fs'} = ${namespace};`)
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 = FS_SYNC.has(clause.name) ? `${clause.name}Sync` : clause.name
662
- this.line(`const ${local} = ${namespace}.${member};`)
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
- if (range.from.kind === 'NumberLit' && range.from.value === 1) {
728
- if (range.inclusive) return `${object}.slice(0, ${this.expr(range.to)})`
729
- return `${object}.slice(0, (${this.expr(range.to)}) - 1)`
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
+ }