kopscript 0.8.0 → 0.9.0

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
@@ -11,8 +11,8 @@ Compiles to plain ES modules.
11
11
  ## CLI
12
12
 
13
13
  ```
14
- ks build <file.ks> # type-check + emit <file>.js next to the source
15
- ks run <file.ks> # build, then execute with node
14
+ ks build <file.ks> # type-check + emit <file>.js and <file>.js.map next to the source
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
18
  ks build|check <file.ks> --json # single JSON object on stdout instead of human text
@@ -25,9 +25,14 @@ ks build|check <file.ks> --json # single JSON object on stdout instead of huma
25
25
  ```
26
26
 
27
27
  `written` is the absolute paths actually written (`build` only, and only on success — a
28
- failed `build` writes nothing). Exit code is `0` iff `success` is `true`. A missing entry
28
+ failed `build` writes nothing; `.js.map` paths aren't listed separately, one is written next
29
+ to each `.js` path in `written`). Exit code is `0` iff `success` is `true`. A missing entry
29
30
  file yields `{ success: false, diagnostics: [], written: [], error: "cannot find file '...'" }`.
30
31
 
32
+ Source maps are statement-level, not column-level (see `src/sourcemap.ts`, hand-rolled VLQ,
33
+ zero added dependencies) — a thrown/uncaught error resolves to the right `.ks` line, but a
34
+ specific sub-expression within one line isn't separately mapped.
35
+
31
36
  ## File shape
32
37
 
33
38
  ```ks
package/README.md CHANGED
@@ -54,6 +54,10 @@ LLM's context, as opposed to this README's narrative explanation.
54
54
  file — interpolation, event/property bindings, `*if`/`*for` — down to the exact same
55
55
  AST a hand-written `Render()` would produce, with auto-`Subscribe` wiring for state
56
56
  referenced directly in the markup. See [Templates](#templates).
57
+ - **Real debugging, not just readable output**: `ks build` emits a real source map next to
58
+ every `.js` file (original `.ks` embedded, no separate file to ship), and `ks run` enables
59
+ it automatically — an uncaught exception names the real `.ks` file and line, not the
60
+ generated JS. Hand-rolled VLQ encoder, zero added dependencies. See "Source maps" below.
57
61
  - **A companion framework, [Kopular](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular)**:
58
62
  components, constructor-injected services via a composition root (no DI container), and
59
63
  real-URL routing (no config DSL) — built entirely on the features above, in a separate
@@ -741,8 +745,8 @@ runtime representation at all and are dropped from the emitted JS entirely.
741
745
  ## CLI
742
746
 
743
747
  ```
744
- ks build <file.ks> # type-check and emit <file>.js next to the source
745
- ks run <file.ks> # build, then execute the emitted JS with node
748
+ ks build <file.ks> # type-check and emit <file>.js (+ <file>.js.map) next to the source
749
+ ks run <file.ks> # build, then execute the emitted JS with node --enable-source-maps
746
750
  ks watch <file.ks> # build, then rebuild on every change to any file in the graph
747
751
  ks check <file.ks> # type-check only — no output files written
