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/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
- constructor(program, diagnostics, imports = emptyModuleExports()) {
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.checkNoDuplicateTopLevelNames(classDecls, interfaceDecls, enumDecls, functionDecls, externFunctionDecls, externClassDecls, externValueDecls);
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
- name: m.name,
284
- params: m.params.map((p) => this.resolveType(p.type, m.line, m.col)),
285
- returnType: this.resolveType(m.returnType, m.line, m.col),
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
- fields.set(p.name, { type: this.resolveType(p.type, p.line, p.col), visibility: p.visibility, hasSetter: p.hasSetter });
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.functions.set(decl.name, {
487
- params: decl.params.map((p) => this.resolveType(p.type, decl.line, decl.col)),
488
- returnType: this.resolveType(decl.returnType, decl.line, decl.col),
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
- this.checkBlock(stmt.thenBranch, scope, ctx);
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, scope, ctx);
786
+ this.checkStatement(stmt.elseBranch, elseScope, ctx);
711
787
  else
712
- this.checkBlock(stmt.elseBranch, scope, ctx);
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
- const rightType = this.checkExpression(expr.right, scope, ctx);
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
- function build(filePath) {
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
- console.error(`ks: cannot find file '${filePath}'`);
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
- 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
- }
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 [, , command, file] = process.argv;
82
- if (!command || !file || (command !== "build" && command !== "run" && command !== "watch")) {
83
- console.error("Usage: ks <build|run|watch> <file.ks>");
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 === "build") {
89
- const { outPath } = build(filePath);
90
- if (outPath)
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 "-":