@abide/abide 0.123.3 → 0.124.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 (89) hide show
  1. package/ABIDE.md +8 -5
  2. package/CHANGELOG.md +31 -0
  3. package/docs/REGISTRY.md +45 -12
  4. package/docs/RULEBOOK.md +24 -14
  5. package/docs/abide.md +999 -459
  6. package/package.json +1 -1
  7. package/src/compiler/ARRAY_MUTATORS.ts +20 -0
  8. package/src/compiler/LoweredFile.ts +609 -0
  9. package/src/compiler/Lowering.ts +17 -0
  10. package/src/compiler/SHARED_NAMES.ts +2 -0
  11. package/src/compiler/ShadowHost.ts +670 -0
  12. package/src/compiler/ShadowMap.ts +1 -1
  13. package/src/compiler/TypeSession.ts +71 -1715
  14. package/src/compiler/appLints.ts +109 -0
  15. package/src/compiler/checkedShadow.ts +1 -1
  16. package/src/compiler/classifyRead.ts +16 -1
  17. package/src/compiler/compileApp.ts +2 -2
  18. package/src/compiler/declaredExports.ts +44 -17
  19. package/src/compiler/diagnoseSources.ts +232 -0
  20. package/src/compiler/formatAbide.ts +5 -6
  21. package/src/compiler/handlersUnder.ts +16 -35
  22. package/src/compiler/reactiveReads.ts +235 -0
  23. package/src/compiler/reactiveTypes.ts +13 -0
  24. package/src/compiler/readsIn.ts +180 -81
  25. package/src/compiler/routesDeclaration.ts +46 -0
  26. package/src/compiler/scaffold.ts +7 -2
  27. package/src/compiler/serveLanguage.ts +26 -20
  28. package/src/compiler/splitImports.ts +2 -2
  29. package/src/server/DesktopSession.ts +213 -0
  30. package/src/server/Followers.ts +399 -0
  31. package/src/server/MODULE_QUERIES.ts +6 -2
  32. package/src/server/PageRender.ts +201 -0
  33. package/src/server/abideModules.ts +7 -7
  34. package/src/server/answerFailure.ts +3 -2
  35. package/src/server/answerRequest.ts +6 -70
  36. package/src/server/answerRpc.ts +48 -260
  37. package/src/server/answerSocket.ts +41 -463
  38. package/src/server/appKit.ts +1 -1
  39. package/src/server/argsContract.ts +1 -1
  40. package/src/server/createApp.ts +8 -2
  41. package/src/server/csp.ts +1 -1
  42. package/src/server/deferredBuilds.ts +3 -16
  43. package/src/server/faceOf.ts +1 -1
  44. package/src/server/generatedSurfaces.ts +2 -1
  45. package/src/server/handlerFileMount.ts +1 -1
  46. package/src/server/index.ts +1 -1
  47. package/src/server/installServer.ts +5 -9
  48. package/src/server/invokeHandler.ts +230 -0
  49. package/src/server/mountHandlers.ts +184 -0
  50. package/src/server/openDesktopWindow.ts +308 -0
  51. package/src/server/preload.ts +18 -0
  52. package/src/server/principal.ts +1 -1
  53. package/src/server/remote.ts +1 -1
  54. package/src/server/render.ts +31 -59
  55. package/src/server/requestScope.ts +5 -36
  56. package/src/server/runDesktop.ts +19 -447
  57. package/src/server/seedBuffer.ts +11 -11
  58. package/src/server/servedApp.ts +1 -1
  59. package/src/server/shell.ts +1 -1
  60. package/src/server/surfaceOf.ts +1 -1
  61. package/src/server/versionOf.ts +1 -1
  62. package/src/shared/RPC_SERVER.ts +27 -31
  63. package/src/shared/Schema.ts +1 -2
  64. package/src/shared/index.ts +1 -1
  65. package/src/shared/{note.ts → issueReport.ts} +22 -0
  66. package/src/shared/loadCall.ts +66 -0
  67. package/src/shared/reactive/Keyed.ts +23 -15
  68. package/src/shared/reactive/Reactive.ts +34 -1001
  69. package/src/shared/reactive/byOf.ts +2 -5
  70. package/src/shared/reactive/callLoad.ts +8 -14
  71. package/src/shared/reactive/cellShape.ts +734 -0
  72. package/src/shared/reactive/index.ts +12 -24
  73. package/src/shared/reactive/pairReactive.ts +5 -3
  74. package/src/shared/reactive/producer.ts +121 -0
  75. package/src/shared/reactive/scoped.ts +3 -2
  76. package/src/shared/reactive/selections.ts +209 -0
  77. package/src/shared/reactive/watch.ts +3 -4
  78. package/src/shared/rpc.ts +8 -5
  79. package/src/shared/validateJson.ts +1 -2
  80. package/src/shared/writePath.ts +29 -0
  81. package/src/ui/Router.ts +53 -69
  82. package/src/ui/outboxQueue.ts +2 -8
  83. package/src/ui/outcomeOf.ts +120 -0
  84. package/src/ui/socketWire.ts +3 -3
  85. package/src/ui/wire.ts +9 -10
  86. package/src/shared/LOAD_OFFLINE.ts +0 -15
  87. package/src/shared/LOAD_SIGNAL.ts +0 -10
  88. package/src/shared/issuesOf.ts +0 -18
  89. package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
@@ -1,237 +1,38 @@
1
- import { type Dirent, readdirSync, realpathSync, statSync } from 'node:fs' // Bun exposes no sync directory listing or realpath of its own
2
- import { basename, dirname, join, resolve } from 'node:path' // Bun exposes no path helpers of its own
1
+ import type { CompletionInfo } from 'typescript/unstable/async'
3
2
 
4
- import {
5
- getTouchingPropertyName,
6
- type Node,
7
- NodeFlags,
8
- SyntaxKind,
9
- } from 'typescript/unstable/ast'
10
- import {
11
- API,
12
- type Checker,
13
- type CompletionEntry,
14
- type CompletionInfo,
15
- CompletionItemKind,
16
- NodeBuilderFlags,
17
- type Project,
18
- SignatureKind,
19
- type Snapshot,
20
- type Symbol as CheckedSymbol,
21
- SymbolFlags,
22
- type Type,
23
- } from 'typescript/unstable/async'
24
-
25
- import { placeOf } from '#server/placement.ts'
26
- import { parseRoutePattern } from '#shared/routePattern.ts'
27
-
28
- import { aliasedSymbol } from './aliasedSymbol.ts'
29
- import { AMBIENT_SOURCES, ambientReadsIn } from './ambientReadsIn.ts'
30
- import type { AppLayout, AppRoute, PlacedSource } from './appLayout.ts'
31
- import { argsDefaultsIn } from './argsDefaultsIn.ts'
32
- import {
33
- checkedShadow,
34
- COMPONENT_MODULE,
35
- COMPONENT_STAND_IN,
36
- moduleConfigShadow,
37
- } from './checkedShadow.ts'
3
+ import type { AppLayout } from './appLayout.ts'
4
+ import { appLints } from './appLints.ts'
38
5
  import { type CheckerAnswer, UNCHECKED } from './CheckerAnswer.ts'
6
+ import { type DiagnosedSource, diagnoseSources } from './diagnoseSources.ts'
7
+ import type { Diagnosis } from './Diagnostics.ts'
39
8
  import {
40
- byPlace,
41
- type Diagnosis,
42
- type Finding,
43
- placedFindings,
44
- } from './Diagnostics.ts'
45
- import { lineOf } from './lineOf.ts'
46
- import { CHECKED_VALUES } from './Lowering.ts'
9
+ completionAt,
10
+ type Inspection,
11
+ type LoweredFile,
12
+ type References,
13
+ } from './LoweredFile.ts'
47
14
  import type { AbideFile } from './parseAbide.ts'
48
- import { projectShadow, type Span } from './projectShadow.ts'
49
- import { type Abide, abideIn, isReactiveType } from './reactiveTypes.ts'
50
- import { readsIn, type ShadowSpans } from './readsIn.ts'
51
- import type { ParsedSource, ReadSource } from './readSources.ts'
52
- import {
53
- CHECKED_SUFFIX,
54
- mapForward,
55
- SHADOW_SUFFIX,
56
- ShadowMap,
57
- } from './ShadowMap.ts'
58
- import { SOURCE_SUFFIX } from './splitImports.ts'
59
- import { type TemplateContext, templateContext } from './templateContext.ts'
60
- import { templateWarnings } from './templateWarnings.ts'
61
-
62
- // Where 31.3 gets its answer. A `.abide` file is projected into a SHADOW `.ts` held in a virtual
63
- // filesystem and never written to disk, and TypeScript's own checker is asked what each declared
64
- // name's type is. That is the whole reason the checker is here rather than a local inference over
65
- // annotations: the corpus writes `let rows = reactive([...])` with no annotation anywhere, so the
66
- // answer comes from the initializer's INFERRED type or it does not come at all.
15
+ import { reactiveReads } from './reactiveReads.ts'
16
+ import type { ParsedSource } from './readSources.ts'
17
+ import { ShadowHost } from './ShadowHost.ts'
18
+
19
+ // Where 31.3 gets its answer: TypeScript's own checker, asked about an app's `.abide` files through
20
+ // the shadows a `ShadowHost` holds for it. That is the whole reason the checker is here rather than
21
+ // a local inference over annotations: the corpus writes `let rows = reactive([...])` with no
22
+ // annotation anywhere, so the answer comes from the initializer's INFERRED type or it does not come
23
+ // at all.
67
24
  //
68
- // The API is the ASYNC one on purpose. `typescript/unstable/sync` reads `child.stdout._handle.fd`
69
- // and bun exposes no fd on a child pipe, so the sync channel cannot connect. The async channel is
70
- // a stream and needs nothing.
71
-
72
- /** Whether a script can bind a name at all — a file that cannot is never handed to the checker. */
73
- const DECLARES = /\b(?:const|let|var|import)\b/
25
+ // ONE OBJECT FOR WHAT A BUILD, A CHECK AND AN EDITOR ASK, each question answered by the module that
26
+ // owns it — the reads (`reactiveReads`), the lints (`appLints`), the diagnosis (`diagnoseSources`)
27
+ // and an editor's questions of a file's lowering (`LoweredFile`) — over the one host.
74
28
 
75
29
  /** A TypeScript checker session over an app, asked about its `.abide` files through shadows. */
