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