kopscript 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LLM.md CHANGED
@@ -167,13 +167,51 @@ string Classify(string input) {
167
167
  return match input {
168
168
  "cat", "dog" => "animal", // comma = OR within one arm
169
169
  r"^[0-9]+$" => "number", // regex pattern, r"..."
170
- _ => "unknown" // wildcard REQUIRED exhaustiveness-checked
170
+ _ => "unknown" // wildcard REQUIRED for a string subject
171
171
  };
172
172
  }
173
173
  ```
174
174
 
175
- `match` is an expression (has a value), matches on literal/regex patterns only (not
176
- type patterns, not destructuring), and always requires a trailing `_` arm.
175
+ `match` is an expression (has a value) over one of two kinds of subject:
176
+
177
+ - **`string`** — literal/regex patterns only (not type patterns, not destructuring),
178
+ and always requires a trailing `_` arm, exactly as above.
179
+ - **an `enum`** — patterns name a member of that same enum (`EnumName.Member`, comma
180
+ for OR within one arm, same as a string match); no regex patterns (there's nothing
181
+ to run a regex against). **Real exhaustiveness**: covering every declared member
182
+ lets the trailing `_` be omitted entirely — there's nothing left for it to catch —
183
+ and a member missing from BOTH the arms and a trailing wildcard is a compile error
184
+ naming exactly which member(s) are missing:
185
+
186
+ ```ks
187
+ enum OrderState { Pending, Shipped, Delivered, Cancelled }
188
+
189
+ string Describe(OrderState s) {
190
+ return match s {
191
+ OrderState.Pending => "placed",
192
+ OrderState.Shipped => "shipped",
193
+ OrderState.Delivered => "delivered",
194
+ OrderState.Cancelled => "cancelled"
195
+ // no `_` needed — every member is covered; add a 5th enum member later
196
+ // without updating this match, and this stops compiling.
197
+ };
198
+ }
199
+ ```
200
+
201
+ A partial enum match still works exactly like a string match — cover the cases you
202
+ care about and fall back with `_`:
203
+
204
+ ```ks
205
+ string UrgencyOf(OrderState s) {
206
+ return match s {
207
+ OrderState.Cancelled => "none",
208
+ _ => "normal"
209
+ };
210
+ }
211
+ ```
212
+
213
+ `match` still doesn't support type patterns or destructuring for either subject kind
214
+ — an enum match's patterns are exactly its own members, nothing more general.
177
215
 
178
216
  ## Statements
179
217
 
@@ -233,6 +271,18 @@ literal in the language, which do interpret `\n`/`\t`/`\\`/etc.
233
271
  lambda's own first parameter cannot itself be a function type (a rare v1 parser
234
272
  limitation, not a semantic one).
235
273
 
274
+ **An expression-bodied lambda may discard a non-`void` result where `() => void` is
275
+ expected** — the fire-and-forget shape a `setTimeout`/`setInterval` callback needs:
276
+ ```ks
277
+ public async task DoWork() { ... }
278
+ SetTimeout(() => this.DoWork(), 300); // () => task discarded into () => void — fine
279
+ ```
280
+ This is narrower than "anything is assignable to `void`": a `void`-returning
281
+ function's own `return expr;` is still a real error, checked separately — this only
282
+ relaxes a lambda's own trailing expression value when nothing will ever read it,
283
+ matching the same allowance TypeScript gives a `Promise`-returning function passed
284
+ where `() => void` is expected.
285
+
236
286
  ### Arrays
237
287
 
238
288
  ```ks
@@ -403,6 +453,16 @@ parameter, not a syntax feature to reach for. A constrained type parameter (`T :
403
453
  IComparable`) works exactly like it does on a class — the inferred type must satisfy it,
404
454
  checked after inference succeeds.
405
455
 
456
+ **Extern generic free functions**: an `extern` free function can carry its own type
457
+ parameter list too, re-describing a real generic free function from another package
458
+ (the same shape `extern class Name<T> { ... }` already gives a generic class):
459
+ ```ks
460
+ extern (T) => string? CombineValidators2<T>((T) => string? v1, (T) => string? v2) from "kopular/forms";
461
+ (string) => string? combined = CombineValidators2(Required, TooLong);
462
+ ```
463
+ Inference, constraints, and arity all work exactly the same as a real generic
464
+ function's — the extern boundary doesn't lose any of it.
465
+
406
466
  **Does not exist (v1 scope cuts, each deliberate)**:
