kopscript 0.2.0 → 0.4.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
@@ -18,6 +18,12 @@ class Scope {
18
18
  constructor(parent = null) {
19
19
  this.parent = parent;
20
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();
21
27
  }
22
28
  declare(name, type, isConst) {
23
29
  this.vars.set(name, { type, isConst });
@@ -25,11 +31,20 @@ class Scope {
25
31
  resolve(name) {
26
32
  return this.vars.get(name) ?? this.parent?.resolve(name) ?? null;
27
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
+ }
28
40
  child() {
29
41
  return new Scope(this);
30
42
  }
31
43
  }
32
44
  export class Checker {
45
+ recordHover(line, col, text) {
46
+ this.hoverEntries.push({ line, col, text });
47
+ }
33
48
  constructor(program, diagnostics, imports = emptyModuleExports(), currentFilePath = "test.ks") {
34
49
  this.program = program;
35
50
  this.diagnostics = diagnostics;
@@ -41,18 +56,31 @@ export class Checker {
41
56
  this.functions = new Map();
42
57
  this.externValues = new Map();
43
58
  this.namedTypes = new Map();
59
+ // className/interfaceName -> its type parameter's name (e.g. "Box" -> "T"),
60
+ // for every generic class/interface — populated up front, before any type
61
+ // resolution runs, specifically so a forward reference (class A's field
62
+ // referencing generic class B, declared later in the same file) still
63
+ // resolves B's genericity correctly. See resolveType/validateGenericArity.
64
+ this.genericTypeParams = new Map();
44
65
  this.importedNames = new Set();
45
66
  this.rawContents = new Map();
67
+ this.hoverEntries = [];
46
68
  }
47
69
  check() {
48
70
  for (const [name, kind] of this.imports.namedTypes) {
49
71
  this.namedTypes.set(name, kind);
50
72
  this.importedNames.add(name);
51
73
  }
52
- for (const [name, info] of this.imports.classes)
74
+ for (const [name, info] of this.imports.classes) {
53
75
  this.classes.set(name, info);
54
- for (const [name, info] of this.imports.interfaces)
76
+ if (info.typeParam)
77
+ this.genericTypeParams.set(name, info.typeParam);
78
+ }
79
+ for (const [name, info] of this.imports.interfaces) {
55
80
  this.interfaces.set(name, info);
81
+ if (info.typeParam)
82
+ this.genericTypeParams.set(name, info.typeParam);
83
+ }
56
84
  for (const [name, info] of this.imports.enums)
57
85
  this.enums.set(name, info);
58
86
  for (const [name, info] of this.imports.functions) {
@@ -80,6 +108,12 @@ export class Checker {
80
108
  this.namedTypes.set(e.name, "enum");
81
109
  for (const c of externClassDecls)
82
110
  this.namedTypes.set(c.name, "class");
111
+ for (const c of classDecls)
112
+ if (c.typeParam)
113
+ this.genericTypeParams.set(c.name, c.typeParam);
114
+ for (const i of interfaceDecls)
115
+ if (i.typeParam)
116
+ this.genericTypeParams.set(i.name, i.typeParam);
83
117
  for (const e of enumDecls)
84
118
  this.registerEnum(e);
85
119
  for (const i of interfaceDecls)
@@ -156,6 +190,7 @@ export class Checker {
156
190
  const ownCtorParams = decl.hasConstructor ? decl.ctorParams.map((p) => this.resolveType(p.type, decl.line, decl.col)) : null;
157
191
  this.classes.set(decl.name, {
158
192
  name: decl.name,
193
+ typeParam: null,
159
194
  superclass: null,
160
195
  interfaces: [],
161
196
  fields,
@@ -291,14 +326,116 @@ export class Checker {
291
326
  }
292
327
  // ---------- registration ----------
293
328
  resolveType(node, line, col) {
294
- const resolved = T.resolveTypeNode(node, this.namedTypes);
329
+ const resolved = T.resolveTypeNode(node, this.namedTypes, (namedNode, type) => {
330
+ this.recordHover(namedNode.line, namedNode.col, T.typeToString(type));
331
+ });
295
332
  if (!resolved) {
296
333
  const name = node.kind === "NamedType" ? node.name : "[]";
297
- this.diagnostics.error(`Unknown type '${name}'`, line, col);
334
+ if (name !== "[]" && this.genericTypeParams.has(name) && !node.typeArgs) {
335
+ this.diagnostics.error(`Generic type '${name}' requires a type argument (e.g. '${name}<T>')`, line, col);
336
+ }
337
+ else {
338
+ this.diagnostics.error(`Unknown type '${name}'`, line, col);
339
+ }
298
340
  return T.UNKNOWN;
299
341
  }
342
+ this.validateGenericArity(resolved, line, col);
300
343
  return resolved;
301
344
  }
345
+ // resolveTypeNode mechanically attaches whatever type argument a
346
+ // NamedType node happened to carry, without knowing which names are
347
+ // actually declared generic (that's checker state, not something the
348
+ // pure type-resolution function has access to) — this is the other half:
349
+ // walking the successfully-resolved type looking for a class/interface
350
+ // whose generic-ness doesn't match whether it got a type argument.
351
+ validateGenericArity(type, line, col) {
352
+ switch (type.kind) {
353
+ case "array":
354
+ this.validateGenericArity(type.element, line, col);
355
+ return;
356
+ case "function":
357
+ type.params.forEach((p) => this.validateGenericArity(p, line, col));
358
+ this.validateGenericArity(type.returnType, line, col);
359
+ return;
360
+ case "task":
361
+ this.validateGenericArity(type.resultType, line, col);
362
+ return;
363
+ case "state":
364
+ this.validateGenericArity(type.valueType, line, col);
365
+ return;
366
+ case "nullable":
367
+ this.validateGenericArity(type.inner, line, col);
368
+ return;
369
+ case "class":
370
+ case "interface": {
371
+ const isGeneric = this.genericTypeParams.has(type.name);
372
+ if (isGeneric && !type.typeArg) {
373
+ this.diagnostics.error(`Generic type '${type.name}' requires a type argument (e.g. '${type.name}<T>')`, line, col);
374
+ }
375
+ else if (!isGeneric && type.typeArg) {
376
+ this.diagnostics.error(`Type '${type.name}' is not generic — it doesn't take a type argument`, line, col);
377
+ }
378
+ if (type.typeArg)
379
+ this.validateGenericArity(type.typeArg, line, col);
380
+ return;
381
+ }
382
+ default:
383
+ return;
384
+ }
385
+ }
386
+ // Temporarily makes `name` (a class/interface's own type parameter, e.g.
387
+ // "T") resolve as a TypeParamType, for the duration of `fn` — used only
388
+ // while registering that class/interface's own declaration (fields,
389
+ // methods, ctor params), so `T Value;` resolves correctly. Scoped this
390
+ // narrowly (set, run, delete/restore) rather than left in `namedTypes`
391
+ // permanently, since it's only ever meaningful inside that one
392
+ // declaration's own body.
393
+ withTypeParamInScope(typeParam, fn) {
394
+ if (!typeParam)
395
+ return fn();
396
+ const previous = this.namedTypes.get(typeParam);
397
+ this.namedTypes.set(typeParam, "typeParam");
398
+ try {
399
+ return fn();
400
+ }
401
+ finally {
402
+ if (previous === undefined)
403
+ this.namedTypes.delete(typeParam);
404
+ else
405
+ this.namedTypes.set(typeParam, previous);
406
+ }
407
+ }
408
+ // Replaces every occurrence of the class/interface's own type parameter
409
+ // (by name) inside `type` with `arg` — the substitution step that turns
410
+ // Box<T>'s abstractly-stored field type `T` into `number` when someone
411
+ // actually asks about `Box<number>.Value`. A type with no occurrence of
412
+ // `paramName` anywhere in it is returned unchanged (including every
413
+ // non-generic type, the overwhelming majority).
414
+ substituteTypeParam(type, paramName, arg) {
415
+ switch (type.kind) {
416
+ case "typeParam":
417
+ return type.name === paramName ? arg : type;
418
+ case "array":
419
+ return T.arrayOf(this.substituteTypeParam(type.element, paramName, arg));
420
+ case "nullable":
421
+ return T.nullableOf(this.substituteTypeParam(type.inner, paramName, arg));
422
+ case "task":
423
+ return T.taskType(this.substituteTypeParam(type.resultType, paramName, arg));
424
+ case "state":
425
+ return T.stateType(this.substituteTypeParam(type.valueType, paramName, arg));
426
+ case "function":
427
+ return T.functionType(type.params.map((p) => this.substituteTypeParam(p, paramName, arg)), this.substituteTypeParam(type.returnType, paramName, arg));
428
+ // A nested generic's own type argument can itself mention the outer
429
+ // T (`Box<T[]>`'s field being List<T> — a v1 corner case, but cheap
430
+ // to handle correctly): substitute inside it too.
431
+ case "class":
432
+ return type.typeArg ? T.classType(type.name, this.substituteTypeParam(type.typeArg, paramName, arg)) : type;
433
+ case "interface":
434
+ return type.typeArg ? T.interfaceType(type.name, this.substituteTypeParam(type.typeArg, paramName, arg)) : type;
435
+ default:
436
+ return type;
437
+ }
438
+ }
302
439
  registerEnum(decl) {
303
440
  const members = new Map();
304
441
  decl.members.forEach((name, index) => {
@@ -309,12 +446,14 @@ export class Checker {
309
446
  members.set(name, index);
310
447
  });
311
448
  this.enums.set(decl.name, { name: decl.name, members });
449
+ this.recordHover(decl.line, decl.col, `enum ${decl.name}`);
312
450
  }
313
451
  registerInterface(decl) {
314
- const methods = decl.methods.map((m) => ({
315
- name: m.name,
316
- params: m.params.map((p) => this.resolveType(p.type, m.line, m.col)),
317
- returnType: this.resolveType(m.returnType, m.line, m.col),
452
+ const methods = this.withTypeParamInScope(decl.typeParam, () => decl.methods.map((m) => {
453
+ const params = m.params.map((p) => this.resolveType(p.type, m.line, m.col));
454
+ const returnType = this.resolveType(m.returnType, m.line, m.col);
455
+ this.recordHover(m.nameLine, m.nameCol, `${m.name}(${params.map(T.typeToString).join(", ")}): ${T.typeToString(returnType)}`);
456
+ return { name: m.name, params, returnType };
318
457
  }));
319
458
  const bases = [];
320
459
  for (const baseName of decl.baseList) {
@@ -322,9 +461,17 @@ export class Checker {
322
461
  this.diagnostics.error(`Interface '${decl.name}' can only extend other interfaces (unknown interface '${baseName}')`, decl.line, decl.col);
323
462
  continue;
324
463
  }
464
+ // v1 generics can't appear in a base list at all — only as a
465
+ // standalone type (field/param/return/local, `new Box<T>()`). A
466
+ // class/interface always implements/extends a *bare* name.
467
+ if (this.genericTypeParams.has(baseName)) {
468
+ this.diagnostics.error(`Interface '${decl.name}' cannot extend generic interface '${baseName}' — not supported in v1`, decl.line, decl.col);
469
+ continue;
470
+ }
325
471
  bases.push(baseName);
326
472
  }
327
- this.interfaces.set(decl.name, { name: decl.name, bases, methods });
473
+ this.interfaces.set(decl.name, { name: decl.name, typeParam: decl.typeParam, bases, methods });
474
+ this.recordHover(decl.line, decl.col, decl.typeParam ? `interface ${decl.name}<${decl.typeParam}>` : `interface ${decl.name}`);
328
475
  }
329
476
  checkInterfaceHierarchy(decl) {
330
477
  const info = this.interfaces.get(decl.name);
@@ -367,33 +514,48 @@ export class Checker {
367
514
  return info.bases.some((b) => this.interfaceExtends(b, sup));
368
515
  }
369
516
  registerClass(decl) {
370
- const fields = new Map();
371
- const staticFields = new Map();
372
- for (const f of decl.fields) {
373
- const info = { type: this.resolveType(f.type, f.line, f.col), visibility: f.visibility, hasSetter: true };
374
- (f.isStatic ? staticFields : fields).set(f.name, info);
375
- }
376
- for (const p of decl.properties) {
377
- fields.set(p.name, { type: this.resolveType(p.type, p.line, p.col), visibility: p.visibility, hasSetter: p.hasSetter });
378
- }
379
- const methods = new Map();
380
- const staticMethods = new Map();
381
- for (const m of decl.methods) {
382
- const info = {
383
- params: m.params.map((p) => this.resolveType(p.type, m.line, m.col)),
384
- returnType: this.resolveType(m.returnType, m.line, m.col),
385
- visibility: m.visibility,
386
- isVirtual: m.isVirtual,
387
- isOverride: m.isOverride,
388
- };
389
- (m.isStatic ? staticMethods : methods).set(m.name, info);
390
- }
391
- const ownCtorParams = decl.constructor
392
- ? decl.constructor.params.map((p) => this.resolveType(p.type, decl.constructor.line, decl.constructor.col))
393
- : null;
517
+ this.recordHover(decl.line, decl.col, decl.typeParam ? `class ${decl.name}<${decl.typeParam}>` : `class ${decl.name}`);
518
+ const { fields, staticFields, methods, staticMethods, ownCtorParams } = this.withTypeParamInScope(decl.typeParam, () => {
519
+ const fields = new Map();
520
+ const staticFields = new Map();
521
+ for (const f of decl.fields) {
522
+ const info = { type: this.resolveType(f.type, f.line, f.col), visibility: f.visibility, hasSetter: true };
523
+ this.recordHover(f.nameLine, f.nameCol, `${f.name}: ${T.typeToString(info.type)}`);
524
+ (f.isStatic ? staticFields : fields).set(f.name, info);
525
+ }
526
+ for (const p of decl.properties) {
527
+ const type = this.resolveType(p.type, p.line, p.col);
528
+ this.recordHover(p.nameLine, p.nameCol, `${p.name}: ${T.typeToString(type)}`);
529
+ fields.set(p.name, { type, visibility: p.visibility, hasSetter: p.hasSetter });
530
+ }
531
+ const methods = new Map();
532
+ const staticMethods = new Map();
533
+ for (const m of decl.methods) {
534
+ const info = {
535
+ params: m.params.map((p) => this.resolveType(p.type, m.line, m.col)),
536
+ returnType: this.resolveType(m.returnType, m.line, m.col),
537
+ visibility: m.visibility,
538
+ isVirtual: m.isVirtual,
539
+ isOverride: m.isOverride,
540
+ };
541
+ this.recordHover(m.nameLine, m.nameCol, `${m.name}(${info.params.map(T.typeToString).join(", ")}): ${T.typeToString(info.returnType)}`);
542
+ (m.isStatic ? staticMethods : methods).set(m.name, info);
543
+ }
544
+ const ownCtorParams = decl.constructor
545
+ ? decl.constructor.params.map((p) => this.resolveType(p.type, decl.constructor.line, decl.constructor.col))
546
+ : null;
547
+ return { fields, staticFields, methods, staticMethods, ownCtorParams };
548
+ });
394
549
  let superclass = null;
395
550
  const interfaces = [];
396
551
  for (const baseName of decl.baseList) {
552
+ // v1 generics can't appear in a base list at all — only as a
553
+ // standalone type (field/param/return/local, `new Box<T>()`). A
554
+ // class always extends/implements a *bare* name.
555
+ if (this.genericTypeParams.has(baseName)) {
556
+ this.diagnostics.error(`Class '${decl.name}' cannot extend/implement generic type '${baseName}' — not supported in v1`, decl.line, decl.col);
557
+ continue;
558
+ }
397
559
  const kind = this.namedTypes.get(baseName);
398
560
  if (kind === "class") {
399
561
  if (superclass !== null) {
@@ -412,6 +574,7 @@ export class Checker {
412
574
  }
413
575
  this.classes.set(decl.name, {
414
576
  name: decl.name,
577
+ typeParam: decl.typeParam,
415
578
  superclass,
416
579
  interfaces,
417
580
  fields,
@@ -515,10 +678,10 @@ export class Checker {
515
678
  }
516
679
  }
517
680
  registerFunction(decl) {
518
- this.functions.set(decl.name, {
519
- params: decl.params.map((p) => this.resolveType(p.type, decl.line, decl.col)),
520
- returnType: this.resolveType(decl.returnType, decl.line, decl.col),
521
- });
681
+ const params = decl.params.map((p) => this.resolveType(p.type, decl.line, decl.col));
682
+ const returnType = this.resolveType(decl.returnType, decl.line, decl.col);
683
+ this.functions.set(decl.name, { params, returnType });
684
+ this.recordHover(decl.line, decl.col, `function ${decl.name}(${params.map(T.typeToString).join(", ")}): ${T.typeToString(returnType)}`);
522
685
  }
523
686
  isSubclass(sub, sup) {
524
687
  let current = sub;
@@ -612,7 +775,25 @@ export class Checker {
612
775
  isAssignableType(from, to) {
613
776
  if (from.kind === "unknown" || to.kind === "unknown")
614
777
  return true;
778
+ // Widening: a non-null T (or another T?) is always fine where T? is
779
+ // expected. The reverse — a possibly-null value where a non-null type
780
+ // is expected — is never allowed here; that's exactly what narrowing
781
+ // (see detectNullCheck) exists to get past by changing `from` itself
782
+ // at the reference site, not by relaxing this rule.
783
+ if (to.kind === "nullable") {
784
+ return from.kind === "nullable" ? this.isAssignableType(from.inner, to.inner) : this.isAssignableType(from, to.inner);
785
+ }
786
+ if (from.kind === "nullable")
787
+ return false;
615
788
  if (to.kind === "interface") {
789
+ // Invariant generics: v1 has no generic inheritance (a class/interface
790
+ // base list can only name a non-generic type — see registerClass/
791
+ // registerInterface), so the *only* way `from`'s interface name can
792
+ // ever match `to`'s is a literal same-interface reference (never a
793
+ // distinct generic instantiation implementing another) — still worth
794
+ // checking the type argument actually matches, same as the class case.
795
+ if (from.kind === "interface" && from.name === to.name)
796
+ return this.typeArgsMatch(from, to);
616
797
  if (from.kind === "class")
617
798
  return this.classImplementsInterface(from.name, to.name);
618
799
  if (from.kind === "interface")
@@ -620,13 +801,26 @@ export class Checker {
620
801
  return false;
621
802
  }
622
803
  if (from.kind === "class" && to.kind === "class") {
623
- return from.name === to.name || this.isSubclass(from.name, to.name);
804
+ if (from.name === to.name)
805
+ return this.typeArgsMatch(from, to);
806
+ return this.isSubclass(from.name, to.name);
624
807
  }
625
808
  if (from.kind === "array" && to.kind === "array") {
626
809
  return this.isAssignableType(from.element, to.element);
627
810
  }
628
811
  return T.typesEqual(from, to);
629
812
  }
813
+ // Invariant: Box<number> and Box<string> are unrelated, and so is a
814
+ // generic type referenced with vs. without its type argument (the latter
815
+ // only ever arises from an arity error already diagnosed at the
816
+ // reference site — see validateGenericArity).
817
+ typeArgsMatch(a, b) {
818
+ if (!a.typeArg && !b.typeArg)
819
+ return true;
820
+ if (!a.typeArg || !b.typeArg)
821
+ return false;
822
+ return T.typesEqual(a.typeArg, b.typeArg);
823
+ }
630
824
  // ---------- top-level ----------
631
825
  // Validates that `isAsync` and the declared return type agree — `async`
632
826
  // requires `task`/`task<T>`, and `task`/`task<T>` requires `async` (there's
@@ -723,6 +917,7 @@ export class Checker {
723
917
  switch (stmt.kind) {
724
918
  case "VarDecl": {
725
919
  const declaredType = this.resolveType(stmt.type, stmt.line, stmt.col);
920
+ this.recordHover(stmt.nameLine, stmt.nameCol, `${stmt.isConst ? "const" : "let"} ${stmt.name}: ${T.typeToString(declaredType)}`);
726
921
  const initType = this.checkExpressionExpecting(stmt.init, declaredType, scope, ctx);
727
922
  if (!this.isAssignableType(initType, declaredType)) {
728
923
  this.diagnostics.error(`Cannot assign value of type '${T.typeToString(initType)}' to variable of type '${T.typeToString(declaredType)}'`, stmt.line, stmt.col);
@@ -736,12 +931,19 @@ export class Checker {
736
931
  case "IfStatement": {
737
932
  const condType = this.checkExpression(stmt.condition, scope, ctx);
738
933
  this.expectType(condType, T.BOOL, stmt.line, stmt.col, "if condition");
739
- this.checkBlock(stmt.thenBranch, scope, ctx);
934
+ // `if (x != null)` narrows x to non-null in the then-branch;
935
+ // `if (x == null) ... else ...` narrows it in the else-branch —
936
+ // see detectNullCheck for exactly what's recognized (bare
937
+ // identifier vs. `null`, either operand order, `==`/`!=` only).
938
+ const nullCheck = this.detectNullCheck(stmt.condition);
939
+ const thenScope = nullCheck?.positiveWhenTrue ? this.narrowedChild(scope, nullCheck.name) : scope;
940
+ const elseScope = nullCheck && !nullCheck.positiveWhenTrue ? this.narrowedChild(scope, nullCheck.name) : scope;
941
+ this.checkBlock(stmt.thenBranch, thenScope, ctx);
740
942
  if (stmt.elseBranch) {
741
943
  if (stmt.elseBranch.kind === "IfStatement")
742
- this.checkStatement(stmt.elseBranch, scope, ctx);
944
+ this.checkStatement(stmt.elseBranch, elseScope, ctx);
743
945
  else
744
- this.checkBlock(stmt.elseBranch, scope, ctx);
946
+ this.checkBlock(stmt.elseBranch, elseScope, ctx);
745
947
  }
746
948
  return;
747
949
  }
@@ -844,8 +1046,41 @@ export class Checker {
844
1046
  this.diagnostics.error(`Expected type '${T.typeToString(expected)}' for ${context}, got '${T.typeToString(actual)}'`, line, col);
845
1047
  }
846
1048
  }
1049
+ // Recognizes `name != null` / `name == null` (either operand order) as a
1050
+ // null-check on a bare identifier — the only shape narrowing understands.
1051
+ // `positiveWhenTrue: true` means the condition being true implies `name`
1052
+ // is non-null (`!=`); `false` means the condition being *false* implies
1053
+ // that (`==`). Deliberately narrow: no `this.Field != null`, no `&&`-
1054
+ // chains inside the checked expression itself, no reachability analysis
1055
+ // for an early-return guard clause (`if (x == null) { return; }` doesn't
1056
+ // narrow `x` afterward) — each would need tracking narrowing by path or
1057
+ // by control-flow reachability rather than by scope, real additional
1058
+ // machinery this v1 doesn't take on.
1059
+ detectNullCheck(condition) {
1060
+ if (condition.kind !== "BinaryExpr" || (condition.op !== "==" && condition.op !== "!="))
1061
+ return null;
1062
+ const { left, right } = condition;
1063
+ const ident = left.kind === "Identifier" ? left : right.kind === "Identifier" ? right : null;
1064
+ const other = ident === left ? right : left;
1065
+ if (!ident || other.kind !== "NullLiteral")
1066
+ return null;
1067
+ return { name: ident.name, positiveWhenTrue: condition.op === "!=" };
1068
+ }
1069
+ narrowedChild(scope, name) {
1070
+ const child = scope.child();
1071
+ child.narrowNonNull(name);
1072
+ return child;
1073
+ }
847
1074
  // ---------- expressions ----------
848
1075
  checkExpression(expr, scope, ctx) {
1076
+ const type = this.checkExpressionInner(expr, scope, ctx);
1077
+ if (expr.kind === "Identifier")
1078
+ this.recordHover(expr.line, expr.col, `${expr.name}: ${T.typeToString(type)}`);
1079
+ if (expr.kind === "ThisExpr")
1080
+ this.recordHover(expr.line, expr.col, `this: ${T.typeToString(type)}`);
1081
+ return type;
1082
+ }
1083
+ checkExpressionInner(expr, scope, ctx) {
849
1084
  switch (expr.kind) {
850
1085
  case "NumberLiteral":
851
1086
  return T.NUMBER;
@@ -853,6 +1088,15 @@ export class Checker {
853
1088
  return T.STRING;
854
1089
  case "BoolLiteral":
855
1090
  return T.BOOL;
1091
+ // Modeled as UNKNOWN rather than a dedicated Type — typesEqual/
1092
+ // isAssignableType already treat UNKNOWN as compatible with anything,
1093
+ // which is exactly right for a bare `null` with no expected type
1094
+ // (e.g. `x == null`). The real enforcement (null only assignable
1095
+ // where a nullable type is actually expected) lives in
1096
+ // checkExpressionExpecting below, the same place LambdaExpr gets its
1097
+ // expected-type-aware handling.
1098
+ case "NullLiteral":
1099
+ return T.UNKNOWN;
856
1100
  case "InterpolatedStringLiteral":
857
1101
  for (const part of expr.parts) {
858
1102
  if (part.kind === "Expr")
@@ -874,8 +1118,16 @@ export class Checker {
874
1118
  }
875
1119
  case "Identifier": {
876
1120
  const found = scope.resolve(expr.name);
877
- if (found)
1121
+ if (found) {
1122
+ // A nullable local/param narrowed by an enclosing `if (x != null)`
1123
+ // (or equivalent — see detectNullCheck) type-checks as its
1124
+ // non-null inner type at this specific reference, without
1125
+ // changing what's actually declared. Narrowing only applies to
1126
+ // bare names, not member-access paths like `this.Field`.
1127
+ if (found.type.kind === "nullable" && scope.isNarrowedNonNull(expr.name))
1128
+ return found.type.inner;
878
1129
  return found.type;
1130
+ }
879
1131
  const externValue = this.externValues.get(expr.name);
880
1132
  if (externValue)
881
1133
  return externValue;
@@ -910,7 +1162,14 @@ export class Checker {
910
1162
  }
911
1163
  case "LogicalExpr": {
912
1164
  const leftType = this.checkExpression(expr.left, scope, ctx);
913
- const rightType = this.checkExpression(expr.right, scope, ctx);
1165
+ // `x != null && x.Foo` narrows x for the right operand (only
1166
+ // reached once the left side is true, i.e. x is non-null); by De
1167
+ // Morgan's, `x == null || x.Foo` narrows it the same way (the
1168
+ // right side only runs once the left is false, i.e. x is non-null).
1169
+ const nullCheck = this.detectNullCheck(expr.left);
1170
+ const narrowRight = nullCheck && ((expr.op === "&&" && nullCheck.positiveWhenTrue) || (expr.op === "||" && !nullCheck.positiveWhenTrue));
1171
+ const rightScope = narrowRight ? this.narrowedChild(scope, nullCheck.name) : scope;
1172
+ const rightType = this.checkExpression(expr.right, rightScope, ctx);
914
1173
  this.expectType(leftType, T.BOOL, expr.line, expr.col, `'${expr.op}' operand`);
915
1174
  this.expectType(rightType, T.BOOL, expr.line, expr.col, `'${expr.op}' operand`);
916
1175
  return T.BOOL;
@@ -988,6 +1247,13 @@ export class Checker {
988
1247
  checkExpressionExpecting(expr, expected, scope, ctx) {
989
1248
  if (expr.kind === "LambdaExpr")
990
1249
  return this.checkLambda(expr, expected, scope, ctx);
1250
+ if (expr.kind === "NullLiteral") {
1251
+ if (expected.kind !== "nullable" && expected.kind !== "unknown") {
1252
+ this.diagnostics.error(`Cannot assign 'null' to non-nullable type '${T.typeToString(expected)}'`, expr.line, expr.col);
1253
+ return T.UNKNOWN;
1254
+ }
1255
+ return expected;
1256
+ }
991
1257
  return this.checkExpression(expr, scope, ctx);
992
1258
  }
993
1259
  checkLambda(expr, expected, scope, ctx) {
@@ -1065,6 +1331,21 @@ export class Checker {
1065
1331
  return T.NUMBER;
1066
1332
  }
1067
1333
  if (op === "==" || op === "!=") {
1334
+ // A literal `null` type-checks as UNKNOWN with no expected type (see
1335
+ // checkExpressionInner), which typesEqual would happily accept
1336
+ // against anything — including a type that can never actually be
1337
+ // null. Checked here, against the AST node itself, specifically so
1338
+ // `nonNullableThing == null` (almost always a leftover from before a
1339
+ // type was made non-nullable, or a copy-pasted guard that can't
1340
+ // trigger) is a compile error instead of a silently-always-false comparison.
1341
+ const nullSide = expr.left.kind === "NullLiteral" ? "left" : expr.right.kind === "NullLiteral" ? "right" : null;
1342
+ if (nullSide) {
1343
+ const otherType = nullSide === "left" ? rightType : leftType;
1344
+ if (otherType.kind !== "nullable" && otherType.kind !== "unknown") {
1345
+ 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);
1346
+ }
1347
+ return T.BOOL;
1348
+ }
1068
1349
  if (!T.typesEqual(leftType, rightType)) {
1069
1350
  this.diagnostics.error(`Cannot compare '${T.typeToString(leftType)}' with '${T.typeToString(rightType)}'`, line, col);
1070
1351
  }
@@ -1091,6 +1372,7 @@ export class Checker {
1091
1372
  expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
1092
1373
  return T.UNKNOWN;
1093
1374
  }
1375
+ this.recordHover(expr.callee.line, expr.callee.col, `function ${expr.callee.name}(${info.params.map(T.typeToString).join(", ")}): ${T.typeToString(info.returnType)}`);
1094
1376
  this.checkArgs(expr, info.params, scope, ctx);
1095
1377
  return info.returnType;
1096
1378
  }
@@ -1101,6 +1383,7 @@ export class Checker {
1101
1383
  if (expr.callee.property === "Map") {
1102
1384
  const objectType = this.checkExpression(expr.callee.object, scope, ctx);
1103
1385
  if (objectType.kind === "array") {
1386
+ expr.callee.isBuiltin = true; // see checkMemberInner's string/array branches
1104
1387
  return this.checkArrayMap(expr, objectType.element, scope, ctx);
1105
1388
  }
1106
1389
  }
@@ -1159,7 +1442,31 @@ export class Checker {
1159
1442
  expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
1160
1443
  return T.UNKNOWN;
1161
1444
  }
1162
- const ctorParams = this.lookupCtorParams(expr.className);
1445
+ let typeArg;
1446
+ if (info.typeParam && !expr.typeArgs) {
1447
+ this.diagnostics.error(`Generic class '${expr.className}' requires a type argument (e.g. 'new ${expr.className}<T>(...)')`, expr.line, expr.col);
1448
+ // Abstractly-typed (T-containing) ctor params, un-substitutable
1449
+ // without a real type argument, would otherwise cascade into a
1450
+ // confusing "expected 'T'" error on every argument — one clear error
1451
+ // beats that pile-on.
1452
+ expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
1453
+ return T.UNKNOWN;
1454
+ }
1455
+ if (!info.typeParam && expr.typeArgs) {
1456
+ this.diagnostics.error(`Class '${expr.className}' is not generic — it doesn't take a type argument`, expr.line, expr.col);
1457
+ expr.args.forEach((a) => this.checkExpression(a, scope, ctx));
1458
+ return T.UNKNOWN;
1459
+ }
1460
+ if (info.typeParam && expr.typeArgs) {
1461
+ typeArg = this.resolveType(expr.typeArgs[0], expr.line, expr.col);
1462
+ }
1463
+ this.recordHover(expr.line, expr.col, typeArg ? `class ${expr.className}<${T.typeToString(typeArg)}>` : `class ${expr.className}`);
1464
+ let ctorParams = this.lookupCtorParams(expr.className);
1465
+ if (info.typeParam && typeArg) {
1466
+ const paramName = info.typeParam;
1467
+ const arg = typeArg;
1468
+ ctorParams = ctorParams.map((p) => this.substituteTypeParam(p, paramName, arg));
1469
+ }
1163
1470
  if (expr.args.length !== ctorParams.length) {
1164
1471
  this.diagnostics.error(`Expected ${ctorParams.length} constructor argument(s), got ${expr.args.length}`, expr.line, expr.col);
1165
1472
  }
@@ -1170,7 +1477,7 @@ export class Checker {
1170
1477
  this.diagnostics.error(`Constructor argument ${i + 1} has type '${T.typeToString(argType)}', expected '${T.typeToString(expected)}'`, arg.line, arg.col);
1171
1478
  }
1172
1479
  });
1173
- return T.classType(expr.className);
1480
+ return T.classType(expr.className, typeArg);
1174
1481
  }
1175
1482
  lookupCtorParams(className) {
1176
1483
  let current = className;
@@ -1231,11 +1538,20 @@ export class Checker {
1231
1538
  return T.arrayOf(argType.returnType);
1232
1539
  }
1233
1540
  checkMember(expr, scope, ctx, isAssignTarget = false) {
1541
+ const result = this.checkMemberInner(expr, scope, ctx, isAssignTarget);
1542
+ const text = result.methodInfo
1543
+ ? `${expr.property}(${result.methodInfo.params.map(T.typeToString).join(", ")}): ${T.typeToString(result.methodInfo.returnType)}`
1544
+ : `${expr.property}: ${T.typeToString(result.type)}`;
1545
+ this.recordHover(expr.line, expr.col, text);
1546
+ return result;
1547
+ }
1548
+ checkMemberInner(expr, scope, ctx, isAssignTarget) {
1234
1549
  // A bare type name as the "object" — Color.Red (enum) or Dog.Count (static) —
1235
1550
  // is a type reference, not a value, so it's handled before the general expression check.
1236
1551
  if (expr.object.kind === "Identifier" && !scope.resolve(expr.object.name)) {
1237
1552
  const objName = expr.object.name;
1238
1553
  if (this.enums.has(objName)) {
1554
+ this.recordHover(expr.object.line, expr.object.col, `enum ${objName}`);
1239
1555
  const enumInfo = this.enums.get(objName);
1240
1556
  if (!enumInfo.members.has(expr.property)) {
1241
1557
  this.diagnostics.error(`Enum '${objName}' has no member '${expr.property}'`, expr.line, expr.col);
@@ -1244,6 +1560,7 @@ export class Checker {
1244
1560
  return { type: T.enumType(objName), methodInfo: null };
1245
1561
  }
1246
1562
  if (this.classes.has(objName)) {
1563
+ this.recordHover(expr.object.line, expr.object.col, `class ${objName}`);
1247
1564
  const field = this.lookupStaticField(objName, expr.property);
1248
1565
  if (field) {
1249
1566
  this.checkAccessibility(field.info.visibility, field.owner, ctx, expr.property, expr.line, expr.col);
@@ -1259,7 +1576,16 @@ export class Checker {
1259
1576
  }
1260
1577
  }
1261
1578
  const objectType = this.checkExpression(expr.object, scope, ctx);
1579
+ if (objectType.kind === "nullable") {
1580
+ 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);
1581
+ return { type: T.UNKNOWN, methodInfo: null };
1582
+ }
1262
1583
  if (objectType.kind === "string") {
1584
+ // Marks this specific access as resolved against string's own
1585
+ // built-ins, not some user member that merely shares the name —
1586
+ // codegen's PascalCase -> camelCase renames rely on this, since they
1587
+ // have no type information of their own to tell the two apart.
1588
+ expr.isBuiltin = true;
1263
1589
  if (expr.property === "Length")
1264
1590
  return { type: T.NUMBER, methodInfo: null };
1265
1591
  const method = STRING_METHODS[expr.property];
@@ -1269,6 +1595,7 @@ export class Checker {
1269
1595
  return { type: T.UNKNOWN, methodInfo: null };
1270
1596
  }
1271
1597
  if (objectType.kind === "array") {
1598
+ expr.isBuiltin = true; // see the string branch above
1272
1599
  if (expr.property === "Length")
1273
1600
  return { type: T.NUMBER, methodInfo: null };
1274
1601
  const method = this.arrayMethod(objectType.element, expr.property);
@@ -1300,6 +1627,12 @@ export class Checker {
1300
1627
  return { type: T.UNKNOWN, methodInfo: null };
1301
1628
  }
1302
1629
  if (objectType.kind === "class") {
1630
+ // v1 has no generic inheritance (a generic class's base list can only
1631
+ // name non-generic types), so a member found on a generic instance
1632
+ // was always declared directly on that same class — substituting by
1633
+ // its own type parameter, not some ancestor's, is always correct.
1634
+ const paramName = objectType.typeArg ? this.genericTypeParams.get(objectType.name) : undefined;
1635
+ const substitute = (type) => (paramName && objectType.typeArg ? this.substituteTypeParam(type, paramName, objectType.typeArg) : type);
1303
1636
  const field = this.lookupField(objectType.name, expr.property);
1304
1637
  if (field) {
1305
1638
  this.checkAccessibility(field.info.visibility, field.owner, ctx, expr.property, expr.line, expr.col);
@@ -1309,12 +1642,13 @@ export class Checker {
1309
1642
  this.diagnostics.error(`'${expr.property}' has no setter and can only be assigned within ${field.owner}'s constructor`, expr.line, expr.col);
1310
1643
  }
1311
1644
  }
1312
- return { type: field.info.type, methodInfo: null };
1645
+ return { type: substitute(field.info.type), methodInfo: null };
1313
1646
  }
1314
1647
  const method = this.lookupMethod(objectType.name, expr.property);
1315
1648
  if (method) {
1316
1649
  this.checkAccessibility(method.info.visibility, method.owner, ctx, expr.property, expr.line, expr.col);
1317
- return { type: method.info.returnType, methodInfo: method.info };
1650
+ const info = paramName ? { ...method.info, params: method.info.params.map(substitute), returnType: substitute(method.info.returnType) } : method.info;
1651
+ return { type: info.returnType, methodInfo: info };
1318
1652
  }
1319
1653
  this.diagnostics.error(`Class '${objectType.name}' has no member '${expr.property}'`, expr.line, expr.col);
1320
1654
  return { type: T.UNKNOWN, methodInfo: null };
@@ -1322,7 +1656,14 @@ export class Checker {
1322
1656
  if (objectType.kind === "interface") {
1323
1657
  const sig = this.collectInterfaceMethods(objectType.name).find((m) => m.name === expr.property);
1324
1658
  if (sig) {
1325
- return { type: sig.returnType, methodInfo: { params: sig.params, returnType: sig.returnType, visibility: "public", isVirtual: false, isOverride: false } };
1659
+ // v1 has no generic interface inheritance either (same restriction
1660
+ // as classes — see registerInterface), so a signature found here
1661
+ // was always declared directly on this same interface.
1662
+ const paramName = objectType.typeArg ? this.genericTypeParams.get(objectType.name) : undefined;
1663
+ const substitute = (type) => (paramName && objectType.typeArg ? this.substituteTypeParam(type, paramName, objectType.typeArg) : type);
1664
+ const params = sig.params.map(substitute);
1665
+ const returnType = substitute(sig.returnType);
1666
+ return { type: returnType, methodInfo: { params, returnType, visibility: "public", isVirtual: false, isOverride: false } };
1326
1667
  }
1327
1668
  this.diagnostics.error(`Interface '${objectType.name}' has no member '${expr.property}'`, expr.line, expr.col);
1328
1669
  return { type: T.UNKNOWN, methodInfo: null };