@abide/abide 0.41.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/AGENTS.md +234 -294
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +51 -95
  4. package/package.json +3 -2
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +107 -19
  7. package/src/buildCli.ts +1 -1
  8. package/src/discoveryEntry.ts +3 -3
  9. package/src/lib/bundle/BundleMenuItem.ts +1 -1
  10. package/src/lib/cli/createClient.ts +12 -12
  11. package/src/lib/cli/dispatchCommand.ts +1 -1
  12. package/src/lib/cli/printSessionStatus.ts +1 -1
  13. package/src/lib/cli/resolveCliTarget.ts +1 -1
  14. package/src/lib/cli/runCli.ts +1 -1
  15. package/src/lib/cli/types/CliManifest.ts +1 -1
  16. package/src/lib/cli/types/CliManifestEntry.ts +2 -2
  17. package/src/lib/mcp/annotationsForMethod.ts +5 -5
  18. package/src/lib/mcp/createMcpServer.ts +3 -3
  19. package/src/lib/mcp/mcpSurface.ts +14 -14
  20. package/src/lib/mcp/types/McpServerOptions.ts +1 -1
  21. package/src/lib/server/DELETE.ts +4 -4
  22. package/src/lib/server/GET.ts +4 -4
  23. package/src/lib/server/HEAD.ts +4 -4
  24. package/src/lib/server/PATCH.ts +4 -4
  25. package/src/lib/server/POST.ts +4 -4
  26. package/src/lib/server/PUT.ts +4 -4
  27. package/src/lib/server/agent.ts +4 -4
  28. package/src/lib/server/env.ts +1 -1
  29. package/src/lib/server/error.ts +49 -7
  30. package/src/lib/server/json.ts +1 -1
  31. package/src/lib/server/rpc/buildErrorConstructors.ts +19 -0
  32. package/src/lib/server/rpc/{defineVerb.ts → defineRpc.ts} +52 -34
  33. package/src/lib/server/rpc/{dispatchVerbInProcess.ts → dispatchRpcInProcess.ts} +3 -3
  34. package/src/lib/server/rpc/fieldErrorsFromIssues.ts +23 -0
  35. package/src/lib/server/rpc/{findVerbByCommandName.ts → findRpcByCommandName.ts} +5 -5
  36. package/src/lib/server/rpc/parseArgs.ts +6 -6
  37. package/src/lib/server/rpc/readBodyWithinLimit.ts +2 -2
  38. package/src/lib/server/rpc/registerRpc.ts +6 -0
  39. package/src/lib/server/rpc/{verbRegistry.ts → rpcRegistry.ts} +4 -4
  40. package/src/lib/server/rpc/{runWithVerbTimeout.ts → runWithRpcTimeout.ts} +4 -4
  41. package/src/lib/server/rpc/types/RemoteHandler.ts +9 -3
  42. package/src/lib/server/rpc/types/{VerbHelper.ts → RpcHelper.ts} +57 -23
  43. package/src/lib/server/rpc/types/{VerbRegistryEntry.ts → RpcRegistryEntry.ts} +3 -3
  44. package/src/lib/server/rpc/types/TypedResponse.ts +2 -2
  45. package/src/lib/server/rpc/unprocessed.ts +6 -7
  46. package/src/lib/server/rpc/validationError.ts +17 -0
  47. package/src/lib/server/runtime/buildInspectorSurface.ts +7 -7
  48. package/src/lib/server/runtime/buildOpenApiSpec.ts +6 -6
  49. package/src/lib/server/runtime/createRouteDispatcher.ts +7 -7
  50. package/src/lib/server/runtime/createServer.ts +4 -4
  51. package/src/lib/server/runtime/crossOriginForbidden.ts +1 -1
  52. package/src/lib/server/runtime/crossOriginGate.ts +4 -4
  53. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  54. package/src/lib/server/runtime/logExposedSurfaces.ts +4 -4
  55. package/src/lib/server/runtime/registryManifests.ts +2 -2
  56. package/src/lib/server/runtime/types/InspectorCacheEntry.ts +1 -1
  57. package/src/lib/server/runtime/types/InspectorRpc.ts +27 -0
  58. package/src/lib/server/runtime/types/InspectorSurface.ts +4 -4
  59. package/src/lib/server/runtime/types/RequestStore.ts +1 -1
  60. package/src/lib/server/runtime/warnUnguardedMcp.ts +2 -2
  61. package/src/lib/server/sockets/createSocketDispatcher.ts +15 -14
  62. package/src/lib/server/sockets/types/SocketOperation.ts +2 -2
  63. package/src/lib/shared/HttpError.ts +15 -1
  64. package/src/lib/shared/UNREACHABLE_STATUSES.ts +13 -0
  65. package/src/lib/shared/buildRpcRequest.ts +5 -5
  66. package/src/lib/shared/cache.ts +50 -168
  67. package/src/lib/shared/carriesBodyArgs.ts +4 -4
  68. package/src/lib/shared/createCacheStore.ts +12 -0
  69. package/src/lib/shared/createRemoteFunction.ts +11 -6
  70. package/src/lib/shared/decodeResponse.ts +2 -2
  71. package/src/lib/shared/detectRpcMethod.ts +17 -0
  72. package/src/lib/shared/emitLogRecord.ts +3 -3
  73. package/src/lib/shared/escapeRegex.ts +9 -0
  74. package/src/lib/shared/extraForwardHeaders.ts +1 -1
  75. package/src/lib/shared/fileName.ts +9 -0
  76. package/src/lib/shared/fileStem.ts +3 -1
  77. package/src/lib/shared/findExportCallSite.ts +30 -273
  78. package/src/lib/shared/forwardHeaders.ts +2 -2
  79. package/src/lib/shared/httpErrorFor.ts +26 -0
  80. package/src/lib/shared/isAsciiWhitespace.ts +5 -0
  81. package/src/lib/shared/isIdentPart.ts +9 -0
  82. package/src/lib/shared/isIdentStart.ts +8 -0
  83. package/src/lib/shared/isReadOnlyMethod.ts +4 -4
  84. package/src/lib/shared/keyForRemoteCall.ts +4 -4
  85. package/src/lib/shared/log.ts +1 -1
  86. package/src/lib/shared/outboxProbeSlot.ts +20 -0
  87. package/src/lib/shared/pending.ts +4 -1
  88. package/src/lib/shared/prepareRpcModule.ts +96 -14
  89. package/src/lib/shared/probeRegistries.ts +17 -4
  90. package/src/lib/shared/programNameForPackage.ts +3 -1
  91. package/src/lib/shared/queryStringFromArgs.ts +1 -1
  92. package/src/lib/shared/remoteMetaStore.ts +2 -2
  93. package/src/lib/shared/resolveClientFlags.ts +1 -1
  94. package/src/lib/shared/skipNonCode.ts +230 -0
  95. package/src/lib/shared/streamResponse.ts +9 -6
  96. package/src/lib/shared/stripImport.ts +6 -4
  97. package/src/lib/shared/types/CacheEntry.ts +2 -4
  98. package/src/lib/shared/types/CacheOnContext.ts +1 -11
  99. package/src/lib/shared/types/CacheStore.ts +13 -7
  100. package/src/lib/shared/types/ClientFlags.ts +1 -1
  101. package/src/lib/shared/types/ErrorConstructors.ts +17 -0
  102. package/src/lib/shared/types/ErrorDescriptor.ts +10 -0
  103. package/src/lib/shared/types/ErrorSpec.ts +11 -0
  104. package/src/lib/shared/types/HttpMethod.ts +1 -0
  105. package/src/lib/shared/types/Outbox.ts +9 -0
  106. package/src/lib/shared/types/OutboxEntry.ts +27 -0
  107. package/src/lib/shared/types/RawRemoteFunction.ts +2 -2
  108. package/src/lib/shared/types/RemoteCallable.ts +2 -2
  109. package/src/lib/shared/types/RemoteFunction.ts +21 -6
  110. package/src/lib/shared/types/RequestScopeInfo.ts +1 -1
  111. package/src/lib/shared/types/RpcErrorGuard.ts +40 -0
  112. package/src/lib/shared/types/RpcInvoker.ts +1 -1
  113. package/src/lib/shared/types/StandardSchemaV1.ts +1 -1
  114. package/src/lib/shared/types/ValidationErrorData.ts +20 -0
  115. package/src/lib/shared/url.ts +3 -3
  116. package/src/lib/shared/writeRpcDts.ts +7 -7
  117. package/src/lib/shared/writeTestRpcDts.ts +3 -3
  118. package/src/lib/test/createTestApp.ts +10 -10
  119. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  120. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  121. package/src/lib/ui/compile/awaitPlan.ts +48 -0
  122. package/src/lib/ui/compile/compileShadow.ts +117 -31
  123. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  124. package/src/lib/ui/compile/desugarSignals.ts +42 -6
  125. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  126. package/src/lib/ui/compile/generateBuild.ts +45 -62
  127. package/src/lib/ui/compile/generateSSR.ts +90 -60
  128. package/src/lib/ui/compile/ifPlan.ts +30 -0
  129. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  130. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  131. package/src/lib/ui/compile/parseTemplate.ts +565 -115
  132. package/src/lib/ui/compile/stripEffects.ts +17 -9
  133. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  134. package/src/lib/ui/compile/switchPlan.ts +22 -0
  135. package/src/lib/ui/compile/tryPlan.ts +29 -0
  136. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  137. package/src/lib/ui/compile/types/TemplateAttr.ts +8 -2
  138. package/src/lib/ui/compile/types/TemplateNode.ts +15 -2
  139. package/src/lib/ui/createScope.ts +20 -2
  140. package/src/lib/ui/dom/appendText.ts +1 -5
  141. package/src/lib/ui/dom/applyResolved.ts +1 -5
  142. package/src/lib/ui/dom/disposeRange.ts +6 -10
  143. package/src/lib/ui/dom/hydrate.ts +2 -5
  144. package/src/lib/ui/dom/isComment.ts +6 -0
  145. package/src/lib/ui/dom/mount.ts +1 -2
  146. package/src/lib/ui/dom/withScope.ts +6 -9
  147. package/src/lib/ui/effect.ts +4 -2
  148. package/src/lib/ui/navigate.ts +40 -13
  149. package/src/lib/ui/outbox.ts +30 -110
  150. package/src/lib/ui/remoteProxy.ts +160 -10
  151. package/src/lib/ui/router.ts +3 -3
  152. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +281 -0
  153. package/src/lib/ui/rpcOutbox/outboxRegistry.ts +69 -0
  154. package/src/lib/ui/runtime/createDoc.ts +3 -3
  155. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  156. package/src/lib/ui/socketChannel.ts +7 -1
  157. package/src/lib/ui/types/Scope.ts +20 -5
  158. package/template/.zed/settings.json +4 -0
  159. package/template/CLAUDE.md +1 -1
  160. package/template/src/server/rpc/getHello.ts +3 -3
  161. package/template/src/ui/pages/page.abide +4 -4
  162. package/template/test/app.test.ts +2 -2
  163. package/src/lib/server/rpc/registerVerb.ts +0 -6
  164. package/src/lib/server/runtime/types/InspectorVerb.ts +0 -27
  165. package/src/lib/shared/detectVerbMethod.ts +0 -17
  166. package/src/lib/shared/types/HttpVerb.ts +0 -1
  167. package/src/lib/ui/types/Outbox.ts +0 -14
