@abide/abide 0.105.1 → 0.107.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 (100) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/abide.ts +4 -1
  3. package/docs/REGISTRY.md +156 -44
  4. package/docs/RULEBOOK.md +392 -88
  5. package/docs/abide.md +7245 -10248
  6. package/package.json +4 -1
  7. package/scaffold/AGENTS.md +2 -0
  8. package/scaffold/src/getGreeting.ts +6 -0
  9. package/scaffold/src/index.abide +3 -1
  10. package/scaffold/src/tests/getGreeting.test.ts +6 -0
  11. package/scaffold/src/tests/index.test.ts +18 -0
  12. package/src/compiler/DOCS_SITE.ts +2 -0
  13. package/src/compiler/Lowering.ts +234 -14
  14. package/src/compiler/PositionTable.ts +14 -7
  15. package/src/compiler/PositionedRefusal.ts +13 -0
  16. package/src/compiler/Refusals.ts +18 -11
  17. package/src/compiler/SHARED_NAMES.ts +2 -0
  18. package/src/compiler/ShadowMap.ts +37 -0
  19. package/src/compiler/TEMPLATE_BLOCKS.ts +12 -0
  20. package/src/compiler/TypeSession.ts +410 -31
  21. package/src/compiler/appLayout.ts +18 -2
  22. package/src/compiler/argsSchemaOf.ts +55 -9
  23. package/src/compiler/checkApp.ts +33 -0
  24. package/src/compiler/checkedShadow.ts +36 -22
  25. package/src/compiler/citedClauses.ts +14 -0
  26. package/src/compiler/classifyRead.ts +6 -0
  27. package/src/compiler/classifyTemplate.ts +7 -4
  28. package/src/compiler/compileApp.ts +2 -2
  29. package/src/compiler/declaredExports.ts +27 -6
  30. package/src/compiler/emitClient.ts +306 -103
  31. package/src/compiler/handlersUnder.ts +4 -0
  32. package/src/compiler/invocationProps.ts +1 -1
  33. package/src/compiler/isIgnored.ts +27 -0
  34. package/src/compiler/parseAbide.ts +176 -76
  35. package/src/compiler/projectShadow.ts +30 -6
  36. package/src/compiler/readSources.ts +1 -1
  37. package/src/compiler/readsIn.ts +100 -1
  38. package/src/compiler/renderMarkup.ts +36 -5
  39. package/src/compiler/scaffold.ts +19 -6
  40. package/src/compiler/serveLanguage.ts +156 -21
  41. package/src/compiler/templateContext.ts +150 -0
  42. package/src/compiler/tokenize.ts +6 -0
  43. package/src/server/BRIDGE_VERSION.ts +1 -1
  44. package/src/server/Inbox.ts +8 -0
  45. package/src/server/MCP_PROTOCOL_VERSION.ts +1 -1
  46. package/src/server/MOUNTABLE.ts +6 -0
  47. package/src/server/abideModules.ts +8 -1
  48. package/src/server/answerRequest.ts +3 -1
  49. package/src/server/answerRpc.ts +26 -12
  50. package/src/server/answerSocket.ts +26 -1
  51. package/src/server/argsFor.ts +4 -3
  52. package/src/server/assistant.ts +21 -14
  53. package/src/server/bridgeRelay.ts +108 -20
  54. package/src/server/claudeApi.ts +19 -11
  55. package/src/server/claudeCode.ts +19 -14
  56. package/src/server/commands.ts +73 -9
  57. package/src/server/createApp.ts +41 -7
  58. package/src/server/devWorker.ts +3 -3
  59. package/src/server/generatedSurfaces.ts +37 -4
  60. package/src/server/muxUpgrade.ts +47 -0
  61. package/src/server/preload.ts +13 -2
  62. package/src/server/render.ts +4 -1
  63. package/src/server/runCompiled.ts +1 -0
  64. package/src/server/seedBuffer.ts +31 -1
  65. package/src/server/serveDev.ts +83 -72
  66. package/src/server/surfaceOf.ts +3 -0
  67. package/src/server/testMount.ts +53 -0
  68. package/src/shared/ADDRESSES.ts +1 -1
  69. package/src/shared/ERROR_META.ts +3 -0
  70. package/src/shared/Schema.ts +1 -1
  71. package/src/shared/WORK.ts +14 -12
  72. package/src/shared/applyPatch.ts +1 -1
  73. package/src/shared/boundValue.ts +81 -0
  74. package/src/shared/index.ts +7 -2
  75. package/src/shared/jsonSchema.ts +25 -2
  76. package/src/shared/log.ts +11 -1
  77. package/src/shared/reactive/Keyed.ts +30 -6
  78. package/src/shared/reactive/PATCHES.ts +1 -1
  79. package/src/shared/reactive/Reactive.ts +118 -60
  80. package/src/shared/reactive/canonicalKey.ts +1 -1
  81. package/src/shared/reactive/index.ts +13 -5
  82. package/src/shared/reactive/recordWrites.ts +1 -1
  83. package/src/shared/reactive/watch.ts +26 -4
  84. package/src/shared/rpc.ts +6 -1
  85. package/src/shared/url.ts +7 -3
  86. package/src/shared/validateJson.ts +1 -1
  87. package/src/ui/ArmRegion.ts +2 -2
  88. package/src/ui/List.ts +9 -9
  89. package/src/ui/RawRun.ts +1 -1
  90. package/src/ui/Router.ts +194 -36
  91. package/src/ui/Scope.ts +29 -0
  92. package/src/ui/adoptWalk.ts +3 -3
  93. package/src/ui/applyHead.ts +25 -3
  94. package/src/ui/bindElement.ts +4 -0
  95. package/src/ui/bindProperty.ts +70 -22
  96. package/src/ui/index.ts +1 -1
  97. package/src/ui/listenForRoutes.ts +14 -3
  98. package/src/ui/reconcile.ts +23 -28
  99. package/src/ui/regions.ts +9 -2
  100. package/src/ui/rows.ts +108 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.105.1",