748
752
  ```
@@ -776,6 +780,19 @@ here), `KS5xxx` templates. There's no generated reference doc mapping every code
776
780
  explanation yet — for now, `message` is still the primary explanation; `code` is for
777
781
  matching, not (yet) for looking up docs.
778
782
 
783
+ **Source maps**: `build` writes a real source-map v3 `<file>.js.map` alongside every
784
+ `<file>.js`, with the original `.ks` source embedded (`sourcesContent`) so a deployed app
785
+ doesn't need to ship its `.ks` files for devtools/stack traces to show real source. `run`
786
+ passes Node's own `--enable-source-maps` flag automatically, so an uncaught exception
787
+ during `ks run` names the real `.ks` file and line, not the generated `.js`. Hand-rolled
788
+ (base64 VLQ), not the `source-map` npm package — kopscript has zero runtime dependencies
789
+ and this keeps it that way (see `src/sourcemap.ts`). **Scope**: statement-level, not full
790
+ expression/column-level — every statement (a class member, a block statement, a top-level
791
+ declaration) gets its own mapping, but a specific sub-expression *within* one line doesn't.
792
+ This is a deliberate v1 cut, the same spirit as generics/nullable types/templates: real,
793
+ useful debugging (correct stack-trace lines, working breakpoints) without a full rewrite of
794
+ codegen's string-concatenation architecture into a position-tracking writer.
795
+
779
796
  During development, use `npm run ks -- <build|run|watch|check> <file.ks>` (backed by
780
797
  `tsx`), or run `npm run build` to compile the TypeScript compiler itself to `dist/` and
781
798
  use `node dist/cli.js` directly.
package/dist/cli.js CHANGED
@@ -66,7 +66,14 @@ function build(filePath, jsonMode = false) {
66
66
  return { outPath: null, watchFiles: allWatchFiles(result) };
67
67
  }
68
68
  for (const absPath of result.order) {
69
- writeFileSync(outputPathFor(absPath), result.outputs.get(absPath), "utf-8");
69
+ const outPath = outputPathFor(absPath);
70
+ writeFileSync(outPath, result.outputs.get(absPath), "utf-8");
71
+ const map = result.sourceMaps.get(absPath);
72
+ // Only a file with real code got a `sourceMappingURL` comment appended
73
+ // (see codegen.ts) — skip writing an orphaned .map for one that didn't.
74
+ if (result.outputs.get(absPath).includes("//# sourceMappingURL=")) {
75
+ writeFileSync(`${outPath}.map`, map, "utf-8");
76
+ }
70
77
  }
71
78
  if (jsonMode)
72
79
  printJson(result, result.order.map(outputPathFor));
@@ -102,7 +109,10 @@ function run(filePath) {
102
109
  const { outPath } = build(filePath);
103
110
  if (!outPath)
104
111
  return;
105
- const result = spawnSync(process.execPath, [outPath], { stdio: "inherit" });
112
+ // Node has resolved `//# sourceMappingURL` comments natively since v12.12
113
+ // — this is what makes an uncaught exception during `ks run` show the
114
+ // real .ks file/line instead of the generated .js, with no extra setup.
115
+ const result = spawnSync(process.execPath, ["--enable-source-maps", outPath], { stdio: "inherit" });
106
116
  process.exitCode = result.status ?? 0;
107
117
  }
108
118
  // Rebuilds on every save of any file in the graph (not just the entry —
package/dist/codegen.js CHANGED
@@ -1,3 +1,11 @@
1
+ import { SourceMapBuilder } from "./sourcemap.js";
2
+ function countNewlines(text) {
3
+ let n = 0;
4
+ for (let i = 0; i < text.length; i++)
5
+ if (text[i] === "\n")
6
+ n++;
7
+ return n;
8
+ }
1
9
  // KopScript's PascalCase stdlib member names, mapped to their real JS names — a
2
10
  // blind syntactic rename (codegen has no type information, so this fires on
3
11
  // any member access with a matching name, string/array/user-class alike;
@@ -55,19 +63,45 @@ export class CodeGenerator {
55
63
  this.matchCounter = 0;
56
64
  this.interfaceNames = new Set();
57
65
  this.usesState = false;
66
+ // The current 1-indexed *generated* output line — kept in sync with the
67
+ // text being built by every method that can introduce a literal `\n`
68
+ // (genBlock, genClass, genConstructor, genMatch, genInterpolatedString,
69
+ // and this method's own top-level loop). Nothing else needs to touch it:
70
+ // a "simple" single-line gen* case just leaves it untouched, and by the
71
+ // time control returns to whichever loop called it, `outputLine` already
72
+ // correctly reflects how many lines that call's output actually took —
73
+ // including a lambda/match-expression buried inside it, since those
74
+ // recurse back into the same bookkeeping. See sourcemap.ts for the actual
75
+ // encoder; see README.md's "Source maps" section for the v1 scope this
76
+ // is deliberately limited to (statement-level, not per-expression/column).
77
+ this.outputLine = 1;
78
+ }
79
+ mark(node, generatedColumn) {
80
+ this.sourceMap.addMapping(this.outputLine, generatedColumn, node.line, node.col);
58
81
  }
59
82
  // `usingExports` maps each `using "<path>"` path (as written in source) to
