rip-lang 3.15.4 → 3.16.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.
Files changed (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/src/compiler.js CHANGED
@@ -11,13 +11,14 @@
11
11
  import { Lexer } from './lexer.js';
12
12
  import { parser } from './parser.js';
13
13
  import { installComponentSupport } from './components.js';
14
- // Type emission is CLI/editor-only. types-emit.js registers itself via
15
- // setTypesEmitter() at module load. The browser never imports types-emit,
14
+ // Type emission is CLI/editor-only. dts.js registers itself via
15
+ // setTypesEmitter() at module load. The browser never imports dts.js,
16
16
  // so _typesEmitter stays null and .d.ts output is silently skipped.
17
17
  let _typesEmitter = null;
18
18
  export function setTypesEmitter(fn) { _typesEmitter = fn; }
19
19
  import { installSchemaSupport } from './schema/schema.js';
20
20
  import { SourceMapGenerator } from './sourcemaps.js';
21
+ import { stringify, getStdlibCode } from './stdlib.js';
21
22
  import { RipError, toRipError } from './error.js';
22
23
 
23
24
  // =============================================================================
@@ -321,6 +322,20 @@ export class CodeEmitter {
321
322
  // Each entry pairs a statement's generated code with its source loc.
322
323
  // Output line positions are computed by exact arithmetic — no heuristics.
323
324
  buildMappings() {
325
+ // Imports are emitted at the top of the file (before the preamble),
326
+ // starting at line 0. Process them first with their own line offset.
327
+ if (this._importEntries) {
328
+ let importLineOffset = 0;
329
+ for (let entry of this._importEntries) {
330
+ if (entry.loc) {
331
+ this.sourceMap.addMapping(importLineOffset, 0, entry.loc.r, entry.loc.c);
332
+ }
333
+ if (entry.sexpr && entry.loc) {
334
+ this.recordSubMappings(entry.code, entry.sexpr, importLineOffset);
335
+ }
336
+ importLineOffset += entry.code.split('\n').length;
337
+ }
338
+ }
324
339
  if (!this._stmtEntries) return;
325
340
  let lineOffset = this._preambleLines;
326
341
  for (let entry of this._stmtEntries) {
@@ -340,11 +355,30 @@ export class CodeEmitter {
340
355
  // matches (e.g. identifiers appearing as values inside union type strings).
341
356
  static _isColInsideString(line, col) {
342
357
  let inStr = false, quote = '';
358
+ // When inside a template literal (`...`), `${...}` opens an interpolation
359
+ // expression that is NOT part of the string. Track interp depth so
360
+ // identifiers inside `${expr}` aren't incorrectly classified as strings.
361
+ let interpDepth = 0;
343
362
  for (let i = 0; i < line.length && i < col; i++) {
344
363
  let ch = line[i];
345
364
  if (inStr) {
346
365
  if (ch === '\\') { i++; continue; }
366
+ if (quote === '`' && ch === '$' && line[i + 1] === '{') {
367
+ interpDepth = 1;
368
+ inStr = false;
369
+ i++; // skip `{`
370
+ continue;
371
+ }
347
372
  if (ch === quote) inStr = false;
373
+ } else if (interpDepth > 0) {
374
+ if (ch === '{') interpDepth++;
375
+ else if (ch === '}') {
376
+ interpDepth--;
377
+ if (interpDepth === 0) { inStr = true; quote = '`'; }
378
+ } else if (ch === '"' || ch === "'" || ch === '`') {
379
+ // Nested string inside interpolation — recurse via inStr handling
380
+ inStr = true; quote = ch;
381
+ }
348
382
  } else if (ch === '"' || ch === "'" || ch === '`') {
349
383
  inStr = true; quote = ch;
350
384
  }
@@ -385,26 +419,106 @@ export class CodeEmitter {
385
419
  }
386
420
  return hi;
387
421
  };
422
+ // Track generated positions already claimed by an earlier sub-expression
423
+ // in this statement, so distinct source positions (e.g. `a` in two
424
+ // adjacent arrow functions) can't all collapse onto the first match.
425
+ // Without this, identical identifiers in repeated structures get
426
+ // mis-mapped — e.g. `else if` branches inheriting the `if` branch's
427
+ // generated coordinates, leaving the real branch unmapped.
428
+ const usedGenPositions = new Set();
429
+ // Cache `// @rip-src:N` annotations per generated line so render-block
430
+ // stub mappings can be honored over unrelated heuristic matches.
431
+ const ripSrcCache = new Map();
432
+ const getRipSrcAnnot = (genLineInStmt) => {
433
+ if (ripSrcCache.has(genLineInStmt)) return ripSrcCache.get(genLineInStmt);
434
+ const lt = codeLines[genLineInStmt];
435
+ const m = lt && lt.match(/\/\/ @rip-src:(\d+)\s*$/);
436
+ const v = m ? parseInt(m[1], 10) : null;
437
+ ripSrcCache.set(genLineInStmt, v);
438
+ return v;
439
+ };
440
+ // Inline type annotations (emitted when `inlineTypes: true`) inject
441
+ // identifiers into function-signature lines that have no source
442
+ // counterpart — e.g. `header(name, value, opts: { append?: boolean })`.
443
+ // Their identifiers (`append`, `boolean`, etc.) would otherwise compete
444
+ // with real body identifiers in the regex matcher below, mis-mapping
445
+ // source positions onto the type literal. Detect those brace ranges
446
+ // per line and skip matches inside them.
447
+ const inlineTypeRangesCache = new Map();
448
+ const getInlineTypeRanges = (genLineInStmt) => {
449
+ if (inlineTypeRangesCache.has(genLineInStmt)) return inlineTypeRangesCache.get(genLineInStmt);
450
+ const lt = codeLines[genLineInStmt];
451
+ const ranges = [];
452
+ // Only look at function-signature shaped lines: contain `(...) {` or `(...) =>`.
453
+ if (lt && /\)\s*(\{|=>)\s*$/.test(lt)) {
454
+ // Find `: {` after an identifier (with optional `?` and whitespace).
455
+ const annotRe = /\b[a-zA-Z_$][\w$]*\??\s*:\s*\{/g;
456
+ let am;
457
+ while ((am = annotRe.exec(lt)) !== null) {
458
+ const braceStart = am.index + am[0].length - 1; // position of `{`
459
+ // Skip inside strings (defensive — unlikely here)
460
+ if (CodeEmitter._isColInsideString(lt, braceStart)) continue;
461
+ // Walk to matching `}` honoring brace depth and strings
462
+ let depth = 1, j = braceStart + 1, inStr = false, quote = '';
463
+ while (j < lt.length && depth > 0) {
464
+ const ch = lt[j];
465
+ if (inStr) {
466
+ if (ch === '\\') { j += 2; continue; }
467
+ if (ch === quote) inStr = false;
468
+ } else if (ch === '"' || ch === "'" || ch === '`') {
469
+ inStr = true; quote = ch;
470
+ } else if (ch === '{') depth++;
471
+ else if (ch === '}') depth--;
472
+ j++;
473
+ }
474
+ ranges.push([braceStart, j]); // exclude positions [braceStart, j)
475
+ annotRe.lastIndex = j;
476
+ }
477
+ }
478
+ inlineTypeRangesCache.set(genLineInStmt, ranges);
479
+ return ranges;
480
+ };
388
481
  for (let { name, origLine, origCol } of subs) {
389
482
  let escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
390
483
  let re = new RegExp('\\b' + escaped + '\\b', 'g');
391
- let m, bestMatch = null, bestDist = Infinity;
484
+ let m;
485
+ let bestMatch = null, bestDist = Infinity;
486
+ let bestUnused = null, bestUnusedDist = Infinity;
392
487
  let origLineInStmt = origLine - stmtOrigLine;
393
488
  while ((m = re.exec(code)) !== null) {
394
489
  const genLineInStmt = offsetToLine(m.index);
395
490
  const genCol = m.index - lineStarts[genLineInStmt];
396
- // Skip matches inside string literals — prevents false mappings when
397
- // an identifier also appears as a string value (e.g. union type member)
398
491
  let lineText = codeLines[genLineInStmt];
399
- if (lineText && CodeEmitter._isColInsideString(lineText, genCol)) continue;
492
+ // A `// @rip-src:N` annotation tags the generated line as derived
493
+ // from source line N (used by render-block stubs). When N matches
494
+ // the sub-expression's origLine, accept matches here even if they
495
+ // fall inside string literals — render stubs emit tag names as
496
+ // `'p'` and interpolations as `${...}` inside template strings.
497
+ const annotSrc = getRipSrcAnnot(genLineInStmt);
498
+ const annotMatches = annotSrc != null && annotSrc === origLine;
499
+ if (lineText && CodeEmitter._isColInsideString(lineText, genCol) && !annotMatches) continue;
500
+ // Skip matches inside inline type annotation brace ranges (e.g.
501
+ // `opts: { append?: boolean }`) — those identifiers are emitted
502
+ // for TS type-checking only and have no real source counterpart.
503
+ const itRanges = getInlineTypeRanges(genLineInStmt);
504
+ if (itRanges.length && itRanges.some(([s, e]) => genCol >= s && genCol < e)) continue;
400
505
  let genLine = lineOffset + genLineInStmt;
401
- // Prefer matches on the same relative line within the statement,
402
- // falling back to column distance as tiebreaker.
403
- let dist = Math.abs(genLineInStmt - origLineInStmt) * 10000 + Math.abs(genCol - origCol);
506
+ // Annotation-matched lines are the authoritative gen position for
507
+ // their source line score them as a perfect line match so they
508
+ // beat any heuristic match elsewhere in the statement.
509
+ let dist = annotMatches
510
+ ? Math.abs(genCol - origCol)
511
+ : Math.abs(genLineInStmt - origLineInStmt) * 10000 + Math.abs(genCol - origCol);
404
512
  if (dist < bestDist) { bestDist = dist; bestMatch = { genLine, genCol }; }
513
+ const key = genLine + ':' + genCol;
514
+ if (!usedGenPositions.has(key) && dist < bestUnusedDist) {
515
+ bestUnusedDist = dist; bestUnused = { genLine, genCol };
516
+ }
405
517
  }
406
- if (bestMatch) {
407
- this.sourceMap.addMapping(bestMatch.genLine, bestMatch.genCol, origLine, origCol);
518
+ const chosen = bestUnused || bestMatch;
519
+ if (chosen) {
520
+ this.sourceMap.addMapping(chosen.genLine, chosen.genCol, origLine, origCol);
521
+ usedGenPositions.add(chosen.genLine + ':' + chosen.genCol);
408
522
  }
409
523
  }
410
524
  }
@@ -419,25 +533,73 @@ export class CodeEmitter {
419
533
  for (let i = 0; i < node.length; i++) {
420
534
  if (Array.isArray(node[i])) this.collectSubExprs(node[i], result);
421
535
  }
536
+ // Also honor side-channel anchors attached to nodes whose head is an
537
+ // array (e.g. tag-shorthand `[(. p error), error]` where the tag
538
+ // node's head is itself an access expression). Without this, anchors
539
+ // attached by walkRender to such nodes are silently dropped.
540
+ if (Array.isArray(node._anchors)) {
541
+ for (const a of node._anchors) result.push(a);
542
+ }
422
543
  return;
423
544
  }
424
545
  if (node.loc) {
425
546
  head = str(head);
426
547
  let ident = null;
548
+ let identCol = node.loc.c;
427
549
  // Property access: anchor is the property name (check BEFORE operators
428
550
  // because the operator regex also matches '.' via ^\.\.?$)
429
551
  if (head === '.') {
430
- if (typeof node[2] === 'string') ident = node[2];
552
+ if (typeof node[2] === 'string') {
553
+ ident = node[2];
554
+ // Adjust origCol to point at the property name itself, not the start
555
+ // of the access expression. node.loc.c marks the object start;
556
+ // shift past it and the `.` so source-mapping anchors land on the
557
+ // property identifier (e.g. `image` in `product.image`).
558
+ if (typeof node[1] === 'string') {
559
+ identCol = node.loc.c + node[1].length + 1;
560
+ }
561
+ }
431
562
  }
432
563
  // Operators/keywords: anchor is the subject at index 1
433
- else if (typeof head === 'string' && /^[=+\-*/%<>!&|?~^]|^\.\.?$|^def$|^class$|^state$|^computed$|^readonly$|^for-/.test(head)) {
434
- if (typeof node[1] === 'string' && /^[a-zA-Z_$]/.test(node[1])) ident = node[1];
564
+ else if (typeof head === 'string' && /^[=+\-*/%<>!&|?~^]|^\.{1,3}$|^def$|^class$|^state$|^computed$|^readonly$|^for-/.test(head)) {
565
+ if (typeof node[1] === 'string' && /^[a-zA-Z_$]/.test(node[1])) {
566
+ ident = node[1];
567
+ // Spread `...x`: node.loc.c marks the `...` start; shift past the
568
+ // operator so the anchor lands on the operand identifier.
569
+ if (head === '...') identCol = node.loc.c + 3;
570
+ }
435
571
  }
436
572
  // Function call (head is identifier)
437
573
  else if (typeof head === 'string' && /^[a-zA-Z_$]/.test(head)) {
438
574
  ident = head;
439
575
  }
440
- if (ident) result.push({ name: ident, origLine: node.loc.r, origCol: node.loc.c });
576
+ if (ident) result.push({ name: ident, origLine: node.loc.r, origCol: identCol });
577
+
578
+ // Arrow body bare-identifier anchor: a single-expression arrow body
579
+ // like `-> products` parses as `['->', [], ['block', 'products']]`.
580
+ // The body atom has no .loc (parser only attaches loc to arrays), and
581
+ // the `block` wrapper has bogus `loc=0:0`, so the identifier reference
582
+ // is invisible to the heuristic mapping. Synthesize an anchor by
583
+ // scanning source forward from the arrow's location.
584
+ if ((head === '->' || head === '=>') && Array.isArray(node[2]) && str(node[2][0]) === 'block') {
585
+ const body = node[2];
586
+ for (let i = 1; i < body.length; i++) {
587
+ const leaf = body[i];
588
+ const leafStr = typeof leaf === 'string' || leaf instanceof String ? str(leaf) : null;
589
+ if (leafStr && /^[a-zA-Z_$][\w$]*$/.test(leafStr) && !leaf.loc) {
590
+ const anchor = this._scanForIdentAfter(leafStr, node.loc);
591
+ if (anchor) result.push(anchor);
592
+ }
593
+ }
594
+ }
595
+ }
596
+ // Side-channel anchors attached by walkRender for bare-identifier
597
+ // children of template-tag nodes (e.g. `error` in `p.error error`).
598
+ // The s-expression child is a plain string atom with no .loc, so the
599
+ // walk would otherwise miss it; the anchor carries the source position
600
+ // computed from the parent's loc plus a regex scan over source lines.
601
+ if (Array.isArray(node._anchors)) {
602
+ for (const a of node._anchors) result.push(a);
441
603
  }
442
604
  // Recurse into children (skip head at index 0 — already processed via parent).
443
605
  // For arrow functions (-> / =>), skip index 1 (params array) — parameter
@@ -449,6 +611,30 @@ export class CodeEmitter {
449
611
  }
450
612
  }
451
613
 
614
+ // Scan original source for the first occurrence of `ident` after the
615
+ // given start location (typically an arrow's `->` loc). Skips matches
616
+ // inside string/comment regions. Returns a sub-expression anchor or null.
617
+ _scanForIdentAfter(ident, startLoc) {
618
+ const source = this.options && this.options.source;
619
+ if (!source || !startLoc) return null;
620
+ const lines = this._sourceLinesCache || (this._sourceLinesCache = source.split('\n'));
621
+ const re = new RegExp('\\b' + ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
622
+ const startRow = startLoc.r;
623
+ const startCol = startLoc.c;
624
+ // Search current line from startCol forward, then up to 20 subsequent lines.
625
+ for (let r = startRow; r < Math.min(lines.length, startRow + 20); r++) {
626
+ const line = lines[r];
627
+ if (!line) continue;
628
+ re.lastIndex = r === startRow ? startCol : 0;
629
+ let m;
630
+ while ((m = re.exec(line)) !== null) {
631
+ if (CodeEmitter._isColInsideString(line, m.index)) continue;
632
+ return { name: ident, origLine: r, origCol: m.index };
633
+ }
634
+ }
635
+ return null;
636
+ }
637
+
452
638
  // ---------------------------------------------------------------------------
453
639
  // Variable collection
454
640
  // ---------------------------------------------------------------------------
@@ -498,7 +684,12 @@ export class CodeEmitter {
498
684
  }
499
685
 
500
686
  if (head === 'for-in' || head === 'for-of' || head === 'for-as') {
501
- this.collectVarsFromLoopHead(rest[0], this.programVars);
687
+ // Don't hoist loop vars: emitForIn/emitForOf/emitForAs already
688
+ // emit `for (let x of ...)`, which is block-scoped AND gives JS's
689
+ // per-iteration binding semantics (critical for closures captured
690
+ // inside the loop). Hoisting `let x` to the surrounding scope
691
+ // would shadow that as dead code (TS6133 "declared but never
692
+ // read") without changing runtime behavior.
502
693
  rest.slice(1).forEach(item => this.collectProgramVariables(item));
503
694
  return;
504
695
  }
@@ -549,14 +740,22 @@ export class CodeEmitter {
549
740
  if (Array.isArray(head)) { sexpr.forEach(item => collect(item)); return; }
550
741
  if (CodeEmitter.ASSIGNMENT_OPS.has(head)) {
551
742
  let [target, value] = rest;
552
- if (typeof target === 'string') vars.add(target);
743
+ // Match collectProgramVariables: identifier targets may arrive as
744
+ // String wrappers when types.js attaches `.data.type` metadata
745
+ // to a typed local. Without unwrapping the wrapper, typed locals
746
+ // skip the function-top `let` declaration and silently leak to
747
+ // the global scope.
748
+ if (typeof target === 'string' || target instanceof String) vars.add(str(target));
553
749
  else if (this.is(target, 'array')) this.collectVarsFromArray(target, vars);
554
750
  else if (this.is(target, 'object')) this.collectVarsFromObject(target, vars);
555
751
  collect(value);
556
752
  return;
557
753
  }
558
754
  if (head === 'for-in' || head === 'for-of' || head === 'for-as') {
559
- this.collectVarsFromLoopHead(rest[0], vars);
755
+ // See collectProgramVariables for-loop branch: loop vars are
756
+ // block-scoped via the for-header's `let`; hoisting them here
757
+ // would only produce a redundant outer `let x` flagged as
758
+ // unused.
560
759
  rest.slice(1).forEach(collect);
561
760
  return;
562
761
  }
@@ -584,20 +783,55 @@ export class CodeEmitter {
584
783
  return vars;
585
784
  }
586
785
 
786
+ // Walk a function body and collect typed local assignments. Returns a
787
+ // Map<name, typeString> for every `name:: T = value` whose target is a
788
+ // String-wrapped identifier carrying `data.type` (attached by types.js).
789
+ //
790
+ // Used by `emitBodyWithReturns` in `inlineTypes` mode to annotate the
791
+ // function-top hoist (`let a, y: boolean, b;`) so shadow-TS sees the
792
+ // intended type instead of inferring a literal from the first RHS. Stops
793
+ // at nested function boundaries — each function owns its own typed locals.
794
+ //
795
+ // Conflict policy: first annotation wins. Same-name re-annotations are
796
+ // silently ignored; mixing different types on the same local is an
797
+ // unusual pattern best surfaced by TS itself once the hoist carries the
798
+ // first annotation.
799
+ collectTypedLocals(body) {
800
+ let typed = new Map();
801
+ let walk = (sexpr) => {
802
+ if (!Array.isArray(sexpr)) return;
803
+ let [head, ...rest] = sexpr;
804
+ head = str(head);
805
+ if (Array.isArray(head)) { sexpr.forEach(walk); return; }
806
+ if (CodeEmitter.ASSIGNMENT_OPS.has(head)) {
807
+ let [target, value] = rest;
808
+ if (target instanceof String && target.type && !typed.has(str(target))) {
809
+ typed.set(str(target), target.type);
810
+ }
811
+ walk(value);
812
+ return;
813
+ }
814
+ if (head === 'def' || head === '->' || head === '=>' || head === 'effect') return;
815
+ rest.forEach(walk);
816
+ };
817
+ walk(body);
818
+ return typed;
819
+ }
820
+
587
821
  // ---------------------------------------------------------------------------
588
822
  // Main dispatch
589
823
  // ---------------------------------------------------------------------------
590
824
 
591
825
  emit(sexpr, context = 'statement') {
592
- // String object with metadata (quote, await, predicate, heregex, etc.)
826
+ // String object with metadata (quote, bang, optional, heregex, etc.)
593
827
  if (sexpr instanceof String) {
594
828
  // Dammit operator (!)
595
- if (meta(sexpr, 'await') === true) {
829
+ if (meta(sexpr, 'bang') === true) {
596
830
  return `await ${str(sexpr)}()`;
597
831
  }
598
832
 
599
833
  // Existence check (?)
600
- if (meta(sexpr, 'predicate')) {
834
+ if (meta(sexpr, 'optional')) {
601
835
  return `(${str(sexpr)} != null)`;
602
836
  }
603
837
 
@@ -653,8 +887,8 @@ export class CodeEmitter {
653
887
 
654
888
  let [head, ...rest] = sexpr;
655
889
 
656
- // Preserve await metadata before converting head to primitive
657
- let headAwaitMeta = meta(head, 'await');
890
+ // Preserve bang metadata before converting head to primitive
891
+ let headBangMeta = meta(head, 'bang');
658
892
  head = str(head);
659
893
 
660
894
  // Dispatch table
@@ -674,7 +908,7 @@ export class CodeEmitter {
674
908
  let postfix = this._tryPostfixCall(head, rest, context);
675
909
  if (postfix) return postfix;
676
910
 
677
- let needsAwait = headAwaitMeta === true;
911
+ let needsAwait = headBangMeta === true;
678
912
  let callStr = `${this.emit(head, 'value')}(${this._emitArgs(rest)})`;
679
913
  return needsAwait ? `await ${callStr}` : callStr;
680
914
  }
@@ -700,15 +934,16 @@ export class CodeEmitter {
700
934
  let postfix = this._tryPostfixCall(head, rest, context);
701
935
  if (postfix) return postfix;
702
936
 
703
- // Property access with await sigil on property
937
+ // Property access with bang sigil on property
704
938
  let needsAwait = false;
705
939
  let calleeCode;
706
- if (head[0] === '.' && meta(head[2], 'await') === true) {
940
+ if (head[0] === '.' && meta(head[2], 'bang') === true) {
707
941
  needsAwait = true;
708
942
  let [obj, prop] = head.slice(1);
709
943
  let objCode = this.emit(obj, 'value');
710
944
  let needsParens = CodeEmitter.NUMBER_LITERAL_RE.test(objCode) ||
711
- ((this.is(obj, 'object') || this.is(obj, 'await') || this.is(obj, 'yield')));
945
+ ((this.is(obj, 'object') || this.is(obj, 'await') || this.is(obj, 'yield'))) ||
946
+ /^(await|yield)\s/.test(objCode);
712
947
  let base = needsParens ? `(${objCode})` : objCode;
713
948
  calleeCode = `${base}.${str(prop)}`;
714
949
  } else {
@@ -766,7 +1001,12 @@ export class CodeEmitter {
766
1001
  let needsBlank = false;
767
1002
 
768
1003
  if (imports.length > 0) {
769
- code += imports.map(s => this.addSemicolon(s, this.emit(s, 'statement'))).join('\n');
1004
+ let importEntries = imports.map(s => {
1005
+ let generated = this.addSemicolon(s, this.emit(s, 'statement'));
1006
+ return { code: generated, loc: Array.isArray(s) ? s.loc : null, sexpr: Array.isArray(s) ? s : null };
1007
+ });
1008
+ this._importEntries = importEntries;
1009
+ code += importEntries.map(e => e.code).join('\n');
770
1010
  needsBlank = true;
771
1011
  }
772
1012
 
@@ -936,6 +1176,15 @@ export class CodeEmitter {
936
1176
  let [target, value] = rest;
937
1177
  let op = head === '?=' ? '??=' : head;
938
1178
 
1179
+ // Reject destructuring shapes that aren't valid binding patterns. The
1180
+ // grammar accepts the full Array / Object expression on the LHS of `=`,
1181
+ // so things like `[a + b] = src` and `{x: y for x in arr} = src` parse
1182
+ // and used to either silently produce broken JS or crash the compiler
1183
+ // with an obscure error. This walk catches them with a clear message.
1184
+ if (this.is(target, 'array') || this.is(target, 'object')) {
1185
+ this._validateBindingPattern(target, sexpr);
1186
+ }
1187
+
939
1188
  // Optional chain assignment: x?.prop = val → if (x != null) x.prop = val
940
1189
  let optInfo = this._findOptionalInTarget(target);
941
1190
  if (optInfo) {
@@ -949,15 +1198,7 @@ export class CodeEmitter {
949
1198
  }
950
1199
 
951
1200
  // Validate: no sigils in assignment targets (except void function syntax)
952
- let isFnValue = (this.is(value, '->') || this.is(value, '=>') || this.is(value, 'def'));
953
- if (target instanceof String && meta(target, 'await') !== undefined && !isFnValue) {
954
- let sigil = meta(target, 'await') === true ? '!' : '&';
955
- this.error(`Cannot use ${sigil} sigil in variable declaration '${str(target)}'`, sexpr);
956
- }
957
-
958
- if (target instanceof String && meta(target, 'await') === true && isFnValue) {
959
- this.nextFunctionIsVoid = true;
960
- }
1201
+ this.applyVoidMarker(target, value, sexpr);
961
1202
 
962
1203
  // Empty destructuring — just evaluate RHS
963
1204
  let isEmptyArr = this.is(target, 'array', 0);
@@ -1046,7 +1287,7 @@ export class CodeEmitter {
1046
1287
 
1047
1288
  // Generate target (handle reactive, sigils)
1048
1289
  let targetCode;
1049
- if (target instanceof String && meta(target, 'await') !== undefined) {
1290
+ if (target instanceof String && meta(target, 'bang') !== undefined) {
1050
1291
  targetCode = str(target);
1051
1292
  } else if (typeof target === 'string' && this.reactiveVars?.has(target)) {
1052
1293
  targetCode = `${target}.value`;
@@ -1093,8 +1334,8 @@ export class CodeEmitter {
1093
1334
  objCode.startsWith('await ') ||
1094
1335
  ((this.is(obj, 'object') || this.is(obj, 'yield')));
1095
1336
  let base = needsParens ? `(${objCode})` : objCode;
1096
- if (meta(prop, 'await') === true) return `await ${base}.${str(prop)}()`;
1097
- if (meta(prop, 'predicate')) return `(${base}.${str(prop)} != null)`;
1337
+ if (meta(prop, 'bang') === true) return `await ${base}.${str(prop)}()`;
1338
+ if (meta(prop, 'optional')) return `(${base}.${str(prop)} != null)`;
1098
1339
  return `${base}.${str(prop)}`;
1099
1340
  }
1100
1341
 
@@ -1260,7 +1501,7 @@ export class CodeEmitter {
1260
1501
 
1261
1502
  emitDef(head, rest, context, sexpr) {
1262
1503
  let [name, params, body] = rest;
1263
- let sideEffectOnly = meta(name, 'await') === true;
1504
+ let sideEffectOnly = meta(name, 'bang') === true;
1264
1505
  let cleanName = str(name);
1265
1506
  let paramList = this.emitParamList(params);
1266
1507
  let bodyCode = this.emitFunctionBody(body, params, sideEffectOnly);
@@ -1272,8 +1513,7 @@ export class CodeEmitter {
1272
1513
  emitThinArrow(head, rest, context, sexpr) {
1273
1514
  let [params, body] = rest;
1274
1515
  if ((!params || (Array.isArray(params) && params.length === 0)) && this.containsIt(body)) params = ['it'];
1275
- let sideEffectOnly = this.nextFunctionIsVoid || false;
1276
- this.nextFunctionIsVoid = false;
1516
+ let sideEffectOnly = sexpr.isVoid || false;
1277
1517
  let paramList = this.emitParamList(params);
1278
1518
  let bodyCode = this.emitFunctionBody(body, params, sideEffectOnly);
1279
1519
  let isAsync = this.containsAwait(body);
@@ -1285,8 +1525,7 @@ export class CodeEmitter {
1285
1525
  emitFatArrow(head, rest, context, sexpr) {
1286
1526
  let [params, body] = rest;
1287
1527
  if ((!params || (Array.isArray(params) && params.length === 0)) && this.containsIt(body)) params = ['it'];
1288
- let sideEffectOnly = this.nextFunctionIsVoid || false;
1289
- this.nextFunctionIsVoid = false;
1528
+ let sideEffectOnly = sexpr.isVoid || false;
1290
1529
  let paramList = this.emitParamList(params);
1291
1530
  let isSingle = params.length === 1 && typeof params[0] === 'string' &&
1292
1531
  !paramList.includes('=') && !paramList.includes('...') &&
@@ -1405,9 +1644,13 @@ export class CodeEmitter {
1405
1644
  let [cond, then_, else_] = rest;
1406
1645
 
1407
1646
  // Hoist assignment: (cond ? (x = a) : b) → x = (cond ? a : b)
1408
- // Enables: x = "admin" if cond else "member" without parens
1647
+ // Enables the Python-style postfix-ternary idiom:
1648
+ // x = "admin" if cond else "member" → x = (cond ? "admin" : "member")
1649
+ // Skip when the assignment is parenthesized — the parser tags those via
1650
+ // `.parenthesized = true` so we can preserve "only assign when cond"
1651
+ // semantics for the explicit form `(x = "a") if cond else "b"`.
1409
1652
  let thenHead = then_?.[0]?.valueOf?.() ?? then_?.[0];
1410
- if (thenHead === '=' && Array.isArray(then_)) {
1653
+ if (thenHead === '=' && Array.isArray(then_) && !then_.parenthesized) {
1411
1654
  let target = this.emit(then_[1], 'value');
1412
1655
  let thenVal = this.emit(then_[2], 'value');
1413
1656
  let elseVal = this.emit(else_, 'value');
@@ -1799,7 +2042,20 @@ export class CodeEmitter {
1799
2042
  // Symbol literals
1800
2043
  // ---------------------------------------------------------------------------
1801
2044
 
1802
- emitSymbol(head, rest) { return `Symbol.for(${JSON.stringify(rest[0])})`; }
2045
+ emitSymbol(head, rest, context, sexpr) {
2046
+ // Anchor the symbol name's source position to the generated `Symbol`
2047
+ // identifier so hover on `:foo` shows `SymbolConstructor` (the
2048
+ // generated `"foo"` is a string literal that TS has no hover for).
2049
+ // sexpr.loc points at the `:`; the name starts at col + 1.
2050
+ if (sexpr && sexpr.loc && typeof rest[0] === 'string') {
2051
+ sexpr._anchors = (sexpr._anchors || []).concat([{
2052
+ name: 'Symbol',
2053
+ origLine: sexpr.loc.r,
2054
+ origCol: sexpr.loc.c + 1,
2055
+ }]);
2056
+ }
2057
+ return `Symbol.for(${JSON.stringify(rest[0])})`;
2058
+ }
1803
2059
 
1804
2060
  // ---------------------------------------------------------------------------
1805
2061
  // Data structures
@@ -1824,24 +2080,180 @@ export class CodeEmitter {
1824
2080
  return this.emit(['object-comprehension', keyVar, valueExpr, iterators, guards], context);
1825
2081
  }
1826
2082
 
1827
- let codes = pairs.map(pair => {
2083
+ // Helper: scan source line for an identifier name within [fromCol, toCol).
2084
+ // Used to attach _anchors so identifiers that the parser dropped position
2085
+ // info from (method-shorthand params, property shorthand keys) can still
2086
+ // produce source-map mappings.
2087
+ const scanIdentCol = (srcRow, name, fromCol = 0, toCol = Infinity) => {
2088
+ const source = this.options && this.options.source;
2089
+ if (!source || typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name)) return -1;
2090
+ const lines = this._sourceLinesCache || (this._sourceLinesCache = source.split('\n'));
2091
+ const line = lines[srcRow];
2092
+ if (!line) return -1;
2093
+ const re = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
2094
+ re.lastIndex = Math.max(0, fromCol);
2095
+ let m;
2096
+ while ((m = re.exec(line)) !== null) {
2097
+ if (m.index >= toCol) return -1;
2098
+ return m.index;
2099
+ }
2100
+ return -1;
2101
+ };
2102
+
2103
+ // Same as scanIdentCol but returns the LAST match in [fromCol, toCol).
2104
+ // Property-shorthand pairs only carry .loc for the *end* of the pair,
2105
+ // so to find the key's actual column we walk all occurrences and take
2106
+ // the rightmost one (the param with the same name appears earlier on
2107
+ // the line and would otherwise win).
2108
+ const scanIdentColLast = (srcRow, name, fromCol = 0, toCol = Infinity) => {
2109
+ const source = this.options && this.options.source;
2110
+ if (!source || typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name)) return -1;
2111
+ const lines = this._sourceLinesCache || (this._sourceLinesCache = source.split('\n'));
2112
+ const line = lines[srcRow];
2113
+ if (!line) return -1;
2114
+ const re = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
2115
+ re.lastIndex = Math.max(0, fromCol);
2116
+ let m, last = -1;
2117
+ while ((m = re.exec(line)) !== null) {
2118
+ if (m.index >= toCol) break;
2119
+ last = m.index;
2120
+ }
2121
+ return last;
2122
+ };
2123
+
2124
+ // Track which pairs emit as method-shorthand so we can format the
2125
+ // object multi-line. Joining sibling methods with `, ` collapses
2126
+ // `}, name(args) {` onto the same generated line as the previous
2127
+ // method's closing brace, which makes per-line source-map mappings
2128
+ // ambiguous (the LSP can't tell which source line owns that gen line,
2129
+ // so parameter classifications get attributed to the wrong source
2130
+ // position). Putting each method on its own gen line keeps the
2131
+ // line→source mapping one-to-one.
2132
+ let hasMethod = false;
2133
+ let isMethod = new Array(pairs.length).fill(false);
2134
+
2135
+ let codes = pairs.map((pair, idx) => {
1828
2136
  if (this.is(pair, '...')) return `...${this.emit(pair[1], 'value')}`;
1829
2137
  let [operator, key, value] = pair;
1830
2138
  let keyCode;
2139
+ let isSimpleKey = false;
1831
2140
  if (this.is(key, 'dynamicKey')) keyCode = `[${this.emit(key[1], 'value')}]`;
1832
2141
  else if (this.is(key, 'str')) keyCode = `[${this.emit(key, 'value')}]`;
1833
2142
  else {
1834
2143
  this.suppressReactiveUnwrap = true;
1835
2144
  keyCode = this.emit(key, 'value');
1836
2145
  this.suppressReactiveUnwrap = false;
2146
+ isSimpleKey = !Array.isArray(key) && typeof keyCode === 'string'
2147
+ && /^[A-Za-z_$][\w$]*$/.test(keyCode);
2148
+ }
2149
+
2150
+ // Method-shorthand: `key: -> body` → `key(args) { body }`. Enables
2151
+ // TypeScript contextual `this` binding when the object is assigned to
2152
+ // a method-shorthand-typed slot, and produces cleaner JS output.
2153
+ // Only for thin-arrow (`->`) — fat arrow has lexical `this` semantics.
2154
+ // Skip when the value carries side-effect (`!`) or non-trivial meta.
2155
+ if (operator === ':' && isSimpleKey && this.is(value, '->')) {
2156
+ let [, mParams, mBody] = value;
2157
+ if ((!mParams || (Array.isArray(mParams) && mParams.length === 0)) && this.containsIt(mBody)) mParams = ['it'];
2158
+ let mSideEffect = value.isVoid || false;
2159
+ let mParamList = this.emitParamList(mParams);
2160
+ let mBodyCode = this.emitFunctionBody(mBody, mParams, mSideEffect);
2161
+ let mIsAsync = this.containsAwait(mBody);
2162
+ let mIsGen = this.containsYield(mBody);
2163
+ let prefix = mIsAsync ? 'async ' : '';
2164
+ let star = mIsGen ? '*' : '';
2165
+ // Inject source-map anchors for plain-string params (e.g. `quantity`
2166
+ // in `(product, quantity) ->`). The parser drops .loc from non-data
2167
+ // identifier tokens and collectSubExprs intentionally skips the
2168
+ // params slot of arrow nodes, so without these the param positions
2169
+ // produce no source mapping and the LSP's gen→src lookup falls back
2170
+ // to a nearby unrelated identifier — typically classifying the
2171
+ // param as `property`.
2172
+ if (value && value.loc && Array.isArray(mParams) && mParams.length) {
2173
+ const srcRow = value.loc.r;
2174
+ // value.loc.c marks the start of the params group (the `(` for
2175
+ // parenthesized params, or the first param's column for bare
2176
+ // single-param arrows). Scan forward from there for each param
2177
+ // in declaration order.
2178
+ const paramsStart = value.loc.c;
2179
+ const anchors = [];
2180
+ let cursor = paramsStart;
2181
+ for (const p of mParams) {
2182
+ if (typeof p !== 'string') continue;
2183
+ const col = scanIdentCol(srcRow, p, cursor);
2184
+ if (col >= 0) {
2185
+ anchors.push({ name: p, origLine: srcRow, origCol: col });
2186
+ cursor = col + p.length;
2187
+ }
2188
+ }
2189
+ if (anchors.length) {
2190
+ value._anchors = (value._anchors || []).concat(anchors);
2191
+ }
2192
+ }
2193
+ hasMethod = true;
2194
+ isMethod[idx] = true;
2195
+ return `${prefix}${star}${keyCode}(${mParamList}) ${mBodyCode}`;
1837
2196
  }
2197
+
1838
2198
  let valCode = this.emit(value, 'value');
2199
+ // Anchor the key for simple `key: value` pairs so the source-map
2200
+ // heuristic has one anchor per source occurrence (otherwise pairs
2201
+ // like `quantity: 1` are silent and downstream same-named anchors
2202
+ // get stolen by the wrong gen position).
2203
+ if (operator === ':' && isSimpleKey && Array.isArray(pair) && pair.loc &&
2204
+ typeof key === 'string') {
2205
+ const col = scanIdentCol(pair.loc.r, key, 0, pair.loc.c + key.length + 1);
2206
+ if (col >= 0) {
2207
+ pair._anchors = (pair._anchors || []).concat([
2208
+ { name: key, origLine: pair.loc.r, origCol: col }
2209
+ ]);
2210
+ }
2211
+ }
1839
2212
  if (operator === '=') return `${keyCode} = ${valCode}`;
1840
2213
  if (operator === ':') return `${keyCode}: ${valCode}`;
1841
- if (keyCode === valCode && !Array.isArray(key)) return keyCode;
2214
+ if (keyCode === valCode && !Array.isArray(key)) {
2215
+ // Property shorthand `{ ...i, quantity }` → pair is [null, "quantity",
2216
+ // "quantity"] with the *pair* carrying .loc but the key/value strings
2217
+ // having none. Without an anchor the heuristic gives the gen position
2218
+ // to whichever same-named identifier in the statement happens to be
2219
+ // closest by line distance.
2220
+ if (Array.isArray(pair) && pair.loc && typeof key === 'string') {
2221
+ const col = scanIdentColLast(pair.loc.r, key, 0, pair.loc.c + 1);
2222
+ if (col >= 0) {
2223
+ pair._anchors = (pair._anchors || []).concat([
2224
+ { name: key, origLine: pair.loc.r, origCol: col }
2225
+ ]);
2226
+ }
2227
+ }
2228
+ return keyCode;
2229
+ }
1842
2230
  return `${keyCode}: ${valCode}`;
1843
- }).join(', ');
1844
- return `{${codes}}`;
2231
+ });
2232
+
2233
+ if (!hasMethod) return `{${codes.join(', ')}}`;
2234
+
2235
+ // Multi-line output when any pair is a method. Pairs preceding the
2236
+ // first method may stay on the opening-brace line, then each method
2237
+ // (and any pair following a method) starts on its own line.
2238
+ let parts = [];
2239
+ let onOpenLine = [];
2240
+ for (let i = 0; i < codes.length; i++) {
2241
+ if (isMethod[i] || (i > 0 && isMethod[i - 1])) {
2242
+ if (onOpenLine.length && parts.length === 0) {
2243
+ parts.push(onOpenLine.join(', '));
2244
+ onOpenLine = [];
2245
+ }
2246
+ parts.push(codes[i]);
2247
+ } else if (parts.length === 0) {
2248
+ onOpenLine.push(codes[i]);
2249
+ } else {
2250
+ parts.push(codes[i]);
2251
+ }
2252
+ }
2253
+ if (onOpenLine.length && parts.length === 0) parts.push(onOpenLine.join(', '));
2254
+ let head0 = parts[0];
2255
+ let rest = parts.slice(1);
2256
+ return rest.length === 0 ? `{${head0}}` : `{${head0},\n${rest.join(',\n')}}`;
1845
2257
  }
1846
2258
 
1847
2259
  emitMap(head, pairs, context) {
@@ -2266,14 +2678,18 @@ export class CodeEmitter {
2266
2678
  let atParamMap = isSubclass ? new Map() : null;
2267
2679
  cleanParams = params.map(p => {
2268
2680
  if (this.is(p, '.') && p[1] === 'this') {
2269
- let name = p[2];
2681
+ // Unwrap String-wrapper identifiers — typed @field params
2682
+ // arrive with their type metadata attached as `.data.type`
2683
+ // and would otherwise miss the atParamMap key match (the
2684
+ // map is queried with primitive strings via `str(prop)`).
2685
+ let name = str(p[2]);
2270
2686
  let param = isSubclass ? `_${name}` : name;
2271
2687
  autoAssign.push(`this.${name} = ${param}`);
2272
2688
  if (isSubclass) atParamMap.set(name, param);
2273
2689
  return param;
2274
2690
  }
2275
2691
  if (this.is(p, 'default') && this.is(p[1], '.') && p[1][1] === 'this') {
2276
- let name = p[1][2];
2692
+ let name = str(p[1][2]);
2277
2693
  let param = isSubclass ? `_${name}` : name;
2278
2694
  autoAssign.push(`this.${name} = ${param}`);
2279
2695
  if (isSubclass) atParamMap.set(name, param);
@@ -2320,7 +2736,7 @@ export class CodeEmitter {
2320
2736
  emitImport(head, rest, context, sexpr) {
2321
2737
  if (rest.length === 1) {
2322
2738
  let importExpr = `import(${this.emit(rest[0], 'value')})`;
2323
- if (meta(sexpr[0], 'await') === true) return `(await ${importExpr})`;
2739
+ if (meta(sexpr[0], 'bang') === true) return `(await ${importExpr})`;
2324
2740
  return importExpr;
2325
2741
  }
2326
2742
  if (this.options.skipImports) return '';
@@ -2329,6 +2745,7 @@ export class CodeEmitter {
2329
2745
  let fixedSource = this.addJsExtensionAndAssertions(source);
2330
2746
  if (named[0] === '*' && named.length === 2) return `import ${def}, * as ${named[1]} from ${fixedSource}`;
2331
2747
  let names = named.map(i => Array.isArray(i) && i.length === 2 ? `${i[0]} as ${i[1]}` : i).join(', ');
2748
+ this._attachImportSpecifierAnchors(sexpr, [def, ...named.flatMap(i => Array.isArray(i) ? i : [i])]);
2332
2749
  return `import ${def}, { ${names} } from ${fixedSource}`;
2333
2750
  }
2334
2751
  let [specifier, source] = rest;
@@ -2337,11 +2754,70 @@ export class CodeEmitter {
2337
2754
  if (Array.isArray(specifier)) {
2338
2755
  if (specifier[0] === '*' && specifier.length === 2) return `import * as ${specifier[1]} from ${fixedSource}`;
2339
2756
  let names = specifier.map(i => Array.isArray(i) && i.length === 2 ? `${i[0]} as ${i[1]}` : i).join(', ');
2757
+ this._attachImportSpecifierAnchors(sexpr, specifier.flatMap(i => Array.isArray(i) ? i : [i]));
2340
2758
  return `import { ${names} } from ${fixedSource}`;
2341
2759
  }
2342
2760
  return `import ${this.emit(specifier, 'value')} from ${fixedSource}`;
2343
2761
  }
2344
2762
 
2763
+ // Attach source-map anchors for each named import specifier so hover /
2764
+ // go-to-def works on individual names in `import { foo, bar } from '...'`.
2765
+ // The parser drops .loc from specifier strings, so we recover positions
2766
+ // by scanning the source forward from the `import` keyword.
2767
+ _attachImportSpecifierAnchors(sexpr, names) {
2768
+ if (!sexpr || !sexpr.loc) return;
2769
+ const source = this.options && this.options.source;
2770
+ if (!source || !names || !names.length) return;
2771
+ const lines = this._sourceLinesCache || (this._sourceLinesCache = source.split('\n'));
2772
+ let row = sexpr.loc.r;
2773
+ let col = sexpr.loc.c;
2774
+ const anchors = [];
2775
+ for (const name of names) {
2776
+ if (typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name)) continue;
2777
+ const re = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
2778
+ let found = false;
2779
+ while (row < lines.length) {
2780
+ const line = lines[row] || '';
2781
+ // Strip trailing line comment so '# foo' doesn't match
2782
+ const codePart = line.replace(/#.*$/, '');
2783
+ re.lastIndex = 0;
2784
+ const slice = codePart.slice(col);
2785
+ const m = re.exec(slice);
2786
+ if (m) {
2787
+ const c = col + m.index;
2788
+ anchors.push({ name, origLine: row, origCol: c });
2789
+ col = c + name.length;
2790
+ found = true;
2791
+ break;
2792
+ }
2793
+ row++;
2794
+ col = 0;
2795
+ }
2796
+ if (!found) break;
2797
+ }
2798
+ if (anchors.length) sexpr._anchors = (sexpr._anchors || []).concat(anchors);
2799
+ }
2800
+
2801
+ // Propagate the void marker from a `name! = fn` LHS onto the function node.
2802
+ // The `!` suffix is recorded as `.bang === true` metadata on the target
2803
+ // identifier; when the value is a function (`->`/`=>`/`def`) the bang means
2804
+ // the function is void (no implicit return). We stamp `isVoid` directly on
2805
+ // the function node so the arrow emitters read it locally — the same way
2806
+ // `emitDef` reads `meta(name, 'bang')` off its own node. Used by assignment
2807
+ // and export declaration paths so `export name! = ->` matches the bare
2808
+ // `name! = ->` semantics. Rejects `!`/`&` sigils on non-function values,
2809
+ // exactly like a plain assignment.
2810
+ applyVoidMarker(target, value, sexpr) {
2811
+ let isFnValue = (this.is(value, '->') || this.is(value, '=>') || this.is(value, 'def'));
2812
+ if (target instanceof String && meta(target, 'bang') !== undefined && !isFnValue) {
2813
+ let sigil = meta(target, 'bang') === true ? '!' : '&';
2814
+ this.error(`Cannot use ${sigil} sigil in variable declaration '${str(target)}'`, sexpr);
2815
+ }
2816
+ if (target instanceof String && meta(target, 'bang') === true && isFnValue) {
2817
+ value.isVoid = true;
2818
+ }
2819
+ }
2820
+
2345
2821
  emitExport(head, rest) {
2346
2822
  let [decl] = rest;
2347
2823
  if (this.options.skipExports) {
@@ -2354,6 +2830,7 @@ export class CodeEmitter {
2354
2830
  this._componentTypeParams = decl[1]?.typeParams || '';
2355
2831
  }
2356
2832
  if (this.is(decl[2], 'schema')) this._schemaName = str(decl[1]);
2833
+ this.applyVoidMarker(decl[1], decl[2], decl);
2357
2834
  const result = `const ${decl[1]} = ${this.emit(decl[2], 'value')}`;
2358
2835
  this._componentName = prev;
2359
2836
  this._componentTypeParams = prevTP;
@@ -2372,6 +2849,7 @@ export class CodeEmitter {
2372
2849
  this._componentTypeParams = decl[1]?.typeParams || '';
2373
2850
  }
2374
2851
  if (this.is(decl[2], 'schema')) this._schemaName = str(decl[1]);
2852
+ this.applyVoidMarker(decl[1], decl[2], decl);
2375
2853
  const result = `export const ${decl[1]} = ${this.emit(decl[2], 'value')}`;
2376
2854
  this._componentName = prev;
2377
2855
  this._componentTypeParams = prevTP;
@@ -2385,10 +2863,14 @@ export class CodeEmitter {
2385
2863
  emitExportDefault(head, rest) {
2386
2864
  let [expr] = rest;
2387
2865
  if (this.options.skipExports) {
2388
- if (this.is(expr, '=')) return `const ${expr[1]} = ${this.emit(expr[2], 'value')}`;
2866
+ if (this.is(expr, '=')) {
2867
+ this.applyVoidMarker(expr[1], expr[2], expr);
2868
+ return `const ${expr[1]} = ${this.emit(expr[2], 'value')}`;
2869
+ }
2389
2870
  return this.emit(expr, 'statement');
2390
2871
  }
2391
2872
  if (this.is(expr, '=')) {
2873
+ this.applyVoidMarker(expr[1], expr[2], expr);
2392
2874
  return `const ${expr[1]} = ${this.emit(expr[2], 'value')};\nexport default ${expr[1]}`;
2393
2875
  }
2394
2876
  return `export default ${this.emit(expr, 'statement')}`;
@@ -2518,14 +3000,36 @@ export class CodeEmitter {
2518
3000
 
2519
3001
  formatParam(param) {
2520
3002
  if (typeof param === 'string') return param;
2521
- if (param instanceof String) return param.valueOf();
2522
- if (this.is(param, 'rest')) return `...${param[1]}`;
3003
+ if (param instanceof String) {
3004
+ // In `inlineTypes` mode (set by typecheck.compileForCheck), emit the
3005
+ // type annotation inline so shadow TS sees `name: T` for typed params
3006
+ // in every function-like position — top-level arrows, class methods,
3007
+ // object-literal method shorthand, nested functions, etc. The user-
3008
+ // facing `-c` output stays untouched because this flag is off by
3009
+ // default. `.type` carries the raw Rip type string (with `::`),
3010
+ // which converts to TS form by swapping `::` → `:`.
3011
+ if (this.options.inlineTypes && param.type) {
3012
+ return `${param.valueOf()}: ${param.type.replace(/::/g, ':')}`;
3013
+ }
3014
+ return param.valueOf();
3015
+ }
3016
+ if (this.is(param, 'rest')) {
3017
+ // Rest param: `...name`. When the name is a String wrapper carrying
3018
+ // a type, emit `...name: T` so shadow TS sees the rest tuple/array.
3019
+ let restName = param[1];
3020
+ if (this.options.inlineTypes && restName instanceof String && restName.type) {
3021
+ return `...${restName.valueOf()}: ${restName.type.replace(/::/g, ':')}`;
3022
+ }
3023
+ return `...${restName}`;
3024
+ }
2523
3025
  if (this.is(param, 'default')) {
2524
3026
  // `param[1]` is either a plain identifier string (e.g. `x = 5`) or a
2525
3027
  // destructuring pattern AST node (e.g. `{a, b} = {}`). Recurse via
2526
3028
  // `formatParam` so patterns emit as `{a, b}` / `[x, y]` instead of
2527
3029
  // being coerced to a string via `Array.prototype.toString`, which
2528
3030
  // produced the famous `(object,,a,a,,b,b = {})` mis-rendering.
3031
+ // The recursion also picks up any inline type annotation on the
3032
+ // name in `inlineTypes` mode, yielding `name: T = default`.
2529
3033
  return `${this.formatParam(param[1])} = ${this.emit(param[2], 'value')}`;
2530
3034
  }
2531
3035
  if (this.is(param, '.') && param[1] === 'this') return param[2];
@@ -2565,10 +3069,14 @@ export class CodeEmitter {
2565
3069
 
2566
3070
  let paramNames = new Set();
2567
3071
  let extractPN = (p) => {
2568
- if (typeof p === 'string') paramNames.add(p);
3072
+ // Unwrap String wrappers — typed params arrive as `new String('name')`
3073
+ // with `.data.type` metadata. Without unwrapping, `paramNames.has('name')`
3074
+ // misses (Set compares wrappers by identity), causing the param name to
3075
+ // be re-hoisted as a local `let`, producing duplicate-declaration errors.
3076
+ if (typeof p === 'string' || p instanceof String) paramNames.add(str(p));
2569
3077
  else if (Array.isArray(p)) {
2570
- if (p[0] === 'rest' || p[0] === '...') { if (typeof p[1] === 'string') paramNames.add(p[1]); }
2571
- else if (p[0] === 'default') { if (typeof p[1] === 'string') paramNames.add(p[1]); }
3078
+ if (p[0] === 'rest' || p[0] === '...') { if (typeof p[1] === 'string' || p[1] instanceof String) paramNames.add(str(p[1])); }
3079
+ else if (p[0] === 'default') { if (typeof p[1] === 'string' || p[1] instanceof String) paramNames.add(str(p[1])); }
2572
3080
  else if (p[0] === 'array' || p[0] === 'object') this.collectVarsFromArray(p, paramNames);
2573
3081
  }
2574
3082
  };
@@ -2612,7 +3120,20 @@ export class CodeEmitter {
2612
3120
 
2613
3121
  this.indentLevel++;
2614
3122
  let code = '{\n';
2615
- if (newVars.size > 0) code += this.indent() + `let ${Array.from(newVars).sort().join(', ')};\n`;
3123
+ if (newVars.size > 0) {
3124
+ // In `inlineTypes` mode, propagate `name:: T = value` annotations from
3125
+ // body-level typed assignments onto the hoisted `let`. Without this,
3126
+ // the hoist emits `let y;` (no type), shadow-TS infers from the first
3127
+ // RHS literal (`y = true` → `y: true`), and any later `y = false`
3128
+ // fails TS2322. With this, the hoist emits `let y: boolean;` and the
3129
+ // body's `y = true` / `y = false` both check cleanly.
3130
+ let typedLocals = this.options.inlineTypes ? this.collectTypedLocals(body) : null;
3131
+ let names = Array.from(newVars).sort().map(n => {
3132
+ let t = typedLocals?.get(n);
3133
+ return t ? `${n}: ${t}` : n;
3134
+ });
3135
+ code += this.indent() + `let ${names.join(', ')};\n`;
3136
+ }
2616
3137
 
2617
3138
  let firstIsSuper = autoAssignments.length > 0 && statements.length > 0 &&
2618
3139
  Array.isArray(statements[0]) && statements[0][0] === 'super';
@@ -2936,20 +3457,21 @@ export class CodeEmitter {
2936
3457
  // ---------------------------------------------------------------------------
2937
3458
 
2938
3459
  emitIfElseWithEarlyReturns(ifStmt) {
2939
- let [head, condition, thenBranch, ...elseBranches] = ifStmt;
2940
- let code = '';
2941
- let condCode = this.emit(condition, 'value');
2942
- code += this.indent() + `if (${condCode}) {\n`;
3460
+ let [, condition, thenBranch, ...elseBranches] = ifStmt;
3461
+ return this.indent() + this.emitIfElseEarlyReturnsChain(condition, thenBranch, elseBranches);
3462
+ }
3463
+
3464
+ // Recursive companion to emitIfElseWithEarlyReturns. Each branch body
3465
+ // is wrapped in braces with the last expression promoted to a return.
3466
+ emitIfElseEarlyReturnsChain(condition, thenBranch, elseBranches) {
3467
+ let code = `if (${this.emit(condition, 'value')}) {\n`;
2943
3468
  code += this.withIndent(() => this.emitBranchWithReturn(thenBranch));
2944
3469
  code += this.indent() + '}';
2945
3470
  for (let branch of elseBranches) {
2946
3471
  code += ' else ';
2947
3472
  if (this.is(branch, 'if')) {
2948
3473
  let [, nc, nt, ...ne] = branch;
2949
- code += `if (${this.emit(nc, 'value')}) {\n`;
2950
- code += this.withIndent(() => this.emitBranchWithReturn(nt));
2951
- code += this.indent() + '}';
2952
- for (let rb of ne) { code += ' else {\n'; code += this.withIndent(() => this.emitBranchWithReturn(rb)); code += this.indent() + '}'; }
3474
+ code += this.emitIfElseEarlyReturnsChain(nc, nt, ne);
2953
3475
  } else {
2954
3476
  code += '{\n';
2955
3477
  code += this.withIndent(() => this.emitBranchWithReturn(branch));
@@ -2979,31 +3501,7 @@ export class CodeEmitter {
2979
3501
  if (needsIIFE) {
2980
3502
  // Enclosed: condition, thenBranch, elseBranches
2981
3503
  let hasAwait = this.containsAwait(condition) || this.containsAwait(thenBranch) || elseBranches.some(b => this.containsAwait(b));
2982
- let code = this.asyncIIFEOpen(hasAwait) + ' ';
2983
- code += `if (${this.emit(condition, 'value')}) `;
2984
- code += this.emitBlockWithReturns(thenBranch);
2985
- for (let branch of elseBranches) {
2986
- code += ' else ';
2987
- if (this.is(branch, 'if')) {
2988
- let [_, nc, nt, ...ne] = branch;
2989
- code += `if (${this.emit(nc, 'value')}) `;
2990
- code += this.emitBlockWithReturns(nt);
2991
- for (let nb of ne) {
2992
- code += ' else ';
2993
- if (this.is(nb, 'if')) {
2994
- let [__, nnc, nnt, ...nne] = nb;
2995
- code += `if (${this.emit(nnc, 'value')}) `;
2996
- code += this.emitBlockWithReturns(nnt);
2997
- elseBranches.push(...nne);
2998
- } else {
2999
- code += this.emitBlockWithReturns(nb);
3000
- }
3001
- }
3002
- } else {
3003
- code += this.emitBlockWithReturns(branch);
3004
- }
3005
- }
3006
- return code + ' })()';
3504
+ return this.asyncIIFEOpen(hasAwait) + ' ' + this.emitIfChain(condition, thenBranch, elseBranches) + ' })()';
3007
3505
  }
3008
3506
  let thenExpr = this.extractExpression(this.unwrapIfBranch(thenBranch));
3009
3507
  let elseExpr = this.buildTernaryChain(elseBranches);
@@ -3012,6 +3510,23 @@ export class CodeEmitter {
3012
3510
  return `(${condCode} ? ${thenExpr} : ${elseExpr})`;
3013
3511
  }
3014
3512
 
3513
+ // Recursive emitter for `if / else if / else` chains in IIFE/value contexts.
3514
+ // Walks the right-recursive AST: ["if", cond, then, else?] where `else` may
3515
+ // itself be another `["if", ...]` for an elseif chain.
3516
+ emitIfChain(condition, thenBranch, elseBranches) {
3517
+ let code = `if (${this.emit(condition, 'value')}) ` + this.emitBlockWithReturns(thenBranch);
3518
+ for (let branch of elseBranches) {
3519
+ code += ' else ';
3520
+ if (this.is(branch, 'if')) {
3521
+ let [, nc, nt, ...ne] = branch;
3522
+ code += this.emitIfChain(nc, nt, ne);
3523
+ } else {
3524
+ code += this.emitBlockWithReturns(branch);
3525
+ }
3526
+ }
3527
+ return code;
3528
+ }
3529
+
3015
3530
  emitIfAsStatement(condition, thenBranch, elseBranches) {
3016
3531
  let code = `if (${this.unwrap(this.emit(condition, 'value'))}) `;
3017
3532
  code += this.emit(this.unwrapIfBranch(thenBranch), 'statement');
@@ -3210,6 +3725,67 @@ export class CodeEmitter {
3210
3725
  return null;
3211
3726
  }
3212
3727
 
3728
+ // Walk a destructuring LHS and reject shapes that aren't valid binding
3729
+ // patterns. `null` array elements are elision (valid). Identifiers are
3730
+ // strings and parse as leaves. Member-access (`obj.x`) and bracket-index
3731
+ // (`arr[i]`) shapes are valid as assignment targets. Optional chains
3732
+ // (`obj?.x`, `arr?.[i]`) are NOT valid in JS destructuring assignment
3733
+ // targets, so we reject them. Comprehensions, arithmetic, calls, etc.
3734
+ // are unconditionally rejected.
3735
+ _validateBindingPattern(node, sexpr) {
3736
+ if (!Array.isArray(node)) return;
3737
+ let head = node[0];
3738
+ if (head === 'comprehension' || head === 'object-comprehension') {
3739
+ this.error(`Cannot use ${head} as a destructuring target`, sexpr);
3740
+ }
3741
+ if (head === 'array') {
3742
+ for (let elem of node.slice(1)) this._validateBindingPattern(elem, sexpr);
3743
+ return;
3744
+ }
3745
+ if (head === 'object') {
3746
+ // Object entry shapes used by the grammar:
3747
+ // [null, k, k] — shorthand {x}; both slots are the same identifier
3748
+ // [":", k, target] — rename {a: target}; recurse into target
3749
+ // ["=", k, default] — default {a = 5}; key is identifier, default is RHS
3750
+ // ["...", target] — rest {...rest}; recurse into target
3751
+ // Anything else is unexpected; fail loudly so future grammar additions
3752
+ // can't silently pass through unvalidated.
3753
+ for (let entry of node.slice(1)) {
3754
+ if (!Array.isArray(entry)) continue;
3755
+ let h = entry[0];
3756
+ if (h === null) {
3757
+ // Shorthand {x}: target is entry[2], which must be a bare identifier
3758
+ // (validated by the array-leaf rules below).
3759
+ this._validateBindingPattern(entry[2], sexpr);
3760
+ } else if (h === ':') {
3761
+ this._validateBindingPattern(entry[2], sexpr);
3762
+ } else if (h === '=') {
3763
+ // Default {a = 5}: key (entry[1]) is the binding target.
3764
+ this._validateBindingPattern(entry[1], sexpr);
3765
+ } else if (h === '...') {
3766
+ this._validateBindingPattern(entry[1], sexpr);
3767
+ } else {
3768
+ this.error(`Unexpected object entry '${h}' in destructuring target`, sexpr);
3769
+ }
3770
+ }
3771
+ return;
3772
+ }
3773
+ // Wrapper shapes that contain a nested target.
3774
+ if (head === '...' || head === 'default' || head === '=') {
3775
+ this._validateBindingPattern(node[1], sexpr);
3776
+ return;
3777
+ }
3778
+ // Member access and bracket index are valid assignment targets.
3779
+ if (head === '.' || head === '[]') return;
3780
+ // Optional chains are NOT valid in JS destructuring-assignment context
3781
+ // (only in optional-chain GET expressions). Reject explicitly.
3782
+ if (head === '?.' || head === 'optindex' || head === 'optcall') {
3783
+ this.error(`Cannot use optional chain as a destructuring target`, sexpr);
3784
+ }
3785
+ // Anything else is an expression that isn't a destructuring shape.
3786
+ this.error(`Cannot use '${head}' expression as a destructuring target`, sexpr);
3787
+ }
3788
+
3213
3789
  unwrapLogical(code) {
3214
3790
  if (typeof code !== 'string') return code;
3215
3791
  while (code.startsWith('(') && code.endsWith(')')) {
@@ -3261,17 +3837,19 @@ export class CodeEmitter {
3261
3837
  return false;
3262
3838
  }
3263
3839
 
3840
+ // Walk the right-recursive AST emitting nested ternaries. `branches` is the
3841
+ // tail of an `if` s-expression — either empty (no else) or one element
3842
+ // (the else, which may itself be another `["if", ...]`).
3264
3843
  buildTernaryChain(branches) {
3265
3844
  if (branches.length === 0) return 'undefined';
3266
- if (branches.length === 1) return this.extractExpression(this.unwrapIfBranch(branches[0]));
3267
- let first = branches[0];
3268
- if (this.is(first, 'if')) {
3269
- let [_, cond, then_, ...rest] = first;
3845
+ let branch = this.unwrapIfBranch(branches[0]);
3846
+ if (this.is(branch, 'if')) {
3847
+ let [, cond, then_, ...rest] = branch;
3270
3848
  let thenPart = this.extractExpression(this.unwrapIfBranch(then_));
3271
- let elsePart = this.buildTernaryChain([...rest, ...branches.slice(1)]);
3849
+ let elsePart = this.buildTernaryChain(rest);
3272
3850
  return `(${this.emit(cond, 'value')} ? ${thenPart} : ${elsePart})`;
3273
3851
  }
3274
- return this.extractExpression(this.unwrapIfBranch(first));
3852
+ return this.extractExpression(branch);
3275
3853
  }
3276
3854
 
3277
3855
  // ---------------------------------------------------------------------------
@@ -3392,7 +3970,7 @@ export class CodeEmitter {
3392
3970
 
3393
3971
  containsAwait(sexpr) {
3394
3972
  if (!sexpr) return false;
3395
- if (sexpr instanceof String && meta(sexpr, 'await') === true) return true;
3973
+ if (sexpr instanceof String && meta(sexpr, 'bang') === true) return true;
3396
3974
  if (typeof sexpr !== 'object') return false;
3397
3975
  if (this.is(sexpr, 'await')) return true;
3398
3976
  if (this.is(sexpr, 'for-as') && sexpr[3] === true) return true;
@@ -3449,10 +4027,15 @@ let __pendingEffects = new Set(); // Effects queued to run
3449
4027
  let __batching = false; // Are we inside a batch()?
3450
4028
 
3451
4029
  // Flush all pending effects (called after state updates, or at end of batch)
4030
+ // Defense in depth: skip disposed effects. The primary guard is in
4031
+ // effect.run() itself — but filtering here avoids even calling .run() on
4032
+ // a known-dead effect, which is faster and clearer in stack traces.
3452
4033
  function __flushEffects() {
3453
4034
  const effects = [...__pendingEffects];
3454
4035
  __pendingEffects.clear();
3455
- for (const effect of effects) effect.run();
4036
+ for (const effect of effects) {
4037
+ if (!effect._disposed) effect.run();
4038
+ }
3456
4039
  }
3457
4040
 
3458
4041
  // Shared primitive coercion (used by state and computed)
@@ -3568,11 +4151,46 @@ function __computed(fn) {
3568
4151
  return computed;
3569
4152
  }
3570
4153
 
3571
- function __effect(fn) {
4154
+ function __effect(fn, opts) {
4155
+ let controller = null;
4156
+ let runId = 0; // increments per run; async resolutions check this to drop stale results
3572
4157
  const effect = {
3573
4158
  dependencies: new Set(),
4159
+ _disposed: false,
4160
+ signal: null, // AbortSignal for the current run; aborts on re-run / dispose
3574
4161
 
3575
4162
  run() {
4163
+ // Zombie-run guard. An effect can be queued in __pendingEffects
4164
+ // (when a signal it subscribes to changes) and then disposed
4165
+ // before the flush reaches it — e.g. its parent block was
4166
+ // destroyed by an earlier effect in the same flush, and that
4167
+ // destruction's disposers ran effect.dispose(). Without this
4168
+ // guard, run() would execute fn() with __currentEffect = effect,
4169
+ // and any signal .value read inside fn() would re-subscribe the
4170
+ // effect by adding it back to the signal's subscribers Set —
4171
+ // accumulating one leaked subscriber per flush cycle that hits
4172
+ // this race. Symptom: each navigation creates one more component
4173
+ // instance than the previous (N visits => N synchronous mounts
4174
+ // on visit N).
4175
+ if (effect._disposed) return;
4176
+ // Abort the previous run's signal before allocating a new one.
4177
+ // Any async work from the previous run that's still mid-flight
4178
+ // (an await fetch with the signal, for example) sees its signal
4179
+ // go aborted and can bail. This also fires the 'abort' event on
4180
+ // the previous signal so user code subscribed via
4181
+ // signal.addEventListener can run.
4182
+ if (controller) {
4183
+ try { controller.abort(); } catch {}
4184
+ }
4185
+ controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
4186
+ effect.signal = controller ? controller.signal : null;
4187
+ // Per-run id captured by the closures below. When the effect
4188
+ // re-runs (signal changed) while a prior async body is still
4189
+ // awaiting, the prior body's eventual resolution sees myRun !==
4190
+ // runId and bails — preventing stale cleanup from overwriting
4191
+ // the current run's cleanup.
4192
+ const myRun = ++runId;
4193
+
3576
4194
  if (effect._cleanup) { effect._cleanup(); effect._cleanup = null; }
3577
4195
  for (const dep of effect.dependencies) dep.delete(effect);
3578
4196
  effect.dependencies.clear();
@@ -3580,11 +4198,63 @@ function __effect(fn) {
3580
4198
  __currentEffect = effect;
3581
4199
  try {
3582
4200
  const result = fn();
3583
- if (typeof result === 'function') effect._cleanup = result;
4201
+ if (typeof result === 'function') {
4202
+ effect._cleanup = result;
4203
+ } else if (result && typeof result.then === 'function') {
4204
+ // Async effect body. We can't unwind a pending await, but we
4205
+ // CAN intercept the eventual resolution and decide whether
4206
+ // to honor any cleanup it returned. Two failure modes are
4207
+ // handled:
4208
+ // - Effect disposed while body was awaiting: run cleanup
4209
+ // immediately so resources release, but don't store it.
4210
+ // - Effect re-ran (newer run superseded this one): same.
4211
+ // In both cases we do NOT touch effect._cleanup, which now
4212
+ // belongs to a different run.
4213
+ result.then(
4214
+ (cleanup) => {
4215
+ if (myRun !== runId || effect._disposed) {
4216
+ if (typeof cleanup === 'function') {
4217
+ try { cleanup(); }
4218
+ catch (e) { console.error('[Rip] superseded async cleanup error:', e); }
4219
+ }
4220
+ return;
4221
+ }
4222
+ if (typeof cleanup === 'function') effect._cleanup = cleanup;
4223
+ },
4224
+ (err) => {
4225
+ // AbortError from a dispose or supersede is expected
4226
+ // (the user passed our signal to fetch/etc. and it
4227
+ // aborted). Swallow silently.
4228
+ if (err && err.name === 'AbortError') return;
4229
+ // Stale rejection from a superseded run: caller has
4230
+ // already moved on, no point surfacing.
4231
+ if (myRun !== runId || effect._disposed) return;
4232
+ console.error('[Rip] async effect error:', err);
4233
+ }
4234
+ );
4235
+ }
3584
4236
  } finally { __currentEffect = prev; }
3585
4237
  },
3586
4238
 
3587
4239
  dispose() {
4240
+ // Idempotent: a parent disposer chain may legitimately reach the
4241
+ // same effect twice in tangled cleanup paths. Quick exit avoids
4242
+ // re-running cleanup and re-walking already-empty dependencies.
4243
+ if (effect._disposed) return;
4244
+ effect._disposed = true;
4245
+ // Proactive pending-set eviction. The flush-time guard in
4246
+ // __flushEffects also handles this, but pulling the effect out
4247
+ // of __pendingEffects here keeps the set bounded across long
4248
+ // batched cycles and makes disposal semantics direct rather
4249
+ // than dependent on flush ordering.
4250
+ __pendingEffects.delete(effect);
4251
+ // Abort the current signal so any in-flight async work (a
4252
+ // user's fetch with the signal, a setTimeout-via-signal, etc.)
4253
+ // unwinds via AbortError and the body can bail without mutating
4254
+ // signals on a destroyed component.
4255
+ if (controller) {
4256
+ try { controller.abort(); } catch {}
4257
+ }
3588
4258
  if (effect._cleanup) { effect._cleanup(); effect._cleanup = null; }
3589
4259
  for (const dep of effect.dependencies) dep.delete(effect);
3590
4260
  effect.dependencies.clear();
@@ -3592,7 +4262,29 @@ function __effect(fn) {
3592
4262
  };
3593
4263
 
3594
4264
  effect.run();
3595
- return () => effect.dispose();
4265
+ const dispose = () => effect.dispose();
4266
+ // Auto-register with the current component (if any) so disposers fire
4267
+ // on component unmount. Without this, every __effect created inside a
4268
+ // component's _init / _setup / _create lived forever — its callback
4269
+ // stayed subscribed to its signals, the closure pinned the component,
4270
+ // and any DOM/event-listener cleanup the effect had returned never
4271
+ // fired. The bridge is intentionally cross-module: the reactive
4272
+ // runtime (this file) doesn't depend on components.js, but components.js
4273
+ // exposes a getter on globalThis.__ripComponent at registration time
4274
+ // and we read it lazily so module-load order is irrelevant.
4275
+ //
4276
+ // {skipRegister: true} opts out of auto-registration. Used by factory
4277
+ // blocks (for-loops, if-blocks in render) that maintain their own
4278
+ // local disposers array and call them via the d(detaching) hook.
4279
+ // Without skipRegister, those effects would be registered TWICE — once
4280
+ // in the local factory disposers and again on the parent component's
4281
+ // _disposers — leaking stale disposer references on every block
4282
+ // re-render until the parent itself unmounts.
4283
+ if (!opts || !opts.skipRegister) {
4284
+ const cur = globalThis.__ripComponent?.__getCurrentComponent?.();
4285
+ if (cur) (cur._disposers ??= []).push(dispose);
4286
+ }
4287
+ return dispose;
3596
4288
  }
3597
4289
 
3598
4290
  function __batch(fn) {
@@ -3606,6 +4298,24 @@ function __batch(fn) {
3606
4298
  }
3607
4299
  }
3608
4300
 
4301
+ // Returns the AbortSignal of the currently-running effect, or null if
4302
+ // called outside an effect or before AbortController is available.
4303
+ // Designed for async-aware effect bodies — capture the signal BEFORE
4304
+ // any await so it stays valid for the duration of the body:
4305
+ //
4306
+ // (in Rip source)
4307
+ // ~>
4308
+ // signal = getEffectSignal()
4309
+ // data = fetch! url, {signal}
4310
+ // this.data = data
4311
+ //
4312
+ // On effect re-run or component unmount, the signal aborts; the
4313
+ // fetch rejects with AbortError; the body unwinds without touching
4314
+ // signals on a destroyed component.
4315
+ function __getEffectSignal() {
4316
+ return __currentEffect ? __currentEffect.signal : null;
4317
+ }
4318
+
3609
4319
  function __readonly(value) {
3610
4320
  return Object.freeze({ value });
3611
4321
  }
@@ -3647,7 +4357,11 @@ function __catchErrors(fn) {
3647
4357
 
3648
4358
  // Register on globalThis for runtime deduplication
3649
4359
  if (typeof globalThis !== 'undefined') {
3650
- globalThis.__rip = { __state, __computed, __effect, __batch, __readonly, __setErrorHandler, __handleError, __catchErrors };
4360
+ globalThis.__rip = { __state, __computed, __effect, __batch, __readonly, __setErrorHandler, __handleError, __catchErrors, __getEffectSignal };
4361
+ // Stdlib-style global so user code can call getEffectSignal() in a
4362
+ // ~> body without importing or destructuring. Mirrors how p, pp,
4363
+ // assert, etc. are registered for ergonomic use.
4364
+ globalThis.getEffectSignal ??= __getEffectSignal;
3651
4365
  }
3652
4366
 
3653
4367
  // === End Reactive Runtime ===
@@ -3702,9 +4416,26 @@ export class Compiler {
3702
4416
 
3703
4417
  // Elide type-only imports — after type stripping, imported names that were
3704
4418
  // only used in type annotations no longer appear in the token stream.
3705
- // Only elide when at least one name was consumed by type annotation stripping.
3706
- if (lexer.typeRefNames?.size > 0) {
3707
- let typeRefNames = lexer.typeRefNames;
4419
+ // The elision is per-specifier, not per-import: a single declaration like
4420
+ // `import { ApiErrors, parseError } from 'm'` where only `ApiErrors` is
4421
+ // type-only becomes `import { parseError } from 'm'`. If all named
4422
+ // specifiers drop and no default/namespace specifier remains, the whole
4423
+ // import is removed.
4424
+ //
4425
+ // A named specifier is "type-only" from the importing file's point of view
4426
+ // when its local binding is unused at runtime. That catches both
4427
+ // (a) names referenced solely in this file's type annotations (now in
4428
+ // typeRefNames) and (b) names imported only as types but never referenced
4429
+ // in this file at all — the exporting module strips its `export type` to
4430
+ // nothing, so leaving the specifier in place would cause a runtime
4431
+ // "module does not provide an export named X" error.
4432
+ //
4433
+ // Skip elision in `inlineTypes` mode (set by typecheck.compileForCheck).
4434
+ // The shadow-TS output is fed to the TypeScript language service, not
4435
+ // executed — so keeping the specifiers preserves go-to-definition and
4436
+ // hover for type-only imports like `RetryConfig` that resolve to
4437
+ // `export type` declarations in the target module.
4438
+ if (lexer.typeRefNames?.size > 0 && !this.options.inlineTypes) {
3708
4439
  let usedNames = new Set();
3709
4440
  let inImport = false;
3710
4441
  for (let t of tokens) {
@@ -3713,6 +4444,7 @@ export class Compiler {
3713
4444
  if (inImport) continue;
3714
4445
  if (t[0] === 'IDENTIFIER') usedNames.add(t[1]);
3715
4446
  }
4447
+ let isTypeOnly = (local) => !usedNames.has(local);
3716
4448
  for (let i = tokens.length - 1; i >= 0; i--) {
3717
4449
  if (tokens[i][0] !== 'IMPORT') continue;
3718
4450
  let j = i + 1;
@@ -3721,22 +4453,119 @@ export class Compiler {
3721
4453
  if (tokens[j][0] === 'CALL_START' || tokens[j][0] === '(') continue;
3722
4454
  // Skip side-effect imports: import 'module'
3723
4455
  if (tokens[j][0] === 'STRING') continue;
3724
- // Collect imported names between IMPORT and FROM
3725
- let names = [];
3726
- while (j < tokens.length && tokens[j][0] !== 'FROM' && tokens[j][0] !== 'TERMINATOR') {
3727
- if (tokens[j][0] === 'IDENTIFIER') names.push(tokens[j][1]);
3728
- j++;
4456
+ // Find FROM / TERMINATOR bounds
4457
+ let fromIdx = -1, endIdx = j;
4458
+ while (endIdx < tokens.length && tokens[endIdx][0] !== 'TERMINATOR') {
4459
+ if (fromIdx === -1 && tokens[endIdx][0] === 'FROM') fromIdx = endIdx;
4460
+ endIdx++;
4461
+ }
4462
+ if (fromIdx === -1) continue;
4463
+ // Locate `{` / `}` for named specifiers
4464
+ let lbIdx = -1, rbIdx = -1;
4465
+ for (let k = i + 1; k < fromIdx; k++) {
4466
+ if (tokens[k][0] === '{') { lbIdx = k; break; }
4467
+ }
4468
+ if (lbIdx !== -1) {
4469
+ for (let k = lbIdx + 1; k < fromIdx; k++) {
4470
+ if (tokens[k][0] === '}') { rbIdx = k; break; }
4471
+ }
4472
+ }
4473
+ // Determine whether a default or namespace specifier exists outside the braces
4474
+ let hasOtherSpec = false;
4475
+ let scanEnd = lbIdx !== -1 ? lbIdx : fromIdx;
4476
+ for (let k = i + 1; k < scanEnd; k++) {
4477
+ let tag = tokens[k][0];
4478
+ if (tag === 'IDENTIFIER' || tag === '*') { hasOtherSpec = true; break; }
4479
+ }
4480
+ // No named specifiers — fall back to whole-import elision
4481
+ if (lbIdx === -1 || rbIdx === -1) {
4482
+ if (hasOtherSpec) {
4483
+ // Default / namespace import: collect outer local names
4484
+ let names = [];
4485
+ let k = i + 1;
4486
+ while (k < fromIdx) {
4487
+ if (tokens[k][0] === 'AS' && k + 1 < fromIdx && tokens[k + 1][0] === 'IDENTIFIER') {
4488
+ names.push(tokens[k + 1][1]); k += 2;
4489
+ } else if (tokens[k][0] === 'IDENTIFIER') {
4490
+ names.push(tokens[k][1]); k++;
4491
+ } else { k++; }
4492
+ }
4493
+ if (names.length === 0 || !names.every(isTypeOnly)) continue;
4494
+ let end = endIdx < tokens.length ? endIdx + 1 : endIdx;
4495
+ tokens.splice(i, end - i);
4496
+ }
4497
+ continue;
4498
+ }
4499
+ // Split brace contents into specifier ranges (start inclusive, end exclusive)
4500
+ // Each specifier is delimited by `,` at depth 0 (the `{}` themselves).
4501
+ let specs = [];
4502
+ let s = lbIdx + 1;
4503
+ while (s < rbIdx) {
4504
+ let e = s;
4505
+ while (e < rbIdx && tokens[e][0] !== ',') e++;
4506
+ if (e > s) specs.push({ start: s, end: e });
4507
+ s = e + 1;
4508
+ }
4509
+ // Determine local name per specifier (after `as` if present)
4510
+ for (let spec of specs) {
4511
+ let local = null;
4512
+ for (let k = spec.start; k < spec.end; k++) {
4513
+ if (tokens[k][0] !== 'IDENTIFIER') continue;
4514
+ if (local === null) local = tokens[k][1];
4515
+ else if (tokens[k - 1]?.[0] === 'AS') local = tokens[k][1];
4516
+ }
4517
+ spec.local = local;
4518
+ spec.drop = local != null && isTypeOnly(local);
4519
+ }
4520
+ let droppedAny = specs.some(s => s.drop);
4521
+ if (!droppedAny) continue;
4522
+ let allDropped = specs.every(s => s.drop);
4523
+ // Outer (default / namespace) local names — needed when all named drop
4524
+ let outerNames = [];
4525
+ if (allDropped && hasOtherSpec) {
4526
+ let k = i + 1;
4527
+ while (k < lbIdx) {
4528
+ if (tokens[k][0] === 'AS' && k + 1 < lbIdx && tokens[k + 1][0] === 'IDENTIFIER') {
4529
+ outerNames.push(tokens[k + 1][1]); k += 2;
4530
+ } else if (tokens[k][0] === 'IDENTIFIER') {
4531
+ outerNames.push(tokens[k][1]); k++;
4532
+ } else { k++; }
4533
+ }
4534
+ }
4535
+ // Splice from right to left to preserve indices
4536
+ if (allDropped) {
4537
+ if (!hasOtherSpec) {
4538
+ // Drop entire import
4539
+ let end = endIdx < tokens.length ? endIdx + 1 : endIdx;
4540
+ tokens.splice(i, end - i);
4541
+ } else if (outerNames.length > 0 && outerNames.every(isTypeOnly)) {
4542
+ // Outer specifiers are also type-only → drop whole import
4543
+ let end = endIdx < tokens.length ? endIdx + 1 : endIdx;
4544
+ tokens.splice(i, end - i);
4545
+ } else {
4546
+ // Remove `{ ... }` and the comma between default and `{`
4547
+ let removeStart = lbIdx, removeEnd = rbIdx + 1;
4548
+ // Strip a leading comma that separated default from `{`
4549
+ let k = lbIdx - 1;
4550
+ while (k > i && tokens[k][0] !== ',' && tokens[k][0] !== 'IDENTIFIER' && tokens[k][0] !== '*') k--;
4551
+ if (k > i && tokens[k][0] === ',') removeStart = k;
4552
+ tokens.splice(removeStart, removeEnd - removeStart);
4553
+ }
4554
+ } else {
4555
+ // Remove dropped specifiers individually, right-to-left, taking one adjacent comma
4556
+ for (let idx = specs.length - 1; idx >= 0; idx--) {
4557
+ let spec = specs[idx];
4558
+ if (!spec.drop) continue;
4559
+ let removeStart = spec.start, removeEnd = spec.end;
4560
+ // Prefer trailing comma; otherwise take leading comma
4561
+ if (removeEnd < rbIdx && tokens[removeEnd][0] === ',') {
4562
+ removeEnd++;
4563
+ } else if (removeStart > lbIdx + 1 && tokens[removeStart - 1][0] === ',') {
4564
+ removeStart--;
4565
+ }
4566
+ tokens.splice(removeStart, removeEnd - removeStart);
4567
+ }
3729
4568
  }
3730
- if (names.length === 0) continue;
3731
- // Keep if any name is used at runtime
3732
- if (names.some(n => usedNames.has(n))) continue;
3733
- // Only elide if at least one name was used in a type annotation
3734
- if (!names.some(n => typeRefNames.has(n))) continue;
3735
- // All imported names are type-only — remove IMPORT through TERMINATOR
3736
- let end = j;
3737
- while (end < tokens.length && tokens[end][0] !== 'TERMINATOR') end++;
3738
- if (end < tokens.length) end++; // include TERMINATOR
3739
- tokens.splice(i, end - i);
3740
4569
  }
3741
4570
  }
3742
4571
 
@@ -3817,6 +4646,11 @@ export class Compiler {
3817
4646
  skipDataPart: this.options.skipDataPart,
3818
4647
  stubComponents: this.options.stubComponents,
3819
4648
  reactiveVars: this.options.reactiveVars,
4649
+ // Emit `name: T` inline on typed params so shadow TS in compileForCheck
4650
+ // sees annotations on every function-like position (top-level arrows,
4651
+ // class methods, object-literal method shorthand, nested functions).
4652
+ // Off by default — only set when producing input for the shadow TS pass.
4653
+ inlineTypes: this.options.inlineTypes,
3820
4654
  // Schema runtime mode: 'browser' / 'validate' / 'server' / 'migration'.
3821
4655
  // Default 'migration' covers the common case (CLI, server, tests) where
3822
4656
  // the user might call any schema feature including .toSQL(). The browser
@@ -3920,24 +4754,6 @@ export function emit(sexpr, options = {}) {
3920
4754
  return new CodeEmitter(options).compile(sexpr);
3921
4755
  }
3922
4756
 
3923
- export function getStdlibCode() {
3924
- return `\
3925
- globalThis.abort ??= (msg) => { if (msg) console.error(msg); process.exit(1); };
3926
- globalThis.assert ??= (v, msg) => { if (!v) throw new Error(msg || "Assertion failed"); };
3927
- globalThis.exit ??= (code) => process.exit(code || 0);
3928
- globalThis.kind ??= (v) => v != null ? (v.constructor?.name || Object.prototype.toString.call(v).slice(8, -1)).toLowerCase() : String(v);
3929
- globalThis.noop ??= () => {};
3930
- globalThis.p ??= console.log;
3931
- globalThis.pp ??= (v) => { console.log(JSON.stringify(v, null, 2)); return v; };
3932
- globalThis.raise ??= (a, b) => { throw (b !== undefined ? new a(b) : new Error(a)); };
3933
- globalThis.rand ??= (a, b) => b !== undefined ? (a > b && ([a, b] = [b, a]), Math.floor(Math.random() * (b - a + 1) + a)) : a ? Math.floor(Math.random() * a) : Math.random();
3934
- globalThis.sleep ??= (ms) => new Promise(r => setTimeout(r, ms));
3935
- globalThis.todo ??= (msg) => { throw new Error(msg || "Not implemented"); };
3936
- globalThis.warn ??= console.warn;
3937
- globalThis.zip ??= (...a) => a[0].map((_, i) => a.map(b => b[i]));
3938
- `;
3939
- }
3940
-
3941
4757
  export function getReactiveRuntime() {
3942
4758
  return new CodeEmitter({}).getReactiveRuntime();
3943
4759
  }
@@ -3947,4 +4763,5 @@ export function getComponentRuntime() {
3947
4763
  }
3948
4764
 
3949
4765
  export { formatSExpr };
4766
+ export { stringify, getStdlibCode };
3950
4767
  export { RipError, toRipError, formatError, formatErrorHTML } from './error.js';