407
467
  - **Generic methods.** A class method can't introduce its own new type parameter beyond
408
468
  its enclosing class's (generic functions are free-function-only — see above).
@@ -492,6 +552,13 @@ constraint errors included) — and a real KopScript class can extend a generic
492
552
  class` with a concrete or threaded-through type argument, same as extending a real generic
493
553
  base.
494
554
 
555
+ An extern *free function* (the first form above) can carry its own type parameter list
556
+ too, the same place a real generic free function's goes — right after the name:
557
+ `extern (T) => string? CombineValidators2<T>((T) => string? v1, (T) => string? v2) from
558
+ "kopular/forms";`. Inference/constraints/arity all work identically to a real generic
559
+ function (see "Generics" above) — only an extern *value* declaration (the second form)
560
+ can never have one, since there's no call site for a type argument to attach to.
561
+
495
562
  **Never write `async` on an extern function/method signature** — declare its return type
496
563
  as `task`/`task<T>` directly (`task<string> text();`, not `async task<string> text();`).
497
564
  `async` only means something for a *body* the checker validates (legalizing `await`
package/README.md CHANGED
@@ -330,6 +330,12 @@ if (c == Color.Green) { print("It's green"); }
330
330
 
331
331
  Members are numbered from `0` in declaration order, compiling to a frozen JS object.
332
332
 
333
+ An enum is also a valid `match` subject (`match c { Color.Red => ..., Color.Green =>
334
+ ..., Color.Blue => ..., _ => ... }`, patterns naming its own members) — covering
335
+ every member lets the trailing `_` be omitted, with a real compile error if one is
336
+ missing and there's no `_` to fall back on. See LLM.md's "`match` expression" section
337
+ for the full exhaustiveness rules.
338
+
333
339
  ### Modules
334
340
 
335
341
  `using "./shapes";` brings every `public` top-level declaration from that file (resolved
@@ -432,6 +438,11 @@ extern class Box<T> {
432
438
  Box<number> nb = new Box<number>(5);
433
439
  ```
434
440
 
441
+ An extern *free function* can be generic the same way — `extern (T) => string?
442
+ CombineValidators2<T>((T) => string? v1, (T) => string? v2) from "kopular/forms";`
443
+ re-describes a real generic free function from another package, with the same
444
+ inference/constraints a local one gets.
445
+
435
446
  Now that KopScript has `async`/`await` and `task<T>` (see below), a Promise-based JS API is
436
447
  describable too — `extern task<string> Fetch(...) from "..." as "fetch";` is legitimate,
437
448
  and `await`ing it works exactly like awaiting any other KopScript task. What's still not cleanly
package/dist/checker.js CHANGED
@@ -194,14 +194,21 @@ export class Checker {
194
194
  this.checkClassBody(c);
195
195
  }