60
83
  // the list of names that module exports — resolved by the caller from that
61
84
  // module's own checked Program, since a bare `using` brings everything
62
- // `public` into scope without naming it explicitly.
63
- generate(program, usingExports = new Map(), rawContents = new Map()) {
85
+ // `public` into scope without naming it explicitly. `sourceFileName`/
86
+ // `sourceText` are for the emitted source map only (both optional so every
87
+ // existing test call site keeps compiling unchanged).
88
+ generate(program, usingExports = new Map(), rawContents = new Map(), sourceFileName = "source.ks", sourceText = "") {
64
89
  this.interfaceNames = new Set(program.statements.filter((s) => s.kind === "InterfaceDecl").map((s) => s.name));
90
+ this.outputLine = 1;
91
+ const outFileName = sourceFileName.replace(/\.ks$/, ".js");
92
+ this.sourceMap = new SourceMapBuilder(outFileName, sourceFileName, sourceText);
65
93
  const importLines = program.usings
66
94
  .map((u) => {
67
95
  const names = usingExports.get(u.path) ?? [];
68
96
  return names.length > 0 ? `import { ${names.join(", ")} } from "${u.path}.js";` : null;
69
97
  })
70
98
  .filter((line) => line !== null);
99
+ // Body statements are generated (and marked) starting from *relative*
100
+ // line 1 — whether the import/prelude sections actually precede them in
101
+ // the final output depends on `this.usesState`, which genExpr only
102
+ // finishes deciding partway through this very loop (StateExpr flips it
103
+ // lazily). Every mark recorded below gets shifted by the real prefix's
104
+ // line count right after, once that's known for certain.
71
105
  const lines = [];
72
106
  for (const stmt of program.statements) {
73
107
  if (stmt.kind === "InterfaceDecl")
@@ -78,24 +112,41 @@ export class CodeGenerator {
78
112
  // whatever the class's own binding resolves to at runtime; only the
79
113
  // binding itself (import / ambient alias / re-export) needs emitting.
80
114
  const binding = this.genExternBinding(stmt.name, stmt.jsName, stmt.modulePath, stmt.isExported);
81
- if (binding)
115
+ if (binding) {
116
+ this.mark(stmt, 0);
82
117
  lines.push(binding);
118
+ this.outputLine += countNewlines(binding) + 1;
119
+ }
83
120
  continue;
84
121
  }
85
122
  if (stmt.kind === "RawStringDecl") {
86
123
  const content = rawContents.get(stmt.name) ?? "";
87
- lines.push(`${stmt.isExported ? "export const" : "const"} ${stmt.name} = ${JSON.stringify(content)};`);
124
+ const code = `${stmt.isExported ? "export const" : "const"} ${stmt.name} = ${JSON.stringify(content)};`;
125
+ this.mark(stmt, 0);
126
+ lines.push(code);
127
+ this.outputLine += countNewlines(code) + 1;
88
128
  continue;
89
129
  }
130
+ this.mark(stmt, 0);
90
131
  let code = this.genStatement(stmt, 0);
91
132
  if ((stmt.kind === "ClassDecl" || stmt.kind === "EnumDecl" || stmt.kind === "FunctionDecl") && stmt.isExported) {
92
133
  code = `export ${code}`;
93
134
  }
94
135
  lines.push(code);
136
+ this.outputLine += 1; // move past this statement's own last line, to the "\n" join separator
95
137
  }
96
138
  const body = lines.join("\n") + "\n";
97
139
  const sections = [importLines.join("\n"), this.usesState ? STATE_PRELUDE : "", body].filter((s) => s.length > 0);
98
- return sections.join("\n\n");
140
+ const prefixSections = sections.slice(0, -1); // body is always last (see filter above) and always non-empty
141
+ const prefixText = prefixSections.length > 0 ? prefixSections.join("\n\n") + "\n\n" : "";
142
+ this.sourceMap.shiftLines(countNewlines(prefixText));
143
+ const rawCode = sections.join("\n\n");
144
+ // A file whose only statements were private/unexported/ambient-with-no-
145
+ // rename externs (nothing else) legitimately generates zero real code —
146
+ // leave it exactly empty rather than tacking on a comment pointing at a
147
+ // map with nothing worth mapping.
148
+ const code = rawCode.trim().length > 0 ? rawCode + `\n//# sourceMappingURL=${outFileName.split("/").pop()}.map\n` : rawCode;
149
+ return { code, map: this.sourceMap.toJSON() };
99
150
  }
100
151
  // Builds the code (if any) that creates the local `name` binding for an
101
152
  // extern declaration, wiring in `export` when needed:
@@ -179,11 +230,30 @@ export class CodeGenerator {
179
230
  }
180
231
  return code;
181
232
  }
