@abide/abide 0.41.1 → 0.42.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 (32) hide show
  1. package/AGENTS.md +19 -13
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +13 -14
  4. package/package.json +1 -1
  5. package/src/abideLsp.ts +46 -8
  6. package/src/abideResolverPlugin.ts +3 -5
  7. package/src/lib/server/runtime/devClientFingerprint.ts +2 -1
  8. package/src/lib/shared/escapeRegex.ts +9 -0
  9. package/src/lib/shared/fileName.ts +9 -0
  10. package/src/lib/shared/fileStem.ts +3 -1
  11. package/src/lib/shared/programNameForPackage.ts +3 -1
  12. package/src/lib/shared/stripImport.ts +3 -1
  13. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +45 -0
  14. package/src/lib/ui/compile/abideUiPlugin.ts +2 -1
  15. package/src/lib/ui/compile/compileShadow.ts +42 -10
  16. package/src/lib/ui/compile/createShadowLanguageService.ts +48 -0
  17. package/src/lib/ui/compile/encodeSemanticTokens.ts +37 -0
  18. package/src/lib/ui/compile/generateBuild.ts +2 -2
  19. package/src/lib/ui/compile/generateSSR.ts +6 -6
  20. package/src/lib/ui/compile/makeVarNamer.ts +10 -0
  21. package/src/lib/ui/compile/offsetToPosition.ts +9 -0
  22. package/src/lib/ui/compile/parseTemplate.ts +524 -104
  23. package/src/lib/ui/compile/structuralBlockTokens.ts +101 -0
  24. package/src/lib/ui/compile/types/SemanticToken.ts +11 -0
  25. package/src/lib/ui/compile/types/TemplateNode.ts +9 -0
  26. package/src/lib/ui/dom/appendText.ts +1 -5
  27. package/src/lib/ui/dom/applyResolved.ts +1 -5
  28. package/src/lib/ui/dom/isComment.ts +6 -0
  29. package/src/lib/ui/runtime/createDoc.ts +3 -3
  30. package/src/lib/ui/runtime/pathSegments.ts +10 -0
  31. package/template/.zed/settings.json +4 -0
  32. package/template/src/ui/pages/page.abide +4 -4
package/AGENTS.md CHANGED
@@ -143,23 +143,29 @@ import: `scope`, `props` / `prop`, `effect`, `html`, `snippet`.
143
143
  | `{...expr}` | Spread keys as attributes (elements) or props (components). |
144
144
  | `attach={fn}` | Build-time element attachment with optional teardown. |
145
145
 
146
- **Control flow** — native `<template>` tags:
147
-
148
- | Form | Meaning |
149
- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
150
- | `<template if={c}>` / `<template elseif={c}>` / `<template else>` | Conditional, source-order, re-evaluated reactively. |
151
- | `<template each={list} as="x" key="x.id" index="i">` | Keyed list; reorder/re-key updates rows in place; `index` is reactive. |
152
- | `<template each await={asyncIter} as="x" key="">` | Async keyed list; rows append as the iterator yields. |
153
- | `<template await={p}>` / `<template then="v">` / `<template catch="e">` / `<template finally>` | Promise branches; `await={p} then="v"` on one tag renders inline (blocking) on the server. |
154
- | `<template switch={s}>` / `<template case="v">` / `<template default>` | First strict (`===`) match wins. |
155
- | `<template try>` / `<template catch="e">` / `<template finally>` | Synchronous render error boundary. |
156
- | `<template name="row" args={item}>` `{row(item)}` | Snippet definition + call. |
146
+ **Control flow** — `{#…}` blocks (each head reads as the JS clause it lowers to):
147
+
148
+ | Form | Meaning |
149
+ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
150
+ | `{#if c}` / `{:else if c}` / `{:else}` / `{/if}` | Conditional, source-order, re-evaluated reactively. |
151
+ | `{#for x of list}` / `{#for x, i of list by x.id}` / `{/for}` | Keyed list (`for…of`); `, i` index reactive; `by` key reconciles rows in place; key defaults to item. |
152
+ | `{#for await x of asyncIter by x.id}` `{:catch e}` / `{/for}` | Async keyed list (`for await…of`); rows append as the iterator yields. |
153
+ | `{#await p}` / `{:then v}` / `{:catch e}` / `{:finally}` / `{/await}` | Promise branches; pending content (before `{:then}`) streams. |
154
+ | `{#await p then v}` / `{/await}` | Blocking: no pending, resolved inline (SSR settles before the first flush). |
155
+ | `{#switch s}` / `{:case v}` / `{:default}` / `{/switch}` | First strict (`===`) match wins. |
156
+ | `{#try}` / `{:catch e}` / `{:finally}` / `{/try}` | Synchronous render error boundary. |
157
+
158
+ **Reusable markup** — `<template>`:
159
+
160
+ | Form | Meaning |
161
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
162
+ | `<template name="row" args={item}>` … `{row(item)}` | Named template (snippet) definition + call. A `<template>` with a control-flow attribute (`if`/`each`/…) is a compile error — use the `{#…}` block. |
157
163
 