76
30
  export class TypeSession {
77
- private api: API
78
- /** The `tsconfig` the session was opened on, which a file outside its project is refused by. */
79
- readonly tsconfig: string
80
- /**
81
- * Per checker key, the text the checker reads for a file this session wrote, and the path it
82
- * was registered under.
83
- *
84
- * KEYED AS THE CHECKER KEYS ITS OWN CACHE (`keyOf`), not by the string a caller handed in. On a
85
- * case-insensitive disk the checker folds `Page.abide.shadow.ts` and `page.abide.shadow.ts` to
86
- * one file, so a map telling them apart reported the second spelling `created` for a file the
87
- * checker already held — it read nothing, and answered the second text's reads at the first
88
- * text's offsets.
89
- */
90
- private shadows = new Map<string, { path: string; text: string }>()
91
- /** The checker's own key for a path: resolved, and case-folded where its host folds case. */
92
- private keyOf: (path: string) => string
93
- /**
94
- * Per `.abide` path as the checker keys it (`keyOf`), what `diagnose` last lowered it to —
95
- * kept for `inspect` alone.
96
- *
97
- * THE ONE THING CARRIED FROM ONE CALL TO ANOTHER, because an editor's hover is a request of
98
- * its own that arrives after the check that lowered the file, and has to map its offset
99
- * through the lowering the checker's program holds rather than one made again. Everything a
100
- * check's own caller needs of the lowering, `diagnose` hands back.
101
- */
102
- private loweredForInspect = new Map<
103
- string,
104
- { text: string; map: ShadowMap }
105
- >()
31
+ /** The checker, the files it reads out of this session, and the snapshots it holds of them. */
32
+ readonly host: ShadowHost
106
33
 
107
- private constructor(
108
- api: API,
109
- tsconfig: string,
110
- keyOf: (path: string) => string,
111
- root: string,
112
- ) {
113
- this.api = api
114
- this.tsconfig = tsconfig
115
- this.root = root
116
- this.opened = `${dirname(tsconfig)}/${SESSION_CONFIG}`
117
- this.keyOf = keyOf
118
- }
119
-
120
- /**
121
- * The project this session opens: the app's tsconfig, with every shadow in `outside` a root.
122
- *
123
- * 34.25 — A DEPENDENCY'S `.abide` IS TYPED AS THE APP'S OWN, and its tsconfig's `include` never
124
- * reaches `node_modules` or a linked package's directory. The session opens a config of its own —
125
- * served from here, never written — that extends the app's, names those shadows as files, which
126
- * TypeScript adds to what the base config includes (a linked package's shadow, asked for through
127
- * the link, is reached no other way), and allows the declaration a package's `exports` is asked
128
- * for by (`standIn`).
129
- */
130
- private readonly opened: string
131
-
132
- /** The shadows of the files outside the app's own, as `opened` names them. */
133
- private outside = new Set<string>()
134
-
135
- private sessionConfig(): string {
136
- const config: {
137
- extends: string
138
- compilerOptions: Record<string, unknown>
139
- files?: string[]
140
- } = {
141
- extends: `./${basename(this.tsconfig)}`,
142
- // A package's `exports` names `./x.abide` exactly, so the checker asks for its
143
- // declaration as `./x.d.abide.ts` (`standIn`) — and takes the answer only with this.
144
- compilerOptions: { allowArbitraryExtensions: true },
145
- }
146
- // An empty `files` is a config error, and the base's `include` alone is the app.
147
- if (this.outside.size > 0) config.files = [...this.outside]
148
- return JSON.stringify(config)
149
- }
150
-
151
- /**
152
- * Checked-module paths a stand-in answered for, without the `.abide` being lowered.
153
- *
154
- * 38.39 — A `.abide` THIS SESSION HAS NOT LOWERED IS STILL A MODULE ANOTHER CAN IMPORT. What its
155
- * checked module exports does not depend on what the component holds (`COMPONENT_MODULE`), so an
156
- * editor checking one page resolves the components it imports without lowering each of them.
157
- * Remembered, so the lowering that replaces a stand-in is reported as a change to it.
158
- */
159
- private stoodIn = new Set<string>()
160
-
161
- /** The app's root, real. */
162
- readonly root: string
163
-
164
- /**
165
- * `path` below the app's root, where it lies under it.
166
- *
167
- * 23.14 — what a page's route is read off: the directories below the root are it, and the
168
- * machine's directories above the root are not.
169
- */
170
- belowRoot(path: string): string {
171
- return path.startsWith(`${this.root}/`)
172
- ? path.slice(this.root.length + 1)
173
- : path
174
- }
175
-
176
- /**
177
- * The shadow registered at `name`, or at the same file through its directory's real path.
178
- *
179
- * 34.25 — A LINKED PACKAGE IS ASKED FOR THROUGH ITS LINK: the checker resolves `kit/x.abide` to
180
- * `node_modules/kit/x.d.abide.ts` and the re-export there to the shadow beside it, while the
181
- * shadow is registered where bun resolved the file — the package's real directory.
182
- */
183
- private shadowAt(name: string): { path: string; text: string } | undefined {
184
- const found = this.shadows.get(this.keyOf(name))
185
- // Only a shadow is registered through a link, so any other name is spared the realpath.
186
- if (
187
- found !== undefined ||
188
- !name.includes('/node_modules/') ||
189
- !(name.endsWith(SHADOW_SUFFIX) || name.endsWith(CHECKED_SUFFIX))
190
- )
191
- return found
192
- let real: string
193
- try {
194
- real = realpathSync(dirname(name))
195
- } catch {
196
- return undefined
197
- }
198
- return this.shadows.get(this.keyOf(`${real}/${basename(name)}`))
199
- }
200
-
201
- private standIn(name: string): string | undefined {
202
- // 34.21 — a file imported as text is its text, which the projection names apart.
203
- if (name.endsWith(`${SOURCE_SUFFIX}.d.ts`))
204
- return isFile(name.slice(0, -`${SOURCE_SUFFIX}.d.ts`.length))
205
- ? 'declare const text: string\nexport default text\n'
206
- : undefined
207
- // 34.17 — WHAT A `.abide` EXPORTS, to anything importing it: the component its checked
208
- // module declares, and nothing else. The checker resolves `./x.abide` by trying
209
- // `./x.abide.d.ts`; the bindings the projection re-exports for the build (16.70) are not
210
- // the importer's to read.
211
- //
212
- // 34.25 — AND THROUGH A PACKAGE'S `exports`, which the checker follows to the exact file and
213
- // so asks for the arbitrary extension's declaration, `./x.d.abide.ts`, instead.
214
- const abide = name.endsWith(DECLARATION_SUFFIX)
215
- ? name.slice(0, -'.d.ts'.length)
216
- : name.endsWith(EXPORTED_DECLARATION_SUFFIX)
217
- ? `${name.slice(0, -EXPORTED_DECLARATION_SUFFIX.length)}.abide`
218
- : undefined
219
- if (abide !== undefined) {
220
- if (!isFile(abide)) return undefined
221
- const own = `./${basename(abide)}`
222
- return `export { default } from '${own}${CHECKED_SUFFIX.slice(0, -'.ts'.length)}'
223
- `
224
- }
225
- // A projection not registered — a file with nothing to read, or one this session has not
226
- // projected yet — exports nothing, so the declaration above always resolves.
227
- if (name.endsWith(`.abide${SHADOW_SUFFIX}`))
228
- return isFile(name.slice(0, -SHADOW_SUFFIX.length))
229
- ? 'export {}\n'
230
- : undefined
231
- if (!name.endsWith(`.abide${CHECKED_SUFFIX}`)) return undefined
232
- if (!isFile(name.slice(0, -CHECKED_SUFFIX.length))) return undefined
233
- this.stoodIn.add(this.keyOf(name))
234
- return COMPONENT_MODULE
34
+ private constructor(host: ShadowHost) {
35
+ this.host = host
235
36
  }
236
37
 
237
38
  /**
@@ -239,1558 +40,113 @@ export class TypeSession {
239
40
  * for a session asked about no app's routes.
240
41
  */
241
42
  static async open(tsconfig: string, root = ''): Promise<TypeSession> {
242
- const api = new API({
243
- cwd: dirname(tsconfig),
244
- fs: {
245
- readFile: (name) =>
246
- name === session.opened
247
- ? session.sessionConfig()
248
- : (session.shadowAt(name)?.text ??
249
- session.standIn(name)),
250
- fileExists: (name) =>
251
- name === session.opened ||
252
- session.shadowAt(name) !== undefined ||
253
- session.standIn(name) !== undefined
254
- ? true
255
- : undefined,
256
- getAccessibleEntries: (directory) =>
257
- session.entriesIn(directory),
258
- },
259
- })
260
- // Whether the host folds case is the checker process's answer to `initialize`, and the API
261
- // keeps it only inside the private `toPath` its own `getSourceFile` keys by — so that is
262
- // what is read, rather than a second guess at the disk that could disagree with it.
263
- const internals = api as unknown as {
264
- ensureInitialized(): Promise<void>
265
- toPath?: (path: string) => string
266
- }
267
- await internals.ensureInitialized()
268
- const toPath = internals.toPath
269
- if (typeof toPath !== 'function')
270
- throw new Error(
271
- 'abide: the TypeScript API no longer keeps `toPath` where the type session reads it, so a path cannot be keyed as the checker keys it.',
272
- )
273
- const session = new TypeSession(api, tsconfig, toPath, root)
274
- return session
43
+ return new TypeSession(await ShadowHost.open(tsconfig, root))
275
44
  }
276
45
 
277
- /**
278
- * The project as of the last snapshot, so a question asked after `readsFor` shares its snapshot
279
- * rather than taking a second one.
280
- */
281
- private latest: Project | undefined = undefined
282
- private latestSnapshot: Snapshot | undefined = undefined
283
-
284
- /**
285
- * Snapshots a later one replaced, released once no call is in flight (`hold`).
286
- *
287
- * THE CHECKER PROCESS KEEPS EVERY SNAPSHOT UNTIL IT IS RELEASED — `updateSnapshot` releases
288
- * nothing — and each holds a whole program and checker. Dropped unreleased, a kept session grew
289
- * without bound: an `abide lsp` checker reached 59 GB in two hours of watcher-driven snapshots.
290
- * Released the moment it is replaced, a question still being answered over it throws, so the
291
- * release waits for the last call out.
292
- */
293
- private retired: Snapshot[] = []
294
- private inFlight = 0
295
-
296
- /**
297
- * Marks a call in flight until the returned value is disposed (`using`); the last one out
298
- * releases what was retired meanwhile. A caller holding a `Project` across calls (`projectWith`)
299
- * holds the session for as long.
300
- */
301
- hold(): Disposable {
302
- this.inFlight += 1
303
- return {
304
- [Symbol.dispose]: () => {
305
- this.inFlight -= 1
306
- if (this.inFlight === 0) this.releaseRetired()
307
- },
308
- }
46
+ /** The `tsconfig` the session was opened on, which a file outside its project is refused by. */
47
+ get tsconfig(): string {
48
+ return this.host.tsconfig
309
49
  }
310
50
 
311
- private retire(snapshot: Snapshot): void {
312
- this.retired.push(snapshot)
313
- if (this.inFlight === 0) this.releaseRetired()
51
+ /** The app's root, real. */
52
+ get root(): string {
53
+ return this.host.root
314
54
  }
315
55
 
316
- private releaseRetired(): void {
317
- for (const snapshot of this.retired) {
318
- // A process that has stopped answering rejects the release, and dies with what it held.
319
- const release = snapshot.dispose().catch(() => {})
320
- this.releasing.add(release)
321
- void release.finally(() => this.releasing.delete(release))
322
- }
323
- this.retired = []
56
+ /** How many snapshots this session has taken (`ShadowHost.snapshots`). */
57
+ get snapshots(): number {
58
+ return this.host.snapshots
324
59
  }
325
60
 
326
- /** Releases sent and not yet answered, which `close` lets finish before ending the connection. */
327
- private releasing = new Set<Promise<void>>()
328
-
329
- /**
330
- * Paths edited or deleted on disk since the last snapshot, or `everything`.
331
- *
332
- * WHAT THE AUTHOR CHANGED ON DISK since the last snapshot, for a session kept across compiles
333
- * (`abide dev`). The checker caches every file it has read and is told only about the shadows
334
- * this class writes, so an edit to a module a page imports answered with the type it had when
335
- * the session opened: a name made reactive in a `.ts` stayed static, and a handler exported
336
- * since was not there. `everything` is the edit nobody could name, and drops the whole cache.
337
- */
338
- private edits = { present: [] as string[], deleted: [] as string[] }
339
- private everything = false
340
-
341
- /**
342
- * How many snapshots this session has taken.
343
- *
344
- * SNAPSHOTS TAKEN, and it is a counter rather than a timing because that is the only form of
345
- * this claim that survives a loaded machine. One per build is the contract; one per FILE is
346
- * the shape that made the checker 99.8% of compile time, and it reports the same answers.
347
- */
348
- snapshots = 0
349
-
350
- /**
351
- * A directory's listing as the checker sees it, with this session's shadows added.
352
- *
353
- * The shadow has to appear in its directory's listing or it is not a root of the project and
354
- * the checker never reads it.
355
- */
356
- private entriesIn(
357
- directory: string,
358
- ): { files: string[]; directories: string[] } | undefined {
359
- // TWO PATHS TO ONE DIRECTORY ARE ONE DIRECTORY. A raw string compare said otherwise on a
360
- // doubled separator, a trailing slash or a relative path, and the shadow then appeared in
361
- // nobody's listing — so the checker never read it and the refusal below blamed the app's
362
- // tsconfig for a path this class had mangled.
363
- const here = this.keyOf(directory)
364
- this.listed.add(here)
365
- const mine: string[] = []
366
- // The key is the checker's where it is registered, so only the incoming directory needs
367
- // keying — one `toPath` a call rather than one a shadow.
368
- for (const [key, shadow] of this.shadows)
369
- if (dirname(key) === here) mine.push(basename(shadow.path))
370
- if (mine.length === 0) return undefined
371
-
372
- const files: string[] = []
373
- const directories: string[] = []
374
- // A DIRECTORY THAT IS NOT THERE IS A LISTING OF THE SHADOWS ALONE, not a throw. The
375
- // checker calls this over the api and a throw crosses back as a remote error, which
376
- // PANICS the checker process — so a directory deleted between the glob and the ask took
377
- // the whole command down with it. `abide dev` is where that happens as a matter of
378
- // course: a generator that rewrites a tree of pages deletes every one of them first, and
379
- // the watcher fires while it is doing it.
380
- let entries: Dirent[]
381
- try {
382
- entries = readdirSync(directory, { withFileTypes: true })
383
- } catch (thrown) {
384
- if ((thrown as { code?: unknown }).code !== 'ENOENT') throw thrown
385
- return { files: mine, directories: [] }
386
- }
387
- // THE LISTING ALREADY KNOWS. A `statSync` per entry was N syscalls for an answer
388
- // `readdirSync` hands back with the name, and it THREW on a dangling symlink — an Emacs
389
- // `.#page.abide` lock beside a page took the whole build down with it.
390
- for (const entry of entries) {
391
- // A symlink is the one entry the listing cannot answer for, and a dangling one has no
392
- // answer at all — which is a file as far as this is concerned.
393
- if (entry.isSymbolicLink()) {
394
- let followed = false
395
- try {
396
- followed = statSync(
397
- join(directory, entry.name),
398
- ).isDirectory()
399
- } catch {
400
- // Dangling: a file, as `followed` already says.
401
- }
402
- if (followed) directories.push(entry.name)
403
- else files.push(entry.name)
404
- continue
405
- }
406
- if (entry.isDirectory()) directories.push(entry.name)
407
- else files.push(entry.name)
408
- }
409
- for (const name of mine) if (!files.includes(name)) files.push(name)
410
- return { files, directories }
61
+ /** `path` below the app's root, where it lies under it (23.14). */
62
+ belowRoot(path: string): string {
63
+ return this.host.belowRoot(path)
411
64
  }
412
65
 
413
- /**
414
- * Records the paths a watcher saw move, for the next snapshot to invalidate.
415
- *
416
- * THE PATHS A WATCHER SAW MOVE, or `undefined` where it saw a move and could not say where.
417
- * A file that is there is reported CREATED AND CHANGED: `changed` alone leaves a new file out
418
- * of the project's root set (a `.d.ts` nothing imports is never read), and `created` alone
419
- * leaves an edited file's cached text in place. A directory is dropped wholesale, because the
420
- * checker is not told what was under it.
421
- */
66
+ /** Records the paths a watcher saw move, for the next snapshot to invalidate. */
422
67
  edited(paths: string[] | undefined): void {
423
- if (paths === undefined) {
424
- this.everything = true
425
- return
426
- }
427
- for (const path of paths) {
428
- let directory: boolean
429
- try {
430
- directory = statSync(path).isDirectory()
431
- } catch {
432
- this.edits.deleted.push(path)
433
- // A `.abide` that is gone takes its shadows with it, or the checker keeps a page
434
- // nobody has as a root of the project for the rest of the session — and a page
435
- // importing it goes on reading the props its checked module declared.
436
- const gone = this.keyOf(path)
437
- for (const [key, shadow] of this.shadows)
438
- if (
439
- ShadowMap.isShadowOf(key, gone) ||
440
- key.startsWith(`${gone}/`)
441
- ) {
442
- this.shadows.delete(key)
443
- this.edits.deleted.push(shadow.path)
444
- }
445
- continue
446
- }
447
- // A FILE IN A DIRECTORY THE CHECKER HAS NEVER LISTED is a directory move the watcher
448
- // reported as the file alone — a save that creates `late/` and the page in it
449
- // at once — and the listing it already holds of the parent does not have it.
450
- if (directory || !this.listed.has(this.keyOf(dirname(path))))
451
- this.everything = true
452
- else this.edits.present.push(path)
453
- }
454
- }
455
-
456
- /** Every directory the checker has asked this session to list (`entriesIn`). */
457
- private listed = new Set<string>()
458
-
459
- /**
460
- * ONE SNAPSHOT TAKES EVERY EDIT WITH IT: the shadows written for it and whatever moved on disk.
461
- */
462
- private async snapshot(
463
- created: string[],
464
- changed: string[],
465
- ): Promise<Project | undefined> {
466
- const edits = this.edits
467
- const everything = this.everything
468
- this.edits = { present: [], deleted: [] }
469
- this.everything = false
470
- this.snapshots += 1
471
- // AN EDIT NOBODY COULD NAME DROPS THE CACHE, and the files created with it are still named
472
- // after it: a dropped cache re-reads what the project already listed, and a shadow written
473
- // for a page in a directory it never listed is not among them.
474
- if (everything)
475
- this.retire(
476
- await this.api.updateSnapshot({
477
- openProjects: [this.opened],
478
- fileChanges: { invalidateAll: true },
479
- }),
480
- )
481
- const snapshot = await this.api.updateSnapshot({
482
- openProjects: [this.opened],
483
- fileChanges: {
484
- created: [...created, ...edits.present],
485
- changed: [...changed, ...edits.present],
486
- deleted: edits.deleted,
487
- },
488
- })
489
- if (this.latestSnapshot !== undefined) this.retire(this.latestSnapshot)
490
- this.latestSnapshot = snapshot
491
- this.latest = snapshot.getProjects()[0]
492
- return this.latest
493
- }
494
-
495
- /** Whether the last snapshot still answers: there is one, and nothing has moved since. */
496
- private get current(): boolean {
497
- return (
498
- this.latest !== undefined &&
499
- !this.everything &&
500
- this.edits.present.length === 0 &&
501
- this.edits.deleted.length === 0
502
- )
503
- }
504
-
505
- private async currentProject(
506
- created: string[] = [],
507
- changed: string[] = [],
508
- ): Promise<Project | undefined> {
509
- return created.length === 0 && changed.length === 0 && this.current
510
- ? this.latest
511
- : await this.snapshot(created, changed)
512
- }
513
-
514
- /**
515
- * 34.25, 23.19 — where `source` is a dependency's file, makes `path` — it, or what it lowers to
516
- * — a root of the opened config, telling `changed` the config moved where it was not one
517
- * already. The app's own is a root already: telling the config it moved reloads the project.
518
- */
519
- private openOutside(source: string, path: string, changed: string[]): void {
520
- if (placeOf(this.belowRoot(source)) !== 'dependency') return
521
- if (this.outside.has(path)) return
522
- this.outside.add(path)
523
- if (!changed.includes(this.opened)) changed.push(this.opened)
524
- }
525
-
526
- /**
527
- * The project, with each of `modules` outside the app's own a root of it.
528
- *
529
- * THE SNAPSHOT IS THE SESSION'S, AND A QUESTION IS ANY MODULE'S: what a handler declares
530
- * (`declaredExports`) is asked of the same snapshot the reads were, taken only where something
531
- * moved since.
532
- *
533
- * 23.19 — a handler file a mount places is a package's, which the app's `include` does not reach:
534
- * what it exports is asked all the same (16.73), as a mounted `.abide`'s shadow is.
535
- */
536
- async projectWith(
537
- modules: readonly string[],
538
- ): Promise<Project | undefined> {
539
- using _held = this.hold()
540
- const changed: string[] = []
541
- for (const path of modules) {
542
- const canonical = this.canonical(path)
543
- this.openOutside(canonical, canonical, changed)
544
- }
545
- const project = await this.currentProject([], changed)
546
- if (project === undefined) return project
547
- // A KEPT SESSION HEARS OF A FILE ONLY AS A WATCHER REPORTS IT, and one saved into a
548
- // directory made since the last snapshot is reported as the directory: it is no root yet,
549
- // and is told to the next snapshot as created.
550
- const found = await Promise.all(
551
- modules.map((path) => project.program.getSourceFile(path)),
552
- )
553
- const missing: string[] = []
554
- for (let at = 0; at < modules.length; at += 1)
555
- if (found[at] === undefined) missing.push(modules[at] as string)
556
- return missing.length === 0
557
- ? project
558
- : await this.currentProject(missing, [...missing])
559
- }
560
-
561
- /**
562
- * THE APP'S OWN FILES the checker holds as roots: under `within`, and neither a dependency nor a
563
- * declaration file. Resolved against the tsconfig's directory; a caller asking `shadows` keys it.
564
- */
565
- private rootsUnder(project: Project, within: string): string[] {
566
- const root = `${this.canonical(within)}/`
567
- const roots: string[] = []
568
- for (const path of project.rootFiles) {
569
- const canonical = this.canonical(path)
570
- if (
571
- canonical.startsWith(root) &&
572
- !path.includes('/node_modules/') &&
573
- !path.endsWith('.d.ts')
574
- )
575
- roots.push(canonical)
576
- }
577
- return roots
578
- }
579
-
580
- /**
581
- * A relative path from the checker is relative to the API's own cwd, which is the tsconfig's
582
- * directory rather than the process's.
583
- */
584
- private canonical(path: string): string {
585
- return resolve(dirname(this.tsconfig), path)
586
- }
587
-
588
- /**
589
- * Where the checker holds a `.abide`'s projection, once `readsFor` has registered it.
590
- *
591
- * 16.70 — what a `<script server>` binds is read off it as a `.ts` module's exports are.
592
- */
593
- projectionOf(abidePath: string): string {
594
- return `${this.canonical(abidePath)}${SHADOW_SUFFIX}`
595
- }
596
-
597
- /**
598
- * A file the checker reads out of `shadows`, reported to the next snapshot only when it is new,
599
- * its text moved, or a stand-in answered for it until now.
600
- */
601
- private register(
602
- path: string,
603
- text: string,
604
- created: string[],
605
- changed: string[],
606
- ): void {
607
- const key = this.keyOf(path)
608
- const previous = this.shadows.get(key)
609
- const stoodIn = this.stoodIn.delete(key)
610
- if (previous === undefined) created.push(path)
611
- if (stoodIn || (previous !== undefined && previous.text !== text))
612
- changed.push(path)
613
- this.shadows.set(key, { path, text })
68
+ this.host.edited(paths)
614
69
  }
615
70
 
616
71
  /**
617
72
  * The classified reactive reads in one `.abide` file.
618
73
  *
619
- * THE READS IN `file`: every occurrence of a name bound to a `Reactive` (31.3), classified, in
620
- * its projection (`projectShadow`). Everything else is left exactly as written. ONE FILE; a
621
- * build wants `readsFor`.
74
+ * ONE FILE; a build wants `readsFor`.
622
75
  */
623
76
  async reads(abidePath: string, file: AbideFile): Promise<CheckerAnswer> {
624
77
  const answered = await this.readsFor([{ path: abidePath, file }])
625
78
  return answered.answers[0] ?? UNCHECKED
626
79
  }
627
80
 
628
- /**
629
- * Reads and compiler warnings for every file, in one checker snapshot.
630
- *
631
- * EVERY FILE IN ONE SNAPSHOT, which is the whole of the compiler's build time. Measured at
632
- * 19.7 ms a file taking a snapshot each against 0.8 ms a file sharing one — 25x, because
633
- * adding a file to the project's root set is what costs, not asking about it. What each file's
634
- * shadow is asked is `readsIn`'s.
635
- *
636
- * WHAT IT HAS TO SAY THAT IS NOT A TYPE ERROR comes back beside the reads: the compiler's own
637
- * warnings (14.14 to 14.17, 32.14), each on the `.abide` line where the source's text
638
- * was handed in.
639
- */
640
- async readsFor(
641
- sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
642
- text?: string
643
- })[],
644
- ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
645
- using _held = this.hold()
646
- const answered = await this.classify(sources)
647
- for (let at = 0; at < sources.length; at += 1) {
648
- const source = sources[at] as (typeof sources)[number]
649
- if (source.text !== undefined)
650
- answered.warnings.push(
651
- ...templateWarnings(
652
- source.path,
653
- source.text,
654
- source.file,
655
- answered.answers[at] ?? UNCHECKED,
656
- ),
657
- )
658
- }
659
- answered.warnings.sort(byPlace)
660
- return answered
661
- }
662
-
663
- private async classify(
81
+ /** Reads and compiler warnings for every file, in one checker snapshot (`reactiveReads`). */
82
+ readsFor(
664
83
  sources: readonly (Pick<ParsedSource, 'path' | 'file'> & {
665
84
  text?: string
666
85
  })[],
667
86
  ): Promise<{ answers: CheckerAnswer[]; warnings: Diagnosis[] }> {
668
- const warnings: Diagnosis[] = []
669
- // Per source, its shadow and what is asked of it, or `undefined` where nothing is declared.
670
- const queries: ({ map: ShadowMap; spans: ShadowSpans } | undefined)[] =
671
- []
672
- const created: string[] = []
673
- const changed: string[] = []
674
-
675
- // EVERY SOURCE PROJECTS BEFORE ANY SHADOW IS REGISTERED. A projection refuses (a top-level
676
- // `await`), and a shadow registered ahead of the refusal was in `shadows` with the lists
677
- // that would have reported it dropped — so the retry found nothing moved, and the checker
678
- // answered from the old shadow at the new one's offsets, or never had the file at all.
679
- const projected = sources.map((source) => ({
680
- source,
681
- projection: projectShadow(source.file),
682
- }))
683
- for (const { source, projection } of projected) {
684
- // 34.2 — A `<script module>` DECLARES NAMES TOO, and the `<script>` under it is inside
685
- // that scope on both sides. The projection holds both from offset 0.
686
- const scripts = projection.text.slice(0, projection.scripts.end)
687
- // A TEMPLATE POSITION IS A QUESTION TOO (14.2): a file that declares nothing but
688
- // writes `{format(total)}` has a position only the checker can prove static. And a
689
- // `<script server>` always is (16.70): what it binds is read off this shadow, re-exported
690
- // under `PRIVATE_EXPORT`.
691
- if (
692
- !DECLARES.test(scripts) &&
693
- projection.slots.size === 0 &&
694
- source.file.servers.length === 0
695
- ) {
696
- queries.push(undefined)
697
- continue
698
- }
699
- // CANONICAL AT THE KEY, not at the comparison. A doubled separator or a relative
700
- // path is the same file and a raw string is not the same string — registered as it
701
- // arrived, the shadow appeared in nobody's directory listing AND named a file the
702
- // checker could not open, and the refusal below blamed the app's tsconfig for it.
703
- const map = new ShadowMap(
704
- this.canonical(source.path),
705
- projection.origins,
706
- )
707
- // A shadow STAYS registered for the life of the session and the snapshot is told it
708
- // changed. Deleting and re-adding under the same path does not invalidate the
709
- // source-file cache, and the checker then answers about the PREVIOUS file at these
710
- // offsets — right-looking types for the wrong names, with nothing to say so.
711
- // AN UNCHANGED SHADOW IS NOT REPORTED, which is what a kept session saves on a save:
712
- // the one page that moved is re-read rather than all of them.
713
- this.register(map.shadowPath, projection.text, created, changed)
714
- this.openOutside(source.path, map.shadowPath, changed)
715
- queries.push({
716
- map,
717
- spans: {
718
- scriptsEnd: projection.scripts.end,
719
- modules: projection.modules,
720
- servers: projection.servers,
721
- scripts: projection.scripts,
722
- positions: projection.positions,
723
- items: new Set(projection.items.values()),
724
- },
725
- })
726
- }
727
-
728
- const answers = sources.map(() => UNCHECKED)
729
- if (queries.every((query) => query === undefined))
730
- return { answers, warnings }
731
-
732
- let project = await this.currentProject(created, changed)
733
- if (project === undefined) return { answers, warnings }
734
- // Per source, its compiler warnings, the bindings the projection could not type
735
- // (`bindingsOfReads`), and those the lowered program proved a `Reactive`.
736
- const warned: Diagnosis[][] = sources.map(() => [])
737
- const suspects: number[][] = sources.map(() => [])
738
- const held: Set<number>[] = sources.map(() => new Set())
739
- const answer = async (
740
- asked: Project,
741
- abide: Abide,
742
- at: number,
743
- ): Promise<void> => {
744
- const query = queries[at]
745
- if (query === undefined) return
746
- const source = sources[at] as (typeof sources)[number]
747
- const sourceFile = await asked.program.getSourceFile(
748
- query.map.shadowPath,
749
- )
750
- // The shadow has to be INSIDE the opened project or the checker never reads it.
751
- // Refusing loudly rather than answering nothing: no reads is a file whose every
752
- // name lowers as written, which renders `[object Object]` where a value was due —
753
- // a wrong page, with nothing about it saying so.
754
- if (sourceFile === undefined)
755
- throw new Error(
756
- `abide: ${source.path} is not inside the project ${this.tsconfig}, so no name in it can be typed (RULEBOOK 31.3). Give the app a tsconfig whose include reaches it.`,
757
- )
758
- const read = await readsIn(
759
- asked.checker,
760
- abide,
761
- sourceFile,
762
- query.map.shadowPath,
763
- query.spans,
764
- source.file,
765
- held[at] as Set<number>,
766
- )
767
- answers[at] = read.answer
768
- suspects[at] = read.suspects
769
- warned[at] = placedFindings(
770
- source.path,
771
- source.text,
772
- read.warnings,
773
- (offset) => query.map.toSource(query.map.shadowPath, offset),
774
- 'warning',
775
- true,
776
- )
777
- }
778
- const every = queries.map((_, at) => at)
779
- const first = project
780
- const firstAbide = await abideIn(first)
781
- await Promise.all(every.map((at) => answer(first, firstAbide, at)))
782
- // 31.1 — A BINDING THE PROJECTION TYPED FROM A `Reactive` IS ASKED OF THE PROGRAM IT RUNS
783
- // AS: each file holding one is lowered with what it has answered so far, and a binding that
784
- // is a `Reactive` there is one in the projection too. A binding typed from one that just
785
- // turned out to be a `Reactive` is asked again, until a round turns none. Nothing is asked,
786
- // and no second snapshot taken, where no file holds one (`bindingsOfReads`).
787
- for (;;) {
788
- const asking = every.filter(
789
- (at) => (suspects[at] as number[]).length > 0,
790
- )
791
- if (asking.length === 0) break
792
- const loweredCreated: string[] = []
793
- const loweredChanged: string[] = []
794
- const lowered = asking.map((at) => {
795
- const source = sources[at] as (typeof sources)[number]
796
- const query = queries[at] as { map: ShadowMap }
797
- const shadow = checkedShadow(
798
- source.file,
799
- answers[at] as CheckerAnswer,
800
- )
801
- this.register(
802
- query.map.checkedPath,
803
- shadow.text,
804
- loweredCreated,
805
- loweredChanged,
806
- )
807
- this.openOutside(
808
- source.path,
809
- query.map.checkedPath,
810
- loweredChanged,
811
- )
812
- return {
813
- at,
814
- path: query.map.checkedPath,
815
- origins: shadow.origins,
816
- }
817
- })
818
- project = await this.currentProject(loweredCreated, loweredChanged)
819
- if (project === undefined) return { answers, warnings }
820
- const asked = project
821
- const abide = await abideIn(asked)
822
- const turned = new Set<number>()
823
- await Promise.all(
824
- lowered.map(async ({ at, path, origins }) => {
825
- const sourceFile = await asked.program.getSourceFile(path)
826
- if (sourceFile === undefined) return
827
- for (const start of suspects[at] as number[]) {
828
- const position = mapForward(origins, start)
829
- if (position === undefined) continue
830
- const symbol = await asked.checker.getSymbolAtLocation(
831
- getTouchingPropertyName(sourceFile, position),
832
- )
833
- const type =
834
- symbol === undefined
835
- ? undefined
836
- : await asked.checker.getTypeOfSymbol(symbol)
837
- if (
838
- type !== undefined &&
839
- (await isReactiveType(
840
- asked.checker,
841
- abide,
842
- type,
843
- false,
844
- ))
845
- ) {
846
- ;(held[at] as Set<number>).add(start)
847
- turned.add(at)
848
- }
849
- }
850
- }),
851
- )
852
- if (turned.size === 0) break
853
- await Promise.all([...turned].map((at) => answer(asked, abide, at)))
854
- for (const at of asking) if (!turned.has(at)) suspects[at] = []
855
- }
856
- for (const placed of warned) warnings.push(...placed)
857
- return { answers, warnings }
87
+ return reactiveReads(this.host, sources)
858
88
  }
859
89
 
860
- /**
861
- * What the build refuses and warns on in the app's `.ts` files and its `.abide` `sources`.
862
- *
863
- * 11.37 — `ambient`: every read of a request's ambient inside a `global` value's body, as errors.
864
- * 11.30 — `unkeyed`: every `args` defaulting a member its body's parameter does not, as warnings.
865
- * Both over every `.ts` under `within` and each of `sources` as the shadow `readsFor` registered
866
- * for it, because a keyed value is declared in a `.ts` as often as in a `.abide` — one walk of
867
- * the files for the two, each finding on its own line of the file an author wrote. `scripts`
868
- * as `diagnose` reads it: an editor asking about one page does not walk the app's `.ts`.
869
- */
870
- async lints(
90
+ /** What the build refuses and warns on in the app's `.ts` and `.abide` files (`appLints`). */
91
+ lints(
871
92
  within: string,
872
93
  sources: readonly Omit<ParsedSource, 'quiet'>[],
873
94
  scripts = true,
874
95
  ): Promise<{ ambient: Diagnosis[]; unkeyed: Diagnosis[] }> {
875
- using _held = this.hold()
876
- const ambient: Diagnosis[] = []
877
- const unkeyed: Diagnosis[] = []
878
- const project = await this.currentProject()
879
- if (project === undefined) return { ambient, unkeyed }
880
- const checker = project.checker
881
- const ambientPaths = new Map<string, string>()
882
- const declared = await Promise.all(
883
- AMBIENT_SOURCES.map((source) =>
884
- project.program.getSourceFile(source),
885
- ),
886
- )
887
- for (let at = 0; at < AMBIENT_SOURCES.length; at += 1) {
888
- const found = declared[at]?.path
889
- if (found !== undefined)
890
- ambientPaths.set(AMBIENT_SOURCES[at] as string, found)
891
- }
892
- const authored = new Map<
893
- string,
894
- { source: (typeof sources)[number]; map: ShadowMap }
895
- >()
896
- for (const source of sources) {
897
- const map = new ShadowMap(
898
- this.canonical(source.path),
899
- projectShadow(source.file).origins,
900
- )
901
- authored.set(this.keyOf(map.shadowPath), { source, map })
902
- }
903
- // A file of this session's own is one of `sources`' shadows or not walked at all: a
904
- // checked module is the lowering's, and placed nowhere an author wrote.
905
- const files: string[] = []
906
- if (scripts)
907
- for (const path of this.rootsUnder(project, within)) {
908
- const key = this.keyOf(path)
909
- if (authored.has(key) || !this.shadows.has(key))
910
- files.push(path)
911
- }
912
- else for (const { map } of authored.values()) files.push(map.shadowPath)
913
- await Promise.all(
914
- files.map(async (path) => {
915
- const sourceFile = await project.program.getSourceFile(path)
916
- if (sourceFile === undefined) return
917
- const [reads, defaults] = await Promise.all([
918
- ambientPaths.size === 0
919
- ? []
920
- : ambientReadsIn(checker, sourceFile, ambientPaths),
921
- argsDefaultsIn(checker, sourceFile),
922
- ])
923
- if (reads.length === 0 && defaults.length === 0) return
924
- const shadow = authored.get(this.keyOf(path))
925
- const source = shadow?.source
926
- const text =
927
- source === undefined
928
- ? await Bun.file(path).text()
929
- : source.text
930
- const place = (at: number): number =>
931
- shadow === undefined ? at : shadow.map.toSource(path, at)
932
- const named = source?.path ?? path
933
- const ambientReads: Finding[] = []
934
- for (const read of reads)
935
- ambientReads.push({
936
- at: read.at,
937
- message: `abide: a \`global\` value's body reads \`${read.ambient}\`, which belongs to the request it was built in — every later request would be answered with this one's. Take it out of the body, or drop \`global\`. RULEBOOK 11.37.`,
938
- })
939
- ambient.push(
940
- ...placedFindings(
941
- named,
942
- text,
943
- ambientReads,
944
- place,
945
- 'error',
946
- ),
947
- )
948
- unkeyed.push(
949
- ...placedFindings(named, text, defaults, place, 'warning'),
950
- )
951
- }),
952
- )
953
- ambient.sort(byPlace)
954
- unkeyed.sort(byPlace)
955
- return { ambient, unkeyed }
96
+ return appLints(this.host, within, sources, scripts)
956
97
  }
957
98
 
958
99
  /**
959
- * The checker's diagnostics for each `.abide` source and the app's `.ts` roots.
960
- *
961
- * 38.3 — WHAT THE CHECKER SAYS ABOUT EACH FILE AS IT RUNS, placed on the `.abide` line. Each
962
- * source is checked as its lowered program (`checkedShadow`), which is registered for the life
963
- * of the session beside its shadow for the reason a shadow is; and every `.ts` root of the
964
- * project under the app's root is checked as it stands, where `scripts` asks for them — an editor
965
- * asking about one page does not want the whole app. `sources` carry their text, which is what
966
- * a position is a line of. A `quiet` source is lowered and not reported on: a component an
967
- * editor's page imports, which is there for the props the page is held to (33.4).
968
- *
969
- * `app` is the app the sources are checked in (`appLayout`): its route table, which `url`'s
970
- * literal is held to (30.26), and its handler files, which type `Config` (27.13). A source placed
971
- * as a page or a layout carries its `kind` and `route`, which its `route.params` is typed by (23.9).
972
- *
973
- * `refused` is the path of every source whose checked lowering refused, which `diagnoses` has
974
- * reported at its line — `abide check` emits the rest of the app without them.
100
+ * The checker's diagnostics for each `.abide` source and the app's `.ts` roots, and each
101
+ * source's lowering (`diagnoseSources`).
975
102
  */
976
- async diagnose(
977
- sources: (Omit<ReadSource, 'quiet'> &
978
- Partial<Pick<PlacedSource, 'kind' | 'route'>> & {
979
- quiet?: boolean
980
- })[],
103
+ diagnose(
104
+ sources: readonly DiagnosedSource[],
981
105
  app: Pick<AppLayout, 'root' | 'addresses' | 'handlers' | 'routes'>,
982
106
  scripts = true,
983
- ): Promise<{ diagnoses: Diagnosis[]; refused: Set<string> }> {
984
- using _held = this.hold()
985
- const within = app.root
986
- const created: string[] = []
987
- const changed: string[] = []
988
- const checked: {
989
- source: (typeof sources)[number]
990
- map: ShadowMap
991
- refusals: readonly Finding[]
992
- }[] = []
993
- for (const source of sources) {
994
- const shadow = checkedShadow(
995
- source.file,
996
- source.answer,
997
- (source.kind === 'page' || source.kind === 'layout') &&
998
- source.route !== undefined
999
- ? { route: source.route, kind: source.kind }
1000
- : undefined,
1001
- )
1002
- const map = new ShadowMap(
1003
- this.canonical(source.path),
1004
- projectShadow(source.file).origins,
1005
- shadow.origins,
1006
- )
1007
- this.register(map.checkedPath, shadow.text, created, changed)
1008
- // 23.19 — a package's page an editor opens is reported on, so its checked module is a
1009
- // root of the project as its shadow is (`classify`).
1010
- if (!source.quiet)
1011
- this.openOutside(source.path, map.checkedPath, changed)
1012
- checked.push({ source, map, refusals: shadow.refusals })
1013
- }
1014
- // 34.6 — WHAT THE BUNDLER RESOLVES AND THE CHECKER CANNOT: a stylesheet imported for its
1015
- // effect, and (34.21) markdown imported as text into a `.ts`, which bun's own types leave
1016
- // out where they declare `.txt`. Declared in a file of its own at the app's root, which is
1017
- // a script, because a pattern module can only be declared outside a module.
1018
- this.register(
1019
- `${this.canonical(within)}/${AMBIENT}`,
1020
- AMBIENT_DECLARATIONS,
1021
- created,
1022
- changed,
1023
- )
1024
- // 27.13 — what a handler file defaults, beside it.
1025
- for (const handler of app.handlers) {
1026
- const at = `${this.canonical(handler.path)}${CONFIG_SUFFIX}`
1027
- const shadow = handler.hooks.includes('onConfig')
1028
- ? moduleConfigShadow(handler.text)
1029
- : undefined
1030
- // One that stops filing keeps its place and declares nothing, so the next snapshot is
1031
- // told it moved.
1032
- if (shadow !== undefined || this.shadows.has(this.keyOf(at)))
1033
- this.register(at, shadow ?? 'export {}\n', created, changed)
1034
- }
1035
- // 30.26 — THE APP'S ROUTE TABLE, which `url`'s literal is held to: every page the build
1036
- // routes, declared into abide's own `Routes`. A module rather than a script, since only a
1037
- // module augments one.
1038
- this.register(
1039
- `${this.canonical(within)}/${ROUTES_DECLARATION}`,
1040
- routesDeclaration(app.addresses, app.routes),
1041
- created,
1042
- changed,
1043
- )
1044
- const refusing = new Set<string>()
1045
- for (const { source, refusals } of checked)
1046
- if (refusals.length > 0) refusing.add(source.path)
1047
- const project = await this.currentProject(created, changed)
1048
- if (project === undefined) return { diagnoses: [], refused: refusing }
1049
- const program = project.program
1050
- // `path` is the file the author wrote, which for a checked module is not the file asked.
1051
- const report = async (
1052
- asked: string,
1053
- path: string,
1054
- place: (position: number) => { line: number; column: number },
1055
- ): Promise<Diagnosis[]> => {
1056
- const [syntactic, semantic] = await Promise.all([
1057
- program.getSyntacticDiagnostics(asked),
1058
- program.getSemanticDiagnostics(asked),
1059
- ])
1060
- const said: Diagnosis[] = []
1061
- for (const diagnostic of [...syntactic, ...semantic])
1062
- said.push({
1063
- path,
1064
- ...place(diagnostic.pos),
1065
- code: diagnostic.code,
1066
- category: CATEGORIES[diagnostic.category] ?? 'error',
1067
- // A checked module's own names are the lowering's, and read as the author's.
1068
- message: flatten(diagnostic).replaceAll(
1069
- `${CHECKED_VALUES}.`,
1070
- '',
1071
- ),
1072
- })
1073
- return said
1074
- }
1075
- const reports: Promise<Diagnosis[]>[] = []
1076
- const refused: Diagnosis[] = []
1077
- for (const { source, map, refusals } of checked) {
1078
- this.loweredForInspect.set(this.keyOf(map.abidePath), {
1079
- text: source.text,
1080
- map,
1081
- })
1082
- if (source.quiet) continue
1083
- // A refusal is at a projection offset (`Lowering.refusals`).
1084
- refused.push(
1085
- ...placedFindings(
1086
- source.path,
1087
- source.text,
1088
- refusals,
1089
- (at) => map.toSource(map.shadowPath, at),
1090
- 'error',
1091
- ),
1092
- )
1093
- reports.push(
1094
- report(map.checkedPath, source.path, (position) =>
1095
- lineOf(
1096
- source.text,
1097
- map.toSource(map.checkedPath, position),
1098
- ),
1099
- ),
1100
- )
1101
- }
1102
- const standing = scripts ? this.rootsUnder(project, within) : []
1103
- for (const path of standing)
1104
- if (!this.shadows.has(this.keyOf(path)))
1105
- reports.push(
1106
- Bun.file(path)
1107
- .text()
1108
- .then((text) =>
1109
- report(path, path, (position) =>
1110
- lineOf(text, position),
1111
- ),
1112
- ),
1113
- )
1114
- const reported = [...refused, ...(await Promise.all(reports)).flat()]
1115
- reported.sort(byPlace)
1116
- // A component's props type is checked twice — in its `props` call and in what a tag is held
1117
- // to (`checkedShadow`) — and both copies place a finding in it at the one place written.
1118
- const found: Diagnosis[] = []
1119
- const placed = new Set<string>()
1120
- for (const diagnosis of reported) {
1121
- const key = `${diagnosis.path}:${diagnosis.line}:${diagnosis.column}:${diagnosis.code}:${diagnosis.message}`
1122
- if (placed.has(key)) continue
1123
- placed.add(key)
1124
- found.push(diagnosis)
1125
- }
1126
- return { diagnoses: found, refused: refusing }
107
+ ): Promise<{
108
+ diagnoses: Diagnosis[]
109
+ refused: Set<string>
110
+ lowered: LoweredFile[]
111
+ }> {
112
+ return diagnoseSources(this.host, sources, app, scripts)
1127
113
  }
1128
114
 
1129
- /**
1130
- * Where the author's `offset` in a `.abide` stands in what the checker reads, and the program
1131
- * `diagnose` last lowered it to — or `undefined` where `text` is not what was lowered.
1132
- */
1133
- private askedAt(
1134
- path: string,
1135
- text: string,
1136
- offset: number,
1137
- ):
1138
- | { project: Project; asked: { path: string; position: number } }
1139
- | undefined {
1140
- const lowered = this.loweredForInspect.get(this.keyOf(path))
1141
- const project = this.latest
1142
- if (
1143
- lowered === undefined ||
1144
- lowered.text !== text ||
1145
- project === undefined
1146
- )
1147
- return undefined
1148
- const asked = lowered.map.fromSource(offset)
1149
- if (asked === undefined || !this.shadows.has(this.keyOf(asked.path)))
1150
- return undefined
1151
- return { project, asked }
115
+ /** The lowering a diagnosis last registered for the `.abide` at `path`, whatever its text. */
116
+ lowered(path: string): LoweredFile | undefined {
117
+ return this.host.lowered(path)
1152
118
  }
1153
119
 
1154
- /**
1155
- * The members that may be written at `offset` in a `.abide`, or `undefined` where none can be.
1156
- *
1157
- * 38.98 — ASKED WHERE A HOVER IS (`askedAt`), so `config().` offers the fields 27.13 declared and
1158
- * a name read by name offers its value's members (31.1). MEMBERS ALONE in a script or an
1159
- * expression: at an identifier the API rejects ("completion list needs auto imports"), which
1160
- * the language server answers as nothing offered. Markup is answered apart (38.117).
1161
- */
1162
- async complete(
120
+ /** The members or markup that may be written at `offset` in a `.abide` (`completionAt`). */
121
+ complete(
1163
122
  path: string,
1164
123
  text: string,
1165
124
  offset: number,
1166
125
  triggerCharacter?: string,
1167
126
  ): Promise<CompletionInfo | undefined> {
1168
- using _held = this.hold()
1169
- const template = templateContext(text, offset)
1170
- if (template !== undefined)
1171
- return await this.completeTemplate(path, template)
1172
- const at = this.askedAt(path, text, offset)
1173
- if (at === undefined) return undefined
1174
- return await at.project.checker.getCompletionsAtPosition(
1175
- at.asked.path,
1176
- at.asked.position,
1177
- { triggerCharacter },
1178
- )
127
+ return completionAt(this.lowered(path), text, offset, triggerCharacter)
1179
128
  }
1180
129
 
1181
- /**
1182
- * 38.117 — what markup offers: a block keyword, a component in scope, or a component's props.
1183
- *
1184
- * A PROP IS ASKED OF THE LAST LOWERING, by the component's name: the tag being typed is text the
1185
- * parser refuses, and the import the name resolves through is the same in the lowering before it.
1186
- */
1187
- private async completeTemplate(
1188
- path: string,
1189
- template: TemplateContext,
1190
- ): Promise<CompletionInfo> {
1191
- const entries: CompletionEntry[] = []
1192
- if (template.kind !== 'attribute') {
1193
- const kind =
1194
- template.kind === 'block'
1195
- ? CompletionItemKind.Keyword
1196
- : CompletionItemKind.Class
1197
- for (const name of template.offered) entries.push({ name, kind })
1198
- return { isIncomplete: false, entries }
1199
- }
1200
- const lowered = this.loweredForInspect.get(this.keyOf(path))
1201
- const project = this.latest
1202
- if (lowered === undefined || project === undefined)
1203
- return { isIncomplete: false, entries }
1204
- const checker = project.checker
1205
- const found = await checker.resolveName(
1206
- template.tag,
1207
- SymbolFlags.Value,
1208
- { document: lowered.map.shadowPath, position: 0 },
1209
- )
1210
- const record =
1211
- found === undefined
1212
- ? undefined
1213
- : await propsRecordOf(
1214
- checker,
1215
- await aliasedSymbol(checker, found),
1216
- )
1217
- if (record === undefined) return { isIncomplete: false, entries }
1218
- const unwritten = (await checker.getPropertiesOfType(record)).filter(
1219
- (prop) => !template.written.includes(prop.name),
1220
- )
1221
- for (const entry of await Promise.all(
1222
- unwritten.map(async (prop) => {
1223
- const type = await checker.getTypeOfSymbol(prop)
1224
- return {
1225
- name: prop.name,
1226
- kind: CompletionItemKind.Property,
1227
- detail:
1228
- type === undefined
1229
- ? undefined
1230
- : await checker.typeToString(
1231
- type,
1232
- undefined,
1233
- NodeBuilderFlags.NoTruncation,
1234
- ),
1235
- }
1236
- }),
1237
- ))
1238
- entries.push(entry)
1239
- return { isIncomplete: false, entries }
1240
- }
1241
-
1242
- /**
1243
- * The hover signature, documentation and definitions of the name at `offset` in a `.abide`.
1244
- *
1245
- * 38.39 — WHAT THE NAME AT `offset` IN A `.abide` IS, asked of the program `diagnose` last lowered
1246
- * it to: its type and documentation for a hover, and where it is declared for a jump. The
1247
- * checked module is asked where the position was copied into it, which is what a member hangs
1248
- * off — `regions.join` is `Array`'s — and the projection where the lowering rewrote it, so a
1249
- * name read by name is the `Reactive` it was declared as.
1250
- *
1251
- * `text` IS WHAT THE EDITOR SHOWS, and an offset into it maps through a lowering of the same
1252
- * text or not at all: a file the parser refuses keeps its last lowering, whose offsets have
1253
- * moved under every edit since.
1254
- */
130
+ /** The name at `offset` in a `.abide` as its last lowering holds it (`LoweredFile.inspect`). */
1255
131
  async inspect(
1256
132
  path: string,
1257
133
  text: string,
1258
134
  offset: number,
1259
135
  ): Promise<Inspection | undefined> {
1260
- using _held = this.hold()
1261
- const at = this.askedAt(path, text, offset)
1262
- if (at === undefined) return undefined
1263
- const { project, asked } = at
1264
- const checker = project.checker
1265
- const found = await checker.getSymbolAtPosition(
1266
- asked.path,
1267
- asked.position,
1268
- )
1269
- if (found === undefined) return undefined
1270
- const symbol = await aliasedSymbol(checker, found)
1271
- const declarations: Node[] = []
1272
- for (const handle of symbol.declarations) {
1273
- const node = await handle.resolve(project)
1274
- if (node !== undefined) declarations.push(node)
1275
- }
1276
- // A `.abide` import resolves to its checked module's stand-in, which is the component.
1277
- const component =
1278
- symbol.name === COMPONENT_STAND_IN &&
1279
- declarations[0]?.getSourceFile().fileName.endsWith(CHECKED_SUFFIX)
1280
- const [signature, documentation] = component
1281
- ? [
1282
- `(component) ${found.name}(props: ${await propsTypeOf(checker, symbol)})`,
1283
- '',
1284
- ]
1285
- : await Promise.all([
1286
- describe(project, symbol, declarations[0]),
1287
- symbol.getDocumentationComment(checker),
1288
- ])
1289
- const definitions: Place[] = []
1290
- for (const node of declarations) {
1291
- const named = (node as { name?: Node }).name ?? node
1292
- const file = node.getSourceFile()
1293
- definitions.push(
1294
- this.spanInSource(
1295
- file.fileName,
1296
- file.text,
1297
- named.getStart(file),
1298
- named.end,
1299
- ),
1300
- )
1301
- }
1302
- return {
1303
- signature: signature.replaceAll(`${CHECKED_VALUES}.`, ''),
1304
- documentation,
1305
- definitions,
1306
- }
136
+ return this.lowered(path)?.inspect(text, offset)
1307
137
  }
1308
138
 
1309
- /**
1310
- * Every place the name at `offset` in a `.abide` is written, across the files the checker holds,
1311
- * or `undefined` where nothing is named there.
1312
- *
1313
- * 38.116 — ASKED OF EVERY PLACE THE CHECKER READS THE OFFSET AT (`ShadowMap.placesFrom`), because
1314
- * the checked module rewrites a name read by name into another symbol's read: the projection is
1315
- * where a reactive's markup uses are the declaration's references. A place is kept only where
1316
- * both layers copied it from the author's text and that text is the name, so scaffolding a
1317
- * lowering wrote is never a place to rename.
1318
- *
1319
- * AND ASKED AGAIN WHERE A REFERENCE IS TEXT THE CHECKED MODULE COPIES TWICE. A component's props
1320
- * type is a second copy of its `props` call (`checkedShadow`), and a tag's keys are typed by
1321
- * that copy: the checker answers them as another symbol's references, so a reference landing
1322
- * on one copy is asked at every place the author's text is read.
1323
- */
139
+ /** Every place the name at `offset` in a `.abide` is written (`LoweredFile.references`). */
1324
140
  async references(
1325
141
  path: string,
1326
142
  text: string,
1327
143
  offset: number,
1328
144
  ): Promise<References | undefined> {
1329
- using _held = this.hold()
1330
- const lowered = this.loweredForInspect.get(this.keyOf(path))
1331
- const project = this.latest
1332
- if (
1333
- lowered === undefined ||
1334
- lowered.text !== text ||
1335
- project === undefined
1336
- )
1337
- return undefined
1338
- const asked = lowered.map.placesFrom(offset)
1339
- if (asked.length === 0) return undefined
1340
- const askedAt = new Set<string>()
1341
- for (const place of asked)
1342
- askedAt.add(`${place.path}:${place.position}`)
1343
- let name: Place | undefined
1344
- let spelled = ''
1345
- let renamable = true
1346
- const seen = new Set<string>()
1347
- const occurrences: Occurrence[] = []
1348
- for (let index = 0; index < asked.length; index += 1) {
1349
- const { path: file, position } = asked[index] as {
1350
- path: string
1351
- position: number
1352
- }
1353
- const sourceFile = await project.program.getSourceFile(file)
1354
- if (sourceFile === undefined) continue
1355
- const node = getTouchingPropertyName(sourceFile, position)
1356
- if (node.kind !== SyntaxKind.Identifier) continue
1357
- const written = node.getText(sourceFile)
1358
- if (name === undefined) {
1359
- const copied = this.copiedSpan(node, written)
1360
- name = copied === undefined ? undefined : placeOfSpan(copied)
1361
- spelled = written
1362
- }
1363
- const entries = await project.checker.getReferencedSymbolsForNode(
1364
- node,
1365
- position,
1366
- )
1367
- for (const entry of entries) {
1368
- const definition = await entry.definition.resolve(project)
1369
- if (definition === undefined) continue
1370
- const declaredIn = definition.getSourceFile().fileName
1371
- if (!this.authored(declaredIn)) renamable = false
1372
- const declared = (definition as { name?: Node }).name
1373
- const references = await Promise.all(
1374
- entry.references.map((handle) => handle.resolve(project)),
1375
- )
1376
- for (const reference of references) {
1377
- if (reference === undefined) continue
1378
- const copied = this.copiedSpan(reference, written)
1379
- if (copied === undefined) continue
1380
- const declaration =
1381
- declared !== undefined &&
1382
- declared.pos === reference.pos &&
1383
- declaredIn === reference.getSourceFile().fileName
1384
- // Text the checker reads at more than one place is asked at every one.
1385
- const copies = copied.map?.placesFrom(copied.start) ?? []
1386
- if (copies.length > 2)
1387
- for (const place of copies) {
1388
- const at = `${place.path}:${place.position}`
1389
- if (askedAt.has(at)) continue
1390
- askedAt.add(at)
1391
- asked.push(place)
1392
- }
1393
- const placed = placeOfSpan(copied)
1394
- const key = `${placed.path}:${placed.start.line}:${placed.start.column}`
1395
- if (seen.has(key)) continue
1396
- seen.add(key)
1397
- const holder = reference.parent
1398
- const declaresHolder =
1399
- definition.pos === holder.pos &&
1400
- declaredIn === reference.getSourceFile().fileName
1401
- const shorthand =
1402
- holder.kind ===
1403
- SyntaxKind.ShorthandPropertyAssignment ||
1404
- (holder.kind === SyntaxKind.BindingElement &&
1405
- (holder as { propertyName?: Node }).propertyName ===
1406
- undefined)
1407
- occurrences.push({
1408
- ...placed,
1409
- declaration,
1410
- // A shorthand names a property and a binding at once, and renaming one
1411
- // writes the other out beside it: the binding element declares the local
1412
- // and the shorthand assignment the property.
1413
- shorthand: !shorthand
1414
- ? undefined
1415
- : declaresHolder ===
1416
- (holder.kind === SyntaxKind.BindingElement)
1417
- ? 'local'
1418
- : 'property',
1419
- })
1420
- }
1421
- }
1422
- }
1423
- if (name === undefined) return undefined
1424
- return { name, written: spelled, renamable, occurrences }
145
+ return this.lowered(path)?.references(text, offset)
1425
146
  }
1426
147
 
1427
- /**
1428
- * Whether a file the checker read is one an author wrote and can edit: neither a library's
1429
- * declaration, nor a dependency's, nor scaffolding this session serves that stands for no `.abide`.
1430
- */
1431
- private authored(fileName: string): boolean {
1432
- if (fileName.endsWith('.d.ts') || fileName.includes('/node_modules/'))
1433
- return false
1434
- return (
1435
- ShadowMap.fileFor(this.canonical(fileName)) !== undefined ||
1436
- !this.shadows.has(this.keyOf(fileName))
1437
- )
148
+ /** Closes the checker, and ends its process. */
149
+ close(): Promise<void> {
150
+ return this.host.close()
1438
151
  }
1439
-
1440
- /**
1441
- * Where a node the checker read stands in the file the author wrote, with that file's map where
1442
- * it is a `.abide`, or `undefined` where the author did not write `name` there: in scaffolding,
1443
- * or in a file this session serves.
1444
- */
1445
- private copiedSpan(node: Node, name: string): CopiedSpan | undefined {
1446
- const file = node.getSourceFile()
1447
- let start = node.getStart(file)
1448
- let end = node.end
1449
- // A string literal names a property by its text, inside the quotes.
1450
- if (node.kind === SyntaxKind.StringLiteral) {
1451
- start += 1
1452
- end -= 1
1453
- }
1454
- const canonical = this.canonical(file.fileName)
1455
- const abidePath = ShadowMap.fileFor(canonical)
1456
- if (abidePath === undefined) {
1457
- if (!this.authored(file.fileName)) return undefined
1458
- if (file.text.slice(start, end) !== name) return undefined
1459
- return {
1460
- path: file.fileName,
1461
- text: file.text,
1462
- start,
1463
- end,
1464
- map: undefined,
1465
- }
1466
- }
1467
- const lowered = this.loweredForInspect.get(this.keyOf(abidePath))
1468
- const span = lowered?.map.copiedToSource(canonical, start, end)
1469
- if (
1470
- lowered === undefined ||
1471
- span === undefined ||
1472
- lowered.text.slice(span.start, span.end) !== name
1473
- )
1474
- return undefined
1475
- return {
1476
- path: abidePath,
1477
- text: lowered.text,
1478
- start: span.start,
1479
- end: span.end,
1480
- map: lowered.map,
1481
- }
1482
- }
1483
-
1484
- /**
1485
- * A span the checker read, placed in the file the author wrote.
1486
- *
1487
- * A span of a file the checker read, as the file the author wrote: a lowered `.abide` mapped
1488
- * back through both of its maps, and one this session has not lowered — or a span of the
1489
- * scaffolding a lowering adds, which the author never wrote — placed at its top.
1490
- */
1491
- private spanInSource(
1492
- fileName: string,
1493
- text: string,
1494
- start: number,
1495
- end: number,
1496
- ): Place {
1497
- const canonical = this.canonical(fileName)
1498
- const abidePath = ShadowMap.fileFor(canonical)
1499
- if (abidePath === undefined)
1500
- return {
1501
- path: fileName,
1502
- start: lineOf(text, start),
1503
- end: lineOf(text, end),
1504
- }
1505
- const lowered = this.loweredForInspect.get(this.keyOf(abidePath))
1506
- const placed = lowered?.map.spanToSource(canonical, start, end)
1507
- if (lowered === undefined || placed === undefined) {
1508
- const top = { line: 1, column: 1 }
1509
- return { path: abidePath, start: top, end: top }
1510
- }
1511
- return {
1512
- path: abidePath,
1513
- start: lineOf(lowered.text, placed.start),
1514
- end: lineOf(lowered.text, placed.end),
1515
- }
1516
- }
1517
-
1518
- /**
1519
- * Closes the checker, and ends its process.
1520
- *
1521
- * KILLED, NOT LEFT TO END ITSELF. The API's own close only ends the process's stdin, and a
1522
- * `tsgo` that does not read the end of it lives on with this process's stderr in hand — one
1523
- * outlived its build by eight hours, and anything reading that build's stderr to its end
1524
- * (`abide build 2>&1 | tee`, a test's pipe) waits as long. The API holds the process on a
1525
- * private field and forgets it as it closes, so it is taken first; `typeSession.test.ts` gates
1526
- * that the field is still where this reads it.
1527
- *
1528
- * THE CLIENT IS CLOSED, NOT THE API: the API's close first asks the process to drop each
1529
- * snapshot, and one that has stopped answering never settles it. The client's asks nothing —
1530
- * it ends the connection, which rejects what was in flight — and the snapshots die with the
1531
- * process.
1532
- */
1533
- async close(): Promise<void> {
1534
- const client = (
1535
- this.api as unknown as {
1536
- client: {
1537
- process?: { kill(): boolean }
1538
- close(): Promise<void>
1539
- }
1540
- }
1541
- ).client
1542
- const child = client.process
1543
- // A release still being written when the connection ends is a write after end, thrown
1544
- // where nothing catches it. Bounded, for the same process that has stopped answering.
1545
- if (this.releasing.size > 0)
1546
- await Promise.race([
1547
- Promise.allSettled(this.releasing),
1548
- Bun.sleep(RELEASE_GRACE_MS),
1549
- ])
1550
- await client.close()
1551
- child?.kill()
1552
- }
1553
- }
1554
-
1555
- const AMBIENT = 'abide-check.d.ts'
1556
-
1557
- // How long `close` waits on releases in flight before ending the connection under them.
1558
- const RELEASE_GRACE_MS = 1000
1559
-
1560
- // What the checker asks for when it resolves an import of `x.abide` (`standIn`).
1561
- const DECLARATION_SUFFIX = '.abide.d.ts'
1562
- // Beside the app's tsconfig, so `extends` and every path in it resolve as they would from there.
1563
- const SESSION_CONFIG = 'tsconfig.abide-session.json'
1564
- const EXPORTED_DECLARATION_SUFFIX = '.d.abide.ts'
1565
-
1566
- function isFile(path: string): boolean {
1567
- try {
1568
- return statSync(path).isFile()
1569
- } catch {
1570
- return false
1571
- }
1572
- }
1573
- const ROUTES_DECLARATION = 'abide-routes.d.ts'
1574
- const URL_SOURCE = Bun.fileURLToPath(new URL('../shared/url', import.meta.url))
1575
-
1576
- /**
1577
- * 30.26 — each of `addresses` as a key of abide's `Routes`, and 23.9, 30.3 — a page route's params
1578
- * narrowed where its matcher is an alternation of literals, which is the one matcher a type can say.
1579
- */
1580
- function routesDeclaration(
1581
- addresses: readonly string[],
1582
- routes: readonly AppRoute[],
1583
- ): string {
1584
- const narrowed = new Map<string, string>()
1585
- for (const route of routes) {
1586
- let members = ''
1587
- for (const segment of parseRoutePattern(route.pattern)) {
1588
- if (segment.kind !== 'required' && segment.kind !== 'optional')
1589
- continue
1590
- const literals = literalsOf(
1591
- segment.matcher === undefined
1592
- ? undefined
1593
- : route.matchers[segment.matcher],
1594
- )
1595
- if (literals === undefined) continue
1596
- members += ` ${JSON.stringify(segment.name)}${segment.kind === 'optional' ? '?' : ''}: ${literals};`
1597
- }
1598
- if (members !== '') narrowed.set(route.pattern, `{${members} }`)
1599
- }
1600
- let keys = ''
1601
- for (const route of addresses)
1602
- keys += ` ${JSON.stringify(route)}: ${narrowed.get(route) ?? 'true'}\n`
1603
- return `export {}\ndeclare module ${JSON.stringify(URL_SOURCE)} {\n interface Routes {\n${keys} }\n}\n`
1604
- }
1605
-
1606
- /** The union of the literals an anchored matcher source alternates between, or `undefined`. */
1607
- function literalsOf(source: string | undefined): string | undefined {
1608
- const body = source?.match(/^\^\(\?!\\\.\\\.\?\$\)\(\?:(.*)\)\$$/)?.[1]
1609
- if (body === undefined || !/^[\w~-]+(?:\|[\w~-]+)*$/.test(body))
1610
- return undefined
1611
- return body
1612
- .split('|')
1613
- .map((literal) => JSON.stringify(literal))
1614
- .join(' | ')
1615
- }
1616
- const AMBIENT_DECLARATIONS = `declare module '*.css' {}
1617
- declare module '*.md' {
1618
- const text: string
1619
- export default text
1620
152
  }
1621
- `
1622
-
1623
- /**
1624
- * Where something is in a file the author wrote, line and column from 1.
1625
- *
1626
- * 38.39.
1627
- */
1628
- export type Place = {
1629
- path: string
1630
- start: { line: number; column: number }
1631
- end: { line: number; column: number }
1632
- }
1633
-
1634
- /**
1635
- * The name under a cursor: how it is declared, what its doc comment says, and where.
1636
- *
1637
- * 38.39.
1638
- */
1639
- export type Inspection = {
1640
- signature: string
1641
- documentation: string
1642
- definitions: Place[]
1643
- }
1644
-
1645
- /**
1646
- * The name under a cursor and every place it is written.
1647
- *
1648
- * 38.116 — `name` is where the cursor's own name stands; `renamable` is false where any symbol it
1649
- * names is declared somewhere an author does not write.
1650
- */
1651
- export type References = {
1652
- name: Place
1653
- /** The name as it is written. */
1654
- written: string
1655
- renamable: boolean
1656
- occurrences: Occurrence[]
1657
- }
1658
-
1659
- /**
1660
- * One place a name is written: whether it is where the name is declared, and where it is a
1661
- * shorthand, which side of it a rename renames — the `local` binding or the `property`.
1662
- */
1663
- export type Occurrence = Place & {
1664
- declaration: boolean
1665
- shorthand: 'local' | 'property' | undefined
1666
- }
1667
-
1668
- /** A span of a file the author wrote, and the map of the `.abide` it is in, where it is in one. */
1669
- type CopiedSpan = Span & {
1670
- path: string
1671
- text: string
1672
- map: ShadowMap | undefined
1673
- }
1674
-
1675
- /** A `CopiedSpan` as lines and columns. */
1676
- function placeOfSpan(span: CopiedSpan): Place {
1677
- return {
1678
- path: span.path,
1679
- start: lineOf(span.text, span.start),
1680
- end: lineOf(span.text, span.end),
1681
- }
1682
- }
1683
-
1684
- /**
1685
- * The props type a component's tag is held to, printed.
1686
- *
1687
- * 33.4 — the record its stand-in is called with.
1688
- */
1689
- async function propsTypeOf(
1690
- checker: Checker,
1691
- component: CheckedSymbol,
1692
- ): Promise<string> {
1693
- const record = await propsRecordOf(checker, component)
1694
- return record === undefined
1695
- ? 'any'
1696
- : checker.typeToString(record, undefined, NodeBuilderFlags.NoTruncation)
1697
- }
1698
-
1699
- /** The record a component is called with — the props its tag is held to (33.4). */
1700
- async function propsRecordOf(
1701
- checker: Checker,
1702
- component: CheckedSymbol,
1703
- ): Promise<Type | undefined> {
1704
- const type = await checker.getTypeOfSymbol(component)
1705
- const call =
1706
- type === undefined
1707
- ? undefined
1708
- : (await checker.getSignaturesOfType(type, SignatureKind.Call))[0]
1709
- return call === undefined ? undefined : checker.getParameterType(call, 0)
1710
- }
1711
-
1712
- /**
1713
- * How a hover spells a symbol, as tsserver does: the keyword it was declared with, its name, and its
1714
- * type — a function's first signature, with how many more it has.
1715
- */
1716
- async function describe(
1717
- project: Project,
1718
- symbol: CheckedSymbol,
1719
- declaration: Node | undefined,
1720
- ): Promise<string> {
1721
- const checker = project.checker
1722
- const flags = symbol.flags
1723
- const name = symbol.name
1724
- if (flags & SymbolFlags.Interface) return `interface ${name}`
1725
- if (flags & SymbolFlags.Class) return `class ${name}`
1726
- if (flags & SymbolFlags.TypeAlias)
1727
- return `type ${name} = ${await checker.typeToString(
1728
- await checker.getDeclaredTypeOfSymbol(symbol),
1729
- undefined,
1730
- NodeBuilderFlags.NoTruncation,
1731
- )}`
1732
- const type = await checker.getTypeOfSymbol(symbol)
1733
- if (type === undefined) return name
1734
- if (flags & (SymbolFlags.Function | SymbolFlags.Method)) {
1735
- const signatures = await checker.getSignaturesOfType(
1736
- type,
1737
- SignatureKind.Call,
1738
- )
1739
- const first = signatures[0]
1740
- const declared =
1741
- first === undefined
1742
- ? undefined
1743
- : await checker.signatureToSignatureDeclaration(
1744
- first,
1745
- SyntaxKind.MethodSignature,
1746
- undefined,
1747
- NodeBuilderFlags.NoTruncation,
1748
- )
1749
- if (declared !== undefined) {
1750
- const printed = (await project.emitter.printNode(declared))
1751
- .trim()
1752
- .replace(/;$/, '')
1753
- const more = signatures.length - 1
1754
- return `${flags & SymbolFlags.Function ? 'function' : '(method)'} ${name}${printed}${
1755
- more > 0 ? ` (+${more} overload${more === 1 ? '' : 's'})` : ''
1756
- }`
1757
- }
1758
- }
1759
- const spelled = await checker.typeToString(
1760
- type,
1761
- undefined,
1762
- NodeBuilderFlags.NoTruncation,
1763
- )
1764
- let keyword = '(property)'
1765
- if (flags & SymbolFlags.Function) keyword = 'function'
1766
- else if (flags & SymbolFlags.Method) keyword = '(method)'
1767
- else if (declaration?.kind === SyntaxKind.Parameter) keyword = '(parameter)'
1768
- else if (flags & SymbolFlags.Variable) {
1769
- const list = declaration?.parent.flags ?? 0
1770
- keyword =
1771
- list & NodeFlags.Const
1772
- ? 'const'
1773
- : list & NodeFlags.Let
1774
- ? 'let'
1775
- : 'var'
1776
- }
1777
- return `${keyword} ${name}: ${spelled}`
1778
- }
1779
-
1780
- /** Indexed by the API's `DiagnosticCategory`. */
1781
- const CATEGORIES = ['warning', 'error', 'suggestion', 'message'] as const
1782
-
1783
- function flatten(diagnostic: {
1784
- text: string
1785
- messageChain?:
1786
- readonly { text: string; messageChain?: unknown }[] | undefined
1787
- }): string {
1788
- let message = diagnostic.text
1789
- const chain = diagnostic.messageChain ?? []
1790
- for (const link of chain)
1791
- message += `\n ${flatten(link as Parameters<typeof flatten>[0]).replaceAll('\n', '\n ')}`
1792
- return message
1793
- }
1794
-
1795
- /** What a module that is no `.abide` is checked beside as, for the `Config` it files (27.13). */
1796
- const CONFIG_SUFFIX = '.config-shadow.ts'