233
+ // Entry: `outputLine` is the line the caller's own `{` is being appended
234
+ // to (a block never starts its own fresh line — see e.g. genIf's ` else `
235
+ // continuing straight on from the previous block's own closing `}`).
236
+ // Exit: `outputLine` is left AT this block's own closing-`}` line (not one
237
+ // past it), so a caller that continues on the same line (`} else {`,
238
+ // `} catch (e) {`) doesn't need to undo anything, and one that doesn't
239
+ // (the statement/member loops below) just adds one more line itself.
182
240
  genBlock(block, indent) {
183
241
  const pad = this.indentStr(indent);
184
- if (block.statements.length === 0)
242
+ if (block.statements.length === 0) {
243
+ this.outputLine += 1; // the "\n" between "{" and the closing pad}
185
244
  return `{\n${pad}}`;
186
- const body = block.statements.map((s) => this.genStatement(s, indent + 1)).join("\n");
245
+ }
246
+ const memberCol = this.indentStr(indent + 1).length;
247
+ this.outputLine += 1; // "\n" after "{", moving to the first statement's line
248
+ const pieces = block.statements.map((s, i) => {
249
+ this.mark(s, memberCol);
250
+ const text = this.genStatement(s, indent + 1);
251
+ if (i < block.statements.length - 1)
252
+ this.outputLine += 1; // "\n" before the next statement
253
+ return text;
254
+ });
255
+ this.outputLine += 1; // "\n" before the closing pad}
256
+ const body = pieces.join("\n");
187
257
  return `{\n${body}\n${pad}}`;
188
258
  }
