kopscript 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, watch as fsWatch } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { resolve, basename, join } from "node:path";
5
+ import { compileGraph } from "./modules.js";
6
+ function outputPathFor(filePath) {
7
+ const name = basename(filePath, ".ks");
8
+ return join(resolve(filePath, ".."), `${name}.js`);
9
+ }
10
+ // Compiles the whole module graph reachable from `filePath`, writing one
11
+ // .js file next to each .ks source.
12
+ function build(filePath) {
13
+ const result = compileGraph(filePath);
14
+ if (result.entryMissing) {
15
+ console.error(`ks: cannot find file '${filePath}'`);
16
+ process.exitCode = 1;
17
+ return { outPath: null, watchFiles: [] };
18
+ }
19
+ if (!result.success) {
20
+ for (const absPath of result.order) {
21
+ const mod = result.modules.get(absPath);
22
+ if (mod.diagnostics.hasErrors) {
23
+ console.error(mod.diagnostics.format(mod.source, absPath));
24
+ console.error("");
25
+ }
26
+ }
27
+ process.exitCode = 1;
28
+ return { outPath: null, watchFiles: result.order };
29
+ }
30
+ for (const absPath of result.order) {
31
+ writeFileSync(outputPathFor(absPath), result.outputs.get(absPath), "utf-8");
32
+ }
33
+ return { outPath: outputPathFor(filePath), watchFiles: result.order };
34
+ }
35
+ function run(filePath) {
36
+ const { outPath } = build(filePath);
37
+ if (!outPath)
38
+ return;
39
+ const result = spawnSync(process.execPath, [outPath], { stdio: "inherit" });
40
+ process.exitCode = result.status ?? 0;
41
+ }
42
+ // Rebuilds on every save of any file in the graph (not just the entry —
43
+ // editing a `using`'d dependency triggers a rebuild too). Watchers are torn
44
+ // down and re-established after each rebuild, since the dependency set
45
+ // itself can change (a `using` added or removed). Multiple change events
46
+ // from one save (common with editors that write-then-rename) are
47
+ // debounced into a single rebuild.
48
+ function watchCommand(filePath) {
49
+ let watchers = [];
50
+ let debounceTimer = null;
51
+ const clearWatchers = () => {
52
+ for (const w of watchers)
53
+ w.close();
54
+ watchers = [];
55
+ };
56
+ const rebuild = () => {
57
+ clearWatchers();
58
+ const timestamp = new Date().toLocaleTimeString();
59
+ process.exitCode = 0; // each rebuild gets a clean slate; a fixed error shouldn't leave the process pre-marked failed
60
+ const { outPath, watchFiles } = build(filePath);
61
+ console.log(outPath ? `[${timestamp}] Wrote ${outPath}` : `[${timestamp}] Build failed`);
62
+ const filesToWatch = watchFiles.length > 0 ? watchFiles : [filePath];
63
+ for (const f of filesToWatch) {
64
+ try {
65
+ watchers.push(fsWatch(f, () => scheduleRebuild()));
66
+ }
67
+ catch {
68
+ // Missing file (e.g. a typo'd entry path) — nothing to watch until it exists.
69
+ }
70
+ }
71
+ };
72
+ const scheduleRebuild = () => {
73
+ if (debounceTimer)
74
+ clearTimeout(debounceTimer);
75
+ debounceTimer = setTimeout(rebuild, 80);
76
+ };
77
+ console.log(`[watch] ${filePath} — watching for changes. Press Ctrl+C to stop.`);
78
+ rebuild();
79
+ }
80
+ function main() {
81
+ const [, , command, file] = process.argv;
82
+ if (!command || !file || (command !== "build" && command !== "run" && command !== "watch")) {
83
+ console.error("Usage: ks <build|run|watch> <file.ks>");
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ const filePath = resolve(file);
88
+ if (command === "build") {
89
+ const { outPath } = build(filePath);
90
+ if (outPath)
91
+ console.log(`Wrote ${outPath}`);
92
+ }
93
+ else if (command === "watch") {
94
+ watchCommand(filePath);
95
+ }
96
+ else {
97
+ run(filePath);
98
+ }
99
+ }
100
+ main();
@@ -0,0 +1,339 @@
1
+ // KopScript's PascalCase stdlib member names, mapped to their real JS names — a
2
+ // blind syntactic rename (codegen has no type information, so this fires on
3
+ // any member access with a matching name, string/array/user-class alike;
4
+ // same pre-existing risk as any of the entries below, going back to the
5
+ // original string stdlib). Map/Filter/ForEach need nothing beyond the
6
+ // rename — the callback and everything else already codegens generically.
7
+ // Push is handled separately in genCall, since it's non-mutating in KopScript
8
+ // (unlike JS's own Array.push) and needs a different call shape entirely.
9
+ const MEMBER_METHOD_MAP = {
10
+ Contains: "includes",
11
+ StartsWith: "startsWith",
12
+ EndsWith: "endsWith",
13
+ Replace: "replaceAll",
14
+ Split: "split",
15
+ Trim: "trim",
16
+ ToUpper: "toUpperCase",
17
+ ToLower: "toLowerCase",
18
+ Map: "map",
19
+ Filter: "filter",
20
+ ForEach: "forEach",
21
+ };
22
+ const BINARY_OP_MAP = {
23
+ "+": "+",
24
+ "-": "-",
25
+ "*": "*",
26
+ "/": "/",
27
+ "%": "%",
28
+ "==": "===",
29
+ "!=": "!==",
30
+ "<": "<",
31
+ ">": ">",
32
+ "<=": "<=",
33
+ ">=": ">=",
34
+ };
35
+ // Runtime backing for `state<T>`: a value plus its change listeners. Emitted
36
+ // inline (once per file) rather than pulled from a shared runtime package —
37
+ // consistent with the rest of codegen producing self-contained JS with no
38
+ // external dependency of its own.
39
+ const STATE_PRELUDE = `class __KopState {
40
+ constructor(value) {
41
+ this._value = value;
42
+ this._listeners = [];
43
+ }
44
+ get Value() { return this._value; }
45
+ set Value(v) {
46
+ this._value = v;
47
+ for (const listener of this._listeners) listener(v);
48
+ }
49
+ Subscribe(listener) {
50
+ this._listeners.push(listener);
51
+ }
52
+ }`;
53
+ export class CodeGenerator {
54
+ constructor() {
55
+ this.matchCounter = 0;
56
+ this.interfaceNames = new Set();
57
+ this.usesState = false;
58
+ }
59
+ // `usingExports` maps each `using "<path>"` path (as written in source) to
60
+ // the list of names that module exports — resolved by the caller from that
61
+ // 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()) {
64
+ this.interfaceNames = new Set(program.statements.filter((s) => s.kind === "InterfaceDecl").map((s) => s.name));
65
+ const importLines = program.usings
66
+ .map((u) => {
67
+ const names = usingExports.get(u.path) ?? [];
68
+ return names.length > 0 ? `import { ${names.join(", ")} } from "${u.path}.js";` : null;
69
+ })
70
+ .filter((line) => line !== null);
71
+ const lines = [];
72
+ for (const stmt of program.statements) {
73
+ if (stmt.kind === "InterfaceDecl")
74
+ continue; // compile-time only, no runtime representation
75
+ if (stmt.kind === "ExternFunctionDecl" || stmt.kind === "ExternClassDecl" || stmt.kind === "ExternValueDecl") {
76
+ // Extern class members need no codegen of their own — a call like
77
+ // `el.addEventListener(...)` is just an ordinary member call against
78
+ // whatever the class's own binding resolves to at runtime; only the
79
+ // binding itself (import / ambient alias / re-export) needs emitting.
80
+ const binding = this.genExternBinding(stmt.name, stmt.jsName, stmt.modulePath, stmt.isExported);
81
+ if (binding)
82
+ lines.push(binding);
83
+ continue;
84
+ }
85
+ let code = this.genStatement(stmt, 0);
86
+ if ((stmt.kind === "ClassDecl" || stmt.kind === "EnumDecl" || stmt.kind === "FunctionDecl") && stmt.isExported) {
87
+ code = `export ${code}`;
88
+ }
89
+ lines.push(code);
90
+ }
91
+ const body = lines.join("\n") + "\n";
92
+ const sections = [importLines.join("\n"), this.usesState ? STATE_PRELUDE : "", body].filter((s) => s.length > 0);
93
+ return sections.join("\n\n");
94
+ }
95
+ // Builds the code (if any) that creates the local `name` binding for an
96
+ // extern declaration, wiring in `export` when needed:
97
+ // - imported (modulePath set): a real `import`, plus a plain re-export if exported
98
+ // - ambient + no rename: nothing at all — the bare global just resolves at runtime
99
+ // - ambient + renamed, or ambient + exported: an explicit `const` alias off
100
+ // `globalThis` (needed either way: to rebind the name, or because a bare
101
+ // ambient reference has no local binding to `export` in the first place)
102
+ genExternBinding(name, jsName, modulePath, isExported) {
103
+ if (modulePath) {
104
+ const importClause = jsName === name ? name : `${jsName} as ${name}`;
105
+ const importLine = `import { ${importClause} } from "${modulePath}";`;
106
+ return isExported ? `${importLine}\nexport { ${name} };` : importLine;
107
+ }
108
+ if (jsName === name && !isExported)
109
+ return null;
110
+ return `${isExported ? "export const" : "const"} ${name} = globalThis.${jsName};`;
111
+ }
112
+ // ---------- statements ----------
113
+ indentStr(indent) {
114
+ return " ".repeat(indent);
115
+ }
116
+ genStatement(stmt, indent) {
117
+ const pad = this.indentStr(indent);
118
+ switch (stmt.kind) {
119
+ case "VarDecl":
120
+ return `${pad}${stmt.isConst ? "const" : "let"} ${stmt.name} = ${this.genExpr(stmt.init)};`;
121
+ case "FunctionDecl":
122
+ return this.genFunction(stmt, indent);
123
+ case "ClassDecl":
124
+ return this.genClass(stmt, indent);
125
+ case "InterfaceDecl":
126
+ case "ExternFunctionDecl":
127
+ case "ExternClassDecl":
128
+ case "ExternValueDecl":
129
+ return ""; // handled directly in generate(); unreachable here except via a nested-decl error path
130
+ case "EnumDecl": {
131
+ const entries = stmt.members.map((m, i) => `${m}: ${i}`).join(", ");
132
+ return `${pad}const ${stmt.name} = Object.freeze({ ${entries} });`;
133
+ }
134
+ case "Block":
135
+ return this.genBlock(stmt, indent);
136
+ case "IfStatement":
137
+ return this.genIf(stmt, indent);
138
+ case "WhileStatement":
139
+ return `${pad}while (${this.genExpr(stmt.condition)}) ${this.genBlock(stmt.body, indent).trimStart()}`;
140
+ case "ForStatement": {
141
+ const init = stmt.init
142
+ ? stmt.init.kind === "VarDecl"
143
+ ? `${stmt.init.isConst ? "const" : "let"} ${stmt.init.name} = ${this.genExpr(stmt.init.init)}`
144
+ : this.genExpr(stmt.init.expression)
145
+ : "";
146
+ const cond = stmt.condition ? this.genExpr(stmt.condition) : "";
147
+ const update = stmt.update ? this.genExpr(stmt.update) : "";
148
+ return `${pad}for (${init}; ${cond}; ${update}) ${this.genBlock(stmt.body, indent).trimStart()}`;
149
+ }
150
+ case "ForInStatement":
151
+ return `${pad}for (const ${stmt.varName} of ${this.genExpr(stmt.iterable)}) ${this.genBlock(stmt.body, indent).trimStart()}`;
152
+ case "ReturnStatement":
153
+ return stmt.value ? `${pad}return ${this.genExpr(stmt.value)};` : `${pad}return;`;
154
+ case "BreakStatement":
155
+ return `${pad}break;`;
156
+ case "ContinueStatement":
157
+ return `${pad}continue;`;
158
+ case "ExpressionStatement":
159
+ return `${pad}${this.genExpr(stmt.expression)};`;
160
+ case "TryStatement":
161
+ return this.genTry(stmt, indent);
162
+ case "ThrowStatement":
163
+ return `${pad}throw ${this.genExpr(stmt.expression)};`;
164
+ }
165
+ }
166
+ genTry(stmt, indent) {
167
+ let code = `${this.indentStr(indent)}try ${this.genBlock(stmt.tryBlock, indent).trimStart()}`;
168
+ if (stmt.catchBlock && stmt.catchParam) {
169
+ code += ` catch (${stmt.catchParam.name}) ${this.genBlock(stmt.catchBlock, indent).trimStart()}`;
170
+ }
171
+ if (stmt.finallyBlock) {
172
+ code += ` finally ${this.genBlock(stmt.finallyBlock, indent).trimStart()}`;
173
+ }
174
+ return code;
175
+ }
176
+ genBlock(block, indent) {
177
+ const pad = this.indentStr(indent);
178
+ if (block.statements.length === 0)
179
+ return `{\n${pad}}`;
180
+ const body = block.statements.map((s) => this.genStatement(s, indent + 1)).join("\n");
181
+ return `{\n${body}\n${pad}}`;
182
+ }
183
+ genIf(stmt, indent) {
184
+ const pad = this.indentStr(indent);
185
+ let code = `${pad}if (${this.genExpr(stmt.condition)}) ${this.genBlock(stmt.thenBranch, indent).trimStart()}`;
186
+ if (stmt.elseBranch) {
187
+ if (stmt.elseBranch.kind === "IfStatement") {
188
+ code += ` else ${this.genIf(stmt.elseBranch, indent).trimStart()}`;
189
+ }
190
+ else {
191
+ code += ` else ${this.genBlock(stmt.elseBranch, indent).trimStart()}`;
192
+ }
193
+ }
194
+ return code;
195
+ }
196
+ genFunction(decl, indent) {
197
+ const pad = this.indentStr(indent);
198
+ const params = decl.params.map((p) => p.name).join(", ");
199
+ const prefix = decl.isAsync ? "async function" : "function";
200
+ return `${pad}${prefix} ${decl.name}(${params}) ${this.genBlock(decl.body, indent).trimStart()}`;
201
+ }
202
+ genClass(decl, indent) {
203
+ const pad = this.indentStr(indent);
204
+ // The base list mixes an optional superclass with interface names (checker-validated);
205
+ // only the non-interface entry, if any, becomes a JS `extends` clause.
206
+ const superclass = decl.baseList.find((n) => !this.interfaceNames.has(n)) ?? null;
207
+ const header = superclass ? `class ${decl.name} extends ${superclass} {` : `class ${decl.name} {`;
208
+ const parts = [];
209
+ const memberPad = this.indentStr(indent + 1);
210
+ for (const field of decl.fields) {
211
+ if (field.isStatic)
212
+ parts.push(`${memberPad}static ${field.name} = ${this.genExpr(field.initializer)};`);
213
+ }
214
+ if (decl.constructor) {
215
+ parts.push(this.genConstructor(decl.constructor, indent + 1));
216
+ }
217
+ for (const method of decl.methods) {
218
+ const params = method.params.map((p) => p.name).join(", ");
219
+ const prefix = `${method.isStatic ? "static " : ""}${method.isAsync ? "async " : ""}`;
220
+ parts.push(`${memberPad}${prefix}${method.name}(${params}) ${this.genBlock(method.body, indent + 1).trimStart()}`);
221
+ }
222
+ const body = parts.join("\n\n");
223
+ return `${pad}${header}\n${body}\n${pad}}`;
224
+ }
225
+ // `: base(...)` compiles to `super(...)` as the literal first statement in
226
+ // the constructor body — required by real JS semantics (super() must run
227
+ // before `this` is touched), which is exactly the rule the checker already
228
+ // enforces, so this can't fail here.
229
+ genConstructor(ctor, indent) {
230
+ const pad = this.indentStr(indent);
231
+ const bodyPad = this.indentStr(indent + 1);
232
+ const params = ctor.params.map((p) => p.name).join(", ");
233
+ const lines = [];
234
+ if (ctor.baseArgs) {
235
+ lines.push(`${bodyPad}super(${ctor.baseArgs.map((a) => this.genExpr(a)).join(", ")});`);
236
+ }
237
+ lines.push(...ctor.body.statements.map((s) => this.genStatement(s, indent + 1)));
238
+ const bodyStr = lines.length > 0 ? `{\n${lines.join("\n")}\n${pad}}` : `{\n${pad}}`;
239
+ return `${pad}constructor(${params}) ${bodyStr}`;
240
+ }
241
+ // ---------- expressions ----------
242
+ genExpr(expr) {
243
+ switch (expr.kind) {
244
+ case "NumberLiteral":
245
+ return String(expr.value);
246
+ case "StringLiteral":
247
+ return JSON.stringify(expr.value);
248
+ case "BoolLiteral":
249
+ return String(expr.value);
250
+ case "InterpolatedStringLiteral":
251
+ return this.genInterpolatedString(expr);
252
+ case "ArrayLiteral":
253
+ return `[${expr.elements.map((e) => this.genExpr(e)).join(", ")}]`;
254
+ case "Identifier":
255
+ return expr.name;
256
+ case "ThisExpr":
257
+ return "this";
258
+ case "UnaryExpr":
259
+ return `${expr.op}${this.genExpr(expr.operand)}`;
260
+ case "BinaryExpr":
261
+ return `(${this.genExpr(expr.left)} ${BINARY_OP_MAP[expr.op]} ${this.genExpr(expr.right)})`;
262
+ case "LogicalExpr":
263
+ return `(${this.genExpr(expr.left)} ${expr.op} ${this.genExpr(expr.right)})`;
264
+ case "AssignExpr":
265
+ return `(${this.genExpr(expr.target)} = ${this.genExpr(expr.value)})`;
266
+ case "CallExpr":
267
+ return this.genCall(expr);
268
+ case "NewExpr":
269
+ return `new ${expr.className}(${expr.args.map((a) => this.genExpr(a)).join(", ")})`;
270
+ case "MemberExpr":
271
+ return this.genMember(expr);
272
+ case "IndexExpr":
273
+ return `${this.genExpr(expr.object)}[${this.genExpr(expr.index)}]`;
274
+ case "MatchExpr":
275
+ return this.genMatch(expr);
276
+ case "LambdaExpr":
277
+ return this.genLambda(expr);
278
+ case "AwaitExpr":
279
+ return `await ${this.genExpr(expr.operand)}`;
280
+ case "StateExpr":
281
+ this.usesState = true;
282
+ return `new __KopState(${this.genExpr(expr.initializer)})`;
283
+ }
284
+ }
285
+ genLambda(expr) {
286
+ const params = expr.params.map((p) => p.name).join(", ");
287
+ if (expr.body.kind === "Block") {
288
+ return `(${params}) => ${this.genBlock(expr.body, 0).trimStart()}`;
289
+ }
290
+ return `(${params}) => (${this.genExpr(expr.body)})`;
291
+ }
292
+ genInterpolatedString(expr) {
293
+ const escapeText = (text) => text.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
294
+ const body = expr.parts
295
+ .map((part) => (part.kind === "Text" ? escapeText(part.text) : `\${${this.genExpr(part.expression)}}`))
296
+ .join("");
297
+ return `\`${body}\``;
298
+ }
299
+ genCall(expr) {
300
+ if (expr.callee.kind === "Identifier" && expr.callee.name === "print") {
301
+ return `console.log(${expr.args.map((a) => this.genExpr(a)).join(", ")})`;
302
+ }
303
+ // Array.Push is non-mutating in KopScript, unlike JS's own (mutating,
304
+ // length-returning) Array.prototype.push — so it can't just be a member
305
+ // rename like Map/Filter/ForEach; it needs an entirely different call
306
+ // shape (a spread into a fresh array).
307
+ if (expr.callee.kind === "MemberExpr" && expr.callee.property === "Push" && expr.args.length === 1) {
308
+ return `[...${this.genExpr(expr.callee.object)}, ${this.genExpr(expr.args[0])}]`;
309
+ }
310
+ return `${this.genExpr(expr.callee)}(${expr.args.map((a) => this.genExpr(a)).join(", ")})`;
311
+ }
312
+ genMember(expr) {
313
+ const jsProperty = expr.property === "Length" ? "length" : MEMBER_METHOD_MAP[expr.property] ?? expr.property;
314
+ return `${this.genExpr(expr.object)}.${jsProperty}`;
315
+ }
316
+ genMatch(expr) {
317
+ const subjectVar = `__subject${this.matchCounter++}`;
318
+ const lines = [`(() => {`, ` const ${subjectVar} = ${this.genExpr(expr.subject)};`];
319
+ let emittedIf = false;
320
+ expr.arms.forEach((arm) => {
321
+ if (arm.pattern.kind === "WildcardPattern") {
322
+ lines.push(emittedIf ? ` else {` : ` if (true) {`);
323
+ lines.push(` return ${this.genExpr(arm.result)};`);
324
+ lines.push(` }`);
325
+ emittedIf = true;
326
+ return;
327
+ }
328
+ const condition = arm.pattern.kind === "RegexPattern"
329
+ ? `new RegExp(${JSON.stringify(arm.pattern.source)}).test(${subjectVar})`
330
+ : arm.pattern.values.map((v) => `${subjectVar} === ${this.genExpr(v)}`).join(" || ");
331
+ lines.push(` ${emittedIf ? "else if" : "if"} (${condition}) {`);
332
+ lines.push(` return ${this.genExpr(arm.result)};`);
333
+ lines.push(` }`);
334
+ emittedIf = true;
335
+ });
336
+ lines.push(`})()`);
337
+ return lines.join("\n");
338
+ }
339
+ }
@@ -0,0 +1,26 @@
1
+ export class DiagnosticBag {
2
+ constructor() {
3
+ this.diagnostics = [];
4
+ }
5
+ error(message, line, col) {
6
+ this.diagnostics.push({ severity: "error", message, line, col });
7
+ }
8
+ warning(message, line, col) {
9
+ this.diagnostics.push({ severity: "warning", message, line, col });
10
+ }
11
+ get hasErrors() {
12
+ return this.diagnostics.some((d) => d.severity === "error");
13
+ }
14
+ format(source, fileName) {
15
+ const lines = source.split("\n");
16
+ return this.diagnostics
17
+ .map((d) => {
18
+ const sourceLine = lines[d.line - 1] ?? "";
19
+ const pointer = " ".repeat(Math.max(0, d.col - 1)) + "^";
20
+ return (`${fileName}:${d.line}:${d.col} - ${d.severity}: ${d.message}\n` +
21
+ ` ${sourceLine}\n` +
22
+ ` ${pointer}`);
23
+ })
24
+ .join("\n\n");
25
+ }
26
+ }