kopscript 0.1.0 → 0.3.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 +377 -0
- package/README.md +111 -22
- package/assets/logo.png +0 -0
- package/dist/checker.js +194 -20
- package/dist/cli.js +85 -16
- package/dist/codegen.js +9 -1
- package/dist/lexer.js +4 -0
- package/dist/modules.js +6 -3
- package/dist/parser.js +78 -18
- package/dist/tokens.js +3 -0
- package/dist/types.js +31 -16
- package/package.json +4 -2
package/dist/checker.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
1
3
|
import * as T from "./types.js";
|
|
2
4
|
export function emptyModuleExports() {
|
|
3
5
|
return { namedTypes: new Map(), classes: new Map(), interfaces: new Map(), enums: new Map(), functions: new Map(), externValues: new Map() };
|
|
@@ -16,6 +18,12 @@ class Scope {
|
|
|
16
18
|
constructor(parent = null) {
|
|
17
19
|
this.parent = parent;
|
|
18
20
|
this.vars = new Map();
|
|
21
|
+
// Names known non-null *in this scope specifically* — an overlay checked
|
|
22
|
+
// independently of where a name was actually declared (which can be many
|
|
23
|
+
// scopes up), set only on the narrow child scope an `if (x != null)`
|
|
24
|
+
// branch (or `&&`/`||` short-circuit) is checked in. See
|
|
25
|
+
// Checker.detectNullCheck/narrowedChild.
|
|
26
|
+
this.narrowed = new Set();
|
|
19
27
|
}
|
|
20
28
|
declare(name, type, isConst) {
|
|
21
29
|
this.vars.set(name, { type, isConst });
|
|
@@ -23,15 +31,25 @@ class Scope {
|
|
|
23
31
|
resolve(name) {
|
|
24
32
|
return this.vars.get(name) ?? this.parent?.resolve(name) ?? null;
|
|
25
33
|
}
|
|
34
|
+
narrowNonNull(name) {
|
|
35
|
+
this.narrowed.add(name);
|
|
36
|
+
}
|
|
37
|
+
isNarrowedNonNull(name) {
|
|
38
|
+
return this.narrowed.has(name) || (this.parent?.isNarrowedNonNull(name) ?? false);
|
|
39
|
+
}
|
|
26
40
|
child() {
|
|
27
41
|
return new Scope(this);
|
|
28
42
|
}
|
|
29
43
|
}
|
|
30
44
|
export class Checker {
|
|
31
|
-
|
|
45
|
+
recordHover(line, col, text) {
|
|
46
|
+
this.hoverEntries.push({ line, col, text });
|
|
47
|
+
}
|
|
48
|
+
constructor(program, diagnostics, imports = emptyModuleExports(), currentFilePath = "test.ks") {
|
|
32
49
|
this.program = program;
|
|
33
50
|
this.diagnostics = diagnostics;
|
|
34
51
|
this.imports = imports;
|
|
52
|
+
this.currentFilePath = currentFilePath;
|
|
35
53
|
this.classes = new Map();
|
|
36
54
|
this.interfaces = new Map();
|
|
37
55
|
this.enums = new Map();
|
|
@@ -39,6 +57,8 @@ export class Checker {
|
|
|
39
57
|
this.externValues = new Map();
|
|
40
58
|
this.namedTypes = new Map();
|
|
41
59
|
this.importedNames = new Set();
|
|
60
|
+
this.rawContents = new Map();
|
|
61
|
+
this.hoverEntries = [];
|
|
42
62
|
}
|
|
43
63
|
check() {
|
|
44
64
|
for (const [name, kind] of this.imports.namedTypes) {
|
|
@@ -66,7 +86,8 @@ export class Checker {
|
|
|
66
86
|
const externFunctionDecls = this.program.statements.filter((s) => s.kind === "ExternFunctionDecl");
|
|
67
87
|
const externClassDecls = this.program.statements.filter((s) => s.kind === "ExternClassDecl");
|
|
68
88
|
const externValueDecls = this.program.statements.filter((s) => s.kind === "ExternValueDecl");
|
|
69
|
-
this.
|
|
89
|
+
const rawStringDecls = this.program.statements.filter((s) => s.kind === "RawStringDecl");
|
|
90
|
+
this.checkNoDuplicateTopLevelNames(classDecls, interfaceDecls, enumDecls, functionDecls, externFunctionDecls, externClassDecls, externValueDecls, rawStringDecls);
|
|
70
91
|
for (const c of classDecls)
|
|
71
92
|
this.namedTypes.set(c.name, "class");
|
|
72
93
|
for (const i of interfaceDecls)
|
|
@@ -105,6 +126,8 @@ export class Checker {
|
|
|
105
126
|
this.registerExternFunction(f);
|
|
106
127
|
for (const v of externValueDecls)
|
|
107
128
|
this.registerExternValue(v);
|
|
129
|
+
for (const r of rawStringDecls)
|
|
130
|
+
this.registerRawStringDecl(r);
|
|
108
131
|
const globalScope = new Scope();
|
|
109
132
|
for (const stmt of this.program.statements) {
|
|
110
133
|
if (stmt.kind === "ClassDecl" ||
|
|
@@ -112,7 +135,8 @@ export class Checker {
|
|
|
112
135
|
stmt.kind === "EnumDecl" ||
|
|
113
136
|
stmt.kind === "ExternFunctionDecl" ||
|
|
114
137
|
stmt.kind === "ExternClassDecl" ||
|
|
115
|
-
stmt.kind === "ExternValueDecl"
|
|
138
|
+
stmt.kind === "ExternValueDecl" ||
|
|
139
|
+
stmt.kind === "RawStringDecl") {
|
|
116
140
|
continue;
|
|
117
141
|
}
|
|
118
142
|
this.checkTopLevelStatement(stmt, globalScope);
|
|
@@ -160,6 +184,25 @@ export class Checker {
|
|
|
160
184
|
registerExternValue(decl) {
|
|
161
185
|
this.externValues.set(decl.name, this.resolveType(decl.type, decl.line, decl.col));
|
|
162
186
|
}
|
|
187
|
+
// `raw string <Name> from "<path>";` — resolves <path> relative to this
|
|
188
|
+
// module's own file, reads it at compile time, and types <Name> as an
|
|
189
|
+
// ordinary string identifier via the same externValues table an ambient
|
|
190
|
+
// extern value uses (free same-file resolution, free export propagation).
|
|
191
|
+
registerRawStringDecl(decl) {
|
|
192
|
+
this.externValues.set(decl.name, T.STRING);
|
|
193
|
+
const resolvedPath = resolve(dirname(this.currentFilePath), decl.path);
|
|
194
|
+
if (!existsSync(resolvedPath)) {
|
|
195
|
+
this.diagnostics.error(`Cannot find file '${decl.path}' referenced by 'raw string ${decl.name}' (looked for '${resolvedPath}')`, decl.line, decl.col);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.rawContents.set(decl.name, readFileSync(resolvedPath, "utf-8"));
|
|
199
|
+
}
|
|
200
|
+
// The raw-declared string contents resolved by this module's own `raw`
|
|
201
|
+
// declarations (not inherited from imports). Call only after check() has
|
|
202
|
+
// run; codegen uses this to emit each declaring file's string constants.
|
|
203
|
+
getRawContents() {
|
|
204
|
+
return this.rawContents;
|
|
205
|
+
}
|
|
163
206
|
// The subset of this module's registered declarations visible to a file
|
|
164
207
|
// that `using`s it. Call only after check() has run.
|
|
165
208
|
getExports() {
|
|
@@ -190,10 +233,13 @@ export class Checker {
|
|
|
190
233
|
else if (stmt.kind === "ExternValueDecl" && stmt.isExported) {
|
|
191
234
|
exports.externValues.set(stmt.name, this.externValues.get(stmt.name));
|
|
192
235
|
}
|
|
236
|
+
else if (stmt.kind === "RawStringDecl" && stmt.isExported) {
|
|
237
|
+
exports.externValues.set(stmt.name, this.externValues.get(stmt.name));
|
|
238
|
+
}
|
|
193
239
|
}
|
|
194
240
|
return exports;
|
|
195
241
|
}
|
|
196
|
-
checkNoDuplicateTopLevelNames(classDecls, interfaceDecls, enumDecls, functionDecls, externFunctionDecls, externClassDecls, externValueDecls) {
|
|
242
|
+
checkNoDuplicateTopLevelNames(classDecls, interfaceDecls, enumDecls, functionDecls, externFunctionDecls, externClassDecls, externValueDecls, rawStringDecls) {
|
|
197
243
|
const seen = new Map();
|
|
198
244
|
const declare = (name, line, col) => {
|
|
199
245
|
if (this.importedNames.has(name)) {
|
|
@@ -218,6 +264,8 @@ export class Checker {
|
|
|
218
264
|
declare(c.name, c.line, c.col);
|
|
219
265
|
for (const v of externValueDecls)
|
|
220
266
|
declare(v.name, v.line, v.col);
|
|
267
|
+
for (const r of rawStringDecls)
|
|
268
|
+
declare(r.name, r.line, r.col);
|
|
221
269
|
}
|
|
222
270
|
// An exported class's superclass and directly-implemented interfaces must
|
|
223
271
|
// also be exported — otherwise a file that imports this class would have
|
|
@@ -259,7 +307,9 @@ export class Checker {
|
|
|
259
307
|
}
|
|
260
308
|
// ---------- registration ----------
|
|
261
309
|
resolveType(node, line, col) {
|
|
262
|
-
const resolved = T.resolveTypeNode(node, this.namedTypes)
|
|
310
|
+
const resolved = T.resolveTypeNode(node, this.namedTypes, (namedNode, type) => {
|
|
311
|
+
this.recordHover(namedNode.line, namedNode.col, T.typeToString(type));
|
|
312
|
+
});
|
|
263
313
|
if (!resolved) {
|
|
264
314
|
const name = node.kind === "NamedType" ? node.name : "[]";
|
|
265
315
|
this.diagnostics.error(`Unknown type '${name}'`, line, col);
|
|
@@ -277,13 +327,15 @@ export class Checker {
|
|
|
277
327
|
members.set(name, index);
|
|
278
328
|
});
|
|
279
329
|
this.enums.set(decl.name, { name: decl.name, members });
|
|
330
|
+
this.recordHover(decl.line, decl.col, `enum ${decl.name}`);
|
|
280
331
|
}
|
|
281
332
|
registerInterface(decl) {
|
|
282
|
-
const methods = decl.methods.map((m) =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
333
|
+
const methods = decl.methods.map((m) => {
|
|
334
|
+
const params = m.params.map((p) => this.resolveType(p.type, m.line, m.col));
|
|
335
|
+
const returnType = this.resolveType(m.returnType, m.line, m.col);
|
|
336
|
+
this.recordHover(m.nameLine, m.nameCol, `${m.name}(${params.map(T.typeToString).join(", ")}): ${T.typeToString(returnType)}`);
|
|
337
|
+
return { name: m.name, params, returnType };
|
|
338
|
+
});
|
|
287
339
|
const bases = [];
|
|
288
340
|
for (const baseName of decl.baseList) {
|
|
289
341
|
if (this.namedTypes.get(baseName) !== "interface") {
|
|
@@ -293,6 +345,7 @@ export class Checker {
|
|
|
293
345
|
bases.push(baseName);
|
|
294
346
|
}
|
|
295
347
|
this.interfaces.set(decl.name, { name: decl.name, bases, methods });
|
|
348
|
+
this.recordHover(decl.line, decl.col, `interface ${decl.name}`);
|
|
296
349
|
}
|
|
297
350
|
checkInterfaceHierarchy(decl) {
|
|
298
351
|
const info = this.interfaces.get(decl.name);
|
|
@@ -335,14 +388,18 @@ export class Checker {
|
|
|
335
388
|
return info.bases.some((b) => this.interfaceExtends(b, sup));
|
|
336
389
|
}
|
|
337
390
|
registerClass(decl) {
|
|
391
|
+
this.recordHover(decl.line, decl.col, `class ${decl.name}`);
|
|
338
392
|
const fields = new Map();
|
|
339
393
|
const staticFields = new Map();
|
|
340
394
|
for (const f of decl.fields) {
|
|
341
395
|
const info = { type: this.resolveType(f.type, f.line, f.col), visibility: f.visibility, hasSetter: true };
|
|
396
|
+
this.recordHover(f.nameLine, f.nameCol, `${f.name}: ${T.typeToString(info.type)}`);
|
|
342
397
|
(f.isStatic ? staticFields : fields).set(f.name, info);
|
|
343
398
|
}
|
|
344
399
|
for (const p of decl.properties) {
|
|
345
|
-
|
|
400
|
+
const type = this.resolveType(p.type, p.line, p.col);
|
|
401
|
+
this.recordHover(p.nameLine, p.nameCol, `${p.name}: ${T.typeToString(type)}`);
|
|
402
|
+
fields.set(p.name, { type, visibility: p.visibility, hasSetter: p.hasSetter });
|
|
346
403
|
}
|
|
347
404
|
const methods = new Map();
|
|
348
405
|
const staticMethods = new Map();
|
|
@@ -354,6 +411,7 @@ export class Checker {
|
|
|
354
411
|
isVirtual: m.isVirtual,
|
|
355
412
|
isOverride: m.isOverride,
|
|
356
413
|
};
|
|
414
|
+
this.recordHover(m.nameLine, m.nameCol, `${m.name}(${info.params.map(T.typeToString).join(", ")}): ${T.typeToString(info.returnType)}`);
|
|
357
415
|
(m.isStatic ? staticMethods : methods).set(m.name, info);
|
|
358
416
|
}
|
|
359
417
|
const ownCtorParams = decl.constructor
|
|
@@ -483,10 +541,10 @@ export class Checker {
|
|
|
483
541
|
}
|
|
484
542
|
}
|
|
485
543
|
registerFunction(decl) {
|
|
486
|
-
this.
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
});
|
|
544
|
+
const params = decl.params.map((p) => this.resolveType(p.type, decl.line, decl.col));
|
|
545
|
+
const returnType = this.resolveType(decl.returnType, decl.line, decl.col);
|
|
546
|
+
this.functions.set(decl.name, { params, returnType });
|
|
547
|
+
this.recordHover(decl.line, decl.col, `function ${decl.name}(${params.map(T.typeToString).join(", ")}): ${T.typeToString(returnType)}`);
|
|
490
548
|
}
|
|
491
549
|
isSubclass(sub, sup) {
|
|
492
550
|
let current = sub;
|
|
@@ -580,6 +638,16 @@ export class Checker {
|
|
|
580
638
|
isAssignableType(from, to) {
|
|
581
639
|
if (from.kind === "unknown" || to.kind === "unknown")
|
|
582
640
|
return true;
|
|
641
|
+
// Widening: a non-null T (or another T?) is always fine where T? is
|
|
642
|
+
// expected. The reverse — a possibly-null value where a non-null type
|
|
643
|
+
// is expected — is never allowed here; that's exactly what narrowing
|
|
644
|
+
// (see detectNullCheck) exists to get past by changing `from` itself
|
|
645
|
+
// at the reference site, not by relaxing this rule.
|
|
646
|
+
if (to.kind === "nullable") {
|
|
647
|
+
return from.kind === "nullable" ? this.isAssignableType(from.inner, to.inner) : this.isAssignableType(from, to.inner);
|
|
648
|
+
}
|
|
649
|
+
if (from.kind === "nullable")
|
|
650
|
+
return false;
|
|
583
651
|
if (to.kind === "interface") {
|
|
584
652
|
if (from.kind === "class")
|
|
585
653
|
return this.classImplementsInterface(from.name, to.name);
|
|
@@ -691,6 +759,7 @@ export class Checker {
|
|
|
691
759
|
switch (stmt.kind) {
|
|
692
760
|
case "VarDecl": {
|
|
693
761
|
const declaredType = this.resolveType(stmt.type, stmt.line, stmt.col);
|
|
762
|
+
this.recordHover(stmt.nameLine, stmt.nameCol, `${stmt.isConst ? "const" : "let"} ${stmt.name}: ${T.typeToString(declaredType)}`);
|
|
694
763
|
const initType = this.checkExpressionExpecting(stmt.init, declaredType, scope, ctx);
|
|
695
764
|
if (!this.isAssignableType(initType, declaredType)) {
|
|
696
765
|
this.diagnostics.error(`Cannot assign value of type '${T.typeToString(initType)}' to variable of type '${T.typeToString(declaredType)}'`, stmt.line, stmt.col);
|
|
@@ -704,12 +773,19 @@ export class Checker {
|
|
|
704
773
|
case "IfStatement": {
|
|
705
774
|
const condType = this.checkExpression(stmt.condition, scope, ctx);
|
|
706
775
|
this.expectType(condType, T.BOOL, stmt.line, stmt.col, "if condition");
|
|
707
|
-
|
|
776
|
+
// `if (x != null)` narrows x to non-null in the then-branch;
|
|
777
|
+
// `if (x == null) ... else ...` narrows it in the else-branch —
|
|
778
|
+
// see detectNullCheck for exactly what's recognized (bare
|
|
779
|
+
// identifier vs. `null`, either operand order, `==`/`!=` only).
|
|
780
|
+
const nullCheck = this.detectNullCheck(stmt.condition);
|
|
781
|
+
const thenScope = nullCheck?.positiveWhenTrue ? this.narrowedChild(scope, nullCheck.name) : scope;
|
|
782
|
+
const elseScope = nullCheck && !nullCheck.positiveWhenTrue ? this.narrowedChild(scope, nullCheck.name) : scope;
|
|
783
|
+
this.checkBlock(stmt.thenBranch, thenScope, ctx);
|
|
708
784
|
if (stmt.elseBranch) {
|
|
709
785
|
if (stmt.elseBranch.kind === "IfStatement")
|
|
710
|
-
this.checkStatement(stmt.elseBranch,
|
|
786
|
+
this.checkStatement(stmt.elseBranch, elseScope, ctx);
|
|
711
787
|
else
|
|
712
|
-
this.checkBlock(stmt.elseBranch,
|
|
788
|
+
this.checkBlock(stmt.elseBranch, elseScope, ctx);
|
|
713
789
|
}
|
|
714
790
|
return;
|
|
715
791
|
}
|
|
@@ -802,6 +878,9 @@ export class Checker {
|
|
|
802
878
|
case "ExternValueDecl":
|
|
803
879
|
this.diagnostics.error(`'extern' declarations are only allowed at the top level of a file`, stmt.line, stmt.col);
|
|
804
880
|
return;
|
|
881
|
+
case "RawStringDecl":
|
|
882
|
+
this.diagnostics.error(`'raw' declarations are only allowed at the top level of a file`, stmt.line, stmt.col);
|
|
883
|
+
return;
|
|
805
884
|
}
|
|
806
885
|
}
|
|
807
886
|
expectType(actual, expected, line, col, context) {
|
|
@@ -809,8 +888,41 @@ export class Checker {
|
|
|
809
888
|
this.diagnostics.error(`Expected type '${T.typeToString(expected)}' for ${context}, got '${T.typeToString(actual)}'`, line, col);
|
|
810
889
|
}
|
|
811
890
|
}
|
|
891
|
+
// Recognizes `name != null` / `name == null` (either operand order) as a
|
|
892
|
+
// null-check on a bare identifier — the only shape narrowing understands.
|
|
893
|
+
// `positiveWhenTrue: true` means the condition being true implies `name`
|
|
894
|
+
// is non-null (`!=`); `false` means the condition being *false* implies
|
|
895
|
+
// that (`==`). Deliberately narrow: no `this.Field != null`, no `&&`-
|
|
896
|
+
// chains inside the checked expression itself, no reachability analysis
|
|
897
|
+
// for an early-return guard clause (`if (x == null) { return; }` doesn't
|
|
898
|
+
// narrow `x` afterward) — each would need tracking narrowing by path or
|
|
899
|
+
// by control-flow reachability rather than by scope, real additional
|
|
900
|
+
// machinery this v1 doesn't take on.
|
|
901
|
+
detectNullCheck(condition) {
|
|
902
|
+
if (condition.kind !== "BinaryExpr" || (condition.op !== "==" && condition.op !== "!="))
|
|
903
|
+
return null;
|
|
904
|
+
const { left, right } = condition;
|
|
905
|
+
const ident = left.kind === "Identifier" ? left : right.kind === "Identifier" ? right : null;
|
|
906
|
+
const other = ident === left ? right : left;
|
|
907
|
+
if (!ident || other.kind !== "NullLiteral")
|
|
908
|
+
return null;
|
|
909
|
+
return { name: ident.name, positiveWhenTrue: condition.op === "!=" };
|
|
910
|
+
}
|
|
911
|
+
narrowedChild(scope, name) {
|
|
912
|
+
const child = scope.child();
|
|
913
|
+
child.narrowNonNull(name);
|
|
914
|
+
return child;
|
|
915
|
+
}
|
|
812
916
|
// ---------- expressions ----------
|
|
813
917
|
checkExpression(expr, scope, ctx) {
|
|
918
|
+
const type = this.checkExpressionInner(expr, scope, ctx);
|
|
919
|
+
if (expr.kind === "Identifier")
|
|
920
|
+
this.recordHover(expr.line, expr.col, `${expr.name}: ${T.typeToString(type)}`);
|
|
921
|
+
if (expr.kind === "ThisExpr")
|
|
922
|
+
this.recordHover(expr.line, expr.col, `this: ${T.typeToString(type)}`);
|
|
923
|
+
return type;
|
|
924
|
+
}
|
|
925
|
+
checkExpressionInner(expr, scope, ctx) {
|
|
814
926
|
switch (expr.kind) {
|
|
815
927
|
case "NumberLiteral":
|
|
816
928
|
return T.NUMBER;
|
|
@@ -818,6 +930,15 @@ export class Checker {
|
|
|
818
930
|
return T.STRING;
|
|
819
931
|
case "BoolLiteral":
|
|
820
932
|
return T.BOOL;
|
|
933
|
+
// Modeled as UNKNOWN rather than a dedicated Type — typesEqual/
|
|
934
|
+
// isAssignableType already treat UNKNOWN as compatible with anything,
|
|
935
|
+
// which is exactly right for a bare `null` with no expected type
|
|
936
|
+
// (e.g. `x == null`). The real enforcement (null only assignable
|
|
937
|
+
// where a nullable type is actually expected) lives in
|
|
938
|
+
// checkExpressionExpecting below, the same place LambdaExpr gets its
|
|
939
|
+
// expected-type-aware handling.
|
|
940
|
+
case "NullLiteral":
|
|
941
|
+
return T.UNKNOWN;
|
|
821
942
|
case "InterpolatedStringLiteral":
|
|
822
943
|
for (const part of expr.parts) {
|
|
823
944
|
if (part.kind === "Expr")
|
|
@@ -839,8 +960,16 @@ export class Checker {
|
|
|
839
960
|
}
|
|
840
961
|
case "Identifier": {
|
|
841
962
|
const found = scope.resolve(expr.name);
|
|
842
|
-
if (found)
|
|
963
|
+
if (found) {
|
|
964
|
+
// A nullable local/param narrowed by an enclosing `if (x != null)`
|
|
965
|
+
// (or equivalent — see detectNullCheck) type-checks as its
|
|
966
|
+
// non-null inner type at this specific reference, without
|
|
967
|
+
// changing what's actually declared. Narrowing only applies to
|
|
968
|
+
// bare names, not member-access paths like `this.Field`.
|
|
969
|
+
if (found.type.kind === "nullable" && scope.isNarrowedNonNull(expr.name))
|
|
970
|
+
return found.type.inner;
|
|
843
971
|
return found.type;
|
|
972
|
+
}
|
|
844
973
|
const externValue = this.externValues.get(expr.name);
|
|
845
974
|
if (externValue)
|
|
846
975
|
return externValue;
|
|
@@ -875,7 +1004,14 @@ export class Checker {
|
|
|
875
1004
|
}
|
|
876
1005
|
case "LogicalExpr": {
|
|
877
1006
|
const leftType = this.checkExpression(expr.left, scope, ctx);
|
|
878
|
-
|
|
1007
|
+
// `x != null && x.Foo` narrows x for the right operand (only
|
|
1008
|
+
// reached once the left side is true, i.e. x is non-null); by De
|
|
1009
|
+
// Morgan's, `x == null || x.Foo` narrows it the same way (the
|
|
1010
|
+
// right side only runs once the left is false, i.e. x is non-null).
|
|
1011
|
+
const nullCheck = this.detectNullCheck(expr.left);
|
|
1012
|
+
const narrowRight = nullCheck && ((expr.op === "&&" && nullCheck.positiveWhenTrue) || (expr.op === "||" && !nullCheck.positiveWhenTrue));
|
|
1013
|
+
const rightScope = narrowRight ? this.narrowedChild(scope, nullCheck.name) : scope;
|
|
1014
|
+
const rightType = this.checkExpression(expr.right, rightScope, ctx);
|
|
879
1015
|
this.expectType(leftType, T.BOOL, expr.line, expr.col, `'${expr.op}' operand`);
|
|
880
1016
|
this.expectType(rightType, T.BOOL, expr.line, expr.col, `'${expr.op}' operand`);
|
|
881
1017
|
return T.BOOL;
|
|
@@ -953,6 +1089,13 @@ export class Checker {
|
|
|
953
1089
|
checkExpressionExpecting(expr, expected, scope, ctx) {
|
|
954
1090
|
if (expr.kind === "LambdaExpr")
|
|
955
1091
|
return this.checkLambda(expr, expected, scope, ctx);
|
|
1092
|
+
if (expr.kind === "NullLiteral") {
|
|
1093
|
+
if (expected.kind !== "nullable" && expected.kind !== "unknown") {
|
|
1094
|
+
this.diagnostics.error(`Cannot assign 'null' to non-nullable type '${T.typeToString(expected)}'`, expr.line, expr.col);
|
|
1095
|
+
return T.UNKNOWN;
|
|
1096
|
+
}
|
|
1097
|
+
return expected;
|
|
1098
|
+
}
|
|
956
1099
|
return this.checkExpression(expr, scope, ctx);
|
|
957
1100
|
}
|
|
958
1101
|
checkLambda(expr, expected, scope, ctx) {
|
|
@@ -1030,6 +1173,21 @@ export class Checker {
|
|
|
1030
1173
|
return T.NUMBER;
|
|
1031
1174
|
}
|
|
1032
1175
|
if (op === "==" || op === "!=") {
|
|
1176
|
+
// A literal `null` type-checks as UNKNOWN with no expected type (see
|
|
1177
|
+
// checkExpressionInner), which typesEqual would happily accept
|
|
1178
|
+
// against anything — including a type that can never actually be
|
|
1179
|
+
// null. Checked here, against the AST node itself, specifically so
|
|
1180
|
+
// `nonNullableThing == null` (almost always a leftover from before a
|
|
1181
|
+
// type was made non-nullable, or a copy-pasted guard that can't
|
|
1182
|
+
// trigger) is a compile error instead of a silently-always-false comparison.
|
|
1183
|
+
const nullSide = expr.left.kind === "NullLiteral" ? "left" : expr.right.kind === "NullLiteral" ? "right" : null;
|
|
1184
|
+
if (nullSide) {
|
|
1185
|
+
const otherType = nullSide === "left" ? rightType : leftType;
|
|
1186
|
+
if (otherType.kind !== "nullable" && otherType.kind !== "unknown") {
|
|
1187
|
+
this.diagnostics.error(`Type '${T.typeToString(otherType)}' can never be null — only a nullable type (e.g. '${T.typeToString(otherType)}?') can be compared to 'null'`, line, col);
|
|
1188
|
+
}
|
|
1189
|
+
return T.BOOL;
|
|
1190
|
+
}
|
|
1033
1191
|
if (!T.typesEqual(leftType, rightType)) {
|
|
1034
1192
|
this.diagnostics.error(`Cannot compare '${T.typeToString(leftType)}' with '${T.typeToString(rightType)}'`, line, col);
|
|
1035
1193
|
}
|
|
@@ -1056,6 +1214,7 @@ export class Checker {
|
|
|
1056
1214
|
expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
|
|
1057
1215
|
return T.UNKNOWN;
|
|
1058
1216
|
}
|
|
1217
|
+
this.recordHover(expr.callee.line, expr.callee.col, `function ${expr.callee.name}(${info.params.map(T.typeToString).join(", ")}): ${T.typeToString(info.returnType)}`);
|
|
1059
1218
|
this.checkArgs(expr, info.params, scope, ctx);
|
|
1060
1219
|
return info.returnType;
|
|
1061
1220
|
}
|
|
@@ -1124,6 +1283,7 @@ export class Checker {
|
|
|
1124
1283
|
expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
|
|
1125
1284
|
return T.UNKNOWN;
|
|
1126
1285
|
}
|
|
1286
|
+
this.recordHover(expr.line, expr.col, `class ${expr.className}`);
|
|
1127
1287
|
const ctorParams = this.lookupCtorParams(expr.className);
|
|
1128
1288
|
if (expr.args.length !== ctorParams.length) {
|
|
1129
1289
|
this.diagnostics.error(`Expected ${ctorParams.length} constructor argument(s), got ${expr.args.length}`, expr.line, expr.col);
|
|
@@ -1196,11 +1356,20 @@ export class Checker {
|
|
|
1196
1356
|
return T.arrayOf(argType.returnType);
|
|
1197
1357
|
}
|
|
1198
1358
|
checkMember(expr, scope, ctx, isAssignTarget = false) {
|
|
1359
|
+
const result = this.checkMemberInner(expr, scope, ctx, isAssignTarget);
|
|
1360
|
+
const text = result.methodInfo
|
|
1361
|
+
? `${expr.property}(${result.methodInfo.params.map(T.typeToString).join(", ")}): ${T.typeToString(result.methodInfo.returnType)}`
|
|
1362
|
+
: `${expr.property}: ${T.typeToString(result.type)}`;
|
|
1363
|
+
this.recordHover(expr.line, expr.col, text);
|
|
1364
|
+
return result;
|
|
1365
|
+
}
|
|
1366
|
+
checkMemberInner(expr, scope, ctx, isAssignTarget) {
|
|
1199
1367
|
// A bare type name as the "object" — Color.Red (enum) or Dog.Count (static) —
|
|
1200
1368
|
// is a type reference, not a value, so it's handled before the general expression check.
|
|
1201
1369
|
if (expr.object.kind === "Identifier" && !scope.resolve(expr.object.name)) {
|
|
1202
1370
|
const objName = expr.object.name;
|
|
1203
1371
|
if (this.enums.has(objName)) {
|
|
1372
|
+
this.recordHover(expr.object.line, expr.object.col, `enum ${objName}`);
|
|
1204
1373
|
const enumInfo = this.enums.get(objName);
|
|
1205
1374
|
if (!enumInfo.members.has(expr.property)) {
|
|
1206
1375
|
this.diagnostics.error(`Enum '${objName}' has no member '${expr.property}'`, expr.line, expr.col);
|
|
@@ -1209,6 +1378,7 @@ export class Checker {
|
|
|
1209
1378
|
return { type: T.enumType(objName), methodInfo: null };
|
|
1210
1379
|
}
|
|
1211
1380
|
if (this.classes.has(objName)) {
|
|
1381
|
+
this.recordHover(expr.object.line, expr.object.col, `class ${objName}`);
|
|
1212
1382
|
const field = this.lookupStaticField(objName, expr.property);
|
|
1213
1383
|
if (field) {
|
|
1214
1384
|
this.checkAccessibility(field.info.visibility, field.owner, ctx, expr.property, expr.line, expr.col);
|
|
@@ -1224,6 +1394,10 @@ export class Checker {
|
|
|
1224
1394
|
}
|
|
1225
1395
|
}
|
|
1226
1396
|
const objectType = this.checkExpression(expr.object, scope, ctx);
|
|
1397
|
+
if (objectType.kind === "nullable") {
|
|
1398
|
+
this.diagnostics.error(`Cannot access member '${expr.property}' on possibly-null type '${T.typeToString(objectType)}' — check for null first (e.g. 'if (x != null) { ... }')`, expr.line, expr.col);
|
|
1399
|
+
return { type: T.UNKNOWN, methodInfo: null };
|
|
1400
|
+
}
|
|
1227
1401
|
if (objectType.kind === "string") {
|
|
1228
1402
|
if (expr.property === "Length")
|
|
1229
1403
|
return { type: T.NUMBER, methodInfo: null };
|
package/dist/cli.js
CHANGED
|
@@ -7,31 +7,89 @@ function outputPathFor(filePath) {
|
|
|
7
7
|
const name = basename(filePath, ".ks");
|
|
8
8
|
return join(resolve(filePath, ".."), `${name}.js`);
|
|
9
9
|
}
|
|
10
|
+
function collectDiagnostics(result) {
|
|
11
|
+
const all = [];
|
|
12
|
+
for (const absPath of result.order) {
|
|
13
|
+
const mod = result.modules.get(absPath);
|
|
14
|
+
for (const d of mod.diagnostics.diagnostics)
|
|
15
|
+
all.push({ ...d, file: absPath });
|
|
16
|
+
}
|
|
17
|
+
return all;
|
|
18
|
+
}
|
|
19
|
+
// One JSON object per invocation, always to stdout (never split across
|
|
20
|
+
// lines, never mixed with human-readable output) — so a caller can pipe
|
|
21
|
+
// this straight into `JSON.parse` without worrying about interleaving.
|
|
22
|
+
function printJson(result, written) {
|
|
23
|
+
const diagnostics = collectDiagnostics(result);
|
|
24
|
+
console.log(JSON.stringify({ success: result.success, diagnostics, written }, null, 2));
|
|
25
|
+
}
|
|
26
|
+
function printJsonEntryMissing(filePath) {
|
|
27
|
+
console.log(JSON.stringify({ success: false, diagnostics: [], written: [], error: `cannot find file '${filePath}'` }, null, 2));
|
|
28
|
+
}
|
|
29
|
+
function printHumanDiagnostics(result) {
|
|
30
|
+
for (const absPath of result.order) {
|
|
31
|
+
const mod = result.modules.get(absPath);
|
|
32
|
+
if (mod.diagnostics.hasErrors) {
|
|
33
|
+
console.error(mod.diagnostics.format(mod.source, absPath));
|
|
34
|
+
console.error("");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
10
38
|
// Compiles the whole module graph reachable from `filePath`, writing one
|
|
11
|
-
// .js file next to each .ks source.
|
|
12
|
-
|
|
39
|
+
// .js file next to each .ks source. With `jsonMode`, nothing but a single
|
|
40
|
+
// JSON object goes to stdout (no "Wrote ..." line, no human diagnostics) —
|
|
41
|
+
// see `printJson` for the shape.
|
|
42
|
+
function build(filePath, jsonMode = false) {
|
|
13
43
|
const result = compileGraph(filePath);
|
|
14
44
|
if (result.entryMissing) {
|
|
15
|
-
|
|
45
|
+
if (jsonMode)
|
|
46
|
+
printJsonEntryMissing(filePath);
|
|
47
|
+
else
|
|
48
|
+
console.error(`ks: cannot find file '${filePath}'`);
|
|
16
49
|
process.exitCode = 1;
|
|
17
50
|
return { outPath: null, watchFiles: [] };
|
|
18
51
|
}
|
|
19
52
|
if (!result.success) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
console.error("");
|
|
25
|
-
}
|
|
26
|
-
}
|
|
53
|
+
if (jsonMode)
|
|
54
|
+
printJson(result, []);
|
|
55
|
+
else
|
|
56
|
+
printHumanDiagnostics(result);
|
|
27
57
|
process.exitCode = 1;
|
|
28
58
|
return { outPath: null, watchFiles: result.order };
|
|
29
59
|
}
|
|
30
60
|
for (const absPath of result.order) {
|
|
31
61
|
writeFileSync(outputPathFor(absPath), result.outputs.get(absPath), "utf-8");
|
|
32
62
|
}
|
|
63
|
+
if (jsonMode)
|
|
64
|
+
printJson(result, result.order.map(outputPathFor));
|
|
33
65
|
return { outPath: outputPathFor(filePath), watchFiles: result.order };
|
|
34
66
|
}
|
|
67
|
+
// Type-checks the graph without writing any output — for CI or an editor/
|
|
68
|
+
// agent that wants pass/fail plus diagnostics without touching the
|
|
69
|
+
// filesystem. Same exit-code convention as `build` (0 clean, 1 on any
|
|
70
|
+
// error or a missing entry file).
|
|
71
|
+
function checkCommand(filePath, jsonMode) {
|
|
72
|
+
const result = compileGraph(filePath);
|
|
73
|
+
if (result.entryMissing) {
|
|
74
|
+
if (jsonMode)
|
|
75
|
+
printJsonEntryMissing(filePath);
|
|
76
|
+
else
|
|
77
|
+
console.error(`ks: cannot find file '${filePath}'`);
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (jsonMode) {
|
|
82
|
+
printJson(result, []);
|
|
83
|
+
}
|
|
84
|
+
else if (result.success) {
|
|
85
|
+
console.log("No errors.");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
printHumanDiagnostics(result);
|
|
89
|
+
}
|
|
90
|
+
if (!result.success)
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
}
|
|
35
93
|
function run(filePath) {
|
|
36
94
|
const { outPath } = build(filePath);
|
|
37
95
|
if (!outPath)
|
|
@@ -77,17 +135,28 @@ function watchCommand(filePath) {
|
|
|
77
135
|
console.log(`[watch] ${filePath} — watching for changes. Press Ctrl+C to stop.`);
|
|
78
136
|
rebuild();
|
|
79
137
|
}
|
|
138
|
+
const COMMANDS = ["build", "run", "watch", "check"];
|
|
80
139
|
function main() {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
const args = process.argv.slice(2);
|
|
141
|
+
const jsonMode = args.includes("--json");
|
|
142
|
+
const [command, file] = args.filter((a) => a !== "--json");
|
|
143
|
+
if (!command || !file || !COMMANDS.includes(command)) {
|
|
144
|
+
console.error("Usage: ks <build|run|watch|check> <file.ks> [--json]");
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (jsonMode && command !== "build" && command !== "check") {
|
|
149
|
+
console.error("ks: --json is only supported with 'build' and 'check'");
|
|
84
150
|
process.exitCode = 1;
|
|
85
151
|
return;
|
|
86
152
|
}
|
|
87
153
|
const filePath = resolve(file);
|
|
88
|
-
if (command === "
|
|
89
|
-
|
|
90
|
-
|
|
154
|
+
if (command === "check") {
|
|
155
|
+
checkCommand(filePath, jsonMode);
|
|
156
|
+
}
|
|
157
|
+
else if (command === "build") {
|
|
158
|
+
const { outPath } = build(filePath, jsonMode);
|
|
159
|
+
if (outPath && !jsonMode)
|
|
91
160
|
console.log(`Wrote ${outPath}`);
|
|
92
161
|
}
|
|
93
162
|
else if (command === "watch") {
|
package/dist/codegen.js
CHANGED
|
@@ -60,7 +60,7 @@ export class CodeGenerator {
|
|
|
60
60
|
// the list of names that module exports — resolved by the caller from that
|
|
61
61
|
// module's own checked Program, since a bare `using` brings everything
|
|
62
62
|
// `public` into scope without naming it explicitly.
|
|
63
|
-
generate(program, usingExports = new Map()) {
|
|
63
|
+
generate(program, usingExports = new Map(), rawContents = new Map()) {
|
|
64
64
|
this.interfaceNames = new Set(program.statements.filter((s) => s.kind === "InterfaceDecl").map((s) => s.name));
|
|
65
65
|
const importLines = program.usings
|
|
66
66
|
.map((u) => {
|
|
@@ -82,6 +82,11 @@ export class CodeGenerator {
|
|
|
82
82
|
lines.push(binding);
|
|
83
83
|
continue;
|
|
84
84
|
}
|
|
85
|
+
if (stmt.kind === "RawStringDecl") {
|
|
86
|
+
const content = rawContents.get(stmt.name) ?? "";
|
|
87
|
+
lines.push(`${stmt.isExported ? "export const" : "const"} ${stmt.name} = ${JSON.stringify(content)};`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
85
90
|
let code = this.genStatement(stmt, 0);
|
|
86
91
|
if ((stmt.kind === "ClassDecl" || stmt.kind === "EnumDecl" || stmt.kind === "FunctionDecl") && stmt.isExported) {
|
|
87
92
|
code = `export ${code}`;
|
|
@@ -126,6 +131,7 @@ export class CodeGenerator {
|
|
|
126
131
|
case "ExternFunctionDecl":
|
|
127
132
|
case "ExternClassDecl":
|
|
128
133
|
case "ExternValueDecl":
|
|
134
|
+
case "RawStringDecl":
|
|
129
135
|
return ""; // handled directly in generate(); unreachable here except via a nested-decl error path
|
|
130
136
|
case "EnumDecl": {
|
|
131
137
|
const entries = stmt.members.map((m, i) => `${m}: ${i}`).join(", ");
|
|
@@ -247,6 +253,8 @@ export class CodeGenerator {
|
|
|
247
253
|
return JSON.stringify(expr.value);
|
|
248
254
|
case "BoolLiteral":
|
|
249
255
|
return String(expr.value);
|
|
256
|
+
case "NullLiteral":
|
|
257
|
+
return "null";
|
|
250
258
|
case "InterpolatedStringLiteral":
|
|
251
259
|
return this.genInterpolatedString(expr);
|
|
252
260
|
case "ArrayLiteral":
|
package/dist/lexer.js
CHANGED
|
@@ -2,6 +2,7 @@ import { TokenKind } from "./tokens.js";
|
|
|
2
2
|
const KEYWORDS = {
|
|
3
3
|
using: TokenKind.Using,
|
|
4
4
|
extern: TokenKind.Extern,
|
|
5
|
+
raw: TokenKind.Raw,
|
|
5
6
|
from: TokenKind.From,
|
|
6
7
|
as: TokenKind.As,
|
|
7
8
|
const: TokenKind.Const,
|
|
@@ -33,6 +34,7 @@ const KEYWORDS = {
|
|
|
33
34
|
void: TokenKind.Void,
|
|
34
35
|
true: TokenKind.True,
|
|
35
36
|
false: TokenKind.False,
|
|
37
|
+
null: TokenKind.Null,
|
|
36
38
|
task: TokenKind.Task,
|
|
37
39
|
state: TokenKind.State,
|
|
38
40
|
async: TokenKind.Async,
|
|
@@ -213,6 +215,8 @@ export class Lexer {
|
|
|
213
215
|
return this.make(TokenKind.Semicolon, c, line, col);
|
|
214
216
|
case ".":
|
|
215
217
|
return this.make(TokenKind.Dot, c, line, col);
|
|
218
|
+
case "?":
|
|
219
|
+
return this.make(TokenKind.Question, c, line, col);
|
|
216
220
|
case "+":
|
|
217
221
|
return this.make(TokenKind.Plus, c, line, col);
|
|
218
222
|
case "-":
|