kopscript 0.10.0 → 0.11.1

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/LLM.md CHANGED
@@ -15,6 +15,8 @@ ks build <file.ks> # type-check + emit <file>.js and <file>.js.map next
15
15
  ks run <file.ks> # build, then execute with node --enable-source-maps
16
16
  ks watch <file.ks> # build, then rebuild on every change to any file in the graph
17
17
  ks check <file.ks> # type-check only, no output files
18
+ ks fmt <file.ks> # rewrite to canonical formatting, in place
19
+ ks fmt <file.ks> --check # exit 1 if not already formatted; writes nothing either way
18
20
  ks build|check <file.ks> --json # single JSON object on stdout instead of human text
19
21
  ```
20
22
 
@@ -33,6 +35,13 @@ Source maps are statement-level, not column-level (see `src/sourcemap.ts`, hand-
33
35
  zero added dependencies) — a thrown/uncaught error resolves to the right `.ks` line, but a
34
36
  specific sub-expression within one line isn't separately mapped.
35
37
 
38
+ `ks fmt` (`src/printer.ts`) is single-file (lex+parse only, no `using`-graph/type-checking):
39
+ 2-space indent, K&R braces, no trailing commas, double quotes only. Two things it does NOT
40
+ preserve, deliberately: original class-member order (always reprints as fields → properties
41
+ → constructor → methods, since `ClassDecl` buckets members into separate arrays — see
42
+ "Classes" below — so any original interleaving is already gone from the AST) and one-line
43
+ collapsed bodies (every body always prints as a full multi-line block). Comments survive it.
44
+
36
45
  ## File shape
37
46
 
38
47
  ```ks
package/README.md CHANGED
@@ -58,6 +58,10 @@ LLM's context, as opposed to this README's narrative explanation.
58
58
  every `.js` file (original `.ks` embedded, no separate file to ship), and `ks run` enables
59
59
  it automatically — an uncaught exception names the real `.ks` file and line, not the
60
60
  generated JS. Hand-rolled VLQ encoder, zero added dependencies. See "Source maps" below.
61
+ - **A real formatter**: `ks fmt` rewrites a file to one canonical style in place — no
62
+ bikeshedding, no config to agree on. Comments survive it. Verified idempotent and
63
+ meaning-preserving (byte-identical compiled output before/after) against every real `.ks`
64
+ file in this repo and Kopular's own source. See "Formatter" below.
61
65
  - **A companion framework, [Kopular](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular)**:
62
66
  components, constructor-injected services via a composition root (no DI container), and
63
67
  real-URL routing (no config DSL) — built entirely on the features above, in a separate
@@ -749,6 +753,8 @@ ks build <file.ks> # type-check and emit <file>.js (+ <file>.js.map) next to t
749
753
  ks run <file.ks> # build, then execute the emitted JS with node --enable-source-maps
750
754
  ks watch <file.ks> # build, then rebuild on every change to any file in the graph
751
755
  ks check <file.ks> # type-check only — no output files written