@@ -91,6 +91,10 @@ export function compileShadow(source: string): CompiledShadow {
91
91
  pre-assembled scope line carrying its own embedded segments. */
92
92
  type Builder = {
93
93
  raw: (text: string) => void
94
+ /* Appends `text` verbatim and maps it back to source, WITHOUT the `expr`
95
+ parens — for binding names and other spans that must stay bare TS syntax
96
+ (`for (const NAME of …)`). `text` must equal the source at `sourceLoc`. */
97
+ mapped: (text: string, sourceLoc: number | undefined) => void
94
98
  expr: (code: string, sourceLoc: number | undefined) => void
95
99
  stmt: (code: string, sourceLoc: number | undefined) => void
96
100
  flush: (line: ScopeLine) => void
@@ -115,6 +119,16 @@ function createBuilder(): Builder {
115
119
  raw(text) {
116
120
  code += text
117
121
  },
122
+ mapped(text, sourceLoc) {
123
+ if (sourceLoc !== undefined) {
124
+ mappings.push({
125
+ shadowStart: code.length,
126
+ sourceStart: sourceLoc,
127
+ length: text.length,
128
+ })
129
+ }
130
+ code += text
131
+ },
118
132
  expr(exprCode, sourceLoc) {
119
133
  code += '('
120
134
  if (sourceLoc !== undefined) {
@@ -268,6 +282,14 @@ function scopeLineFor(
268
282
  propsShapes.push(shape === undefined ? 'Record<string, any>' : verbatim(shape))
269
283
  return { text: `const ${verbatim(declaration)};`, segments: [span(declaration, 6)] }
270
284
  }
285
+ /* The rewrite drops the callee from the line, so hovering `state`/`computed`/
286
+ `linked` at its call site has nothing to resolve. Append the callee as a
287
+ trailing reference statement (`…; state;`), mapped back to its source span,
288
+ so it resolves to the same primitive the destructure does. */
289
+ const withCalleeRef = (line: ScopeLine): ScopeLine => ({
290
+ text: `${line.text} ${verbatim(call.expression)};`,
291
+ segments: [...line.segments, span(call.expression, line.text.length + 1)],
292
+ })
271
293
  if (callee === 'state') {
272
294
  /* state<T>(initial): T is the value type — carry it onto the `let` so an
273
295
  explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
@@ -283,13 +305,16 @@ function scopeLineFor(
283
305
  then correctly flagged possibly-undefined; a guard narrows cleanly. */
284
306
  const valueType = annotation === '' ? ': unknown' : `${annotation} | undefined`
285
307
  /* map the binding name (offset 4, past `let `) so hover/go-to resolve on it */
286
- return { text: `let ${name}!${valueType};`, segments: [span(declaration.name, 4)] }
308
+ return withCalleeRef({
309
+ text: `let ${name}!${valueType};`,
310
+ segments: [span(declaration.name, 4)],
311
+ })
287
312
  }
288
313
  const prefix = `let ${name}${annotation} = (`
289
- return {
314
+ return withCalleeRef({
290
315
  text: `${prefix}${verbatim(init)});`,
291
316
  segments: [span(declaration.name, 4), span(init, prefix.length)],
292
- }
317
+ })
293
318
  }
294
319
  /* computed<T>(compute) / linked<T>(seed) — the only callees left: T is the value
295
320
  type — the call's first arg is a thunk, so invoking it yields the value. Annotate
@@ -303,16 +328,16 @@ function scopeLineFor(
303
328
  /* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
304
329
  const keywordOffset = keyword.length + 1
305
330
  if (fn === undefined) {
306
- return {
331
+ return withCalleeRef({
307
332
  text: `${keyword} ${name} = undefined;`,
308
333
  segments: [span(declaration.name, keywordOffset)],
309
- }
334
+ })
310
335
  }
311
336
  const prefix = `${keyword} ${name}${annotation} = (`
312
- return {
337
+ return withCalleeRef({
313
338
  text: `${prefix}${verbatim(fn)})();`,
314
339
  segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
315
- }
340
+ })
316
341
  }
317
342
 
318
343
  /* Emits a sibling list — each node standalone via `emitNode`. */
@@ -345,23 +370,29 @@ function emitNode(node: TemplateNode, builder: Builder): void {
345
370
  emitNodes(node.children, builder)
346
371
  return
347
372
  case 'component': {
348
- /* Check each prop against the child's declared type. The imported tag
349
- resolves (via the shadow host) to the child's `(props: Props) => …`
350
- default, so `Parameters<typeof Child>[0]["name"]` is that prop's type;
351
- assigning the mapped value to it lands a mismatch diagnostic on the
352
- offending expression (an annotated target reports the error on the RHS,
353
- unlike an object literal which reports it on the key). */
354
- for (const prop of node.props) {
355
- /* Lead with a defensive `;`: this IIFE is the one shadow emission that
356
- starts with `(`, so without it a preceding scope statement left
357
- unterminated (a script ending in a call with no trailing semicolon,
358
- e.g. `effect(() => …)`) merges across the newline into `effect(…)(…)`
359
- a spurious "not callable" on the author's last statement. */
373
+ /* The imported tag resolves (via the shadow host) to the child's
374
+ `(props: Props) => …` default, so `Parameters<typeof Child>[0]` is its prop
375
+ shape. `on*` / `bind:` / `attach` props are framework-handled passthrough
376
+ (not part of the declared shape), so they are checked leniently each value
377
+ against its own declared key type never flagged as excess. */
378
+ const handled = (prop: { name: string; spread?: boolean }): boolean =>
379
+ prop.spread === true ||
380
+ prop.name.startsWith('on') ||
381
+ prop.name.startsWith('bind:') ||
382
+ prop.name.startsWith('class:') ||
383
+ prop.name.startsWith('style:') ||
384
+ prop.name === 'attach'
385
+ const hasSpread = node.props.some((prop) => prop.spread)
386
+ for (const prop of node.props.filter(handled)) {
387
+ /* Lead with a defensive `;`: an IIFE/object-literal arg starts with `(` or
388
+ `{`, so without it a preceding scope statement left unterminated (a script
389
+ ending in a call with no trailing semicolon, e.g. `effect(() => …)`)
390
+ merges across the newline into `effect(…)(…)` — a spurious "not callable". */
360
391
  if (prop.spread) {
361
392
  /* A `{...expr}` spread contributes a SUBSET of the props (required ones
362
393
  may come from another spread/explicit prop), so check it against
363
- `Partial<Props>` — every key it does carry must match the child's
364
- declared type, without demanding completeness. */
394
+ `Partial<Props>` — every key it does carry must match, without
395
+ demanding completeness. */
365
396
  builder.raw(`;((__spread: Partial<Parameters<typeof ${node.name}>[0]>) => {})(`)
366
397
  } else {
367
398
  builder.raw(
@@ -371,6 +402,43 @@ function emitNode(node: TemplateNode, builder: Builder): void {
371
402
  builder.expr(prop.code, prop.loc)
372
403
  builder.raw(');\n')
373
404
  }
405
+ /* The plain data props as one object-literal argument typed to the child's whole
406
+ prop shape: a missing required prop errors on the literal (anchored at the tag
407
+ via the mapped `{`), an unknown prop errors on its key, a wrong type on its
408
+ value. Skipped when a spread is present — a spread may supply required props,
409
+ so completeness can't be demanded; the data props fall back to lenient per-key
410
+ checks instead. */
411
+ const dataProps = node.props.filter((prop) => !handled(prop))
412
+ if (hasSpread) {
413
+ for (const prop of dataProps) {
414
+ builder.raw(
415
+ `;((__prop: Parameters<typeof ${node.name}>[0][${JSON.stringify(prop.name)}]) => {})(`,
416
+ )
417
+ builder.expr(prop.code, prop.loc)
418
+ builder.raw(');\n')
419
+ }
420
+ } else {
421
+ builder.raw(`;((__c: Parameters<typeof ${node.name}>[0]): void => { void __c })({`)
422
+ /* A zero-length anchor right after `{`, pointing at the tag: a missing-
423
+ required-prop error spans the literal from `{`, so it overlaps this and
424
+ maps to the tag (an empty span trivially satisfies the source-text ==
425
+ shadow-text invariant). */
426
+ builder.mapped('', node.loc)
427
+ builder.raw('\n')
428
+ for (const prop of dataProps) {
429
+ /* The key mapped to its source name (excess-prop errors land on the key);
430
+ the value verbatim-mapped (wrong-type errors land on the value). */
431
+ if (prop.nameLoc !== undefined) {
432
+ builder.mapped(prop.name, prop.nameLoc)
433
+ } else {
434
+ builder.raw(prop.name)
435
+ }
436
+ builder.raw(': ')
437
+ builder.expr(prop.code, prop.loc)
438
+ builder.raw(',\n')
439
+ }
440
+ builder.raw('});\n')
441
+ }
374
442
  emitNodes(node.children, builder)
375
443
  return
376
444
  }
@@ -408,19 +476,24 @@ function emitNode(node: TemplateNode, builder: Builder): void {
408
476
  }
409
477
  case 'each':
410
478
  /* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
411
- so the item binds to the element type under either iteration protocol. */
412
- builder.raw(
413
- node.async ? `for await (const ${node.as} of ` : `for (const ${node.as} of `,
414
- )
479
+ so the item binds to the element type under either iteration protocol. The
480
+ binding name is `mapped` (not `raw`) so hover/highlighting land on it. */
481
+ builder.raw(node.async ? 'for await (const ' : 'for (const ')
482
+ builder.mapped(node.as, node.asLoc)
483
+ builder.raw(' of ')
415
484
  builder.expr(node.items, node.loc)
416
485
  builder.raw(') {\n')
417
486
  if (node.key !== undefined) {
418
- builder.raw(`void (${node.key});\n`)
487
+ builder.raw('void ')
488
+ builder.expr(node.key, node.keyLoc)
489
+ builder.raw(';\n')
419
490
  }
420
491
  /* `index="i"` binds the row's position — always a number (the row ordinal,
421
492
  or an async stream's arrival count). Declare it so body references check. */
422
493
  if (node.index !== undefined) {
423
- builder.raw(`const ${node.index}: number = 0;\n`)
494
+ builder.raw('const ')
495
+ builder.mapped(node.index, node.indexLoc)
496
+ builder.raw(': number = 0;\n')
424
497
  }
425
498
  emitNodes(node.children, builder)
426
499
  builder.raw('}\n')
@@ -439,7 +512,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
439
512
  const pending = node.children.filter((child) => child.kind !== 'branch')
440
513
  const branches = node.children.filter((child) => child.kind === 'branch')
441
514
  if (node.blocking && node.as !== undefined) {
442
- builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
515
+ builder.raw('{\nconst ')
516
+ builder.mapped(node.as, node.asLoc)
517
+ builder.raw(` = ${resolved};\n`)
443
518
  emitNodes(pending, builder)
444
519
  builder.raw('}\n')
445
520
  } else {
@@ -451,9 +526,13 @@ function emitNode(node: TemplateNode, builder: Builder): void {
451
526
  }
452
527
  builder.raw('{\n')
453
528
  if (branch.branch === 'then' && branch.as !== undefined) {
454
- builder.raw(`const ${branch.as} = ${resolved};\n`)
529
+ builder.raw('const ')
530
+ builder.mapped(branch.as, branch.asLoc)
531
+ builder.raw(` = ${resolved};\n`)
455
532
  } else if (branch.branch === 'catch' && branch.as !== undefined) {
456
- builder.raw(`const ${branch.as} = undefined as any;\n`)
533
+ builder.raw('const ')
534
+ builder.mapped(branch.as, branch.asLoc)
535
+ builder.raw(' = undefined as any;\n')
457
536
  }
458
537
  emitNodes(branch.children, builder)
459
538
  builder.raw('}\n')
@@ -508,7 +587,14 @@ function emitNode(node: TemplateNode, builder: Builder): void {
508
587
  builder.raw('}\n')
509
588
  return
510
589
  case 'snippet':
511
- builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
590
+ /* `args={}` is the parameter list; `mapped` (not `raw`) so hover/highlighting
591
+ land on the binding. `loc` is the `args` expression's offset (see
592
+ toSnippetOrTemplate). The name is a static attribute with no tracked offset. */
593
+ builder.raw(`const ${node.name} = (`)
594
+ if (node.params !== undefined) {
595
+ builder.mapped(node.params, node.loc)
596
+ }
597
+ builder.raw(') => {\n')
512
598
  emitNodes(node.children, builder)
513
599
  builder.raw('};\n')
514
600
  return
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import ts from 'typescript'
3
3
  import { messageFromError } from '../../shared/messageFromError.ts'
4
+ import { mapTsClassification } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
4
5
  import { assetModulesFile } from './assetModulesFile.ts'
5
6
  import { compileShadow } from './compileShadow.ts'
6
7
  import { loadShadowTsConfig } from './loadShadowTsConfig.ts'
@@ -10,6 +11,7 @@ import { shadowNaming } from './shadowNaming.ts'
10
11
  import { sourceToShadowOffset } from './sourceToShadowOffset.ts'
11
12
  import type { AbideDiagnostic } from './types/AbideDiagnostic.ts'
12
13
  import type { CompiledShadow } from './types/CompiledShadow.ts'
14
+ import type { SemanticToken } from './types/SemanticToken.ts'
13
15
 
14
16
  const { suffixed, isShadow, sourceOf } = shadowNaming
15
17
 
@@ -27,6 +29,8 @@ export type ShadowLanguageService = {
27
29
  /* Hover info at a source offset, or undefined if the offset isn't a checked
28
30
  expression (markup, whitespace) or TypeScript has nothing to report. */
29
31
  quickInfo: (abidePath: string, sourceOffset: number) => ShadowQuickInfo | undefined
32
+ /* Type-aware semantic tokens for every checked expression, in source coords. */
33
+ semanticClassifications: (abidePath: string) => SemanticToken[]
30
34
  }
31
35
 
32
36
  /*
@@ -194,5 +198,49 @@ export function createShadowLanguageService(cwd: string): ShadowLanguageService
194
198
  length: span?.length ?? 1,
195
199
  }
196
200
  },
201
+ semanticClassifications(abidePath) {
202
+ const fileName = suffixed(abidePath)
203
+ /* Compile first so the mappings cache is current. */
204
+ shadowText(abidePath)
205
+ const shadow = shadows.get(abidePath)
206
+ if (shadow === undefined) {
207
+ return []
208
+ }
209
+ const { spans } = service.getEncodedSemanticClassifications(
210
+ fileName,
211
+ { start: 0, length: shadow.code.length },
212
+ ts.SemanticClassificationFormat.TwentyTwenty,
213
+ )
214
+ /* `spans` is flat triples [start, length, classification, …] in shadow
215
+ coords; keep only those overlapping a mapped expression segment. */
216
+ const tokens: SemanticToken[] = []
217
+ for (let index = 0; index + 2 < spans.length; index += 3) {
218
+ const spanStart = spans[index]
219
+ const spanLength = spans[index + 1]
220
+ const classification = spans[index + 2]
221
+ if (
222
+ spanStart === undefined ||
223
+ spanLength === undefined ||
224
+ classification === undefined
225
+ ) {
226
+ continue
227
+ }
228
+ const mapped = mapTsClassification(classification)
229
+ if (mapped === undefined) {
230
+ continue
231
+ }
232
+ const located = remapShadowDiagnostic(shadow.mappings, spanStart, spanLength)
233
+ if (located === undefined) {
234
+ continue
235
+ }
236
+ tokens.push({
237
+ start: located.start,
238
+ length: located.length,
239
+ type: mapped.type,
240
+ modifiers: mapped.modifiers,
241
+ })
242
+ }
243
+ return tokens
244
+ },
197
245
  }
198
246
  }
@@ -4,20 +4,56 @@ import { REACTIVE_CALLEES } from './REACTIVE_CALLEES.ts'
4
4
  const factory = ts.factory
5
5
 
6
6
  /* The reactive primitives that must be reached through a scope. A bare call to one of
7
- these is a compile error: reactive state is owned by a scope and the surface must show
8
- it (`scope().state(...)`), so a reader always sees the scope interaction. */
9
- const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed'])
7
+ these is a compile error: a reactive primitive is owned by a scope and the surface must
8
+ show it (`scope().state(...)`), so a reader always sees the scope interaction. `effect`
9
+ is here too a reaction is scope-owned (it tears down with the scope), so it joins the
10
+ one surface; unlike the cells it stays a runtime call (`scope().effect(...)` passes
11
+ through to the `effect` helper), not a doc slot. */
12
+ const SCOPE_PRIMITIVES: ReadonlySet<string> = new Set(['state', 'linked', 'computed', 'effect'])
13
+
14
+ /* The primitive names a top-level `const { state, computed } = scope()` destructure binds.
15
+ Such a name is scope-bound — its bare call below is the destructured method, not a stray
16
+ global — so it is exempt from the bare-primitive error. Only a destructure of a `scope()`
17
+ call counts (receiver-agnostic on the callee name, matching signalCallee); an aliased
18
+ binding (`{ state: s }`) is not recognised, so the canonical name must be kept. */
19
+ function scopeDestructuredPrimitives(source: ts.SourceFile): Set<string> {
20
+ const bound = new Set<string>()
21
+ for (const statement of source.statements) {
22
+ if (!ts.isVariableStatement(statement)) {
23
+ continue
24
+ }
25
+ for (const declaration of statement.declarationList.declarations) {
26
+ if (
27
+ signalCallee(declaration) === 'scope' &&
28
+ ts.isObjectBindingPattern(declaration.name)
29
+ ) {
30
+ for (const element of declaration.name.elements) {
31
+ if (
32
+ element.propertyName === undefined &&
33
+ ts.isIdentifier(element.name) &&
34
+ SCOPE_PRIMITIVES.has(element.name.text)
35
+ ) {
36
+ bound.add(element.name.text)
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return bound
43
+ }
10
44
 
11
45
  /* Throws on a bare scope primitive (`state(0)` instead of `scope().state(0)`) or on the
12
46
  removed `prop(...)` reader — props are now read by destructuring `props()`. Walks all
13
- calls, so a stray one nested in a function is caught too, not just top-level declarations. */
47
+ calls, so a stray one nested in a function is caught too, not just top-level declarations.
48
+ A primitive destructured from `scope()` at the top is scope-bound and exempt. */
14
49
  function assertScopedPrimitives(source: ts.SourceFile): void {
50
+ const scopeBound = scopeDestructuredPrimitives(source)
15
51
  const visit = (node: ts.Node): void => {
16
52
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
17
53
  const name = node.expression.text
18
- if (SCOPE_PRIMITIVES.has(name)) {
54
+ if (SCOPE_PRIMITIVES.has(name) && !scopeBound.has(name)) {
19
55
  throw new Error(
20
- `abide: bare \`${name}(...)\` is not allowed — reactive state lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
56
+ `abide: bare \`${name}(...)\` is not allowed — a reactive primitive lives on a scope. Use \`scope().${name}(...)\` (or a captured handle: \`const s = scope(); s.${name}(...)\`).`,
21
57
  )
22
58
  }
23
59
  if (name === 'prop') {
@@ -0,0 +1,37 @@
1
+ import { ABIDE_SEMANTIC_TOKENS_LEGEND } from './ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
2
+ import { offsetToPosition } from './offsetToPosition.ts'
3
+ import type { SemanticToken } from './types/SemanticToken.ts'
4
+
5
+ /*
6
+ Encodes source-coordinate tokens into the LSP semantic-tokens `data` array: five
7
+ integers per token (deltaLine, deltaStartChar, length, tokenTypeIndex,
8
+ modifierBitset), each position relative to the previous token. Tokens are sorted
9
+ by start; tokens with an unknown legend type, and any token overlapping the one
10
+ before it, are dropped — the protocol requires a strictly non-overlapping,
11
+ positionally-ordered stream.
12
+ */
13
+ export function encodeSemanticTokens(text: string, tokens: SemanticToken[]): number[] {
14
+ const sorted = [...tokens].sort((a, b) => a.start - b.start || a.length - b.length)
15
+ const data: number[] = []
16
+ let previousLine = 0
17
+ let previousCharacter = 0
18
+ let previousEnd = -1
19
+ for (const token of sorted) {
20
+ const typeIndex = ABIDE_SEMANTIC_TOKENS_LEGEND.tokenTypes.indexOf(token.type)
21
+ if (typeIndex < 0 || token.start < previousEnd) {
22
+ continue
23
+ }
24
+ const modifierBitset = token.modifiers.reduce((bits, name) => {
25
+ const bit = ABIDE_SEMANTIC_TOKENS_LEGEND.tokenModifiers.indexOf(name)
26
+ return bit < 0 ? bits : bits | (1 << bit)
27
+ }, 0)
28
+ const { line, character } = offsetToPosition(text, token.start)
29
+ const deltaLine = line - previousLine
30
+ const deltaCharacter = deltaLine === 0 ? character - previousCharacter : character
31
+ data.push(deltaLine, deltaCharacter, token.length, typeIndex, modifierBitset)
32
+ previousLine = line
33
+ previousCharacter = character
34
+ previousEnd = token.start + token.length
35
+ }
36
+ return data
37
+ }
@@ -3,19 +3,24 @@ import { HOLE_ATTRIBUTE } from '../runtime/HOLE_ATTRIBUTE.ts'
3
3
  import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
4
  import { ANCHOR } from '../runtime/RANGE_MARKER.ts'
5
5
  import { asOutlet } from './asOutlet.ts'
6
+ import { awaitPlan } from './awaitPlan.ts'
6
7
  import { bindListenEvent } from './bindListenEvent.ts'
7
8
  import { composeProps } from './composeProps.ts'
8
9
  import { destructureBindingNames } from './destructureBindingNames.ts'
9
10
  import { groupBindParts } from './groupBindParts.ts'
11
+ import { ifPlan } from './ifPlan.ts'
10
12
  import { isControlFlow } from './isControlFlow.ts'
11
13
  import { isWhitespaceText } from './isWhitespaceText.ts'
12
14
  import { lowerContext } from './lowerContext.ts'
15
+ import { makeVarNamer } from './makeVarNamer.ts'
13
16
  import { resolveBranches } from './resolveBranches.ts'
14
17
  import { scopeAttr } from './scopeAttr.ts'
15
18
  import { skeletonContext } from './skeletonContext.ts'
16
19
  import { spreadExcludedNames } from './spreadExcludedNames.ts'
17
20
  import { staticAttr } from './staticAttr.ts'
18
21
  import { staticTextPart } from './staticTextPart.ts'
22
+ import { switchPlan } from './switchPlan.ts'
23
+ import { tryPlan } from './tryPlan.ts'
19
24
  import type { TemplateNode } from './types/TemplateNode.ts'
20
25
  import { VOID_TAGS } from './VOID_TAGS.ts'
21
26
 
@@ -61,8 +66,7 @@ export function generateBuild(
61
66
  computedNames: ReadonlySet<string>,
62
67
  isLayout = false,
63
68
  ): string {
64
- let counter = 0
65
- const nextVar = (prefix: string): string => `${prefix}${counter++}`
69
+ const nextVar = makeVarNamer()
66
70
 
67
71
  /* In a layout, `<slot/>` outlets are rewritten to `OUTLET_TAG` elements up front
68
72
  (`asOutlet`) so the static-clone path carries them as ordinary structure. `asOutlet`
@@ -143,7 +147,7 @@ export function generateBuild(
143
147
  node: Extract<TemplateNode, { kind: 'element' }>,
144
148
  attr: Extract<
145
149
  (typeof node.attrs)[number],
146
- { kind: 'expression' | 'event' | 'attach' | 'bind' }
150
+ { kind: 'expression' | 'event' | 'attach' | 'bind' | 'class' | 'style' }
147
151
  >,
148
152
  varName: string,
149
153
  ): string {
@@ -156,6 +160,15 @@ export function generateBuild(
156
160
  if (attr.kind === 'attach') {
157
161
  return `attach(${varName}, (${lowerExpression(attr.code)}));\n`
158
162
  }
163
+ /* `class:<name>` — toggle the class by truthiness; surgical, no element re-render.
164
+ Layers on top of any static `class="…"` in the skeleton (classList is additive). */
165
+ if (attr.kind === 'class') {
166
+ return `effect(${namedThunk(`class_${attr.name}`, `${varName}.classList.toggle(${JSON.stringify(attr.name)}, !!(${lowerExpression(attr.code)}));`)});\n`
167
+ }
168
+ /* `style:<property>` — write one inline style / custom property reactively. */
169
+ if (attr.kind === 'style') {
170
+ return `effect(${namedThunk(`style_${attr.property}`, `${varName}.style.setProperty(${JSON.stringify(attr.property)}, String(${lowerExpression(attr.code)}));`)});\n`
171
+ }
159
172
  if (attr.property === 'group') {
160
173
  /* Grouped two-way: radio binds the path to the single checked `value`;
161
174
  checkbox treats the path as an array, adding/removing `value` on toggle.
@@ -471,11 +484,8 @@ export function generateBuild(
471
484
  parentVar: string,
472
485
  before: string,
473
486
  ): string {
474
- const cases = node.children
475
- .filter(
476
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
477
- )
478
- .map((branch) => {
487
+ const cases = switchPlan(node)
488
+ .cases.map((branch) => {
479
489
  const match =
480
490
  branch.match === undefined
481
491
  ? 'undefined'
@@ -534,45 +544,26 @@ export function generateBuild(
534
544
  parentVar: string,
535
545
  before: string,
536
546
  ): string {
537
- const [thenBranch, catchBranch, finallyBranch] = resolveBranches(
538
- node,
539
- 'then',
540
- 'catch',
541
- 'finally',
542
- )
543
- const finallyChildren = finallyBranch?.children ?? []
544
- /* Blocking: no pending, the children are the resolved branch bound to `node.as`.
545
- Streaming: pending is the non-branch children, resolved is the `then` child. */
546
- const pending = node.blocking
547
- ? []
548
- : node.children.filter((child) => child.kind !== 'branch')
547
+ const plan = awaitPlan(node)
549
548
  /* The resolved value is reactive: a re-settle updates it in place rather than
550
- rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell. */
551
- const thenThunk = node.blocking
552
- ? branchThunk(
553
- node.children.filter((child) => child.kind !== 'branch'),
554
- node.as ?? '_value',
555
- finallyChildren,
556
- true,
557
- )
558
- : branchThunk(
559
- thenBranch?.children ?? [],
560
- thenBranch?.as ?? '_value',
561
- finallyChildren,
562
- true,
563
- )
549
+ rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell.
550
+ Build settles blocking and streaming uniformly through `awaitBlock`, so the plan's
551
+ resolved content/binding feeds one thunk regardless of mode. */
552
+ const thenThunk = branchThunk(
553
+ plan.resolvedChildren,
554
+ plan.resolvedAs,
555
+ plan.finallyChildren,
556
+ true,
557
+ )
564
558
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
565
559
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
566
560
  block keeps a catch thunk that renders just finally. */
567
- const catchThunk =
568
- catchBranch === undefined && finallyChildren.length === 0
569
- ? 'undefined'
570
- : branchThunk(
571
- catchBranch?.children ?? [],
572
- catchBranch?.as ?? '_error',
573
- finallyChildren,
574
- )
575
- const pendingThunk = hasRenderableContent(pending) ? branchThunk(pending) : 'undefined'
561
+ const catchThunk = plan.surfaceRejection
562
+ ? 'undefined'
563
+ : branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
564
+ const pendingThunk = hasRenderableContent(plan.pending)
565
+ ? branchThunk(plan.pending)
566
+ : 'undefined'
576
567
  return (
577
568
  `awaitBlock(${parentVar}, nextBlockId(), () => (${lowerExpression(node.promise)}), ` +
578
569
  `${pendingThunk}, ` +
@@ -638,14 +629,11 @@ export function generateBuild(
638
629
  parentVar: string,
639
630
  before: string,
640
631
  ): string {
641
- const [catchBranch, finallyBranch] = resolveBranches(node, 'catch', 'finally')
642
- const finallyChildren = finallyBranch?.children ?? []
643
- const guarded = node.children.filter((child) => child.kind !== 'branch')
644
- const tryThunk = branchThunk(guarded, undefined, finallyChildren)
645
- const catchThunk =
646
- catchBranch === undefined
647
- ? 'undefined'
648
- : branchThunk(catchBranch.children, catchBranch.as ?? '_error', finallyChildren)
632
+ const plan = tryPlan(node)
633
+ const tryThunk = branchThunk(plan.guarded, undefined, plan.finallyChildren)
634
+ const catchThunk = plan.hasCatch
635
+ ? branchThunk(plan.catchChildren, plan.catchAs, plan.finallyChildren)
636
+ : 'undefined'
649
637
  return `tryBlock(${parentVar}, nextBlockId(), ${tryThunk}, ${catchThunk}, ${before});\n`
650
638
  }
651
639
 
@@ -658,25 +646,20 @@ export function generateBuild(
658
646
  ): string {
659
647
  /* The `case` children are the chain's `elseif`/`else` branches in source order;
660
648
  the rest are the `then` content. */
661
- const branches = node.children.filter(
662
- (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
663
- )
664
- const thenChildren = node.children.filter((child) => child.kind !== 'case')
665
- const hasElseif = branches.some((branch) => branch.condition !== undefined)
649
+ const plan = ifPlan(node)
666
650
  /* Fast path: a plain `if` (with optional `else`) is the binary `when` runtime. */
667
- if (!hasElseif) {
668
- const elseBranch = branches.find((branch) => branch.condition === undefined)
669
- const thenThunk = branchThunk(thenChildren)
651
+ if (!plan.hasElseif) {
652
+ const thenThunk = branchThunk(plan.thenChildren)
670
653
  const elseThunk =
671
- elseBranch === undefined ? 'undefined' : branchThunk(elseBranch.children)
654
+ plan.elseBranch === undefined ? 'undefined' : branchThunk(plan.elseBranch.children)
672
655
  return `when(${parentVar}, () => (${lowerExpression(node.condition)}), ${thenThunk}, ${elseThunk}, ${before});\n`
673
656
  }
674
657
  /* if/elseif/else is a cond-chain — reuse `switchBlock` over a constant `true`
675
658
  subject with `Boolean`-coerced match thunks, so the first truthy branch wins
676
659
  (`else` is the match-less default). */
677
660
  const entries = [
678
- `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(thenChildren)} }`,
679
- ...branches.map((branch) =>
661
+ `{ match: () => Boolean(${lowerExpression(node.condition)}), render: ${branchThunk(plan.thenChildren)} }`,
662
+ ...plan.branches.map((branch) =>
680
663
  branch.condition !== undefined
681
664
  ? `{ match: () => Boolean(${lowerExpression(branch.condition)}), render: ${branchThunk(branch.children)} }`
682
665
  : `{ match: undefined, render: ${branchThunk(branch.children)} }`,