3
+ "version": "0.107.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic type-safe framework for reactive async interfaces for humans and machines",
6
6
  "license": "MIT",
@@ -62,6 +62,9 @@
62
62
  "dependencies": {
63
63
  "typescript": "^7.0.2"
64
64
  },
65
+ "devDependencies": {
66
+ "@abide/harness": "workspace:*"
67
+ },
65
68
  "scripts": {
66
69
  "typecheck": "bun check",
67
70
  "prepack": "bun ../../scripts/packDocs.ts"
@@ -17,6 +17,8 @@ before writing code, rather than guessing from Svelte, Vue or React. It installs
17
17
  TypeScript service do not read `.abide` files, so a clean `tsc` says nothing about them. Run
18
18
  `bun run check` after every change, and treat its warnings as bugs: several of them name a read
19
19
  that will not update when its value changes.
20
+ - `bun run test` — runs `bun test`. `src/tests/getGreeting.test.ts` is the pattern: a test imports a
21
+ handler or a `.abide` file and calls it.
20
22
  - `bun run dev` — serves the app on a local port and rebuilds when a file changes.
21
23
  - `bun run build` then `bun run start` — the production build, and the app served from it.
22
24
 
@@ -0,0 +1,6 @@
1
+ import { GET } from '@abide/abide/server'
2
+
3
+ /** Its path is its address, /getGreeting: the page imports it, and so does its test. */
4
+ export default GET(({ name }: { name: string }) => `Hello, ${name}`, {
5
+ description: 'A greeting, by name.',
6
+ })
@@ -1,13 +1,15 @@
1
1
  <script>
2
2
  import { reactive } from '@abide/abide'
3
+ import getGreeting from '#src/getGreeting.ts'
3
4
 
4
5
  let count = reactive(0)
6
+ const greeting = reactive(() => getGreeting({ name: 'world' }))
5
7
  </script>
6
8
 
7
9
  <head><title>Hello</title></head>
8
10
 
9
11
  <main>
10
- <h1>Hello</h1>
12
+ <h1>{greeting}</h1>
11
13
  <p>Clicked {count} times.</p>
12
14
  <button onclick={() => { count = count + 1 }}>Click</button>
13
15
  </main>
@@ -0,0 +1,6 @@
1
+ import { expect, test } from 'bun:test'
2
+ import getGreeting from '../getGreeting.ts'
3
+
4
+ test('the handler answers with the name it is given', async () => {
5
+ expect(await getGreeting({ name: 'Ada' })).toBe('Hello, Ada')
6
+ })
@@ -0,0 +1,18 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { flushSync } from '@abide/abide'
3
+ import { measure, mount } from '@abide/harness/abide'
4
+ import Index from '../index.abide'
5
+
6
+ test('a click rewrites the count and builds nothing', async () => {
7
+ using _page = await mount(Index({}))
8
+ const work = measure(() => {
9
+ document.querySelector('button')?.click()
10
+ flushSync()
11
+ })
12
+ expect(document.querySelector('p')?.textContent).toBe('Clicked 1 times.')
13
+ // One text node written, one effect woken, and no node made or moved.
14
+ expect(work.dataWrites).toBe(1)
15
+ expect(work.wakes).toBe(1)
16
+ expect(work.nodesCreated).toBe(0)
17
+ expect(work.elementsMoved).toBe(0)
18
+ })
@@ -0,0 +1,2 @@
1
+ /** Where abide's documentation app is served, which a finding's clause links into (38.118). */
2
+ export const DOCS_SITE = 'https://abide.dev'
@@ -33,6 +33,20 @@ import { nesting, type Token, tokenize } from './tokenize.ts'
33
33
  // no parentheses to survive one.
34
34
  const SHORT_CIRCUIT: ReadonlySet<string> = new Set(['||', '&&', '??'])
35
35
 
36
+ /**
37
+ * 32.67 — how a `{#for}` row's item is read where it is being emitted.
38
+ *
39
+ * `own` is the row's own top level, whose slots reading the item and nothing that moves are written
40
+ * by the row's `make` and `update` and bind nothing; a binding there is an item watch, the one
41
+ * writer of its slot, reading the item through `itemOf`.
42
+ * `nested` is inside a region of the row, a branch-local `<script>` or anything else the `update`
43
+ * does not reach, where every read but a listener's is a read of the cell. Where an item has no mode
44
+ * it is read as the value.
45
+ */
46
+ type ItemMode = 'own' | 'nested'
47
+ /** 32.67 — how many reads of a row's item were emitted as a cell call and as a record read. */
48
+ export type ItemUses = { cell: number; record: number }
49
+
36
50
  /** 23.9 — a page's or a layout's route, which its `route.params` promises. */
37
51
  export type Routed = { route: string; kind: 'page' | 'layout' }
38
52
 
@@ -93,6 +107,20 @@ export class Lowering {
93
107
  * what its `route.params` is typed by.
94
108
  */
95
109
  private readonly routed: Routed | undefined
110
+ /**
111
+ * 32.67 — per `{#for}` item (by where its binding starts), how a read of it is emitted now, the
112
+ * name its row's record is held under, and how it was read. Set by the client's emitter around
113
+ * a row's mount alone (`readItemsAs`).
114
+ */
115
+ private readonly itemModes = new Map<
116
+ number,
117
+ { mode: ItemMode; record: string; uses: ItemUses }
118
+ >()
119
+ /**
120
+ * What is being lowered that reads a row's item off its record: a listener, at any depth, or a
121
+ * binding of the row's own top level (`binding`); `null` for anything else.
122
+ */
123
+ private recording: 'listener' | 'binding' | null = null
96
124
 
97
125
  constructor(
98
126
  file: AbideFile,
@@ -109,6 +137,110 @@ export class Lowering {
109
137
  this.routed = routed
110
138
  }
111
139
 
140
+ /**
141
+ * 32.67 — where a `{#for}` block's item binding starts, the key every item question is asked by.
142
+ */
143
+ private itemOf(block: object): number {
144
+ return this.projection.items.get(block) ?? -1
145
+ }
146
+
147
+ /**
148
+ * 32.67 — emits `body` with `block`'s item read in `mode`, its record held as `record` for a
149
+ * listener, and answers how the item was read: as a cell, or off the record.
150
+ */
151
+ readItemsAs(
152
+ block: object,
153
+ mode: ItemMode,
154
+ record: string,
155
+ body: () => void,
156
+ ): ItemUses {
157
+ const item = this.itemOf(block)
158
+ const uses = { cell: 0, record: 0 }
159
+ this.itemModes.set(item, { mode, record, uses })
160
+ try {
161
+ body()
162
+ } finally {
163
+ this.itemModes.delete(item)
164
+ }
165
+ return uses
166
+ }
167
+
168
+ /**
169
+ * 32.67 — emits `body` with every row item read at its own top level read as `nested`: what a
170
+ * region of the row binds is bound to the cell, since the row's `update` reaches only its own
171
+ * top level.
172
+ */
173
+ nestItems(body: () => void): void {
174
+ const own: { mode: ItemMode }[] = []
175
+ for (const reading of this.itemModes.values())
176
+ if (reading.mode === 'own') own.push(reading)
177
+ for (const reading of own) reading.mode = 'nested'
178
+ try {
179
+ body()
180
+ } finally {
181
+ for (const reading of own) reading.mode = 'own'
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 32.67 — a listener, lowered: it runs on an event rather than in a binding, so a row's item is
187
+ * read off the row's record, which allocates nothing a row.
188
+ */
189
+ listener(holder: object): string {
190
+ return this.recorded('listener', holder)
191
+ }
192
+
193
+ /**
194
+ * 32.67 — a text or attribute binding's value, lowered: at a row's own top level it reads the
195
+ * item through `itemOf`, off the record until the row's first new item makes it a cell, so a row
196
+ * that never takes one allocates nothing for it.
197
+ */
198
+ binding(holder: object): string {
199
+ return this.recorded('binding', holder)
200
+ }
201
+
202
+ /**
203
+ * 32.67 — whether a binding reads a row's item at that row's own top level, which makes it one
204
+ * of the row's item watches (`Scope.itemWatch`).
205
+ */
206
+ readsOwnItem(holder: object): boolean {
207
+ return this.holdsRead(
208
+ holder,
209
+ (read) =>
210
+ read.item !== -1 &&
211
+ this.itemModes.get(read.item)?.mode === 'own',
212
+ )
213
+ }
214
+
215
+ private recorded(as: 'listener' | 'binding', holder: object): string {
216
+ const was = this.recording
217
+ this.recording = as
218
+ try {
219
+ return this.expression(holder)
220
+ } finally {
221
+ this.recording = was
222
+ }
223
+ }
224
+
225
+ /** 32.67 — whether a position reads `block`'s item. */
226
+ readsItem(holder: object, block: object): boolean {
227
+ const item = this.itemOf(block)
228
+ return this.holdsRead(holder, (read) => read.item === item)
229
+ }
230
+
231
+ /**
232
+ * 32.67 — the names a row reads its item through, in the `{#for}`'s scope: its pattern's names,
233
+ * or the one name.
234
+ */
235
+ itemNames(block: object): Set<string> {
236
+ const item = this.itemOf(block)
237
+ const names = new Set<string>()
238
+ for (const read of this.answer.reads)
239
+ if (read.item === item)
240
+ names.add(this.projection.text.slice(read.start, read.end))
241
+ return names
242
+ }
243
+
112
244
  /**
113
245
  * Whether this file is a component that takes a props record.
114
246
  *
@@ -246,7 +378,12 @@ export class Lowering {
246
378
  const to = span.start + text.trimEnd().length
247
379
  const reads = this.answer.reads
248
380
  const read = reads[this.answer.firstRead(from)]
249
- if (read === undefined || read.from !== from || read.shape !== 'value')
381
+ if (
382
+ read === undefined ||
383
+ read.from !== from ||
384
+ read.shape !== 'value' ||
385
+ read.item !== -1
386
+ )
250
387
  return undefined
251
388
  const path = source.slice(read.to, to)
252
389
  if (!/^(\s*\.\s*[A-Za-z_$][\w$]*)+$/.test(path)) return undefined
@@ -285,7 +422,20 @@ export class Lowering {
285
422
  * it NAMES a `Reactive`, which writes `{big()}` once where `big` reads `n` in its body.
286
423
  */
287
424
  isLive(holder: object): boolean {
288
- return !this.answer.isStatic(this.spanOf(holder))
425
+ const span = this.spanOf(holder)
426
+ if (!this.answer.isStatic(span)) return true
427
+ // 32.67 — and inside a region of a row, a position reading the row's item is bound to its
428
+ // cell, which the proof sets aside: the server renders the row once.
429
+ return (
430
+ this.itemModes.size !== 0 &&
431
+ this.answer.holdsRead(span, (read) => this.isNested(read))
432
+ )
433
+ }
434
+
435
+ private isNested(read: Read): boolean {
436
+ return (
437
+ read.item !== -1 && this.itemModes.get(read.item)?.mode === 'nested'
438
+ )
289
439
  }
290
440
 
291
441
  /**
@@ -323,7 +473,12 @@ export class Lowering {
323
473
  holdsMarkup(run: readonly TemplateNode[]): boolean {
324
474
  for (const piece of run) {
325
475
  if (piece.kind !== 'expression') continue
326
- if (this.isLive(piece) || this.holdsRead(piece, isRawCallee))
476
+ // The PROOF, not `isLive`: a row's item makes a position live on the client alone, and
477
+ // both lanes have to agree on whether a run is anchored (32.30).
478
+ if (
479
+ !this.answer.isStatic(this.spanOf(piece)) ||
480
+ this.holdsRead(piece, isRawCallee)
481
+ )
327
482
  return true
328
483
  }
329
484
  return false
@@ -395,6 +550,16 @@ export class Lowering {
395
550
  const current = reads[read] as Read
396
551
  if (current.start >= to) break
397
552
  if (current.start < at) continue
553
+ // 32.67 — a row's item is the value wherever its row is not being mounted.
554
+ if (current.item !== -1) {
555
+ const rewritten = this.itemRead(current)
556
+ if (rewritten === undefined) continue
557
+ copy(current.from)
558
+ pin(current.from)
559
+ out += rewritten
560
+ at = current.to
561
+ continue
562
+ }
398
563
  // 31.3 — the operand's own read is the one that does not lower — save that a written
399
564
  // prop's name is its accessor, and the operand is the cell the accessor hands back.
400
565
  if (holds && current.from === wholeFrom && current.to === wholeTo) {
@@ -431,6 +596,23 @@ export class Lowering {
431
596
  at = current.to
432
597
  continue
433
598
  }
599
+ // 38.116 — THE CHECKER'S `props` CALL IS COPIED INSIDE ITS WRAPPER, so its type is the
600
+ // author's text where a rename looks for it, and a finding in it lands where written.
601
+ if (this.checking && current.shape === 'props') {
602
+ copy(current.from)
603
+ pin(current.from)
604
+ out += '__accessors('
605
+ origins?.push({
606
+ at: base + out.length,
607
+ source: current.from,
608
+ length: current.to - current.from,
609
+ })
610
+ out += source.slice(current.from, current.to)
611
+ pin(current.to)
612
+ out += this.checkedPropsClose(current)
613
+ at = current.to
614
+ continue
615
+ }
434
616
  const rewritten = this.rewrite(current)
435
617
  if (rewritten === undefined) continue
436
618
  copy(current.from)
@@ -442,6 +624,23 @@ export class Lowering {
442
624
  return out
443
625
  }
444
626
 
627
+ /**
628
+ * 33.4 — what closes the `__accessors(` the checker's `props` call is wrapped in: the record
629
+ * of accessors the caller passed, with the keys the child writes (33.6) and the keys it
630
+ * defaults (33.20).
631
+ */
632
+ private checkedPropsClose(read: Read): string {
633
+ const written =
634
+ read.path.length === 0
635
+ ? ''
636
+ : `, ${JSON.stringify(read.path.map((key) => key.name))}`
637
+ const without =
638
+ read.keys.length === 0
639
+ ? ''
640
+ : `${written === '' ? ', []' : ''}, ${JSON.stringify(read.keys)}`
641
+ return `${written}${without})`
642
+ }
643
+
445
644
  /**
446
645
  * The read's name as emitted: `name()` for a written prop, whose cell its accessor hands back.
447
646
  *
@@ -456,6 +655,37 @@ export class Lowering {
456
655
  return read.through ? `${spelled}()` : spelled
457
656
  }
458
657
 
658
+ /**
659
+ * 32.67 — one read of a row's item: the value as written outside its row's mount, the record's
660
+ * value in a listener, `itemOf` the record in a binding of the row's own top level, and the
661
+ * cell's read everywhere else in it.
662
+ */
663
+ private itemRead(read: Read): string | undefined {
664
+ const reading = this.itemModes.get(read.item)
665
+ if (reading === undefined) return undefined
666
+ const { mode, record, uses } = reading
667
+ const spelled = this.projection.text.slice(read.start, read.end)
668
+ let value = `${spelled}()`
669
+ if (
670
+ this.recording === 'listener' ||
671
+ (this.recording === 'binding' && mode === 'own')
672
+ ) {
673
+ const from =
674
+ this.recording === 'listener'
675
+ ? `${record}.data`
676
+ : `${this.use('itemOf')}(${record})`
677
+ // A PATTERN'S NAME is no member of the value at one path, so it is the list's own picker
678
+ // of that name (`rowPickers`), handed the record's value.
679
+ const text = this.projection.text
680
+ const named =
681
+ text.slice(read.item, read.item + spelled.length) === spelled &&
682
+ !/[\w$]/.test(text[read.item + spelled.length] ?? '')
683
+ value = named ? from : `${record}_${spelled}(${from})`
684
+ uses.record += 1
685
+ } else uses.cell += 1
686
+ return read.shape === 'shorthand' ? `${spelled}: ${value}` : value
687
+ }
688
+
459
689
  /** What one read lowers to, or `undefined` where it stays exactly as written. */
460
690
  private rewrite(read: Read): string | undefined {
461
691
  const spelled = this.projection.text.slice(read.start, read.end)
@@ -498,18 +728,8 @@ export class Lowering {
498
728
  // 33.20 — a defaulted key leaves the record, so its default always stands and asks the
499
729
  // caller's accessor itself (`propOr`); its cell, where the child writes it, is built there.
500
730
  case 'props': {
731
+ // The checker's is bracketed in `lower` (`checkedPropsClose`).
501
732
  const defaulted = read.keys
502
- if (this.checking) {
503
- const written =
504
- read.path.length === 0
505
- ? ''
506
- : `, ${JSON.stringify(read.path.map((key) => key.name))}`
507
- const without =
508
- defaulted.length === 0
509
- ? ''
510
- : `${written === '' ? ', []' : ''}, ${JSON.stringify(defaulted)}`
511
- return `__accessors(${this.projection.text.slice(read.from, read.to)}${written}${without})`
512
- }
513
733
  const writtenKeys: (string | undefined)[] = []
514
734
  for (const key of read.path)
515
735
  if (key.name === undefined || !defaulted.includes(key.name))
@@ -63,20 +63,27 @@ export class PositionTable {
63
63
 
64
64
  /** The position `node` is the whole of, or `undefined` where it is none or only part of one. */
65
65
  of(node: Node): Position | undefined {
66
- const start = node.getStart()
66
+ const position = this.holding(node.getStart())
67
+ if (position === undefined) return undefined
68
+ const parent = node.parent
69
+ return parent === undefined || parent.getStart() < position.start
70
+ ? position
71
+ : undefined
72
+ }
73
+
74
+ /** The position whose span holds `offset`, or `undefined` where none does. */
75
+ holding(offset: number): Position | undefined {
67
76
  let low = 0
68
77
  let high = this.all.length
69
78
  while (low < high) {
70
79
  const middle = (low + high) >>> 1
71
- if ((this.all[middle] as Position).start <= start) low = middle + 1
80
+ if ((this.all[middle] as Position).start <= offset) low = middle + 1
72
81
  else high = middle
73
82
  }
74
83
  const position = this.all[low - 1]
75
- if (position === undefined || start >= position.end) return undefined
76
- const parent = node.parent
77
- return parent === undefined || parent.getStart() < position.start
78
- ? position
79
- : undefined
84
+ return position === undefined || offset >= position.end
85
+ ? undefined
86
+ : position
80
87
  }
81
88
 
82
89
  /** Whether any position is of a kind `kinds` holds. */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A refusal that knows WHERE in the file's text it stands, so a sink handed the text reports it on
3
+ * the line and column the author typed rather than at the file (`Refusals.at`).
4
+ */
5
+ export class PositionedRefusal extends Error {
6
+ /** The offset into the file's text the refusal stands at. */
7
+ readonly offset: number
8
+
9
+ constructor(message: string, offset: number) {
10
+ super(message)
11
+ this.offset = offset
12
+ }
13
+ }
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path' // bun exposes no path helpers of its own
2
2
  import { formatDiagnosis } from './formatDiagnosis.ts'
3
3
  import { diagnosisAt } from './lineOf.ts'
4
+ import { PositionedRefusal } from './PositionedRefusal.ts'
4
5
  import type { Diagnosis } from './TypeSession.ts'
5
6
 
6
7
  /**
@@ -25,18 +26,24 @@ export class Refusals {
25
26
  this.collecting = collecting
26
27
  }
27
28
 
28
- /** Refuses at `file`, below the root or absolute: `thrown` is thrown, or kept. */
29
- at(file: string, thrown: unknown): void {
30
- if (!this.collecting) throw thrown
31
- this.found.push(
32
- diagnosisAt(
33
- resolve(this.root, file),
34
- undefined,
35
- 0,
36
- 'error',
37
- thrown instanceof Error ? thrown.message : String(thrown),
38
- ),
29
+ /**
30
+ * Refuses at `file`, below the root or absolute: `thrown` is thrown, or kept. Handed the file's
31
+ * `text`, a `PositionedRefusal` is placed on its line and column, and thrown as that line.
32
+ */
33
+ at(file: string, thrown: unknown, text?: string): void {
34
+ const positioned =
35
+ text !== undefined && thrown instanceof PositionedRefusal
36
+ if (!this.collecting && !positioned) throw thrown
37
+ const diagnosis = diagnosisAt(
38
+ resolve(this.root, file),
39
+ positioned ? text : undefined,
40
+ positioned ? thrown.offset : 0,
41
+ 'error',
42
+ thrown instanceof Error ? thrown.message : String(thrown),
39
43
  )
44
+ if (!this.collecting)
45
+ throw new Error(formatDiagnosis(diagnosis, process.cwd()))
46
+ this.found.push(diagnosis)
40
47
  }
41
48
 
42
49
  /**
@@ -24,6 +24,8 @@ export const SHARED_NAMES: ReadonlySet<string> = new Set([
24
24
  // 32.5, 32.8 — what a `bind:` operand holds, which the markup writes into the tag.
25
25
  'boundValue',
26
26
  'groupHolds',
27
+ // 32.61 — a bound `<select>`'s options, marked where its value selects them.
28
+ 'selectedMarkup',
27
29
  // 32.6, 33.6, 33.7 — a component's `bind:`, and the cell a child writing that prop builds.
28
30
  'boundProp',
29
31
  'propCell',
@@ -98,6 +98,43 @@ export class ShadowMap {
98
98
  ? { path: this.shadowPath, position: projected }
99
99
  : { path: this.checkedPath, position: checked }
100
100
  }
101
+
102
+ /**
103
+ * EVERY place the checker reads the author's `position` at: the projection where it copied it,
104
+ * and the checked module too wherever the lowering copied it on — a component's props type is
105
+ * a second copy (38.116). A name read by name is a rewrite in the checked module, so its uses
106
+ * there are another symbol's — the projection's holds them.
107
+ */
108
+ placesFrom(position: number): { path: string; position: number }[] {
109
+ const projected = mapForward(this.projected, position)
110
+ if (projected === undefined) return []
111
+ const places = [{ path: this.shadowPath, position: projected }]
112
+ for (const origin of this.checked)
113
+ if (
114
+ projected >= origin.source &&
115
+ projected < origin.source + origin.length
116
+ )
117
+ places.push({
118
+ path: this.checkedPath,
119
+ position: origin.at + projected - origin.source,
120
+ })
121
+ return places
122
+ }
123
+
124
+ /**
125
+ * `start` to `end` in `path`, placed in the `.abide` only where BOTH layers copied it from what
126
+ * the author wrote — the strict `spanToSource`, for an edit that must land on the author's text.
127
+ */
128
+ copiedToSource(path: string, start: number, end: number): Span | undefined {
129
+ let projected: number | undefined = start
130
+ if (path === this.checkedPath)
131
+ projected = copiedFrom(this.checked, start)
132
+ if (projected === undefined) return undefined
133
+ const source = copiedFrom(this.projected, projected)
134
+ return source === undefined
135
+ ? undefined
136
+ : { start: source, end: source + end - start }
137
+ }
101
138
  }
102
139
 
103
140
  /** The start of the run holding `position`, plus how far in; scaffolding is the run before's end. */
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Every control block the template parser reads (`parseAbide`), and the arms each one takes.
3
+ *
4
+ * 38.117 — what `abide lsp` offers after `{#`, `{:` and `{/`; `templateContext.test.ts` holds the
5
+ * parser to the same list.
6
+ */
7
+ export const TEMPLATE_BLOCKS: Readonly<Record<string, readonly string[]>> = {
8
+ if: ['else', 'else if'],
9
+ for: [],
10
+ try: ['catch', 'finally'],
11
+ component: [],
12
+ }