756
+ ks fmt <file.ks> # rewrite the file to canonical formatting, in place
757
+ ks fmt <file.ks> --check # exit 1 if it isn't already formatted; write nothing either way
752
758
  ```
753
759
 
754
760
  `build` and `check` both accept `--json`, which replaces all human-readable output with a
@@ -793,7 +799,31 @@ This is a deliberate v1 cut, the same spirit as generics/nullable types/template
793
799
  useful debugging (correct stack-trace lines, working breakpoints) without a full rewrite of
794
800
  codegen's string-concatenation architecture into a position-tracking writer.
795
801
 
796
- During development, use `npm run ks -- <build|run|watch|check> <file.ks>` (backed by
802
+ **Formatter**: `ks fmt <file.ks>` rewrites a file to canonical formatting in place —
803
+ 2-space indent, K&R braces, single space around operators, one blank line between
804
+ `using`s and the first declaration and between top-level declarations, one blank line
805
+ between class members, no trailing commas, double quotes only. `ks fmt <file.ks> --check`
806
+ reports (exit 1) whether a file isn't already formatted, without writing anything — for CI,
807
+ the same convention `gofmt -l`/`prettier --check` use. Single-file only: formatting needs
808
+ only lexing and parsing, not the full `using`-graph resolution or type-checking `build`/
809
+ `check`/`watch` do. Comments survive formatting (`src/tokens.ts`'s `CommentTrivia`, attached
810
+ to tokens by the lexer and looked up by `src/printer.ts` per node it prints) — two
811
+ deliberate v1 choices worth knowing about, not silent surprises:
812
+ - **Class members print in a fixed canonical order** (fields, then properties, then the
813
+ constructor, then methods), regardless of how the original source interleaved them —
814
+ `ClassDecl` buckets members into separate arrays (see `src/ast.ts`), so any original
815
+ interleaving between e.g. a field and a method is already gone by the time the AST
816
+ exists; there's nothing to preserve.
817
+ - **Every body always prints as a full multi-line block**, even a single-statement one a
818
+ human might have collapsed onto one line — no "when do I collapse this" judgment call for
819
+ the formatter to get subtly wrong.
820
+
821
+ Verified against every file in `examples/` and Kopular's own real `.ks` sources: formatting
822
+ twice is a no-op (idempotent), the reformatted file reparses with zero diagnostics, and —
823
+ the real proof formatting never changes what a program means — the *generated JS* for the
824
+ original and the reformatted source is byte-for-byte identical.
825
+
826
+ During development, use `npm run ks -- <build|run|watch|check|fmt> <file.ks>` (backed by
797
827
  `tsx`), or run `npm run build` to compile the TypeScript compiler itself to `dist/` and
798
828
  use `node dist/cli.js` directly.
799
829
 
@@ -831,12 +861,13 @@ functions/classes.
831
861
  language tour above) — the ceiling that's left is what's *inside* those: no async lambdas,
832
862
  and no way to build a `task` value by hand outside an `async` function body.
833
863
 
834
- No formatter (`ks fmt`) yet — it was blocked on the lexer discarding every comment entirely,
835
- which an AST-based pretty-printer would otherwise silently delete on reformat. That's now
836
- fixed: every `Token` carries its own `leadingComments` (see `src/tokens.ts`'s
837
- `CommentTrivia`), purely additive trivia the parser/checker/codegen never look at. This is
838
- groundwork, not the formatter itself`ks fmt` (walking the AST and re-emitting source with
839
- comments placed back correctly) is a separate, not-yet-started piece of work.
864
+ `ks fmt` now exists (see "Formatter" above) — it was blocked on the lexer discarding every
865
+ comment entirely, which an AST-based pretty-printer would otherwise silently delete on
866
+ reformat, until `Token.leadingComments` (`src/tokens.ts`'s `CommentTrivia`) closed that gap.
867
+ `src/printer.ts` looks up each comment by the line number of the token it attached to
868
+ (not a `(line, col)` pair several node kinds, e.g. `FieldDecl`, deliberately record their
869
+ position at their *type* token, after any `public`/`static`/etc. modifiers, which a
870
+ column-exact lookup would miss).
840
871
 
841
872
  ### A frontend framework: Kopular
842
873
 
package/dist/cli.js CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync, watch as fsWatch } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync, watch as fsWatch } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { resolve, basename, join } from "node:path";
5
5
  import { compileGraph } from "./modules.js";
6
+ import { Lexer } from "./lexer.js";
7
+ import { Parser } from "./parser.js";
8
+ import { DiagnosticBag } from "./diagnostics.js";
9
+ import { Printer, collectComments } from "./printer.js";
6
10
  function outputPathFor(filePath) {
7
11
  const name = basename(filePath, ".ks");
8
12
  return join(resolve(filePath, ".."), `${name}.js`);
@@ -153,13 +157,48 @@ function watchCommand(filePath) {
153
157
  console.log(`[watch] ${filePath} — watching for changes. Press Ctrl+C to stop.`);
154
158
  rebuild();
155
159
  }
156
- const COMMANDS = ["build", "run", "watch", "check"];
160
+ // Rewrites `filePath` to canonical formatting, or (with `check: true`)
161
+ // reports whether it already is without touching it — for CI, matching
162
+ // `gofmt -l`/`prettier --check`'s convention. Single-file only, unlike
163
+ // `build`/`check`/`watch`: formatting only needs lex+parse (see
164
+ // printer.ts), no `using`-graph resolution or type-checking, so there's no
165
+ // `compileGraph` call here at all.
166
+ function fmtCommand(filePath, check) {
167
+ if (!existsSync(filePath)) {
168
+ console.error(`ks: cannot find file '${filePath}'`);
169
+ process.exitCode = 1;
170
+ return;
171
+ }
172
+ const source = readFileSync(filePath, "utf-8");
173
+ const diagnostics = new DiagnosticBag();
174
+ const tokens = new Lexer(source, diagnostics).tokenize();
175
+ const program = new Parser(tokens, diagnostics).parseProgram();
176
+ if (diagnostics.hasErrors) {
177
+ console.error(diagnostics.format(source, filePath));
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+ const formatted = new Printer(collectComments(tokens)).print(program);
182
+ if (check) {
183
+ if (formatted !== source) {
184
+ console.error(`${filePath} is not formatted`);
185
+ process.exitCode = 1;
186
+ }
187
+ return;
188
+ }
189
+ if (formatted !== source) {
190
+ writeFileSync(filePath, formatted, "utf-8");
191
+ console.log(`Formatted ${filePath}`);
192
+ }
193
+ }
194
+ const COMMANDS = ["build", "run", "watch", "check", "fmt"];
157
195
  function main() {
158
196
  const args = process.argv.slice(2);
159
197
  const jsonMode = args.includes("--json");
160
- const [command, file] = args.filter((a) => a !== "--json");
198
+ const checkMode = args.includes("--check");
199
+ const [command, file] = args.filter((a) => a !== "--json" && a !== "--check");
161
200
  if (!command || !file || !COMMANDS.includes(command)) {
162
- console.error("Usage: ks <build|run|watch|check> <file.ks> [--json]");
201
+ console.error("Usage: ks <build|run|watch|check|fmt> <file.ks> [--json] [--check]");
163
202
  process.exitCode = 1;
164
203
  return;
165
204
  }
@@ -168,10 +207,18 @@ function main() {
168
207
  process.exitCode = 1;
169
208
  return;
170
209
  }
210
+ if (checkMode && command !== "fmt") {
211
+ console.error("ks: --check is only supported with 'fmt'");
212
+ process.exitCode = 1;
213
+ return;
214
+ }
171
215
  const filePath = resolve(file);
172
216
  if (command === "check") {
173
217
  checkCommand(filePath, jsonMode);
174
218
  }
219
+ else if (command === "fmt") {
220
+ fmtCommand(filePath, checkMode);
221
+ }
175
222
  else if (command === "build") {
176
223
  const { outPath } = build(filePath, jsonMode);
177
224
  if (outPath && !jsonMode)
@@ -0,0 +1,377 @@
1
+ // Builds the line -> comments lookup the Printer consults at every node it's
2
+ // about to print. A separate pass over the token stream, not parser surgery.
3
+ // Keyed by line number *alone*, not the full (line, col) position: a `//`
4
+ // comment always consumes to end-of-line (lexer.ts's `skipTrivia`), so the
5
+ // token it attaches to is either the first thing on its own following line,
6
+ // or shares a line with real code — either way, that line number is unique
7
+ // within a file, and unambiguous as a lookup key. This matters because
8
+ // several AST node kinds (`FieldDecl`/`MethodDecl`/`PropertyDecl` chief among
9
+ // them) deliberately record `line`/`col` at their *type* token, not their
10
+ // first token — parser.ts captures `memberStart` only after consuming any
11
+ // `public`/`static`/`async`/`virtual` modifiers — so a comment attached to
12
+ // the modifier keyword (the token a leading comment actually lands on) would
13
+ // never match an exact (line, col) key, even though it's unambiguously that
14
+ // member's comment. A handful of node shapes carry no `line`/`col` at all
15
+ // (`Param`, `LambdaParam`, `CatchParam`, the bare `TypeNode` variants,
16
+ // `ExternPropertySig`/`ExternMethodSig`) — a comment immediately before one
17
+ // of those has nothing to attach to and is dropped; a real, documented v1
18
+ // loss, not a silent regression (every comment is dropped everywhere today).
19
+ export function collectComments(tokens) {
20
+ const map = new Map();
21
+ for (const t of tokens) {
22
+ if (t.leadingComments.length > 0)
23
+ map.set(t.line, t.leadingComments);
24
+ }
25
+ return map;
26
+ }
27
+ function indentStr(indent) {
28
+ return " ".repeat(indent);
29
+ }
30
+ // Re-escapes an already-unescaped string literal value back to source form
31
+ // (the lexer resolves `\n \t \r \\ \" \{ \}` at lex time — see
32
+ // lexer.ts's readStringChar). `escapeBraces` is only needed for an
33
+ // interpolated string's literal text chunks, where a bare `{`/`}` would
34
+ // otherwise be misread as starting a new interpolation hole on reparse;
35
+ // a plain `"..."` string has no such ambiguity, so leaves them alone.
36
+ function escapeStringValue(value, escapeBraces) {
37
+ let out = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
38
+ if (escapeBraces)
39
+ out = out.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
40
+ return out;
41
+ }
42
+ const BIG_TOP_LEVEL_KINDS = new Set([
43
+ "ClassDecl",
44
+ "InterfaceDecl",
45
+ "EnumDecl",
46
+ "FunctionDecl",
47
+ "ExternFunctionDecl",
48
+ "ExternClassDecl",
49
+ "ExternValueDecl",
50
+ "RawStringDecl",
51
+ ]);
52
+ // Prints an AST back to canonical KopScript source — the counterpart to
53
+ // codegen.ts's AST -> JS, structured the same way (one private method per
54
+ // node kind) so either file explains the other's shape. See README.md's
55
+ // "Formatter" section for the canonical style rules and the deliberate v1
56
+ // simplifications (always-multi-line bodies, imposed class-member order).
57
+ export class Printer {
58
+ constructor(comments) {
59
+ this.comments = comments;
60
+ }
61
+ print(program) {
62
+ const parts = [];
63
+ for (const u of program.usings) {
64
+ this.emit(parts, u, `using "${u.path}";`, 0);
65
+ }
66
+ const usingsToBody = program.usings.length > 0 && program.statements.length > 0;
67
+ let prevWasBig = false;
68
+ program.statements.forEach((stmt, i) => {
69
+ const isBig = BIG_TOP_LEVEL_KINDS.has(stmt.kind);
70
+ const blankBefore = i === 0 ? usingsToBody : isBig || prevWasBig;
71
+ this.emit(parts, stmt, this.printStatement(stmt, 0), 0, blankBefore);
72
+ prevWasBig = isBig;
73
+ });
74
+ return parts.join("\n") + "\n";
75
+ }
76
+ // Looks up any comments recorded at `node`'s own position and pushes them
77
+ // (plus `text`) onto `parts` in the right shape: a comment tagged
78
+ // `sameLineAsPreviousToken` appends to the *previous* entry already in
79
+ // `parts` (a real trailing comment, e.g. `x = 5; // note`) instead of
80
+ // starting a new line; every other comment becomes its own leading line
81
+ // immediately before `text`, indented to match it. `blankLineBefore`
82
+ // (for the blank line between top-level declarations / class members)
83
+ // has to be inserted *after* a trailing comment attaches to the true
84
+ // previous line but *before* any leading comments/text — inserting it
85
+ // any earlier would make a trailing comment attach to the blank
86
+ // separator itself instead of the line it actually trails.
87
+ emit(parts, node, text, indent, blankLineBefore = false) {
88
+ const found = this.comments.get(node.line) ?? [];
89
+ let rest = found;
90
+ if (rest.length > 0 && rest[0].sameLineAsPreviousToken && parts.length > 0) {
91
+ parts[parts.length - 1] += ` //${rest[0].text}`;
92
+ rest = rest.slice(1);
93
+ }
94
+ if (blankLineBefore && parts.length > 0)
95
+ parts.push("");
96
+ const pad = indentStr(indent);
97
+ for (const c of rest)
98
+ parts.push(`${pad}//${c.text}`);
99
+ parts.push(text);
100
+ }
101
+ // ---------- types ----------
102
+ printType(type) {
103
+ switch (type.kind) {
104
+ case "NamedType":
105
+ return type.typeArgs ? `${type.name}<${type.typeArgs.map((t) => this.printType(t)).join(", ")}>` : type.name;
106
+ case "ArrayType":
107
+ return `${this.printType(type.element)}[]`;
108
+ case "FunctionType":
109
+ return `(${type.params.map((p) => this.printType(p)).join(", ")}) => ${this.printType(type.returnType)}`;
110
+ case "TaskType":
111
+ return type.resultType.kind === "NamedType" && type.resultType.name === "void" ? "task" : `task<${this.printType(type.resultType)}>`;
112
+ case "StateType":
113
+ return `state<${this.printType(type.valueType)}>`;
114
+ case "NullableType":
115
+ return `${this.printType(type.inner)}?`;
116
+ }
117
+ }
118
+ printParams(params) {
119
+ return params.map((p) => `${this.printType(p.type)} ${p.name}`).join(", ");
120
+ }
121
+ // ---------- top-level declarations ----------
122
+ printStatement(stmt, indent) {
123
+ const pad = indentStr(indent);
124
+ switch (stmt.kind) {
125
+ case "VarDecl":
126
+ return `${pad}${stmt.isConst ? "const " : ""}${this.printType(stmt.type)} ${stmt.name} = ${this.printExpr(stmt.init, indent)};`;
127
+ case "FunctionDecl":
128
+ return this.printFunction(stmt, indent);
129
+ case "ClassDecl":
130
+ return this.printClass(stmt, indent);
131
+ case "InterfaceDecl":
132
+ return this.printInterface(stmt, indent);
133
+ case "EnumDecl":
134
+ return `${pad}${stmt.isExported ? "" : "private "}enum ${stmt.name} { ${stmt.members.join(", ")} }`;
135
+ case "ExternFunctionDecl":
136
+ return this.printExternFunction(stmt, indent);
137
+ case "ExternClassDecl":
138
+ return this.printExternClass(stmt, indent);
139
+ case "ExternValueDecl":
140
+ return this.printExternValue(stmt, indent);
141
+ case "RawStringDecl":
142
+ return `${pad}${stmt.isExported ? "" : "private "}raw string ${stmt.name} from "${stmt.path}";`;
143
+ case "Block":
144
+ return this.printBlock(stmt, indent);
145
+ case "IfStatement":
146
+ return this.printIf(stmt, indent);
147
+ case "WhileStatement":
148
+ return `${pad}while (${this.printExpr(stmt.condition, indent)}) ${this.printBlock(stmt.body, indent).trimStart()}`;
149
+ case "ForStatement": {
150
+ const init = stmt.init ? (stmt.init.kind === "VarDecl" ? `${stmt.init.isConst ? "const " : ""}${this.printType(stmt.init.type)} ${stmt.init.name} = ${this.printExpr(stmt.init.init, indent)}` : this.printExpr(stmt.init.expression, indent)) : "";
151
+ const cond = stmt.condition ? this.printExpr(stmt.condition, indent) : "";
152
+ const update = stmt.update ? this.printExpr(stmt.update, indent) : "";
153
+ return `${pad}for (${init}; ${cond}; ${update}) ${this.printBlock(stmt.body, indent).trimStart()}`;
154
+ }
155
+ case "ForInStatement":
156
+ return `${pad}foreach (${this.printType(stmt.varType)} ${stmt.varName} in ${this.printExpr(stmt.iterable, indent)}) ${this.printBlock(stmt.body, indent).trimStart()}`;
157
+ case "ReturnStatement":
158
+ return stmt.value ? `${pad}return ${this.printExpr(stmt.value, indent)};` : `${pad}return;`;
159
+ case "BreakStatement":
160
+ return `${pad}break;`;
161
+ case "ContinueStatement":
162
+ return `${pad}continue;`;
163
+ case "ExpressionStatement":
164
+ return `${pad}${this.printExpr(stmt.expression, indent)};`;
165
+ case "TryStatement":
166
+ return this.printTry(stmt, indent);
167
+ case "ThrowStatement":
168
+ return `${pad}throw ${this.printExpr(stmt.expression, indent)};`;
169
+ }
170
+ }
171
+ printBlock(block, indent) {
172
+ const pad = indentStr(indent);
173
+ if (block.statements.length === 0)
174
+ return `{\n${pad}}`;
175
+ const parts = [];
176
+ for (const s of block.statements) {
177
+ this.emit(parts, s, this.printStatement(s, indent + 1), indent + 1);
178
+ }
179
+ return `{\n${parts.join("\n")}\n${pad}}`;
180
+ }
181
+ printIf(stmt, indent) {
182
+ const pad = indentStr(indent);
183
+ let code = `${pad}if (${this.printExpr(stmt.condition, indent)}) ${this.printBlock(stmt.thenBranch, indent).trimStart()}`;
184
+ if (stmt.elseBranch) {
185
+ code += stmt.elseBranch.kind === "IfStatement" ? ` else ${this.printIf(stmt.elseBranch, indent).trimStart()}` : ` else ${this.printBlock(stmt.elseBranch, indent).trimStart()}`;
186
+ }
187
+ return code;
188
+ }
189
+ printTry(stmt, indent) {
190
+ let code = `${indentStr(indent)}try ${this.printBlock(stmt.tryBlock, indent).trimStart()}`;
191
+ if (stmt.catchBlock && stmt.catchParam) {
192
+ code += ` catch (${this.printType(stmt.catchParam.type)} ${stmt.catchParam.name}) ${this.printBlock(stmt.catchBlock, indent).trimStart()}`;
193
+ }
194
+ if (stmt.finallyBlock) {
195
+ code += ` finally ${this.printBlock(stmt.finallyBlock, indent).trimStart()}`;
196
+ }
197
+ return code;
198
+ }
199
+ printFunction(decl, indent) {
200
+ const pad = indentStr(indent);
201
+ const prefix = `${decl.isExported ? "" : "private "}${decl.isAsync ? "async " : ""}`;
202
+ return `${pad}${prefix}${this.printType(decl.returnType)} ${decl.name}(${this.printParams(decl.params)}) ${this.printBlock(decl.body, indent).trimStart()}`;
203
+ }
204
+ // Canonical member order: fields, then properties, then the constructor,
205
+ // then methods — regardless of how the original source interleaved them.
206
+ // ClassDecl buckets members into four separate arrays (see ast.ts), so
207
+ // any original interleaving between e.g. a field and a method is already
208
+ // gone by the time the AST exists; this imposes one fixed, consistent
209
+ // order rather than guessing at how to restore it. A real, visible
210
+ // behavior on first-format of a file that interleaved members — not
211
+ // hidden, documented in README.md's "Formatter" section.
212
+ printClass(decl, indent) {
213
+ const pad = indentStr(indent);
214
+ const memberPad = indentStr(indent + 1);
215
+ const nameWithTypeParam = decl.typeParam ? `${decl.name}<${decl.typeParam}>` : decl.name;
216
+ const header = decl.baseList.length > 0 ? `class ${nameWithTypeParam} : ${decl.baseList.join(", ")} {` : `class ${nameWithTypeParam} {`;
217
+ const prefix = decl.isExported ? "" : "private ";
218
+ const memberParts = [];
219
+ const pushMember = (node, text) => {
220
+ this.emit(memberParts, node, text, indent + 1, memberParts.length > 0);
221
+ };
222
+ for (const field of decl.fields) {
223
+ const modifiers = `${field.visibility}${field.isStatic ? " static" : ""}`;
224
+ const init = field.initializer ? ` = ${this.printExpr(field.initializer, indent + 1)}` : "";
225
+ pushMember(field, `${memberPad}${modifiers} ${this.printType(field.type)} ${field.name}${init};`);
226
+ }
227
+ for (const prop of decl.properties) {
228
+ const accessors = prop.hasSetter ? "get; set;" : "get;";
229
+ pushMember(prop, `${memberPad}${prop.visibility} ${this.printType(prop.type)} ${prop.name} { ${accessors} }`);
230
+ }
231
+ if (decl.constructor) {
232
+ pushMember(decl.constructor, this.printConstructor(decl.constructor, indent + 1));
233
+ }
234
+ for (const method of decl.methods) {
235
+ const modifiers = [method.visibility, method.isStatic ? "static" : null, method.isAsync ? "async" : null, method.isVirtual ? "virtual" : null, method.isOverride ? "override" : null].filter((m) => m !== null).join(" ");
236
+ pushMember(method, `${memberPad}${modifiers} ${this.printType(method.returnType)} ${method.name}(${this.printParams(method.params)}) ${this.printBlock(method.body, indent + 1).trimStart()}`);
237
+ }
238
+ if (decl.template) {
239
+ pushMember(decl.template, `${memberPad}template from "${decl.template.path}";`);
240
+ }
241
+ if (memberParts.length === 0)
242
+ return `${pad}${prefix}${header}\n${pad}}`;
243
+ return `${pad}${prefix}${header}\n${memberParts.join("\n")}\n${pad}}`;
244
+ }
245
+ printConstructor(ctor, indent) {
246
+ const pad = indentStr(indent);
247
+ const base = ctor.baseArgs ? ` : base(${ctor.baseArgs.map((a) => this.printExpr(a, indent)).join(", ")})` : "";
248
+ return `${pad}constructor(${this.printParams(ctor.params)})${base} ${this.printBlock(ctor.body, indent).trimStart()}`;
249
+ }
250
+ printInterface(decl, indent) {
251
+ const pad = indentStr(indent);
252
+ const memberPad = indentStr(indent + 1);
253
+ const nameWithTypeParam = decl.typeParam ? `${decl.name}<${decl.typeParam}>` : decl.name;
254
+ const header = decl.baseList.length > 0 ? `interface ${nameWithTypeParam} : ${decl.baseList.join(", ")} {` : `interface ${nameWithTypeParam} {`;
255
+ const prefix = decl.isExported ? "" : "private ";
256
+ if (decl.methods.length === 0)
257
+ return `${pad}${prefix}${header}\n${pad}}`;
258
+ const memberParts = [];
259
+ for (const m of decl.methods) {
260
+ this.emit(memberParts, m, `${memberPad}${this.printType(m.returnType)} ${m.name}(${this.printParams(m.params)});`, indent + 1);
261
+ }
262
+ return `${pad}${prefix}${header}\n${memberParts.join("\n")}\n${pad}}`;
263
+ }
264
+ printExternFunction(decl, indent) {
265
+ const pad = indentStr(indent);
266
+ const prefix = decl.isExported ? "" : "private ";
267
+ const as = decl.jsName !== decl.name ? ` as "${decl.jsName}"` : "";
268
+ const from = decl.modulePath ? ` from "${decl.modulePath}"` : "";
269
+ return `${pad}${prefix}extern ${this.printType(decl.returnType)} ${decl.name}(${this.printParams(decl.params)})${from}${as};`;
270
+ }
271
+ printExternValue(decl, indent) {
272
+ const pad = indentStr(indent);
273
+ const prefix = decl.isExported ? "" : "private ";
274
+ const as = decl.jsName !== decl.name ? ` as "${decl.jsName}"` : "";
275
+ const from = decl.modulePath ? ` from "${decl.modulePath}"` : "";
276
+ return `${pad}${prefix}extern ${this.printType(decl.type)} ${decl.name}${from}${as};`;
277
+ }
278
+ printExternClass(decl, indent) {
279
+ const pad = indentStr(indent);
280
+ const memberPad = indentStr(indent + 1);
281
+ const prefix = decl.isExported ? "" : "private ";
282
+ const nameWithTypeParam = decl.typeParam ? `${decl.name}<${decl.typeParam}>` : decl.name;
283
+ const lines = [];
284
+ if (decl.hasConstructor)
285
+ lines.push(`${memberPad}constructor(${this.printParams(decl.ctorParams)});`);
286
+ for (const p of decl.properties) {
287
+ const accessors = p.hasSetter ? "get; set;" : "get;";
288
+ lines.push(`${memberPad}${p.isStatic ? "static " : ""}${this.printType(p.type)} ${p.name} { ${accessors} }`);
289
+ }
290
+ for (const m of decl.methods) {
291
+ const modifiers = `${m.isStatic ? "static " : ""}${m.isVirtual ? "virtual " : ""}`;
292
+ lines.push(`${memberPad}${modifiers}${this.printType(m.returnType)} ${m.name}(${this.printParams(m.params)});`);
293
+ }
294
+ const from = decl.modulePath ? ` from "${decl.modulePath}"` : "";
295
+ if (lines.length === 0)
296
+ return `${pad}${prefix}extern class ${nameWithTypeParam} {\n${pad}}${from};`;
297
+ return `${pad}${prefix}extern class ${nameWithTypeParam} {\n${lines.join("\n")}\n${pad}}${from};`;
298
+ }
299
+ // ---------- expressions ----------
300
+ // `indent` only matters to the two expression kinds that can themselves
301
+ // introduce a real newline (a `match` expression, a lambda with a block
302
+ // body) — every other case ignores it. Threaded through generally rather
303
+ // than special-cased at each call site, so a `match`/block-lambda buried
304
+ // inside a call argument several levels deep still indents correctly,
305
+ // not just one at the top of a statement.
306
+ printExpr(expr, indent = 0) {
307
+ switch (expr.kind) {
308
+ case "NumberLiteral":
309
+ return String(expr.value);
310
+ case "StringLiteral":
311
+ return `"${escapeStringValue(expr.value, false)}"`;
312
+ case "BoolLiteral":
313
+ return String(expr.value);
314
+ case "NullLiteral":
315
+ return "null";
316
+ case "InterpolatedStringLiteral":
317
+ return this.printInterpolatedString(expr, indent);
318
+ case "ArrayLiteral":
319
+ return `[${expr.elements.map((e) => this.printExpr(e, indent)).join(", ")}]`;
320
+ case "Identifier":
321
+ return expr.name;
322
+ case "ThisExpr":
323
+ return "this";
324
+ case "UnaryExpr":
325
+ return `${expr.op}${this.printExpr(expr.operand, indent)}`;
326
+ case "BinaryExpr":
327
+ return `${this.printExpr(expr.left, indent)} ${expr.op} ${this.printExpr(expr.right, indent)}`;
328
+ case "LogicalExpr":
329
+ return `${this.printExpr(expr.left, indent)} ${expr.op} ${this.printExpr(expr.right, indent)}`;
330
+ case "AssignExpr":
331
+ return `${this.printExpr(expr.target, indent)} = ${this.printExpr(expr.value, indent)}`;
332
+ case "CallExpr":
333
+ return `${this.printExpr(expr.callee, indent)}(${expr.args.map((a) => this.printExpr(a, indent)).join(", ")})`;
334
+ case "NewExpr": {
335
+ const typeArgs = expr.typeArgs ? `<${expr.typeArgs.map((t) => this.printType(t)).join(", ")}>` : "";
336
+ return `new ${expr.className}${typeArgs}(${expr.args.map((a) => this.printExpr(a, indent)).join(", ")})`;
337
+ }
338
+ case "MemberExpr":
339
+ return `${this.printExpr(expr.object, indent)}.${expr.property}`;
340
+ case "IndexExpr":
341
+ return `${this.printExpr(expr.object, indent)}[${this.printExpr(expr.index, indent)}]`;
342
+ case "MatchExpr":
343
+ return this.printMatch(expr, indent);
344
+ case "LambdaExpr":
345
+ return this.printLambda(expr, indent);
346
+ case "AwaitExpr":
347
+ return `await ${this.printExpr(expr.operand, indent)}`;
348
+ case "StateExpr":
349
+ return `state(${this.printExpr(expr.initializer, indent)})`;
350
+ }
351
+ }
352
+ printLambda(expr, indent) {
353
+ const params = expr.params.map((p) => `${this.printType(p.type)} ${p.name}`).join(", ");
354
+ if (expr.body.kind === "Block")
355
+ return `(${params}) => ${this.printBlock(expr.body, indent).trimStart()}`;
356
+ return `(${params}) => ${this.printExpr(expr.body, indent)}`;
357
+ }
358
+ printInterpolatedString(expr, indent) {
359
+ const body = expr.parts.map((part) => (part.kind === "Text" ? escapeStringValue(part.text, true) : `{${this.printExpr(part.expression, indent)}}`)).join("");
360
+ return `$"${body}"`;
361
+ }
362
+ printMatch(expr, indent) {
363
+ const pad = indentStr(indent);
364
+ const armPad = indentStr(indent + 1);
365
+ const lines = [`match ${this.printExpr(expr.subject, indent)} {`];
366
+ const armParts = [];
367
+ for (const arm of expr.arms) {
368
+ const pattern = arm.pattern.kind === "WildcardPattern" ? "_" : arm.pattern.kind === "RegexPattern" ? `r"${arm.pattern.source}"` : arm.pattern.values.map((v) => this.printExpr(v, indent)).join(", ");
369
+ this.emit(armParts, arm, `${armPad}${pattern} => ${this.printExpr(arm.result, indent + 1)},`, indent + 1);
370
+ }
371
+ // No trailing comma after the last arm, matching the observed convention.
372
+ if (armParts.length > 0)
373
+ armParts[armParts.length - 1] = armParts[armParts.length - 1].replace(/,$/, "");
374
+ lines.push(...armParts, `${pad}}`);
375
+ return lines.join("\n");
376
+ }
377
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopscript",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "KopScript: a small OOP, strongly-typed language that transpiles to JavaScript, with generics and nullable types",
5
5
  "type": "module",
6
6
  "license": "MIT",