kopscript 0.5.1 → 0.7.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
@@ -421,9 +421,50 @@ exactly the members used, from that package's real module specifier. See Kopular
421
421
  raw string HeroHtml from "./hero.html"; // reads the file at compile time, type is always string
422
422
  ```
423
423
 
424
+ General-purpose only — for a component's actual markup, use `template from` below instead.
425
+
426
+ ## Templates — `template from` (markup files)
427
+
428
+ ```ks
429
+ class Counter : Component {
430
+ public state<number> Count;
431
+ constructor() : base() { this.Count = state(0); }
432
+ public void Increment() { this.Count.Value = this.Count.Value + 1; }
433
+ template from "./counter.html"; // replaces a hand-written Render() entirely
434
+ }
435
+ ```
436
+
437
+ ```html
438
+ <!-- counter.html -->
439
+ <button (click)="Increment()">Count: {{ Count.Value }}</button>
440
+ ```
441
+
442
+ - One `template` per class; a class with both `template` and a hand-written `Render()` is a
443
+ compile error.
444
+ - Desugars, between parsing and checking, into the exact same `MethodDecl` AST a
445
+ hand-written `Render()` would produce — no runtime template engine, no virtual DOM.
446
+ `{{ }}`/binding contents are real KopScript, parsed and type-checked normally.
447
+ - Bindings: `{{ expr }}` (text interpolation, → `InterpolatedStringLiteral`), `(event)="stmt"`
448
+ (→ `addEventListener`), `[prop]="expr"` (→ plain assignment, checked like any member
449
+ assignment), static `attr="..."` (`class` aliases to `className`).
450
+ - Structural directives: `*if="expr"` (→ real `if`), `*for="Type varName of expr"` (→ real
451
+ `for..in`; the element type is explicit — no inference, same stance as Generics).
452
+ At most one structural directive per element.
453
+ - **Auto-subscribe**: a `state<T>` field declared directly on the class and referenced
454
+ directly in the template gets `Subscribe((v) => this.Update())` wired automatically — no
455
+ manual `Subscribe` needed for that field. State reached indirectly (through a method call,
456
+ or `this.SomeService.Count`) still needs a manual `Subscribe`, unchanged from before.
457
+ - Exactly one top-level element per template (no auto-wrap — hard error). No mixing text and
458
+ element children under one element. No two-way binding, no pipes, no stacked directives.
459
+ - `ks watch` tracks the referenced `.html` file as well as `.ks` dependencies.
460
+ - Layering note: `template from` is kopscript grammar, but what it desugars *to*
461
+ (`document.createElement`/`.appendChild`/`.textContent`/`.addEventListener`) assumes
462
+ Kopular's `dom.ks` DOM surface specifically — a deliberate, documented coupling, not a
463
+ generic pluggable target.
464
+
424
465
  ## Keywords (reserved, lowercase, exact match)
425
466
 
426
- `using extern raw from as const class interface enum constructor public private
467
+ `using extern raw template from as const class interface enum constructor public private
427
468
  protected static virtual override get set return if else while for foreach in break
428
469
  continue match this base new void true false null task state async await try catch
429
470
  finally throw`
@@ -438,7 +479,9 @@ Locals/params: **camelCase**. This is convention, not enforced by the compiler.
438
479
 
439
480
  ## Does not exist (don't reach for these)
440
481
 
441
- Generics · `any`/`unknown` annotations ·
482
+ Generics beyond a single unconstrained type parameter (no `Map<K, V>`, no `T : IFoo`
483
+ constraints, no generic functions, no generic inheritance, no variance — see Generics
484
+ above for what *is* supported) · `any`/`unknown` annotations ·
442
485
  a `let`/`var` keyword (locals are just `Type name = value;`) · decorators/annotations ·
443
486
  reflection · type inference on declarations · ternary expression · union/tuple types ·