158
164
  **Components & slots** — a capitalised tag (`<Card title={x}>`) mounts a child;
159
165
  attributes become props, children fill the child's `<slot>` (with fallback when
160
166
  empty). `<style>` is scoped to the component and its children. Component files
161
- end in `.abide`. `then`/`each` value names lexically shadow same-named
162
- component state inside the block.
167
+ end in `.abide`. A block's binding names (`{:then v}`, `{#for x of …}`) lexically
168
+ shadow same-named component state inside the block.
163
169
 
164
170
  ## Server surface — abide/server/\* (server-only)
165
171
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # abide
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - reject stray {:…}/{/…} tokens outside a block ([`1b5d54e`](https://github.com/briancray/abide/commit/1b5d54e7798acfa7c9b15ce6b65b0c2b6a8c2c07))
8
+
9
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#switch}/{:case}/{:default} blocks to the switch AST ([`448da38`](https://github.com/briancray/abide/commit/448da3840c120ac3b15d239c090f123c25527fec))
10
+
11
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#for … of … by …} blocks to the each AST ([`7bb2ac8`](https://github.com/briancray/abide/commit/7bb2ac8f3012e3d40cfcbe8ddf277d6a8732ee93))
12
+
13
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - LSP semantic-token encoder + extract offsetToPosition ([`7daeb92`](https://github.com/briancray/abide/commit/7daeb926f045058aa452b5f539ddd75b0d8efa67))
14
+
15
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - semantic-token legend + TS-classification mapping for abide lsp ([`892f97b`](https://github.com/briancray/abide/commit/892f97bbc6dfd8af9301297276995f7060fe3f9a))
16
+
17
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - type-aware semanticClassifications on the shadow language service ([`9377dd2`](https://github.com/briancray/abide/commit/9377dd24d855cde790d960fceef00255904ee926))
18
+
19
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - block control-flow grammar formatting + shared helpers ([`b39c0a7`](https://github.com/briancray/abide/commit/b39c0a70696b031b63ef157c67360c2da0fd60b0))
20
+
21
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - zed onboarding — auto-install extension + enable semantic tokens ([`b47acc7`](https://github.com/briancray/abide/commit/b47acc7cc106956f115c70b110c29ce017a71cd0))
22
+
23
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#if}/{:else if}/{:else} blocks to the if AST ([`b662397`](https://github.com/briancray/abide/commit/b66239765bae2ec7a24d70bc7b2edbcbe400614c))
24
+
25
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#try}/{:catch}/{:finally} blocks to the try AST ([`b82c3dd`](https://github.com/briancray/abide/commit/b82c3ddaaec3db3941639df952e568f017d6c767))
26
+
27
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - Breaking: remove <template> control-flow directives in favor of {#…} blocks ([`bf5cf89`](https://github.com/briancray/abide/commit/bf5cf89813a8dcbc967e1303bd2509e18e701761))
28
+
29
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - color of/by connectors in {#for} block heads ([`c958614`](https://github.com/briancray/abide/commit/c9586147f8196e9e6301b14cdb08986b36da2751))
30
+
31
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - parse {#await} streaming + blocking blocks to the await AST ([`cfd7ff0`](https://github.com/briancray/abide/commit/cfd7ff0102b2be2ac241a6c0b1965949aa6621bf))
32
+
33
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - serve textDocument/semanticTokens/full for .abide ([`e152663`](https://github.com/briancray/abide/commit/e152663dae9e60e13d16f0edf4b0a140a99b1bfb))
34
+
35
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - structural {#…} block framing tokenizer ([`e1e85e8`](https://github.com/briancray/abide/commit/e1e85e847e9442f8c6140df5346b1d37f8cfad54))
36
+
37
+ ### Patch Changes
38
+
39
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - point block-construct loc at its primary expression (shadow source-map fidelity) ([`0fb0efa`](https://github.com/briancray/abide/commit/0fb0efab43639331c856969fdb6a7b83bd7ab604))
40
+
41
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - unify block/branch child-scan loops behind a shared helper ([`4683098`](https://github.com/briancray/abide/commit/4683098df96edd0321dc66d200372e0fbedce2e2))
42
+
43
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - document {#…} control-flow blocks in the AGENTS surface map ([`68fe96f`](https://github.com/briancray/abide/commit/68fe96f3294a228b91eef5cde37c0300673eb88a))
44
+
45
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - map {#for}/{:then}/{:catch} bindings + by-key to source so hover & semantic tokens land on them ([`e8447e8`](https://github.com/briancray/abide/commit/e8447e8d54934737b80c2f49d4f4d1b134786531))
46
+
47
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - map <template args> snippet param + await/try {:catch} bindings (+ index/catch/snippet tests) ([`f724d10`](https://github.com/briancray/abide/commit/f724d101f5baed79de2d39a1ab9598eb9d1eb818))
48
+
49
+ - [`568956c`](https://github.com/briancray/abide/commit/568956c88ff1d32ca79a2aa8a1a0ffffeb25afe3) - satisfy noUncheckedIndexedAccess in block-token + shadow paths ([`fbb6e44`](https://github.com/briancray/abide/commit/fbb6e4443932e92277fb332b639c746663c7759b))
50
+
3
51
  ## 0.41.1
4
52
 
5
53
  ### Patch Changes
package/README.md CHANGED
@@ -140,29 +140,28 @@ async function send() {
140
140
  <button disabled={!text}>send</button>
141
141
  </form>
142
142
 
143
- <template if={latest}>
143
+ {#if latest}
144
144
  <p>latest from {latest.from}</p>
145
- <template else>
145
+ {:else}
146
146
  <p>no messages yet</p>
147
- </template>
147
+ {/if}
148
148
 
149
- <template switch={view}>
150
- <template case="mine"><p>showing your messages</p></template>
151
- <template default><p>showing every message</p></template>
152
- </template>
149
+ {#switch view}
150
+ {:case 'mine'}<p>showing your messages</p>
151
+ {:default}<p>showing every message</p>
152
+ {/switch}
153
153
 
154
- <template await={history}>
154
+ {#await history}
155
155
  <p>loading…</p>
156
- <template then="data">
156
+ {:then data}
157
157
  <ul>
158
- <template each={data} as="message" key="message.id" index="i">
158
+ {#for message, i of data by message.id}
159
159
  {i}. {line(message)}
160
- </template>
160
+ {/for}
161
161
  </ul>
162
- <template catch="reason">
162
+ {:catch reason}
163
163
  <p>failed: {reason.message}</p>
164
- </template>
165
- </template>
164
+ {/await}
166
165
 
167
166
  <style>
168
167
  form { display: flex; gap: 0.5rem; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
package/src/abideLsp.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { fileURLToPath, pathToFileURL } from 'node:url'
2
2
  import ts from 'typescript'
3
+ import { ABIDE_SEMANTIC_TOKENS_LEGEND } from './lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts'
3
4
  import type {
4
5
  ShadowLanguageService,
5
6
  ShadowQuickInfo,
6
7
  } from './lib/ui/compile/createShadowLanguageService.ts'
7
8
  import { createShadowLanguageService } from './lib/ui/compile/createShadowLanguageService.ts'
9
+ import { encodeSemanticTokens } from './lib/ui/compile/encodeSemanticTokens.ts'
8
10
  import { nearestProjectRoot } from './lib/ui/compile/nearestProjectRoot.ts'
11
+ import { offsetToPosition } from './lib/ui/compile/offsetToPosition.ts'
12
+ import { structuralBlockTokens } from './lib/ui/compile/structuralBlockTokens.ts'
9
13
  import type { AbideDiagnostic } from './lib/ui/compile/types/AbideDiagnostic.ts'
10
14
 
11
15
  /*
@@ -19,6 +23,28 @@ unsaved text as overlays. Each document routes to a shadow service for its
19
23
  nearest tsconfig, so files in a monorepo opened at its root are checked against
20
24
  their own project — matching `abide check` run from that package.
21
25
  */
26
+ /*
27
+ The semantic-tokens `data` array for one component: the structural `{#…}` framing
28
+ merged with the shadow's type-aware expression tokens, encoded to the LSP wire
29
+ format. Never throws — on any internal failure it yields an empty stream so the
30
+ editor falls back to tree-sitter highlighting.
31
+ */
32
+ export function componentSemanticTokens(
33
+ service: ShadowLanguageService,
34
+ abidePath: string,
35
+ text: string,
36
+ ): number[] {
37
+ try {
38
+ const tokens = [
39
+ ...structuralBlockTokens(text),
40
+ ...service.semanticClassifications(abidePath),
41
+ ]
42
+ return encodeSemanticTokens(text, tokens)
43
+ } catch {
44
+ return []
45
+ }
46
+ }
47
+
22
48
  export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
23
49
  const documentText = new Map<string, string>()
24
50
 
@@ -64,7 +90,14 @@ export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
64
90
  jsonrpc: '2.0',
65
91
  id: message.id,
66
92
  result: {
67
- capabilities: { textDocumentSync: 1, hoverProvider: true },
93
+ capabilities: {
94
+ textDocumentSync: 1,
95
+ hoverProvider: true,
96
+ semanticTokensProvider: {
97
+ legend: ABIDE_SEMANTIC_TOKENS_LEGEND,
98
+ full: true,
99
+ },
100
+ },
68
101
  serverInfo: { name: 'abide-lsp' },
69
102
  },
70
103
  })
@@ -115,6 +148,18 @@ export async function abideLsp({ cwd }: { cwd: string }): Promise<void> {
115
148
  })
116
149
  return
117
150
  }
151
+ case 'textDocument/semanticTokens/full': {
152
+ const { uri } = message.params.textDocument
153
+ const data = isAbide(uri)
154
+ ? componentSemanticTokens(
155
+ serviceFor(fileURLToPath(uri)),
156
+ fileURLToPath(uri),
157
+ documentText.get(fileURLToPath(uri)) ?? '',
158
+ )
159
+ : []
160
+ send({ jsonrpc: '2.0', id: message.id, result: { data } })
161
+ return
162
+ }
118
163
  case 'textDocument/didClose': {
119
164
  const { uri } = message.params.textDocument
120
165
  if (isAbide(uri)) {
@@ -194,13 +239,6 @@ function toLspHover(text: string, info: ShadowQuickInfo): object {
194
239
  }
195
240
  }
196
241
 
197
- /* An absolute offset → LSP `{ line, character }` (0-based, UTF-16 code units). */
198
- function offsetToPosition(text: string, offset: number): { line: number; character: number } {
199
- const before = text.slice(0, offset)
200
- const line = before.split('\n').length - 1
201
- return { line, character: offset - (before.lastIndexOf('\n') + 1) }
202
- }
203
-
204
242
  /* An LSP `{ line, character }` (0-based) → absolute offset in `text`. */
205
243
  function positionToOffset(text: string, position: { line: number; character: number }): number {
206
244
  const lineStart = text
@@ -4,6 +4,8 @@ import type { BunPlugin } from 'bun'
4
4
  import { Glob } from 'bun'
5
5
  import { abideImportName } from './lib/shared/abideImportName.ts'
6
6
  import { abideLog } from './lib/shared/abideLog.ts'
7
+ import { escapeRegex } from './lib/shared/escapeRegex.ts'
8
+ import { fileName } from './lib/shared/fileName.ts'
7
9
  import { fileStem } from './lib/shared/fileStem.ts'
8
10
  import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
9
11
  import { manifestModule } from './lib/shared/manifestModule.ts'
@@ -65,10 +67,6 @@ function resolveExtensionUncached(path: string): string {
65
67
 
66
68
  const NS = 'abide-virtual'
67
69
 
68
- function escapeRegex(value: string): string {
69
- return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
70
- }
71
-
72
70
  /* Memoises a zero-arg async producer so repeat calls reuse the first in-flight promise. */
73
71
  function once<T>(produce: () => Promise<T>): () => Promise<T> {
74
72
  let promise: Promise<T> | undefined
@@ -791,7 +789,7 @@ async function scanPages(pagesDir: string): Promise<PagesScan> {
791
789
  return { pageFiles: [], layoutFiles: [] }
792
790
  }
793
791
  const allFiles = await Array.fromAsync(new Glob('**/*.abide').scan({ cwd: pagesDir }))
794
- const leafIs = (name: string) => (file: string) => (file.split('/').pop() ?? '') === name
792
+ const leafIs = (name: string) => (file: string) => fileName(file) === name
795
793
  return {
796
794
  pageFiles: allFiles.filter(leafIs('page.abide')),
797
795
  layoutFiles: allFiles.filter(leafIs('layout.abide')),
@@ -1,4 +1,5 @@
1
1
  import { relative } from 'node:path'
2
+ import { fileName } from '../../shared/fileName.ts'
2
3
  import { analyzeComponent } from '../../ui/compile/analyzeComponent.ts'
3
4
  import { compileComponent } from '../../ui/compile/compileComponent.ts'
4
5
  import { nearestProjectRoot } from '../../ui/compile/nearestProjectRoot.ts'
@@ -19,7 +20,7 @@ const GENERATED = /(^|\/)\.abide\//
19
20
  // page.abide / layout.abide are router-mounted, not `mountChild`-tracked, so they
20
21
  // can't hot-swap — they fold into `structure` (a reload) instead.
21
22
  function isPageOrLayout(moduleId: string): boolean {
22
- const file = moduleId.split('/').pop() ?? ''
23
+ const file = fileName(moduleId)
23
24
  return file === 'page.abide' || file === 'layout.abide'
24
25
  }
25
26
 
@@ -0,0 +1,9 @@
1
+ /*
2
+ Escapes the regex metacharacters in `value` so it can be embedded literally
3
+ inside a `new RegExp(...)` pattern. Shared by the $rpc/$sockets import stripper,
4
+ the resolver plugin's virtual-namespace matcher, and the SSR snippet-call
5
+ rewriter so the same escaping is applied one way everywhere.
6
+ */
7
+ export function escapeRegex(value: string): string {
8
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
9
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ The bare leaf filename of a path — directory stripped, extension kept —
3
+ e.g. `users/list.ts` → `list.ts`. The extension-stripping `fileStem` builds on
4
+ this; call sites that match a full filename (`layout.abide`, `page.abide`) use
5
+ it directly so the leaf-grab is written one way.
6
+ */
7
+ export function fileName(path: string): string {
8
+ return path.split('/').pop() ?? ''
9
+ }
@@ -1,3 +1,5 @@
1
+ import { fileName } from './fileName.ts'
2
+
1
3
  /*
2
4
  The bare filename of a path, with directory and trailing extension stripped —
3
5
  e.g. `users/list.ts` → `list`, `/_virtual/mcp-resources.ts` → `mcp-resources`.
@@ -5,5 +7,5 @@ Used to derive a virtual-module name from its path and to check an $rpc /
5
7
  $sockets module's single export name against its file stem.
6
8
  */
7
9
  export function fileStem(path: string): string {
8
- return (path.split('/').pop() ?? '').replace(/\.[^.]+$/, '')
10
+ return fileName(path).replace(/\.[^.]+$/, '')
9
11
  }
@@ -1,3 +1,5 @@
1
+ import { fileName } from './fileName.ts'
2
+
1
3
  /*
2
4
  Derives the CLI program/binary name from a package.json `name` field.
3
5
  Scoped names (`@scope/tool`) keep only the final segment so the value is
@@ -10,5 +12,5 @@ export function programNameForPackage(name: string | undefined): string {
10
12
  if (name === undefined || name === '') {
11
13
  return 'app'
12
14
  }
13
- return name.split('/').pop() || 'app'
15
+ return fileName(name) || 'app'
14
16
  }
@@ -1,3 +1,5 @@
1
+ import { escapeRegex } from './escapeRegex.ts'
2
+
1
3
  /*
2
4
  Strips the user's `import { … } from '<moduleName>'` declaration from a
3
5
  module source. Used by the $rpc / $sockets rewriters to remove the
@@ -18,7 +20,7 @@ includes newlines, so multi-line braced imports like
18
20
  still match — the body just can't contain another `}` to bound it.
19
21
  */
20
22
  export function stripImport(source: string, moduleName: string): string {
21
- const escaped = moduleName.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
23
+ const escaped = escapeRegex(moduleName)
22
24
  const pattern = new RegExp(
23
25
  `^\\s*import\\s*\\{[^}]*\\}\\s*from\\s*['"]${escaped}['"]\\s*;?\\s*$`,
24
26
  'gm',
@@ -0,0 +1,45 @@
1
+ /*
2
+ The LSP semantic-tokens legend abide lsp advertises, and the decoder from
3
+ TypeScript's encoded classifications to legend names. TypeScript encodes a
4
+ classification as `((tokenType + 1) << 8) + modifierBitset`; its TokenType and
5
+ TokenModifier enums fix the orders below. `keyword`/`operator` carry the `{#…}`
6
+ block framing the structural tokenizer emits.
7
+ */
8
+
9
+ /* TS TokenType order (class=0 … member=11) → LSP token-type name. */
10
+ const TS_TYPE_TO_LSP = [
11
+ 'class',
12
+ 'enum',
13
+ 'interface',
14
+ 'namespace',
15
+ 'typeParameter',
16
+ 'type',
17
+ 'parameter',
18
+ 'variable',
19
+ 'enumMember',
20
+ 'property',
21
+ 'function',
22
+ 'method',
23
+ ]
24
+
25
+ /* TS TokenModifier order (declaration=bit 0 … local=bit 5). */
26
+ const TS_MODIFIERS = ['declaration', 'static', 'async', 'readonly', 'defaultLibrary', 'local']
27
+
28
+ export const ABIDE_SEMANTIC_TOKENS_LEGEND = {
29
+ tokenTypes: [...TS_TYPE_TO_LSP, 'keyword', 'operator'],
30
+ tokenModifiers: TS_MODIFIERS,
31
+ }
32
+
33
+ /* Decodes one TypeScript encoded classification into legend names. */
34
+ export function mapTsClassification(
35
+ classification: number,
36
+ ): { type: string; modifiers: string[] } | undefined {
37
+ const tokenType = (classification >> 8) - 1
38
+ const type = TS_TYPE_TO_LSP[tokenType]
39
+ if (type === undefined) {
40
+ return undefined
41
+ }
42
+ const modifierSet = classification & 255
43
+ const modifiers = TS_MODIFIERS.filter((_, bit) => (modifierSet & (1 << bit)) !== 0)
44
+ return { type, modifiers }
45
+ }
@@ -1,5 +1,6 @@
1
1
  import { relative } from 'node:path'
2
2
  import type { BunPlugin } from 'bun'
3
+ import { fileName } from '../../shared/fileName.ts'
3
4
  import { messageFromError } from '../../shared/messageFromError.ts'
4
5
  import { AbideCompileError } from './AbideCompileError.ts'
5
6
  import { analyzeComponent } from './analyzeComponent.ts'
@@ -42,7 +43,7 @@ export const abideUiPlugin: BunPlugin = {
42
43
  build.onLoad({ filter: /\.abide$/ }, async (args) => {
43
44
  const source = await Bun.file(args.path).text()
44
45
  const moduleId = relative(nearestProjectRoot(args.path, process.cwd()), args.path)
45
- const isLayout = (args.path.split('/').pop() ?? '') === 'layout.abide'
46
+ const isLayout = fileName(args.path) === 'layout.abide'
46
47
  /* Bun frames a plugin throw at `<file>:0` regardless of the real spot, so
47
48
  carry the component path + resolved line:col in the message — otherwise a
48
49
  control-flow / compile error reads as `:0` and (in deep imports) can look
@@ -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) {
@@ -408,19 +422,24 @@ function emitNode(node: TemplateNode, builder: Builder): void {
408
422
  }
409
423
  case 'each':
410
424
  /* `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
- )
425
+ so the item binds to the element type under either iteration protocol. The
426
+ binding name is `mapped` (not `raw`) so hover/highlighting land on it. */
427
+ builder.raw(node.async ? 'for await (const ' : 'for (const ')
428
+ builder.mapped(node.as, node.asLoc)
429
+ builder.raw(' of ')
415
430
  builder.expr(node.items, node.loc)
416
431
  builder.raw(') {\n')
417
432
  if (node.key !== undefined) {
418
- builder.raw(`void (${node.key});\n`)
433
+ builder.raw('void ')
434
+ builder.expr(node.key, node.keyLoc)
435
+ builder.raw(';\n')
419
436
  }
420
437
  /* `index="i"` binds the row's position — always a number (the row ordinal,
421
438
  or an async stream's arrival count). Declare it so body references check. */
422
439
  if (node.index !== undefined) {
423
- builder.raw(`const ${node.index}: number = 0;\n`)
440
+ builder.raw('const ')
441
+ builder.mapped(node.index, node.indexLoc)
442
+ builder.raw(': number = 0;\n')
424
443
  }
425
444
  emitNodes(node.children, builder)
426
445
  builder.raw('}\n')
@@ -439,7 +458,9 @@ function emitNode(node: TemplateNode, builder: Builder): void {
439
458
  const pending = node.children.filter((child) => child.kind !== 'branch')
440
459
  const branches = node.children.filter((child) => child.kind === 'branch')
441
460
  if (node.blocking && node.as !== undefined) {
442
- builder.raw(`{\nconst ${node.as} = ${resolved};\n`)
461
+ builder.raw('{\nconst ')
462
+ builder.mapped(node.as, node.asLoc)
463
+ builder.raw(` = ${resolved};\n`)
443
464
  emitNodes(pending, builder)
444
465
  builder.raw('}\n')
445
466
  } else {
@@ -451,9 +472,13 @@ function emitNode(node: TemplateNode, builder: Builder): void {
451
472
  }
452
473
  builder.raw('{\n')
453
474
  if (branch.branch === 'then' && branch.as !== undefined) {
454
- builder.raw(`const ${branch.as} = ${resolved};\n`)
475
+ builder.raw('const ')
476
+ builder.mapped(branch.as, branch.asLoc)
477
+ builder.raw(` = ${resolved};\n`)
455
478
  } else if (branch.branch === 'catch' && branch.as !== undefined) {
456
- builder.raw(`const ${branch.as} = undefined as any;\n`)
479
+ builder.raw('const ')
480
+ builder.mapped(branch.as, branch.asLoc)
481
+ builder.raw(' = undefined as any;\n')
457
482
  }
458
483
  emitNodes(branch.children, builder)
459
484
  builder.raw('}\n')
@@ -508,7 +533,14 @@ function emitNode(node: TemplateNode, builder: Builder): void {
508
533
  builder.raw('}\n')
509
534
  return
510
535
  case 'snippet':
511
- builder.raw(`const ${node.name} = (${node.params ?? ''}) => {\n`)
536
+ /* `args={}` is the parameter list; `mapped` (not `raw`) so hover/highlighting
537
+ land on the binding. `loc` is the `args` expression's offset (see
538
+ toSnippetOrTemplate). The name is a static attribute with no tracked offset. */
539
+ builder.raw(`const ${node.name} = (`)
540
+ if (node.params !== undefined) {
541
+ builder.mapped(node.params, node.loc)
542
+ }
543
+ builder.raw(') => {\n')
512
544
  emitNodes(node.children, builder)
513
545
  builder.raw('};\n')
514
546
  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
  }
@@ -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
+ }