189
259
  genIf(stmt, indent) {
@@ -205,26 +275,48 @@ export class CodeGenerator {
205
275
  const prefix = decl.isAsync ? "async function" : "function";
206
276
  return `${pad}${prefix} ${decl.name}(${params}) ${this.genBlock(decl.body, indent).trimStart()}`;
207
277
  }
278
+ // Same entry/exit `outputLine` convention as genBlock. The one difference:
279
+ // members are separated by a full blank line (`.join("\n\n")`), so each
280
+ // non-last member advances by 2, not 1. Marking has to happen immediately
281
+ // before each member's *own* generation call (a constructor/method body
282
+ // can itself contain nested blocks that advance `outputLine` further), so
283
+ // member sources are built as `{ node, build }` pairs and only invoked
284
+ // inside the loop below, not gathered as plain strings up front.
208
285
  genClass(decl, indent) {
209
286
  const pad = this.indentStr(indent);
210
287
  // The base list mixes an optional superclass with interface names (checker-validated);
211
288
  // only the non-interface entry, if any, becomes a JS `extends` clause.
212
289
  const superclass = decl.baseList.find((n) => !this.interfaceNames.has(n)) ?? null;
213
290
  const header = superclass ? `class ${decl.name} extends ${superclass} {` : `class ${decl.name} {`;
214
- const parts = [];
215
291
  const memberPad = this.indentStr(indent + 1);
292
+ const memberCol = memberPad.length;
293
+ const sources = [];
216
294
  for (const field of decl.fields) {
217
295
  if (field.isStatic)
218
- parts.push(`${memberPad}static ${field.name} = ${this.genExpr(field.initializer)};`);
296
+ sources.push({ node: field, build: () => `${memberPad}static ${field.name} = ${this.genExpr(field.initializer)};` });
219
297
  }
220
298
  if (decl.constructor) {
221
- parts.push(this.genConstructor(decl.constructor, indent + 1));
299
+ const ctor = decl.constructor;
300
+ sources.push({ node: ctor, build: () => this.genConstructor(ctor, indent + 1) });
222
301
  }
223
302
  for (const method of decl.methods) {
224
303
  const params = method.params.map((p) => p.name).join(", ");
225
304
  const prefix = `${method.isStatic ? "static " : ""}${method.isAsync ? "async " : ""}`;
226
- parts.push(`${memberPad}${prefix}${method.name}(${params}) ${this.genBlock(method.body, indent + 1).trimStart()}`);
305
+ sources.push({ node: method, build: () => `${memberPad}${prefix}${method.name}(${params}) ${this.genBlock(method.body, indent + 1).trimStart()}` });
306
+ }
307
+ if (sources.length === 0) {
308
+ this.outputLine += 2; // "\n" after header, "\n" before the closing pad}
309
+ return `${pad}${header}\n\n${pad}}`;
227
310
  }
311
+ this.outputLine += 1; // "\n" after header, moving to the first member's line
312
+ const parts = sources.map((m, i) => {
313
+ this.mark(m.node, memberCol);
314
+ const text = m.build();
315
+ if (i < sources.length - 1)
316
+ this.outputLine += 2; // blank line before the next member
317
+ return text;
318
+ });
319
+ this.outputLine += 1; // "\n" before the closing pad}
228
320
  const body = parts.join("\n\n");
229
321
  return `${pad}${header}\n${body}\n${pad}}`;
230
322
  }
@@ -235,14 +327,36 @@ export class CodeGenerator {
235
327
  genConstructor(ctor, indent) {
236
328
  const pad = this.indentStr(indent);
237
329
  const bodyPad = this.indentStr(indent + 1);
330
+ const bodyCol = bodyPad.length;
238
331
  const params = ctor.params.map((p) => p.name).join(", ");
332
+ // `super(...)` (if any) has no AST statement node of its own to mark —
333
+ // it's synthesized from `ctor.baseArgs`, a plain expression list, not a
334
+ // Statement — so it's the one line in a constructor body that goes
335
+ // unmapped; every real statement after it still gets its own mark.
336
+ const hasSuper = ctor.baseArgs !== null;
337
+ const totalLines = (hasSuper ? 1 : 0) + ctor.body.statements.length;
338
+ if (totalLines === 0) {
339
+ this.outputLine += 1; // "\n" before the closing pad}
340
+ return `${pad}constructor(${params}) {\n${pad}}`;
341
+ }
342
+ this.outputLine += 1; // "\n" after "{", moving to the first line
239
343
  const lines = [];
240
- if (ctor.baseArgs) {
344
+ let emitted = 0;
345
+ if (hasSuper) {
241
346
  lines.push(`${bodyPad}super(${ctor.baseArgs.map((a) => this.genExpr(a)).join(", ")});`);
347
+ emitted++;
348
+ if (emitted < totalLines)
349
+ this.outputLine += 1;
350
+ }
351
+ for (const s of ctor.body.statements) {
352
+ this.mark(s, bodyCol);
353
+ lines.push(this.genStatement(s, indent + 1));
354
+ emitted++;
355
+ if (emitted < totalLines)
356
+ this.outputLine += 1;
242
357
  }
243
- lines.push(...ctor.body.statements.map((s) => this.genStatement(s, indent + 1)));
244
- const bodyStr = lines.length > 0 ? `{\n${lines.join("\n")}\n${pad}}` : `{\n${pad}}`;
245
- return `${pad}constructor(${params}) ${bodyStr}`;
358
+ this.outputLine += 1; // "\n" before the closing pad}
359
+ return `${pad}constructor(${params}) {\n${lines.join("\n")}\n${pad}}`;
246
360
  }
247
361
  // ---------- expressions ----------
248
362
  // genExpr wraps an assignment in parens unconditionally (`(x = y)`), since
@@ -316,7 +430,19 @@ export class CodeGenerator {
316
430
  genInterpolatedString(expr) {
317
431
  const escapeText = (text) => text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
318
432
  const body = expr.parts
319
- .map((part) => (part.kind === "Text" ? escapeText(part.text) : `\${${this.genExpr(part.expression)}}`))
433
+ .map((part) => {
434
+ if (part.kind === "Text") {
435
+ // A `\n` escape in the source literal is already a real newline
436
+ // character in `part.text` by the time it reaches codegen (the
437
+ // lexer resolves it) — it lands verbatim in the JS template
438
+ // literal too (backticks support real embedded newlines), so
439
+ // `outputLine` has to account for it or every mark recorded after
440
+ // this point in the file would be off.
441
+ this.outputLine += countNewlines(part.text);
442
+ return escapeText(part.text);
443
+ }
444
+ return `\${${this.genExpr(part.expression)}}`;
445
+ })
320
446
  .join("");
321
447
  return `\`${body}\``;
322
448
  }
@@ -348,15 +474,31 @@ export class CodeGenerator {
348
474
  const jsProperty = expr.property === "Length" ? "length" : MEMBER_METHOD_MAP[expr.property] ?? expr.property;
349
475
  return `${this.genExpr(expr.object)}.${jsProperty}`;
350
476
  }
477
+ // Each arm gets its own mark, at the line its own `if`/`else` opens on —
478
+ // MatchArm carries `line`/`col` even though its pattern variants don't
479
+ // (see ast.ts). One known v1 imprecision, same spirit as the rest of this
480
+ // file's statement-level (not full-expression) granularity: if an arm's
481
+ // *result* expression itself contains a nested multi-line construct (a
482
+ // lambda with a block body, or another `match`), the fixed "+1 line" this
483
+ // method advances by per template line undercounts, and marks recorded
484
+ // after that arm land one or more lines early. Rare in practice (a match
485
+ // arm's result is usually a short expression), not attempted here.
351
486
  genMatch(expr) {
352
487
  const subjectVar = `__subject${this.matchCounter++}`;
353
- const lines = [`(() => {`, ` const ${subjectVar} = ${this.genExpr(expr.subject)};`];
488
+ this.outputLine += 1; // "\n" after "(() => {", moving to the "const subjectN = ..." line
489
+ const subjectText = this.genExpr(expr.subject);
490
+ const lines = [`(() => {`, ` const ${subjectVar} = ${subjectText};`];
491
+ this.outputLine += 1; // "\n" before the first arm's own line
354
492
  let emittedIf = false;
355
493
  expr.arms.forEach((arm) => {
494
+ this.mark(arm, 2);
356
495
  if (arm.pattern.kind === "WildcardPattern") {
357
496
  lines.push(emittedIf ? ` else {` : ` if (true) {`);
497
+ this.outputLine += 1;
358
498
  lines.push(` return ${this.genExpr(arm.result)};`);
499
+ this.outputLine += 1;
359
500
  lines.push(` }`);
501
+ this.outputLine += 1;
360
502
  emittedIf = true;
361
503
  return;
362
504
  }
@@ -364,8 +506,11 @@ export class CodeGenerator {
364
506
  ? `new RegExp(${JSON.stringify(arm.pattern.source)}).test(${subjectVar})`
365
507
  : arm.pattern.values.map((v) => `${subjectVar} === ${this.genExpr(v)}`).join(" || ");
366
508
  lines.push(` ${emittedIf ? "else if" : "if"} (${condition}) {`);
509
+ this.outputLine += 1;
367
510
  lines.push(` return ${this.genExpr(arm.result)};`);
511
+ this.outputLine += 1;
368
512
  lines.push(` }`);
513
+ this.outputLine += 1;
369
514
  emittedIf = true;
370
515
  });
371
516
  lines.push(`})()`);
package/dist/modules.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, relative, resolve } from "node:path";
2
+ import { basename, dirname, relative, resolve } from "node:path";
3
3
  import { Lexer } from "./lexer.js";
4
4
  import { Parser } from "./parser.js";
5
5
  import { Checker, emptyModuleExports } from "./checker.js";
@@ -157,7 +157,7 @@ export function loadModuleGraph(entryAbsPath, fileOverrides) {
157
157
  export function compileGraph(entryAbsPath, fileOverrides) {
158
158
  const { modules, order, entryMissing } = loadModuleGraph(entryAbsPath, fileOverrides);
159
159
  if (entryMissing) {
160
- return { success: false, entryMissing: true, modules, order, outputs: new Map() };
160
+ return { success: false, entryMissing: true, modules, order, outputs: new Map(), sourceMaps: new Map() };
161
161
  }
162
162
  const exportsByModule = new Map();
163
163
  const rawContentsByModule = new Map();
@@ -219,9 +219,10 @@ export function compileGraph(entryAbsPath, fileOverrides) {
219
219
  rawContentsByModule.set(absPath, checker.getRawContents());
220
220
  }
221
221
  if (hasErrors) {
222
- return { success: false, entryMissing: false, modules, order, outputs: new Map() };
222
+ return { success: false, entryMissing: false, modules, order, outputs: new Map(), sourceMaps: new Map() };
223
223
  }
224
224
  const outputs = new Map();
225
+ const sourceMaps = new Map();
225
226
  for (const absPath of order) {
226
227
  const mod = modules.get(absPath);
227
228
  const usingExports = new Map();
@@ -235,7 +236,13 @@ export function compileGraph(entryAbsPath, fileOverrides) {
235
236
  usingExports.set(u.path, [...importableTypeNames, ...depExports.functions.keys(), ...depExports.externValues.keys()]);
236
237
  }
237
238
  }
238
- outputs.set(absPath, new CodeGenerator().generate(mod.program, usingExports, rawContentsByModule.get(absPath) ?? new Map()));
239
+ // Just the basename, not a cwd-relative path — the .js.map file always
240
+ // lands right next to its .ks/.js siblings (ks build writes output in
241
+ // place), so "sources" has to resolve relative to *that* directory, not
242
+ // wherever the compiler happened to be invoked from.
243
+ const { code, map } = new CodeGenerator().generate(mod.program, usingExports, rawContentsByModule.get(absPath) ?? new Map(), basename(absPath), mod.source);
244
+ outputs.set(absPath, code);
245
+ sourceMaps.set(absPath, map);
239
246
  }
240
- return { success: true, entryMissing: false, modules, order, outputs };
247
+ return { success: true, entryMissing: false, modules, order, outputs, sourceMaps };
241
248
  }
@@ -0,0 +1,113 @@
1
+ // A minimal, self-contained source-map v3 (base64 VLQ) encoder — hand-rolled
2
+ // rather than pulling in the `source-map` package, to keep kopscript at zero
3
+ // runtime dependencies (see README.md/package.json). No imports beyond this
4
+ // file's own code; nothing here reads or writes the filesystem.
5
+ //
6
+ // Spec: https://sourcemaps.info/spec.html — the encoding this file
7
+ // implements (VLQ digit format, per-line column reset) is described there.
8
+ const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
9
+ const VLQ_BASE_SHIFT = 5;
10
+ const VLQ_BASE = 1 << VLQ_BASE_SHIFT; // 32 — 5 data bits per base64 digit
11
+ const VLQ_BASE_MASK = VLQ_BASE - 1; // 0x1F
12
+ const VLQ_CONTINUATION_BIT = VLQ_BASE; // 0x20 — set on every digit but the last
13
+ // Sign-and-magnitude, not two's complement: the sign lives in the low bit of
14
+ // the shifted value, not as a separate leading digit — this is what makes a
15
+ // small negative delta (the common case for a line's *second* segment
16
+ // resetting a column back down) encode just as compactly as a small
17
+ // positive one.
18
+ function toSignedVLQ(value) {
19
+ return value < 0 ? (-value << 1) + 1 : value << 1;
20
+ }
21
+ function encodeVLQValue(value) {
22
+ let vlq = toSignedVLQ(value);
23
+ let result = "";
24
+ do {
25
+ let digit = vlq & VLQ_BASE_MASK;
26
+ vlq >>>= VLQ_BASE_SHIFT;
27
+ if (vlq > 0)
28
+ digit |= VLQ_CONTINUATION_BIT;
29
+ result += BASE64_CHARS[digit];
30
+ } while (vlq > 0);
31
+ return result;
32
+ }
33
+ // Encodes one mapping segment's fields (already converted to deltas by the
34
+ // caller) as a contiguous run of VLQ digit-groups — e.g. `[0, 0, 0, 0]` (the
35
+ // "no change from the previous segment" case) always encodes to `"AAAA"`.
36
+ export function encodeVLQ(values) {
37
+ return values.map(encodeVLQValue).join("");
38
+ }
39
+ // Builds one file's source-map v3 JSON incrementally, one `addMapping` call
40
+ // per source position worth recording (see codegen.ts for how call sites are
41
+ // chosen — statement-level, not full expression/column granularity). Only
42
+ // ever describes a single source file per generated file, matching
43
+ // kopscript's one-.ks-file-in, one-.js-file-out compilation model.
44
+ export class SourceMapBuilder {
45
+ constructor(fileName, sourceFileName, sourceContent) {
46
+ // `mappings` is one comma-joined segment-string per GENERATED line,
47
+ // joined with `;` at the end — an empty array entry for a generated line
48
+ // with no recorded mapping produces the required-but-empty group between
49
+ // two semicolons, not a skipped line (that would shift every later line's
50
+ // meaning, since position in the `;`-separated list *is* the line number).
51
+ this.lineGroups = [];
52
+ // Per the spec, `generatedColumn` deltas reset to being measured from 0
53
+ // at the start of every generated line, but `sourceLine`/`sourceColumn`
54
+ // deltas accumulate across the *entire* file, never resetting — tracking
55
+ // both correctly is the one genuinely easy-to-get-wrong part of this.
56
+ this.prevGeneratedColumn = 0;
57
+ this.prevSourceLine = 0;
58
+ this.prevSourceColumn = 0;
59
+ this.fileName = fileName;
60
+ this.sourceFileName = sourceFileName;
61
+ this.sourceContent = sourceContent;
62
+ }
63
+ lineGroup(generatedLine) {
64
+ const index = generatedLine - 1;
65
+ while (this.lineGroups.length <= index)
66
+ this.lineGroups.push([]);
67
+ return this.lineGroups[index];
68
+ }
69
+ // `generatedLine`/`sourceLine`/`sourceColumn` are 1-indexed (kopscript's
70
+ // own convention throughout the lexer/parser/AST — see lexer.ts). Per the
71
+ // source-map spec itself, everything is 0-indexed in the encoded output;
72
+ // the `- 1`s below are that conversion, done in exactly one place.
73
+ // `generatedColumn` is the real (already 0-indexed) character offset from
74
+ // the start of its generated line — there's no separate convention to
75
+ // convert there since "0 spaces of indent" already means column 0.
76
+ addMapping(generatedLine, generatedColumn, sourceLine, sourceColumn) {
77
+ const group = this.lineGroup(generatedLine);
78
+ if (group.length === 0)
79
+ this.prevGeneratedColumn = 0; // new line: column deltas restart at 0
80
+ const zSourceLine = sourceLine - 1;
81
+ const zSourceColumn = sourceColumn - 1;
82
+ group.push(encodeVLQ([
83
+ generatedColumn - this.prevGeneratedColumn,
84
+ 0, // sourceIndex delta — always 0, exactly one source file per map
85
+ zSourceLine - this.prevSourceLine,
86
+ zSourceColumn - this.prevSourceColumn,
87
+ ]));
88
+ this.prevGeneratedColumn = generatedColumn;
89
+ this.prevSourceLine = zSourceLine;
90
+ this.prevSourceColumn = zSourceColumn;
91
+ }
92
+ // Prepends `count` empty line-groups — for when marks were recorded
93
+ // relative to a chunk's own start (line 1) before it was known how many
94
+ // lines of *other* content would end up preceding that chunk in the final
95
+ // output. See codegen.ts's `generate()`: body statements are marked
96
+ // before it's known whether the `state<T>` prelude will precede them.
97
+ shiftLines(count) {
98
+ if (count <= 0)
99
+ return;
100
+ this.lineGroups.unshift(...Array.from({ length: count }, () => []));
101
+ }
102
+ toJSON() {
103
+ const mappings = this.lineGroups.map((segments) => segments.join(",")).join(";");
104
+ return JSON.stringify({
105
+ version: 3,
106
+ file: this.fileName,
107
+ sources: [this.sourceFileName],
108
+ sourcesContent: [this.sourceContent],
109
+ names: [],
110
+ mappings,
111
+ });
112
+ }
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopscript",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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",