444
487
  **object-literal syntax** (`{ key: value }` as a value — this doesn't exist *anywhere* in
package/README.md CHANGED
@@ -50,6 +50,10 @@ LLM's context, as opposed to this README's narrative explanation.
50
50
  - **Reactive state without RxJS**: `state<T>` is a small reactive box (`.Value` get/set,
51
51
  `.Subscribe((T) => void)`) for holding state and reacting to it changing — no
52
52
  Observables, no operators, no manual unsubscribe bookkeeping.
53
+ - **Real markup templates**: `template from "./x.html";` compiles a separate, almost-pure-HTML
54
+ file — interpolation, event/property bindings, `*if`/`*for` — down to the exact same
55
+ AST a hand-written `Render()` would produce, with auto-`Subscribe` wiring for state
56
+ referenced directly in the markup. See [Templates](#templates).
53
57
  - **A companion framework, [Kopular](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular)**:
54
58
  components, constructor-injected services via a composition root (no DI container), and
55
59
  real-URL routing (no config DSL) — built entirely on the features above, in a separate
@@ -387,10 +391,11 @@ void Main() {
387
391
  ```
388
392
 
389
393
  Unlike `extern`, there's no real JS export being described here — the compiler fabricates
390
- the value itself — so there's no `as "jsName"` clause. This exists so a component's static
391
- markup can live in a real `.html` file instead of a giant `.innerHTML = "..."` string
392
- literal, the same way Angular's `templateUrl` works resolved once at build time, not
393
- fetched at runtime, so `Component.Render()` stays synchronous either way.
394
+ the value itself — so there's no `as "jsName"` clause. This is a general-purpose,
395
+ compile-time-only file embedding primitive useful for anything that wants a file's
396
+ exact contents as a string constant with no runtime file access. (If what you actually
397
+ want is markup that becomes real DOM construction code, not just a string see
398
+ [Templates](#templates) below, which is the more specific answer for that case.)
394
399
 
395
400
  ### async/await, task&lt;T&gt;, and try/catch
396
401
 
@@ -488,6 +493,82 @@ of a DI container, and hash-based routing instead of a config DSL — both built
488
493
  on ordinary KopScript, no further compiler features required beyond `state<T>` and the
489
494
  `extern`/`virtual` support above.
490
495
 
496
+ Hand-writing `Render()` like this, statement by statement, is still fully supported — but
497
+ a Kopular component today more commonly expresses it as a **template** instead. See
498
+ [Templates](#templates) below for the markup-based alternative to this same method, and
499
+ for how `Count.Subscribe(...)` above can often be skipped entirely.
500
+
501
+ ### Templates
502
+
503
+ `template from "<path>";` inside a class body replaces a hand-written `Render()` with a
504
+ real, separate markup file — almost pure HTML, with data bindings and a small set of
505
+ structural directives standing in for the imperative DOM code above:
506
+
507
+ ```ks
508
+ class Counter : Component {
509
+ public state<number> Count;
510
+ constructor() : base() { this.Count = state(0); }
511
+ public void Increment() { this.Count.Value = this.Count.Value + 1; }
512
+ template from "./counter.html";
513
+ }
514
+ ```
515
+
516
+ ```html
517
+ <!-- counter.html -->
518
+ <button (click)="Increment()">Count: {{ Count.Value }}</button>
519
+ ```
520
+
521
+ This compiles to exactly the `Render()` method you'd otherwise write by hand — the
522
+ template compiler is a pass that runs between parsing and type-checking, turning the
523
+ markup into ordinary `MethodDecl`/statement/expression AST nodes and splicing the result
524
+ into the class before checking ever runs. There's no separate runtime template engine, no
525
+ virtual DOM diffing, and no interpreted expression language: `{{ Count.Value }}` and
526
+ `(click)="Increment()"` contain real KopScript, parsed and type-checked exactly like
527
+ anything else in the file, with errors reported at their real position in the `.html`
528
+ file, not the `.ks` file.
529
+
530
+ A class may have a `template` or a hand-written `Render()`, never both — that's a compile
531
+ error. `ks watch` also tracks the referenced `.html` file, so editing markup alone
532
+ triggers a rebuild.
533
+
534
+ Supported bindings and directives:
535
+
536
+ | Syntax | Desugars to |
537
+ | --------------------------- | --------------------------------------------------------- |
538
+ | `{{ expr }}` (in text) | `el.textContent = $"...{expr}...";` (an `InterpolatedStringLiteral`, same as `$"..."`) |
539
+ | `(event)="stmt"` | `el.addEventListener("event", (Event e) => { stmt });` |
540
+ | `[prop]="expr"` | `el.prop = expr;` — a plain assignment, checked like any other |
541
+ | `class="..."` (static) | `el.className = "...";` (aliased, since `class` is a KopScript keyword) |
542
+ | `*if="expr"` | a real `if (expr) { ... }` around the element's creation |
543
+ | `*for="Type var of expr"` | a real `for (Type var in expr) { ... }` — the element type is explicit, matching KopScript's no-inference stance elsewhere (Generics, Nullable types) |
544
+
545
+ **Auto-subscribe**: a `state<T>` field declared directly on the component and referenced
546
+ directly in its template (like `Count` above) gets its `Subscribe((v) => this.Update())`
547
+ wired up automatically — no manual `Subscribe` call needed in the constructor. This is a
548
+ syntactic check against the class's own declared fields, not a type-checker query, so it
549
+ only covers *direct* field access; state reached indirectly — through a method call, or
550
+ through `this.SomeService.Count` — still needs a manual `Subscribe`, same as before
551
+ templates existed.
552
+
553
+ v1 cuts, same discipline as generics and nullable types: exactly one top-level element per
554
+ template (no auto-wrapping — a clear error instead); no mixing text and element children
555
+ under one element (Kopular's DOM surface has no text-node type, only `.textContent`); no
556
+ two-way binding, no pipes, no stacking two structural directives on one element.
557
+
558
+ **A deliberate layering note**: the `template from` syntax lives in kopscript's own
559
+ grammar (Kopular can't extend a language it doesn't own), but what it desugars *to* —
560
+ `document.createElement`, `.appendChild`, `.textContent`, `.addEventListener` — assumes
561
+ exactly the DOM surface [Kopular](https://dev.azure.com/koppinator/Koppindependence/_git/Kopular)'s
562
+ `dom.ks` declares. That's a real coupling from the compiler to one specific consumer,
563
+ accepted deliberately rather than building a generic pluggable desugaring-target system
564
+ for a hypothetical second framework that doesn't exist today. If one ever does, that's the
565
+ point to generalize this.
566
+
567
+ `raw string <Name> from "./x.html";` (above) still exists as a general-purpose
568
+ compile-time file embedding feature — but for the specific "component markup in its own
569
+ file" use case, `template from` is the real answer; `raw` no longer needs to stand in for
570
+ it.
571
+
491
572
  ### String interpolation
492
573
 
493
574
  ```ks
@@ -692,8 +773,9 @@ use `node dist/cli.js` directly.
692
773
 
693
774
  `watch` rebuilds on a save to *any* `.ks` file it reached while compiling — the entry and
694
775
  everything it (transitively, non-transitively per-file) `using`s — not just the entry
695
- file, and re-establishes its watch list after every rebuild since the dependency set
696
- itself can change (a `using` added or removed). This is the piece that makes a
776
+ file, plus any `.html` file referenced via `template from "...";` in one of those classes,
777
+ and re-establishes its watch list after every rebuild since the dependency set
778
+ itself can change (a `using` added or removed, or a `template` declaration's path). This is the piece that makes a
697
779
  browser-facing dev loop bearable: run `ks watch` in one terminal, a static file server in
698
780
  another, and refreshing the browser after a save is the only manual step left — there's
699
781
  no watch-triggered auto-refresh, since KopScript has no dev-server integration to push
@@ -732,6 +814,6 @@ lives in its own repo, published to npm as `kopular` and consumed like any other
732
814
  (no local checkout or `file:` dependency needed). A real app built on both lives in
733
815
  [KopularDemo](https://dev.azure.com/koppinator/Koppindependence/_git/KopularDemo) — several
734
816
  routed pages, `state<T>`-driven reactivity, constructor-injected services via a
735
- composition root (no DI container), structural-directive equivalents for `*ngIf`/`*ngFor`/
736
- `*ngSwitch`, and real-URL (History API) routing verified against a real DOM via jsdom
737
- and in an actual browser.
817
+ composition root (no DI container), real-URL (History API) routing, and Angular-style
818
+ markup templates (see [Templates](#templates) above) with `*if`/`*for` structural
819
+ directives — verified against a real DOM via jsdom and in an actual browser.
package/dist/cli.js CHANGED
@@ -39,6 +39,14 @@ function printHumanDiagnostics(result) {
39
39
  // .js file next to each .ks source. With `jsonMode`, nothing but a single
40
40
  // JSON object goes to stdout (no "Wrote ..." line, no human diagnostics) —
41
41
  // see `printJson` for the shape.
42
+ //
43
+ // A module's watch list is its own path plus any `template from "...";`
44
+ // file(s) it references — those aren't part of `result.order` (they export
45
+ // nothing a `using` could pull in), but editing one needs to trigger a
46
+ // rebuild exactly the way editing a `using`'d file already does.
47
+ function allWatchFiles(result) {
48
+ return result.order.flatMap((absPath) => [absPath, ...(result.modules.get(absPath)?.extraWatchFiles ?? [])]);
49
+ }
42
50
  function build(filePath, jsonMode = false) {
43
51
  const result = compileGraph(filePath);
44
52
  if (result.entryMissing) {
@@ -55,14 +63,14 @@ function build(filePath, jsonMode = false) {
55
63
  else
56
64
  printHumanDiagnostics(result);
57
65
  process.exitCode = 1;
58
- return { outPath: null, watchFiles: result.order };
66
+ return { outPath: null, watchFiles: allWatchFiles(result) };
59
67
  }
60
68
  for (const absPath of result.order) {
61
69
  writeFileSync(outputPathFor(absPath), result.outputs.get(absPath), "utf-8");
62
70
  }
63
71
  if (jsonMode)
64
72
  printJson(result, result.order.map(outputPathFor));
65
- return { outPath: outputPathFor(filePath), watchFiles: result.order };
73
+ return { outPath: outputPathFor(filePath), watchFiles: allWatchFiles(result) };
66
74
  }
67
75
  // Type-checks the graph without writing any output — for CI or an editor/
68
76
  // agent that wants pass/fail plus diagnostics without touching the
package/dist/lexer.js CHANGED
@@ -3,6 +3,7 @@ const KEYWORDS = {
3
3
  using: TokenKind.Using,
4
4
  extern: TokenKind.Extern,
5
5
  raw: TokenKind.Raw,
6
+ template: TokenKind.Template,
6
7
  from: TokenKind.From,
7
8
  as: TokenKind.As,
8
9
  const: TokenKind.Const,
package/dist/modules.js CHANGED
@@ -5,6 +5,93 @@ import { Parser } from "./parser.js";
5
5
  import { Checker, emptyModuleExports } from "./checker.js";
6
6
  import { CodeGenerator } from "./codegen.js";
7
7
  import { DiagnosticBag } from "./diagnostics.js";
8
+ import { TemplateParser } from "./template_parser.js";
9
+ import { TemplateCompiler } from "./template_compiler.js";
10
+ // Expands every `template from "./x.html";` declaration in `program` into a
11
+ // real, synthetic `Render()` MethodDecl, spliced into that class's own
12
+ // `methods` — before any type-checking happens, so checker.ts/codegen.ts
13
+ // need no knowledge that a template was ever involved (see
14
+ // template_compiler.ts's own header for the full design rationale). Returns
15
+ // the resolved template paths found, for `extraWatchFiles`.
16
+ //
17
+ // Runs entirely on parse-time information: the enclosing ClassDecl's own
18
+ // already-parsed fields (for auto-subscribe field detection) and
19
+ // kopscript's own expression/type parser (for binding expressions) — no
20
+ // dependency on type-checking having run first.
21
+ function expandTemplates(program, absPath, diagnostics, fileOverrides) {
22
+ const watchFiles = [];
23
+ for (const stmt of program.statements) {
24
+ if (stmt.kind !== "ClassDecl" || !stmt.template)
25
+ continue;
26
+ const templateRef = stmt.template;
27
+ if (stmt.methods.some((m) => m.name === "Render" && !m.isStatic)) {
28
+ diagnostics.error(`Class '${stmt.name}' has both a 'template' declaration and a hand-written 'Render()' method — remove one`, templateRef.line, templateRef.col);
29
+ continue;
30
+ }
31
+ const templatePath = resolve(dirname(absPath), templateRef.path);
32
+ watchFiles.push(templatePath);
33
+ const exists = fileOverrides?.has(templatePath) || existsSync(templatePath);
34
+ if (!exists) {
35
+ diagnostics.error(`Cannot find template file '${templateRef.path}' (looked for '${displayPath(templatePath)}')`, templateRef.line, templateRef.col);
36
+ continue;
37
+ }
38
+ const templateSource = fileOverrides?.get(templatePath) ?? readFileSync(templatePath, "utf-8");
39
+ const templateDiagnostics = new DiagnosticBag();
40
+ const root = new TemplateParser(templateSource, templateDiagnostics).parseDocument();
41
+ for (const d of templateDiagnostics.diagnostics) {
42
+ diagnostics.error(`[${templateRef.path}:${d.line}:${d.col}] ${d.message}`, templateRef.line, templateRef.col);
43
+ }
44
+ if (!root)
45
+ continue;
46
+ const stateFieldNames = new Set(stmt.fields.filter((f) => f.type.kind === "StateType").map((f) => f.name));
47
+ const memberNames = new Set([...stmt.fields.map((f) => f.name), ...stmt.properties.map((p) => p.name), ...stmt.methods.map((m) => m.name)]);
48
+ const compileDiagnostics = new DiagnosticBag();
49
+ const { renderMethod, autoSubscribeFields } = new TemplateCompiler(memberNames, stateFieldNames, compileDiagnostics).compile(root, templateRef);
50
+ for (const d of compileDiagnostics.diagnostics) {
51
+ diagnostics.error(`[${templateRef.path}:${d.line}:${d.col}] ${d.message}`, templateRef.line, templateRef.col);
52
+ }
53
+ stmt.methods.push(renderMethod);
54
+ if (autoSubscribeFields.length > 0) {
55
+ if (!stmt.constructor) {
56
+ diagnostics.error(`Class '${stmt.name}' has a template referencing state<T> field(s) (${autoSubscribeFields.join(", ")}) but no constructor to subscribe from`, templateRef.line, templateRef.col);
57
+ continue;
58
+ }
59
+ for (const fieldName of autoSubscribeFields) {
60
+ const field = stmt.fields.find((f) => f.name === fieldName);
61
+ const valueType = field.type.valueType;
62
+ const subscribeCall = {
63
+ kind: "ExpressionStatement",
64
+ expression: {
65
+ kind: "CallExpr",
66
+ callee: { kind: "MemberExpr", object: { kind: "MemberExpr", object: { kind: "ThisExpr", line: 0, col: 0 }, property: fieldName, line: 0, col: 0 }, property: "Subscribe", line: 0, col: 0 },
67
+ args: [
68
+ {
69
+ kind: "LambdaExpr",
70
+ params: [{ name: "__v", type: valueType }],
71
+ body: {
72
+ kind: "Block",
73
+ statements: [
74
+ { kind: "ExpressionStatement", expression: { kind: "CallExpr", callee: { kind: "MemberExpr", object: { kind: "ThisExpr", line: 0, col: 0 }, property: "Update", line: 0, col: 0 }, args: [], line: 0, col: 0 }, line: 0, col: 0 },
75
+ ],
76
+ line: 0,
77
+ col: 0,
78
+ },
79
+ line: 0,
80
+ col: 0,
81
+ },
82
+ ],
83
+ line: 0,
84
+ col: 0,
85
+ },
86
+ line: 0,
87
+ col: 0,
88
+ };
89
+ stmt.constructor.body.statements.push(subscribeCall);
90
+ }
91
+ }
92
+ }
93
+ return watchFiles;
94
+ }
8
95
  function displayPath(absPath) {
9
96
  return relative(process.cwd(), absPath);
10
97
  }
@@ -15,21 +102,28 @@ function displayPath(absPath) {
15
102
  // cyclic dependency is reported on the referencing `using` statement and
16
103
  // simply isn't added to that file's dependency list, so the rest of the
17
104
  // graph can still be explored and reported on in one pass.
18
- export function loadModuleGraph(entryAbsPath) {
105
+ // `fileOverrides` (absPath -> source) lets a caller substitute content that
106
+ // hasn't been saved to disk yet — an editor's live buffer, most likely —
107
+ // for any file in the graph, falling back to the real file for everything
108
+ // else. Without this, editor tooling can only ever see what's on disk,
109
+ // which is stale the moment a file has unsaved edits.
110
+ export function loadModuleGraph(entryAbsPath, fileOverrides) {
19
111
  const modules = new Map();
20
112
  const order = [];
21
113
  const stack = [];
22
- if (!existsSync(entryAbsPath)) {
114
+ const exists = (absPath) => fileOverrides?.has(absPath) || existsSync(absPath);
115
+ if (!exists(entryAbsPath)) {
23
116
  return { modules, order, entryMissing: true };
24
117
  }
25
118
  function visit(absPath) {
26
119
  if (modules.has(absPath))
27
120
  return;
28
- const source = readFileSync(absPath, "utf-8");
121
+ const source = fileOverrides?.get(absPath) ?? readFileSync(absPath, "utf-8");
29
122
  const diagnostics = new DiagnosticBag();
30
123
  const tokens = new Lexer(source, diagnostics).tokenize();
31
124
  const program = new Parser(tokens, diagnostics).parseProgram();
32
- const record = { absPath, source, program, diagnostics, dependencies: [], hoverEntries: [] };
125
+ const extraWatchFiles = expandTemplates(program, absPath, diagnostics, fileOverrides);
126
+ const record = { absPath, source, tokens, program, diagnostics, dependencies: [], hoverEntries: [], extraWatchFiles };
33
127
  modules.set(absPath, record);
34
128
  stack.push(absPath);
35
129
  for (const u of program.usings) {
@@ -38,7 +132,7 @@ export function loadModuleGraph(entryAbsPath) {
38
132
  continue;
39
133
  }
40
134
  const depPath = resolve(dirname(absPath), u.path) + ".ks";
41
- if (!existsSync(depPath)) {
135
+ if (!exists(depPath)) {
42
136
  diagnostics.error(`Cannot find module '${u.path}' (looked for '${displayPath(depPath)}')`, u.line, u.col);
43
137
  continue;
44
138
  }
@@ -60,8 +154,8 @@ export function loadModuleGraph(entryAbsPath) {
60
154
  // with its direct dependencies' merged exports), and — only if the entire
61
155
  // graph is error-free — generates one JS file's contents per module, with
62
156
  // real ES `import`/`export` statements wiring them together.
63
- export function compileGraph(entryAbsPath) {
64
- const { modules, order, entryMissing } = loadModuleGraph(entryAbsPath);
157
+ export function compileGraph(entryAbsPath, fileOverrides) {
158
+ const { modules, order, entryMissing } = loadModuleGraph(entryAbsPath, fileOverrides);
65
159
  if (entryMissing) {
66
160
  return { success: false, entryMissing: true, modules, order, outputs: new Map() };
67
161
  }
@@ -106,7 +200,18 @@ export function compileGraph(entryAbsPath) {
106
200
  merged.enums.set(name, info);
107
201
  }
108
202
  const checker = new Checker(mod.program, mod.diagnostics, merged, absPath);
109
- checker.check();
203
+ try {
204
+ checker.check();
205
+ }
206
+ catch {
207
+ // A malformed-enough program (most likely: mid-edit in an editor,
208
+ // where the rest of the graph is still worth reporting on) can trip
209
+ // an assertion deep in the checker — whatever hover entries/exports
210
+ // it recorded before that are still worth keeping, and this module is
211
+ // conservatively treated as erroring so codegen never runs on a
212
+ // half-checked result.
213
+ hasErrors = true;
214
+ }
110
215
  mod.hoverEntries = checker.hoverEntries;
111
216
  if (mod.diagnostics.hasErrors)
112
217
  hasErrors = true;
package/dist/parser.js CHANGED
@@ -26,6 +26,49 @@ export class Parser {
26
26
  }
27
27
  return { kind: "Program", usings, statements };
28
28
  }
29
+ // Parses a single, complete expression from `tokens` and nothing else —
30
+ // for embedding a real KopScript expression inside something that isn't
31
+ // itself a .ks file (a template binding's attribute value, `(click)=
32
+ // "Increment()"` or `{{ Count.Value }}` — see template_compiler.ts).
33
+ // Errors (including "didn't consume everything") report through the same
34
+ // `diagnostics` bag this Parser was constructed with, so a caller that
35
+ // re-maps positions back into the original file gets ordinary Diagnostics
36
+ // to work with, not a separate error channel. A genuinely malformed
37
+ // fragment (e.g. an unclosed paren) throws internally the same way any
38
+ // other parse failure does (see `parseStatement`'s own try/catch) — this
39
+ // catches that the same way, returning an inert placeholder expression
40
+ // (a `null` literal) rather than propagating the exception; the real
41
+ // diagnostic was already recorded before the throw either way.
42
+ parseStandaloneExpression() {
43
+ try {
44
+ const expr = this.parseExpression();
45
+ if (!this.check(TokenKind.EOF)) {
46
+ const t = this.peek();
47
+ this.diagnostics.error(`Unexpected token '${t.lexeme}' after expression`, t.line, t.col);
48
+ }
49
+ return expr;
50
+ }
51
+ catch {
52
+ const t = this.peek();
53
+ return { kind: "NullLiteral", line: t.line, col: t.col };
54
+ }
55
+ }
56
+ // Same idea as `parseStandaloneExpression`, for a type reference — used
57
+ // for `*for="Item item of Items"`'s explicit element type.
58
+ parseStandaloneType() {
59
+ try {
60
+ const type = this.parseType();
61
+ if (!this.check(TokenKind.EOF)) {
62
+ const t = this.peek();
63
+ this.diagnostics.error(`Unexpected token '${t.lexeme}' after type`, t.line, t.col);
64
+ }
65
+ return type;
66
+ }
67
+ catch {
68
+ const t = this.peek();
69
+ return { kind: "NamedType", name: "void", typeArgs: null, line: t.line, col: t.col };
70
+ }
71
+ }
29
72
  parseUsing() {
30
73
  const start = this.advance(); // 'using'
31
74
  const pathTok = this.consume(TokenKind.String, "Expected a module path string after 'using'");
@@ -225,7 +268,19 @@ export class Parser {
225
268
  const properties = [];
226
269
  const methods = [];
227
270
  let ctor = null;
271
+ let template = null;
228
272
  while (!this.check(TokenKind.RBrace) && !this.check(TokenKind.EOF)) {
273
+ if (this.check(TokenKind.Template)) {
274
+ const templateStart = this.advance();
275
+ if (template) {
276
+ this.diagnostics.error(`Class '${name}' already has a 'template' declaration`, templateStart.line, templateStart.col);
277
+ }
278
+ this.consume(TokenKind.From, "Expected 'from \"<path>\"' after 'template'");
279
+ const pathTok = this.consume(TokenKind.String, "Expected a file path string after 'from'");
280
+ this.consume(TokenKind.Semicolon, "Expected ';' after 'template' declaration");
281
+ template = { path: pathTok.lexeme, line: templateStart.line, col: templateStart.col };
282
+ continue;
283
+ }
229
284
  let visibility = "public";
230
285
  if (this.check(TokenKind.Public) || this.check(TokenKind.Private) || this.check(TokenKind.Protected)) {
231
286
  const v = this.advance().kind;
@@ -357,7 +412,7 @@ export class Parser {
357
412
  }
358
413
  }
359
414
  this.consume(TokenKind.RBrace, "Expected '}' after class body");
360
- return { kind: "ClassDecl", isExported, name, typeParam, baseList, fields, properties, constructor: ctor, methods, line: start.line, col: start.col };
415
+ return { kind: "ClassDecl", isExported, name, typeParam, baseList, fields, properties, constructor: ctor, methods, template, line: start.line, col: start.col };
361
416
  }
362
417
  parseInterfaceDecl(isExported) {
363
418
  const start = this.advance(); // 'interface'
@@ -0,0 +1,276 @@
1
+ const ELEMENT_TYPE = { kind: "NamedType", name: "Element", typeArgs: null, line: 0, col: 0 };
2
+ const VOID_TYPE = { kind: "NamedType", name: "void", typeArgs: null, line: 0, col: 0 };
3
+ const EVENT_TYPE = { kind: "NamedType", name: "Event", typeArgs: null, line: 0, col: 0 };
4
+ function ident(name, at) {
5
+ return { kind: "Identifier", name, line: at.line, col: at.col };
6
+ }
7
+ function stringLiteral(value, at) {
8
+ return { kind: "StringLiteral", value, line: at.line, col: at.col };
9
+ }
10
+ function member(object, property, at) {
11
+ return { kind: "MemberExpr", object, property, line: at.line, col: at.col };
12
+ }
13
+ function call(callee, args, at) {
14
+ return { kind: "CallExpr", callee, args, line: at.line, col: at.col };
15
+ }
16
+ function exprStatement(expression, at) {
17
+ return { kind: "ExpressionStatement", expression, line: at.line, col: at.col };
18
+ }
19
+ function assign(target, value, at) {
20
+ return exprStatement({ kind: "AssignExpr", target, value, line: at.line, col: at.col }, at);
21
+ }
22
+ function varDecl(name, init, at) {
23
+ return { kind: "VarDecl", isConst: false, name, nameLine: at.line, nameCol: at.col, type: ELEMENT_TYPE, init, line: at.line, col: at.col };
24
+ }
25
+ function block(statements, at) {
26
+ return { kind: "Block", statements, line: at.line, col: at.col };
27
+ }
28
+ // Visits every sub-expression reachable from `expr` — used only to find
29
+ // direct references to a state<T> field (see `TemplateCompiler.trackState`)
30
+ // while walking a binding's already-parsed expression; not a general
31
+ // checker replacement.
32
+ function walkExpr(expr, visit) {
33
+ visit(expr);
34
+ switch (expr.kind) {
35
+ case "UnaryExpr":
36
+ walkExpr(expr.operand, visit);
37
+ return;
38
+ case "BinaryExpr":
39
+ case "LogicalExpr":
40
+ walkExpr(expr.left, visit);
41
+ walkExpr(expr.right, visit);
42
+ return;
43
+ case "AssignExpr":
44
+ walkExpr(expr.target, visit);
45
+ walkExpr(expr.value, visit);
46
+ return;
47
+ case "CallExpr":
48
+ walkExpr(expr.callee, visit);
49
+ expr.args.forEach((a) => walkExpr(a, visit));
50
+ return;
51
+ case "NewExpr":
52
+ expr.args.forEach((a) => walkExpr(a, visit));
53
+ return;
54
+ case "MemberExpr":
55
+ walkExpr(expr.object, visit);
56
+ return;
57
+ case "IndexExpr":
58
+ walkExpr(expr.object, visit);
59
+ walkExpr(expr.index, visit);
60
+ return;
61
+ case "InterpolatedStringLiteral":
62
+ for (const part of expr.parts)
63
+ if (part.kind === "Expr")
64
+ walkExpr(part.expression, visit);
65
+ return;
66
+ case "ArrayLiteral":
67
+ expr.elements.forEach((e) => walkExpr(e, visit));
68
+ return;
69
+ case "AwaitExpr":
70
+ walkExpr(expr.operand, visit);
71
+ return;
72
+ case "StateExpr":
73
+ walkExpr(expr.initializer, visit);
74
+ return;
75
+ case "MatchExpr":
76
+ walkExpr(expr.subject, visit);
77
+ for (const arm of expr.arms)
78
+ walkExpr(arm.result, visit);
79
+ return;
80
+ default:
81
+ return; // literals, Identifier, ThisExpr, LambdaExpr (deliberately not descending into a lambda body — see module header)
82
+ }
83
+ }
84
+ // KopScript has no implicit `this` anywhere else in the language — every
85
+ // real example in this codebase writes `this.Foo()`, never a bare `Foo()`
86
+ // meaning the same thing. Template bindings are a deliberate, documented
87
+ // exception: `{{ Count.Value }}`/`(click)="Increment()"` read a bare
88
+ // `Count`/`Increment` the way Angular's own templates do, with `this.`
89
+ // implied. This rebuilds `expr`, rewriting a bare `Identifier` into
90
+ // `this.<name>` exactly when `name` is one of the enclosing class's own
91
+ // directly-declared members (`memberNames`) and isn't shadowed by a
92
+ // closer-in-scope local (`localScope` — a `*for` loop variable, most
93
+ // likely) — anything else (a real global like `print`, a loop variable)
94
+ // resolves exactly as bare KopScript already would.
95
+ //
96
+ // Known limitation: `memberNames` only ever contains the class's *own*
97
+ // declared members, not anything inherited from a base class — this runs
98
+ // at parse time, before the checker has resolved cross-file inheritance,
99
+ // so an inherited member needs an explicit `this.` in a template binding
100
+ // to be found. Most template-relevant state (a component's own counters,
101
+ // handler methods) is declared directly on the component itself in
102
+ // practice; this is a real, accepted v1 gap, not an oversight.
103
+ function qualifyThis(expr, memberNames, localScope) {
104
+ const q = (e) => qualifyThis(e, memberNames, localScope);
105
+ switch (expr.kind) {
106
+ case "Identifier":
107
+ if (localScope.has(expr.name))
108
+ return expr;
109
+ if (memberNames.has(expr.name))
110
+ return member({ kind: "ThisExpr", line: expr.line, col: expr.col }, expr.name, expr);
111
+ return expr;
112
+ case "UnaryExpr":
113
+ return { ...expr, operand: q(expr.operand) };
114
+ case "BinaryExpr":
115
+ case "LogicalExpr":
116
+ return { ...expr, left: q(expr.left), right: q(expr.right) };
117
+ case "AssignExpr":
118
+ return { ...expr, target: q(expr.target), value: q(expr.value) };
119
+ case "CallExpr":
120
+ return { ...expr, callee: q(expr.callee), args: expr.args.map(q) };
121
+ case "NewExpr":
122
+ return { ...expr, args: expr.args.map(q) };
123
+ case "MemberExpr":
124
+ return { ...expr, object: q(expr.object) };
125
+ case "IndexExpr":
126
+ return { ...expr, object: q(expr.object), index: q(expr.index) };
127
+ case "InterpolatedStringLiteral":
128
+ return { ...expr, parts: expr.parts.map((p) => (p.kind === "Expr" ? { kind: "Expr", expression: q(p.expression) } : p)) };
129
+ case "ArrayLiteral":
130
+ return { ...expr, elements: expr.elements.map(q) };
131
+ case "AwaitExpr":
132
+ return { ...expr, operand: q(expr.operand) };
133
+ case "StateExpr":
134
+ return { ...expr, initializer: q(expr.initializer) };
135
+ case "MatchExpr":
136
+ return { ...expr, subject: q(expr.subject), arms: expr.arms.map((arm) => ({ ...arm, result: q(arm.result) })) };
137
+ default:
138
+ return expr; // literals, ThisExpr, LambdaExpr — see walkExpr's own comment
139
+ }
140
+ }
141
+ const NO_LOCALS = new Set();
142
+ export class TemplateCompiler {
143
+ // `memberNames`: every field/property/method declared directly on the
144
+ // enclosing class — what a bare identifier in a binding is allowed to
145
+ // implicitly mean `this.<name>` for (see `qualifyThis`'s own header).
146
+ // `stateFieldNames`: the subset of those that are `state<T>` — tracked
147
+ // separately for auto-subscribe, since not every member qualifies.
148
+ constructor(memberNames, stateFieldNames, diagnostics) {
149
+ this.memberNames = memberNames;
150
+ this.stateFieldNames = stateFieldNames;
151
+ this.diagnostics = diagnostics;
152
+ this.varCounter = 0;
153
+ this.autoSubscribeFields = new Set();
154
+ }
155
+ freshVar() {
156
+ return `__el${this.varCounter++}`;
157
+ }
158
+ // Qualifies bare member references with `this.`, then records any
159
+ // directly-referenced `state<T>` field for auto-subscribe — run once per
160
+ // binding expression, at the point each is pulled out of the template.
161
+ resolve(expr, localScope) {
162
+ const qualified = qualifyThis(expr, this.memberNames, localScope);
163
+ walkExpr(qualified, (e) => {
164
+ if (e.kind === "MemberExpr" && e.object.kind === "ThisExpr" && this.stateFieldNames.has(e.property)) {
165
+ this.autoSubscribeFields.add(e.property);
166
+ }
167
+ });
168
+ return qualified;
169
+ }
170
+ compile(root, templateDeclAt) {
171
+ const { varName, statements } = this.buildElement(root, NO_LOCALS);
172
+ statements.push({ kind: "ReturnStatement", value: ident(varName, templateDeclAt), line: templateDeclAt.line, col: templateDeclAt.col });
173
+ const renderMethod = {
174
+ kind: "MethodDecl",
175
+ visibility: "public",
176
+ isStatic: false,
177
+ isAsync: false,
178
+ isVirtual: false,
179
+ isOverride: true,
180
+ name: "Render",
181
+ nameLine: templateDeclAt.line,
182
+ nameCol: templateDeclAt.col,
183
+ params: [],
184
+ returnType: ELEMENT_TYPE,
185
+ body: block(statements, templateDeclAt),
186
+ line: templateDeclAt.line,
187
+ col: templateDeclAt.col,
188
+ };
189
+ return { renderMethod, autoSubscribeFields: [...this.autoSubscribeFields] };
190
+ }
191
+ // Builds `node` (ignoring any *if/*for on it — see buildAppendStatements,
192
+ // which strips those before delegating here) and returns the local
193
+ // variable name holding the finished Element, plus the statements that
194
+ // build it. Doesn't append it anywhere; the caller decides that (the
195
+ // root just returns it, a non-root child appends it to its parent).
196
+ // `localScope`: names that resolve as plain locals rather than `this.`
197
+ // members within this subtree — a `*for` loop variable, most likely.
198
+ buildElement(node, localScope) {
199
+ const at = node;
200
+ const varName = this.freshVar();
201
+ const statements = [];
202
+ const self = ident(varName, at);
203
+ statements.push(varDecl(varName, call(member(ident("document", at), "createElement", at), [stringLiteral(node.tag, at)], at), at));
204
+ for (const attr of node.staticAttrs)
205
+ statements.push(assign(member(self, attr.name, attr), stringLiteral(attr.value, attr), attr));
206
+ for (const bind of node.propBindings) {
207
+ const value = this.resolve(bind.value, localScope);
208
+ statements.push(assign(member(self, bind.name, bind), value, bind));
209
+ }
210
+ for (const bind of node.eventBindings) {
211
+ const handler = this.resolve(bind.handler, localScope);
212
+ const handlerParam = { name: "e", type: EVENT_TYPE };
213
+ const lambda = {
214
+ kind: "LambdaExpr",
215
+ params: [{ name: handlerParam.name, type: handlerParam.type }],
216
+ body: block([exprStatement(handler, bind)], bind),
217
+ line: bind.line,
218
+ col: bind.col,
219
+ };
220
+ statements.push(exprStatement(call(member(self, "addEventListener", bind), [stringLiteral(bind.name, bind), lambda], bind), bind));
221
+ }
222
+ const elementChildren = node.children.filter((c) => c.kind === "element");
223
+ const textChildren = node.children.filter((c) => c.kind === "text");
224
+ if (textChildren.length > 0 && elementChildren.length > 0) {
225
+ this.diagnostics.error(`<${node.tag}> cannot mix text and element children — Kopular's DOM bindings have no text-node API, so text can only be a whole element's content`, node.line, node.col);
226
+ }
227
+ else if (textChildren.length > 0) {
228
+ const parts = textChildren.flatMap((t) => t.parts).map((p) => (p.kind === "Expr" ? { kind: "Expr", expression: this.resolve(p.expression, localScope) } : p));
229
+ const interpolated = { kind: "InterpolatedStringLiteral", parts, line: at.line, col: at.col };
230
+ statements.push(assign(member(self, "textContent", at), interpolated, at));
231
+ }
232
+ else {
233
+ for (const child of elementChildren)
234
+ statements.push(...this.buildAppendStatements(child, varName, localScope));
235
+ }
236
+ return { varName, statements };
237
+ }
238
+ buildAppendStatements(node, parentVar, localScope) {
239
+ if (node.forBinding && node.ifCondition) {
240
+ // Already reported by the parser (only one structural directive per
241
+ // element) — avoid also emitting a confusing double-wrapped loop/if.
242
+ node = { ...node, ifCondition: null };
243
+ }
244
+ if (node.forBinding) {
245
+ const forBind = node.forBinding;
246
+ // The iterable is evaluated in the *enclosing* scope — the loop
247
+ // variable it's about to introduce isn't in scope for it yet.
248
+ const iterable = this.resolve(forBind.iterable, localScope);
249
+ const innerScope = new Set([...localScope, forBind.varName]);
250
+ const { varName: elVar, statements: buildStatements } = this.buildElement({ ...node, forBinding: null }, innerScope);
251
+ const body = block([...buildStatements, this.appendChild(parentVar, elVar, node)], node);
252
+ const forIn = {
253
+ kind: "ForInStatement",
254
+ varType: forBind.varType,
255
+ varName: forBind.varName,
256
+ iterable,
257
+ body,
258
+ line: forBind.line,
259
+ col: forBind.col,
260
+ };
261
+ return [forIn];
262
+ }
263
+ if (node.ifCondition) {
264
+ const condition = this.resolve(node.ifCondition, localScope);
265
+ const { varName: elVar, statements: buildStatements } = this.buildElement({ ...node, ifCondition: null }, localScope);
266
+ const thenBranch = block([...buildStatements, this.appendChild(parentVar, elVar, node)], node);
267
+ const ifStmt = { kind: "IfStatement", condition, thenBranch, elseBranch: null, line: node.line, col: node.col };
268
+ return [ifStmt];
269
+ }
270
+ const { varName: elVar, statements } = this.buildElement(node, localScope);
271
+ return [...statements, this.appendChild(parentVar, elVar, node)];
272
+ }
273
+ appendChild(parentVar, childVar, at) {
274
+ return exprStatement(call(member(ident(parentVar, at), "appendChild", at), [ident(childVar, at)], at), at);
275
+ }
276
+ }
@@ -0,0 +1,162 @@
1
+ export var TemplateTokenKind;
2
+ (function (TemplateTokenKind) {
3
+ TemplateTokenKind["TagOpen"] = "TagOpen";
4
+ TemplateTokenKind["TagSelfClose"] = "TagSelfClose";
5
+ TemplateTokenKind["TagOpenEnd"] = "TagOpenEnd";
6
+ TemplateTokenKind["TagClose"] = "TagClose";
7
+ TemplateTokenKind["AttrName"] = "AttrName";
8
+ TemplateTokenKind["Equals"] = "Equals";
9
+ TemplateTokenKind["AttrValue"] = "AttrValue";
10
+ TemplateTokenKind["Text"] = "Text";
11
+ TemplateTokenKind["EOF"] = "EOF";
12
+ })(TemplateTokenKind || (TemplateTokenKind = {}));
13
+ export class TemplateLexer {
14
+ constructor(source, diagnostics) {
15
+ this.source = source;
16
+ this.diagnostics = diagnostics;
17
+ this.pos = 0;
18
+ this.line = 1;
19
+ this.col = 1;
20
+ // Set while scanning inside a tag (between '<name' and the closing '>'
21
+ // or '/>') — text-vs-markup scanning rules differ there (attribute
22
+ // syntax vs. raw text content).
23
+ this.inTag = false;
24
+ }
25
+ tokenize() {
26
+ const tokens = [];
27
+ while (true) {
28
+ const token = this.inTag ? this.nextTagToken() : this.nextContentToken();
29
+ tokens.push(token);
30
+ if (token.kind === TemplateTokenKind.EOF)
31
+ break;
32
+ }
33
+ return tokens;
34
+ }
35
+ isAtEnd() {
36
+ return this.pos >= this.source.length;
37
+ }
38
+ peek(offset = 0) {
39
+ return this.source[this.pos + offset] ?? "";
40
+ }
41
+ advance() {
42
+ const c = this.source[this.pos++];
43
+ if (c === "\n") {
44
+ this.line++;
45
+ this.col = 1;
46
+ }
47
+ else {
48
+ this.col++;
49
+ }
50
+ return c;
51
+ }
52
+ make(kind, lexeme, line, col) {
53
+ return { kind, lexeme, line, col };
54
+ }
55
+ skipComment() {
56
+ // Already past '<!--' when called.
57
+ while (!this.isAtEnd() && !(this.peek() === "-" && this.peek(1) === "-" && this.peek(2) === ">"))
58
+ this.advance();
59
+ if (!this.isAtEnd()) {
60
+ this.advance();
61
+ this.advance();
62
+ this.advance(); // '-->'
63
+ }
64
+ }
65
+ // Outside a tag: either a '<...' (comment, closing tag, or opening tag)
66
+ // or a run of plain text up to the next '<'.
67
+ nextContentToken() {
68
+ if (this.isAtEnd())
69
+ return this.make(TemplateTokenKind.EOF, "", this.line, this.col);
70
+ if (this.peek() === "<") {
71
+ if (this.peek(1) === "!" && this.peek(2) === "-" && this.peek(3) === "-") {
72
+ this.advance();
73
+ this.advance();
74
+ this.advance();
75
+ this.advance(); // '<!--'
76
+ this.skipComment();
77
+ return this.nextContentToken(); // comments produce no token at all
78
+ }
79
+ if (this.peek(1) === "/") {
80
+ const line = this.line;
81
+ const col = this.col;
82
+ this.advance();
83
+ this.advance(); // '</'
84
+ const name = this.readName();
85
+ this.skipInlineWhitespace();
86
+ if (this.peek() === ">")
87
+ this.advance();
88
+ return this.make(TemplateTokenKind.TagClose, name, line, col);
89
+ }
90
+ const line = this.line;
91
+ const col = this.col;
92
+ this.advance(); // '<'
93
+ const name = this.readName();
94
+ this.inTag = true;
95
+ return this.make(TemplateTokenKind.TagOpen, name, line, col);
96
+ }
97
+ const line = this.line;
98
+ const col = this.col;
99
+ let text = "";
100
+ while (!this.isAtEnd() && this.peek() !== "<")
101
+ text += this.advance();
102
+ return this.make(TemplateTokenKind.Text, text, line, col);
103
+ }
104
+ // Inside a tag, after the tag name: attribute names/values, '=', and the
105
+ // '>' or '/>' that ends it.
106
+ nextTagToken() {
107
+ this.skipInlineWhitespace();
108
+ if (this.isAtEnd())
109
+ return this.make(TemplateTokenKind.EOF, "", this.line, this.col);
110
+ const line = this.line;
111
+ const col = this.col;
112
+ if (this.peek() === "/" && this.peek(1) === ">") {
113
+ this.advance();
114
+ this.advance();
115
+ this.inTag = false;
116
+ return this.make(TemplateTokenKind.TagSelfClose, "/>", line, col);
117
+ }
118
+ if (this.peek() === ">") {
119
+ this.advance();
120
+ this.inTag = false;
121
+ return this.make(TemplateTokenKind.TagOpenEnd, ">", line, col);
122
+ }
123
+ if (this.peek() === "=") {
124
+ this.advance();
125
+ return this.make(TemplateTokenKind.Equals, "=", line, col);
126
+ }
127
+ if (this.peek() === '"') {
128
+ this.advance();
129
+ let value = "";
130
+ while (!this.isAtEnd() && this.peek() !== '"')
131
+ value += this.advance();
132
+ if (this.isAtEnd()) {
133
+ this.diagnostics.error("Unterminated attribute value", line, col);
134
+ }
135
+ else {
136
+ this.advance(); // closing '"'
137
+ }
138
+ return this.make(TemplateTokenKind.AttrValue, value, line, col);
139
+ }
140
+ // An attribute name: `(click)`, `[className]`, `*if`, `*for`, or a
141
+ // plain `id` — every character up to whitespace/'='/'>'/'/'.
142
+ let name = "";
143
+ while (!this.isAtEnd() && !/[\s=>/]/.test(this.peek()))
144
+ name += this.advance();
145
+ if (name.length === 0) {
146
+ this.diagnostics.error(`Unexpected character '${this.peek()}' in tag`, line, col);
147
+ this.advance(); // avoid an infinite loop on a genuinely unexpected character
148
+ return this.nextTagToken();
149
+ }
150
+ return this.make(TemplateTokenKind.AttrName, name, line, col);
151
+ }
152
+ skipInlineWhitespace() {
153
+ while (!this.isAtEnd() && /\s/.test(this.peek()))
154
+ this.advance();
155
+ }
156
+ readName() {
157
+ let name = "";
158
+ while (!this.isAtEnd() && /[A-Za-z0-9-]/.test(this.peek()))
159
+ name += this.advance();
160
+ return name;
161
+ }
162
+ }
@@ -0,0 +1,249 @@
1
+ // Builds a template's element/text tree from TemplateLexer's token stream,
2
+ // parsing every binding's value (event/property bindings, *if/*for,
3
+ // {{ }} interpolation) as a real KopScript expression via kopscript's own
4
+ // Lexer/Parser — reusing parseStandaloneExpression/parseStandaloneType
5
+ // (see parser.ts) rather than inventing a second expression language.
6
+ //
7
+ // A fragment parsed out of an attribute value or a {{ }} span starts its
8
+ // own line/col numbering at (1, 1); `remapPosition` translates a position
9
+ // found while parsing one back into real coordinates in the .html file, so
10
+ // a mistake inside a binding ("Cuont.Value") reports at the right place in
11
+ // the template, not "line 1, column 6" every time.
12
+ import { TokenKind } from "./tokens.js";
13
+ import { Lexer } from "./lexer.js";
14
+ import { Parser } from "./parser.js";
15
+ import { DiagnosticBag } from "./diagnostics.js";
16
+ import { TemplateLexer, TemplateTokenKind } from "./template_lexer.js";
17
+ const PROPERTY_ALIASES = { class: "className" };
18
+ function remapPosition(fragmentLine, fragmentCol, localLine, localCol) {
19
+ if (localLine === 1)
20
+ return { line: fragmentLine, col: fragmentCol + localCol - 1 };
21
+ return { line: fragmentLine + localLine - 1, col: localCol };
22
+ }
23
+ export class TemplateParser {
24
+ constructor(source, diagnostics) {
25
+ this.diagnostics = diagnostics;
26
+ this.pos = 0;
27
+ this.tokens = new TemplateLexer(source, diagnostics).tokenize();
28
+ }
29
+ peek() {
30
+ return this.tokens[this.pos];
31
+ }
32
+ advance() {
33
+ return this.tokens[this.pos++];
34
+ }
35
+ check(kind) {
36
+ return this.peek().kind === kind;
37
+ }
38
+ // Parses a fragment of real KopScript source (an attribute value, or the
39
+ // text inside {{ }}) as a standalone expression, remapping any
40
+ // diagnostics it produces back into the template file's own coordinates.
41
+ parseEmbeddedExpression(source, fragmentLine, fragmentCol) {
42
+ const localDiagnostics = new DiagnosticBag();
43
+ const tokens = new Lexer(source, localDiagnostics).tokenize();
44
+ const expr = new Parser(tokens, localDiagnostics).parseStandaloneExpression();
45
+ for (const d of localDiagnostics.diagnostics) {
46
+ const { line, col } = remapPosition(fragmentLine, fragmentCol, d.line, d.col);
47
+ this.diagnostics.error(d.message, line, col);
48
+ }
49
+ return expr;
50
+ }
51
+ // "Item item of Items" — an explicit element type (matching KopScript's
52
+ // own no-inference philosophy), a variable name, the contextual "of"
53
+ // separator (a plain identifier, not a reserved keyword), then the
54
+ // iterable expression.
55
+ parseForBinding(source, fragmentLine, fragmentCol) {
56
+ const localDiagnostics = new DiagnosticBag();
57
+ const tokens = new Lexer(source, localDiagnostics).tokenize();
58
+ const ofIndex = tokens.findIndex((t) => t.kind === TokenKind.Identifier && t.lexeme === "of");
59
+ if (ofIndex < 1 || tokens[ofIndex - 1].kind !== TokenKind.Identifier) {
60
+ this.diagnostics.error(`Expected '*for="Type varName of iterable"', got '${source}'`, fragmentLine, fragmentCol);
61
+ return null;
62
+ }
63
+ const varNameToken = tokens[ofIndex - 1];
64
+ const typeTokens = [...tokens.slice(0, ofIndex - 1), tokens[tokens.length - 1]]; // real tokens + the shared EOF
65
+ const iterableTokens = tokens.slice(ofIndex + 1);
66
+ const varType = new Parser(typeTokens, localDiagnostics).parseStandaloneType();
67
+ const iterable = new Parser(iterableTokens, localDiagnostics).parseStandaloneExpression();
68
+ for (const d of localDiagnostics.diagnostics) {
69
+ const { line, col } = remapPosition(fragmentLine, fragmentCol, d.line, d.col);
70
+ this.diagnostics.error(d.message, line, col);
71
+ }
72
+ return { varType, varName: varNameToken.lexeme, iterable, line: fragmentLine, col: fragmentCol };
73
+ }
74
+ // Splits raw text into alternating literal/`{{ expr }}` parts, matching
75
+ // AST.InterpolatedPart — the exact shape `$"...{expr}..."` already
76
+ // produces, so a text node desugars straight into an InterpolatedStringLiteral
77
+ // with zero new codegen (see template_compiler.ts).
78
+ parseTextParts(text, startLine, startCol) {
79
+ const parts = [];
80
+ let i = 0;
81
+ let line = startLine;
82
+ let col = startCol;
83
+ let literal = "";
84
+ const advancePos = (chunk) => {
85
+ for (const ch of chunk) {
86
+ if (ch === "\n") {
87
+ line++;
88
+ col = 1;
89
+ }
90
+ else {
91
+ col++;
92
+ }
93
+ }
94
+ };
95
+ while (i < text.length) {
96
+ if (text[i] === "{" && text[i + 1] === "{") {
97
+ if (literal) {
98
+ parts.push({ kind: "Text", text: literal });
99
+ literal = "";
100
+ }
101
+ const exprStartOffset = i + 2;
102
+ const end = text.indexOf("}}", exprStartOffset);
103
+ const exprLine = line;
104
+ const exprCol = col;
105
+ advancePos(text.slice(i, exprStartOffset));
106
+ if (end === -1) {
107
+ this.diagnostics.error("Unterminated '{{' interpolation", exprLine, exprCol);
108
+ break;
109
+ }
110
+ const exprSource = text.slice(exprStartOffset, end);
111
+ const expr = this.parseEmbeddedExpression(exprSource, exprLine, exprCol);
112
+ parts.push({ kind: "Expr", expression: expr });
113
+ advancePos(text.slice(exprStartOffset, end + 2));
114
+ i = end + 2;
115
+ continue;
116
+ }
117
+ literal += text[i];
118
+ advancePos(text[i]);
119
+ i++;
120
+ }
121
+ if (literal)
122
+ parts.push({ kind: "Text", text: literal });
123
+ return parts;
124
+ }
125
+ // Parses the whole template, enforcing the single-root-element rule
126
+ // (Component.Render() returns exactly one Element — no silent
127
+ // auto-wrapping if the author writes multiple top-level tags).
128
+ parseDocument() {
129
+ const roots = [];
130
+ while (!this.check(TemplateTokenKind.EOF)) {
131
+ if (this.check(TemplateTokenKind.Text) && this.peek().lexeme.trim() === "") {
132
+ this.advance(); // insignificant top-level whitespace
133
+ continue;
134
+ }
135
+ const node = this.parseNode();
136
+ if (node)
137
+ roots.push(node);
138
+ }
139
+ const elementRoots = roots.filter((r) => r.kind === "element");
140
+ if (elementRoots.length !== 1) {
141
+ const at = roots[0] ?? { line: 1, col: 1 };
142
+ this.diagnostics.error(`A template must have exactly one top-level element, found ${elementRoots.length}`, at.line, at.col);
143
+ return null;
144
+ }
145
+ return elementRoots[0];
146
+ }
147
+ parseNode() {
148
+ if (this.check(TemplateTokenKind.Text)) {
149
+ const t = this.advance();
150
+ return { kind: "text", parts: this.parseTextParts(t.lexeme, t.line, t.col), line: t.line, col: t.col };
151
+ }
152
+ if (this.check(TemplateTokenKind.TagOpen)) {
153
+ return this.parseElement();
154
+ }
155
+ const t = this.advance();
156
+ this.diagnostics.error(`Unexpected token in template ('${t.lexeme}')`, t.line, t.col);
157
+ return null;
158
+ }
159
+ parseElement() {
160
+ const tagTok = this.advance(); // TagOpen
161
+ const tag = tagTok.lexeme;
162
+ const staticAttrs = [];
163
+ const propBindings = [];
164
+ const eventBindings = [];
165
+ let ifCondition = null;
166
+ let forBinding = null;
167
+ while (this.check(TemplateTokenKind.AttrName)) {
168
+ const nameTok = this.advance();
169
+ this.consumeEquals(nameTok);
170
+ const valueTok = this.consumeAttrValue(nameTok);
171
+ if (!valueTok)
172
+ continue;
173
+ const name = nameTok.lexeme;
174
+ if (name.startsWith("(") && name.endsWith(")")) {
175
+ const eventName = name.slice(1, -1);
176
+ const handler = this.parseEmbeddedExpression(valueTok.lexeme, valueTok.line, valueTok.col);
177
+ eventBindings.push({ name: eventName, handler, line: nameTok.line, col: nameTok.col });
178
+ }
179
+ else if (name.startsWith("[") && name.endsWith("]")) {
180
+ const propName = name.slice(1, -1);
181
+ const value = this.parseEmbeddedExpression(valueTok.lexeme, valueTok.line, valueTok.col);
182
+ propBindings.push({ name: PROPERTY_ALIASES[propName] ?? propName, value, line: nameTok.line, col: nameTok.col });
183
+ }
184
+ else if (name === "*if") {
185
+ if (ifCondition || forBinding) {
186
+ this.diagnostics.error(`Only one structural directive ('*if'/'*for') is allowed per element`, nameTok.line, nameTok.col);
187
+ }
188
+ else {
189
+ ifCondition = this.parseEmbeddedExpression(valueTok.lexeme, valueTok.line, valueTok.col);
190
+ }
191
+ }
192
+ else if (name === "*for") {
193
+ if (ifCondition || forBinding) {
194
+ this.diagnostics.error(`Only one structural directive ('*if'/'*for') is allowed per element`, nameTok.line, nameTok.col);
195
+ }
196
+ else {
197
+ forBinding = this.parseForBinding(valueTok.lexeme, valueTok.line, valueTok.col);
198
+ }
199
+ }
200
+ else {
201
+ staticAttrs.push({ name: PROPERTY_ALIASES[name] ?? name, value: valueTok.lexeme, line: nameTok.line, col: nameTok.col });
202
+ }
203
+ }
204
+ const children = [];
205
+ if (this.check(TemplateTokenKind.TagSelfClose)) {
206
+ this.advance();
207
+ }
208
+ else {
209
+ this.consume(TemplateTokenKind.TagOpenEnd, tagTok, `Expected '>' or '/>' to close '<${tag}'`);
210
+ while (!this.check(TemplateTokenKind.TagClose) && !this.check(TemplateTokenKind.EOF)) {
211
+ if (this.check(TemplateTokenKind.Text) && this.peek().lexeme.trim() === "") {
212
+ this.advance(); // insignificant whitespace between child elements
213
+ continue;
214
+ }
215
+ const child = this.parseNode();
216
+ if (child)
217
+ children.push(child);
218
+ }
219
+ if (this.check(TemplateTokenKind.TagClose)) {
220
+ const closeTok = this.advance();
221
+ if (closeTok.lexeme !== tag) {
222
+ this.diagnostics.error(`Mismatched closing tag: expected '</${tag}>', got '</${closeTok.lexeme}>'`, closeTok.line, closeTok.col);
223
+ }
224
+ }
225
+ else {
226
+ this.diagnostics.error(`Expected closing tag '</${tag}>'`, tagTok.line, tagTok.col);
227
+ }
228
+ }
229
+ return { kind: "element", tag, staticAttrs, propBindings, eventBindings, ifCondition, forBinding, children, line: tagTok.line, col: tagTok.col };
230
+ }
231
+ consumeEquals(attrNameTok) {
232
+ this.consume(TemplateTokenKind.Equals, attrNameTok, `Expected '=' after attribute '${attrNameTok.lexeme}'`);
233
+ }
234
+ consumeAttrValue(attrNameTok) {
235
+ if (!this.check(TemplateTokenKind.AttrValue)) {
236
+ this.diagnostics.error(`Expected a quoted value after '${attrNameTok.lexeme}='`, attrNameTok.line, attrNameTok.col);
237
+ return null;
238
+ }
239
+ return this.advance();
240
+ }
241
+ consume(kind, at, message) {
242
+ if (this.check(kind)) {
243
+ this.advance();
244
+ }
245
+ else {
246
+ this.diagnostics.error(message, at.line, at.col);
247
+ }
248
+ }
249
+ }
package/dist/tokens.js CHANGED
@@ -13,6 +13,7 @@ export var TokenKind;
13
13
  TokenKind["Using"] = "Using";
14
14
  TokenKind["Extern"] = "Extern";
15
15
  TokenKind["Raw"] = "Raw";
16
+ TokenKind["Template"] = "Template";
16
17
  TokenKind["From"] = "From";
17
18
  TokenKind["As"] = "As";
18
19
  TokenKind["Const"] = "Const";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kopscript",
3
- "version": "0.5.1",
3
+ "version": "0.7.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",
@@ -9,12 +9,17 @@
9
9
  "type": "git",
10
10
  "url": "https://dev.azure.com/koppinator/Koppindependence/_git/Kop"
11
11
  },
12
+ "homepage": "https://kopular.dev",
12
13
  "keywords": [
13
14
  "language",
14
15
  "compiler",
15
16
  "transpiler",
16
17
  "typescript",
17
- "javascript"
18
+ "javascript",
19
+ "ai",
20
+ "llm",
21
+ "codegen",
22
+ "agent"
18
23
  ],
19
24
  "main": "./dist/modules.js",
20
25
  "files": [
@@ -37,7 +42,7 @@
37
42
  "@types/node": "^20.14.0",
38
43
  "tsx": "^4.16.0",
39
44
  "typescript": "^5.5.0",
40
- "vitest": "^2.0.0"
45
+ "vitest": "^4.1.11"
41
46
  },
42
47
  "engines": {
43
48
  "node": ">=18"