196
196
  registerExternFunction(decl) {
197
- // extern functions never take a type parameter of their own in v1 —
198
- // generic functions (see registerFunction) are a real-function-only
199
- // feature; an extern binding always describes a concrete signature.
200
- this.functions.set(decl.name, {
201
- typeParams: [],
202
- typeParamConstraints: [],
197
+ // Same withTypeParamsInScope treatment registerFunction gives a real
198
+ // generic function's own signature an extern free function can be
199
+ // generic too (re-describing a real generic free function from another
200
+ // package, e.g. Kopular's own `CombineValidators2<T>`), resolved the
201
+ // same way so `T` inside `decl.params`/`decl.returnType` resolves
202
+ // correctly instead of erroring as an unknown type.
203
+ const { params, returnType } = this.withTypeParamsInScope(decl.typeParams, () => ({
203
204
  params: decl.params.map((p) => this.resolveType(p.type, decl.line, decl.col)),
204
205
  returnType: this.resolveType(decl.returnType, decl.line, decl.col),
206
+ }));
207
+ this.functions.set(decl.name, {
208
+ typeParams: decl.typeParams.map((p) => p.name),
209
+ typeParamConstraints: decl.typeParams.map((p) => p.constraint),
210
+ params,
211
+ returnType,
205
212
  });
206
213
  }
207
214
  registerExternClass(decl) {
@@ -1644,6 +1651,19 @@ export class Checker {
1644
1651
  return expectedReturnType;
1645
1652
  }
1646
1653
  const actual = this.checkExpressionExpecting(expr.body, expectedReturnType, scope, lambdaCtx);
1654
+ // An expression-bodied lambda whose expected type is `() => void` may
1655
+ // still have a body that naturally evaluates to something non-void
1656
+ // (`() => this.SubmitTask()` where SubmitTask is `async task`, the
1657
+ // fire-and-forget shape `setTimeout`/`setInterval` callbacks need) —
1658
+ // discarding that value is always safe, the same allowance TypeScript
1659
+ // gives a `Promise`-returning function passed where `() => void` is
1660
+ // expected. This is narrower than "anything is assignable to void": a
1661
+ // `void`-returning FUNCTION's own `return expr;` is still checked
1662
+ // separately and is still a real error — this only relaxes a lambda's
1663
+ // own trailing expression value when nothing will ever read it.
1664
+ if (expectedReturnType.kind === "void") {
1665
+ return T.VOID;
1666
+ }
1647
1667
  if (expectedReturnType.kind !== "unknown" && !this.isAssignableType(actual, expectedReturnType)) {
1648
1668
  this.diagnostics.error("KS4059", `Lambda body has type '${T.typeToString(actual)}', expected '${T.typeToString(expectedReturnType)}'`, expr.line, expr.col);
1649
1669
  }
@@ -2176,30 +2196,69 @@ export class Checker {
2176
2196
  }
2177
2197
  return { type: T.UNKNOWN, methodInfo: null };
2178
2198
  }
2199
+ // `match` accepts two kinds of subject: `string` (the original, matching
2200
+ // literal/regex patterns — unchanged below) and, as of this check, an
2201
+ // `enum` (matching `EnumName.Member` patterns, with real exhaustiveness:
2202
+ // every member covered lets the trailing `_` be omitted entirely, since
2203
+ // there's nothing left for it to catch). Nothing else is valid — a
2204
+ // `match` over a class/number/etc. was never supported and still isn't.
2179
2205
  checkMatch(expr, scope, ctx) {
2180
2206
  const subjectType = this.checkExpression(expr.subject, scope, ctx);
2181
- this.expectType(subjectType, T.STRING, expr.line, expr.col, "match subject");
2207
+ const isEnumSubject = subjectType.kind === "enum";
2208
+ // Still routed through the shared expectType helper (one literal
2209
+ // KS4046 emission site for the whole checker — see diagnostics.test.ts's
2210
+ // "codes are unique across every emission site"), so the message loses
2211
+ // the "...or an enum" alternative when the subject is neither — an
2212
+ // acceptable trade against introducing a second call site for what's
2213
+ // still fundamentally "wrong type here."
2214
+ if (!isEnumSubject) {
2215
+ this.expectType(subjectType, T.STRING, expr.line, expr.col, "match subject");
2216
+ }
2217
+ const enumName = isEnumSubject ? subjectType.name : null;
2182
2218
  if (expr.arms.length === 0) {
2183
2219
  this.diagnostics.error("KS4086", `match expression must have at least one arm`, expr.line, expr.col);
2184
2220
  return T.UNKNOWN;
2185
2221
  }
2186
2222
  const lastArm = expr.arms[expr.arms.length - 1];
2187
- if (lastArm.pattern.kind !== "WildcardPattern") {
2223
+ const hasTrailingWildcard = lastArm.pattern.kind === "WildcardPattern";
2224
+ // A plain string match still always requires it (unchanged from
2225
+ // before enums were supported) — only an enum subject can skip it, and
2226
+ // only by covering every member explicitly instead (checked below,
2227
+ // once every arm's patterns have been walked).
2228
+ if (!hasTrailingWildcard && !isEnumSubject) {
2188
2229
  this.diagnostics.error("KS4087", `match expression must end with a wildcard '_' arm`, lastArm.line, lastArm.col);
2189
2230
  }
2231
+ const coveredEnumMembers = new Set();
2190
2232
  let resultType = null;
2191
2233
  for (const arm of expr.arms) {
2192
2234
  if (arm.pattern.kind === "LiteralPattern") {
2193
2235
  for (const value of arm.pattern.values) {
2194
- if (value.kind !== "StringLiteral") {
2195
- this.diagnostics.error("KS4088", `match patterns must be string literals`, value.line, value.col);
2236
+ if (isEnumSubject) {
2237
+ const valueType = this.checkExpression(value, scope, ctx);
2238
+ const isSameEnumMember = value.kind === "MemberExpr" && valueType.kind === "enum" && valueType.name === enumName;
2239
+ if (isSameEnumMember) {
2240
+ coveredEnumMembers.add(value.property);
2241
+ }
2242
+ else if (valueType.kind !== "unknown") {
2243
+ // valueType === "unknown" means checkExpression already
2244
+ // reported its own error (e.g. "Enum has no member X") —
2245
+ // don't pile a second, less specific one on top of it.
2246
+ this.reportInvalidMatchPattern(`match pattern must be a member of enum '${enumName}' (e.g. '${enumName}.SomeMember')`, value.line, value.col);
2247
+ }
2248
+ }
2249
+ else if (value.kind !== "StringLiteral") {
2250
+ this.reportInvalidMatchPattern(`match patterns must be string literals`, value.line, value.col);
2196
2251
  }
2197
2252
  else {
2198
2253
  this.checkExpression(value, scope, ctx);
2199
2254
  }
2200
2255
  }
2201
2256
  }
2202
- // RegexPattern and WildcardPattern need no further checking here.
2257
+ else if (arm.pattern.kind === "RegexPattern" && isEnumSubject) {
2258
+ this.reportInvalidMatchPattern(`a regex pattern is not valid for a match over enum '${enumName}' — enum patterns must name a member`, arm.line, arm.col);
2259
+ }
2260
+ // WildcardPattern (and RegexPattern against a string subject) need no
2261
+ // further checking here.
2203
2262
  const armResultType = this.checkExpression(arm.result, scope, ctx);
2204
2263
  if (resultType === null) {
2205
2264
  resultType = armResultType;
@@ -2208,6 +2267,22 @@ export class Checker {
2208
2267
  this.diagnostics.error("KS4089", `match arm result type '${T.typeToString(armResultType)}' does not match preceding arms' type '${T.typeToString(resultType)}'`, arm.line, arm.col);
2209
2268
  }
2210
2269
  }
2270
+ if (isEnumSubject && enumName && !hasTrailingWildcard) {
2271
+ const allMembers = [...(this.enums.get(enumName)?.members.keys() ?? [])];
2272
+ const missing = allMembers.filter((m) => !coveredEnumMembers.has(m));
2273
+ if (missing.length > 0) {
2274
+ this.diagnostics.error("KS4105", `match over enum '${enumName}' is not exhaustive — missing case(s) for: ${missing.join(", ")} (add a case for each, or a trailing '_' arm)`, expr.line, expr.col);
2275
+ }
2276
+ }
2211
2277
  return resultType ?? T.UNKNOWN;
2212
2278
  }
2279
+ // The one literal KS4088 emission site (three call sites in checkMatch
2280
+ // route through it — see diagnostics.test.ts's "codes are unique across
2281
+ // every emission site"): a match pattern that isn't valid for its
2282
+ // subject's kind (not a string literal against a string subject, not a
2283
+ // same-enum member reference against an enum subject, or a regex pattern
2284
+ // against an enum subject at all).
2285
+ reportInvalidMatchPattern(message, line, col) {
2286
+ this.diagnostics.error("KS4088", message, line, col);
2287
+ }
2213
2288
  }
package/dist/codegen.js CHANGED
@@ -98,8 +98,27 @@ export class CodeGenerator {
98
98
  // `public` into scope without naming it explicitly. `sourceFileName`/
99
99
  // `sourceText` are for the emitted source map only (both optional so every
100
100
  // existing test call site keeps compiling unchanged).
101
- generate(program, usingExports = new Map(), rawContents = new Map(), sourceFileName = "source.ks", sourceText = "") {
102
- this.interfaceNames = new Set(program.statements.filter((s) => s.kind === "InterfaceDecl").map((s) => s.name));
101
+ generate(program, usingExports = new Map(), rawContents = new Map(), sourceFileName = "source.ks", sourceText = "",
102
+ // Interface names visible via a `using` this module doesn't declare
103
+ // itself — separate from `usingExports` above, which deliberately
104
+ // excludes interfaces (they're compile-time only and never produce a
105
+ // JS import). Without this, a class implementing an interface declared
106
+ // in a DIFFERENT file (e.g. `class Component : Flushable` where
107
+ // `Flushable` lives in vdom.ks) has that interface name missing from
108
+ // `interfaceNames` entirely, so genClass's own "only a non-interface
109
+ // base list entry becomes `extends`" check can't tell it apart from a
110
+ // real (missing) superclass — it gets emitted as `extends Flushable`,
111
+ // a real runtime reference to a name nothing ever imports, throwing
112
+ // `ReferenceError: Flushable is not defined` the moment the class is
113
+ // ever loaded. Found for real building Kopular's own event-batching
114
+ // fix — the checker already resolves a cross-module interface's base
115
+ // list entry correctly (it type-checks fine); only codegen's own,
116
+ // separate, same-file-only interfaceNames set didn't know about it.
117
+ importedInterfaceNames = new Set()) {
118
+ this.interfaceNames = new Set([
119
+ ...program.statements.filter((s) => s.kind === "InterfaceDecl").map((s) => s.name),
120
+ ...importedInterfaceNames,
121
+ ]);
103
122
  this.outputLine = 1;
104
123
  const outFileName = sourceFileName.replace(/\.ks$/, ".js");
105
124
  this.sourceMap = new SourceMapBuilder(outFileName, sourceFileName, sourceText);
package/dist/modules.js CHANGED
@@ -226,6 +226,13 @@ export function compileGraph(entryAbsPath, fileOverrides) {
226
226
  for (const absPath of order) {
227
227
  const mod = modules.get(absPath);
228
228
  const usingExports = new Map();
229
+ // Separate from usingExports (interfaces are excluded there — see its
230
+ // own comment below) — this is what tells codegen's own interfaceNames
231
+ // set about an interface declared in a DIFFERENT file this module
232
+ // `using`s, so a class implementing one doesn't get miscompiled into a
233
+ // real `extends <InterfaceName>` runtime reference (see
234
+ // CodeGenerator.generate's own comment on importedInterfaceNames).
235
+ const importedInterfaceNames = new Set();
229
236
  for (const u of mod.program.usings) {
230
237
  const depPath = resolve(dirname(absPath), u.path) + ".ks";
231
238
  const depExports = exportsByModule.get(depPath);
@@ -234,13 +241,17 @@ export function compileGraph(entryAbsPath, fileOverrides) {
234
241
  // they'd make an invalid import specifier if listed here.
235
242
  const importableTypeNames = [...depExports.namedTypes.entries()].filter(([, kind]) => kind !== "interface").map(([name]) => name);
236
243
  usingExports.set(u.path, [...importableTypeNames, ...depExports.functions.keys(), ...depExports.externValues.keys()]);
244
+ for (const [name, kind] of depExports.namedTypes) {
245
+ if (kind === "interface")
246
+ importedInterfaceNames.add(name);
247
+ }
237
248
  }
238
249
  }
239
250
  // Just the basename, not a cwd-relative path — the .js.map file always
240
251
  // lands right next to its .ks/.js siblings (ks build writes output in
241
252
  // place), so "sources" has to resolve relative to *that* directory, not
242
253
  // wherever the compiler happened to be invoked from.
243
- const { code, map } = new CodeGenerator().generate(mod.program, usingExports, rawContentsByModule.get(absPath) ?? new Map(), basename(absPath), mod.source);
254
+ const { code, map } = new CodeGenerator().generate(mod.program, usingExports, rawContentsByModule.get(absPath) ?? new Map(), basename(absPath), mod.source, importedInterfaceNames);
244
255
  outputs.set(absPath, code);
245
256
  sourceMaps.set(absPath, map);
246
257
  }
package/dist/parser.js CHANGED
@@ -214,7 +214,7 @@ export class Parser {
214
214
  this.diagnostics.error("KS2006", "'async' cannot modify a variable declaration", start.line, start.col);
215
215
  }
216
216
  if (typeParams.length > 0) {
217
- this.diagnostics.error("KS2023", "A type parameter list is only allowed on a function declaration", start.line, start.col);
217
+ this.reportStrayTypeParamList(start.line, start.col);
218
218
  }
219
219
  this.consume(TokenKind.Assign, "Expected '=' in variable declaration");
220
220
  const init = this.parseExpression();
@@ -503,16 +503,33 @@ export class Parser {
503
503
  }
504
504
  const type = this.parseType();
505
505
  const name = this.consume(TokenKind.Identifier, "Expected name").lexeme;
506
+ // `extern (T) => string? CombineValidators2<T>(...) from "...";` — same
507
+ // type-param-list-right-after-the-name shape a real generic free
508
+ // function uses (see parseDeclaration); harmless to try unconditionally
509
+ // here too, since parseTypeParamList consumes nothing when `<` isn't
510
+ // next.
511
+ const typeParams = this.parseTypeParamList();
506
512
  if (this.check(TokenKind.LParen)) {
507
513
  const params = this.parseParamList();
508
514
  const { modulePath, jsName } = this.parseExternTail(name);
509
515
  this.consume(TokenKind.Semicolon, "Expected ';' after extern function declaration");
510
- return { kind: "ExternFunctionDecl", isExported, name, jsName, params, returnType: type, modulePath, line: start.line, col: start.col };
516
+ return { kind: "ExternFunctionDecl", isExported, name, typeParams, jsName, params, returnType: type, modulePath, line: start.line, col: start.col };
517
+ }
518
+ if (typeParams.length > 0) {
519
+ this.reportStrayTypeParamList(start.line, start.col);
511
520
  }
512
521
  const { modulePath, jsName } = this.parseExternTail(name);
513
522
  this.consume(TokenKind.Semicolon, "Expected ';' after extern declaration");
514
523
  return { kind: "ExternValueDecl", isExported, name, jsName, type, modulePath, line: start.line, col: start.col };
515
524
  }
525
+ // The one literal KS2023 emission site (both parseDeclaration's plain
526
+ // VarDecl case and parseExternDecl's ExternValueDecl case route through
527
+ // it — see diagnostics.test.ts's "codes are unique across every emission
528
+ // site"): a type-parameter list appeared somewhere only a function
529
+ // declaration (real or extern) may have one.
530
+ reportStrayTypeParamList(line, col) {
531
+ this.diagnostics.error("KS2023", "A type parameter list is only allowed on a function declaration", line, col);
532
+ }
516
533
  // Optional `from "<path>"` (omit for an ambient global) and optional
517
534
  // `as "<jsName>"` (omit when the JS-side name matches the KopScript-declared one).
518
535
  parseExternTail(defaultJsName) {
package/dist/printer.js CHANGED
@@ -276,7 +276,8 @@ export class Printer {
276
276
  const prefix = decl.isExported ? "" : "private ";
277
277
  const as = decl.jsName !== decl.name ? ` as "${decl.jsName}"` : "";
278
278
  const from = decl.modulePath ? ` from "${decl.modulePath}"` : "";
279
- return `${pad}${prefix}extern ${this.printType(decl.returnType)} ${decl.name}(${this.printParams(decl.params)})${from}${as};`;
279
+ const nameWithTypeParams = this.printTypeParamName(decl);
280
+ return `${pad}${prefix}extern ${this.printType(decl.returnType)} ${nameWithTypeParams}(${this.printParams(decl.params)})${from}${as};`;
280
281
  }
281
282
  printExternValue(decl, indent) {
282
283
  const pad = indentStr(indent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopscript",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "KopScript: a small OOP, strongly-typed language that transpiles to JavaScript, with generics and nullable types",
5
5
  "type": "module",
6
6
  "license": "MIT",