@abide/abide 0.100.0 → 0.102.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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/bin/abide.ts +227 -163
  3. package/docs/REGISTRY.md +726 -0
  4. package/docs/RULEBOOK.md +2753 -0
  5. package/docs/abide.md +12878 -0
  6. package/package.json +10 -3
  7. package/scaffold/AGENTS.md +31 -0
  8. package/scaffold/CLAUDE.md +1 -0
  9. package/scaffold/src/layout.abide +18 -0
  10. package/src/compiler/Lowering.ts +78 -9
  11. package/src/compiler/ShadowMap.ts +2 -2
  12. package/src/compiler/TypeSession.ts +262 -67
  13. package/src/compiler/argsSchemaOf.ts +1 -1
  14. package/src/compiler/checkApp.ts +43 -15
  15. package/src/compiler/checkedShadow.ts +39 -5
  16. package/src/compiler/classifyRead.ts +6 -4
  17. package/src/compiler/classifyTemplate.ts +1 -1
  18. package/src/compiler/compileApp.ts +433 -193
  19. package/src/compiler/compiledEntry.ts +43 -39
  20. package/src/compiler/componentGraph.ts +58 -71
  21. package/src/compiler/declaredExports.ts +24 -7
  22. package/src/compiler/emitClient.ts +141 -71
  23. package/src/compiler/emitComponent.ts +37 -8
  24. package/src/compiler/emitSource.ts +106 -13
  25. package/src/compiler/handlersUnder.ts +106 -56
  26. package/src/compiler/index.ts +7 -0
  27. package/src/compiler/mountsOf.ts +138 -0
  28. package/src/compiler/nearest.ts +14 -0
  29. package/src/compiler/parseAbide.ts +157 -20
  30. package/src/compiler/projectShadow.ts +213 -7
  31. package/src/compiler/reactiveTypes.ts +20 -7
  32. package/src/compiler/readSources.ts +15 -2
  33. package/src/compiler/readsIn.ts +61 -7
  34. package/src/compiler/renderMarkup.ts +3 -3
  35. package/src/compiler/scaffold.ts +30 -11
  36. package/src/compiler/serveLanguage.ts +125 -34
  37. package/src/compiler/sourceMapOf.ts +193 -0
  38. package/src/compiler/splitImports.ts +205 -83
  39. package/src/compiler/syncTypes.ts +32 -0
  40. package/src/compiler/tokenize.ts +14 -0
  41. package/src/compiler/writeDefaultProject.ts +1 -1
  42. package/src/compiler/writeTypes.ts +82 -0
  43. package/src/server/CLIENT_BUILD.ts +11 -13
  44. package/src/server/DEFAULT_PORT.ts +1 -1
  45. package/src/server/LAUNCHER_FILES.ts +9 -0
  46. package/src/server/MODULE_QUERIES.ts +18 -0
  47. package/src/server/TABLES.ts +7 -0
  48. package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
  49. package/src/server/abideModules.ts +391 -0
  50. package/src/server/answerFailure.ts +16 -6
  51. package/src/server/answerRequest.ts +235 -106
  52. package/src/server/answerRpc.ts +124 -39
  53. package/src/server/answerSocket.ts +141 -48
  54. package/src/server/answerValue.ts +22 -9
  55. package/src/server/appDirectories.ts +1 -1
  56. package/src/server/appPlugins.ts +1 -1
  57. package/src/server/appTree.ts +179 -0
  58. package/src/server/argsFor.ts +2 -2
  59. package/src/server/assistant.ts +2 -2
  60. package/src/server/bridgeRelay.ts +6 -8
  61. package/src/server/claudeCode.ts +0 -6
  62. package/src/server/clientBuildOptions.ts +8 -3
  63. package/src/server/coerceArgs.ts +1 -1
  64. package/src/server/commands.ts +37 -37
  65. package/src/server/componentModules.d.ts +3 -1
  66. package/src/server/compressAnswer.ts +48 -14
  67. package/src/server/config.ts +25 -29
  68. package/src/server/configFields.ts +31 -25
  69. package/src/server/createApp.ts +59 -73
  70. package/src/server/devWorker.ts +144 -183
  71. package/src/server/executableName.ts +4 -0
  72. package/src/server/fenceServer.ts +37 -35
  73. package/src/server/generatedSurfaces.ts +99 -66
  74. package/src/server/hooks.ts +124 -52
  75. package/src/server/iconAccent.ts +191 -0
  76. package/src/server/index.ts +11 -7
  77. package/src/server/installSourceMaps.ts +88 -0
  78. package/src/server/launcherOptions.ts +34 -31
  79. package/src/server/launcherPage.ts +107 -64
  80. package/src/server/manifestOf.ts +16 -13
  81. package/src/server/namesUnder.ts +2 -2
  82. package/src/server/nativeMenu.ts +5 -5
  83. package/src/server/negotiateEncoding.ts +1 -1
  84. package/src/server/principal.ts +10 -5
  85. package/src/server/realOrAsGiven.ts +10 -0
  86. package/src/server/refuseAddressCollision.ts +21 -14
  87. package/src/server/remote.ts +49 -52
  88. package/src/server/render.ts +65 -94
  89. package/src/server/renderComponent.ts +5 -0
  90. package/src/server/reportHealth.ts +2 -2
  91. package/src/server/requestScope.ts +12 -1
  92. package/src/server/runBuild.ts +24 -0
  93. package/src/server/runCompiled.ts +5 -5
  94. package/src/server/runDesktop.ts +52 -27
  95. package/src/server/serveBuild.ts +43 -52
  96. package/src/server/serveDev.ts +81 -39
  97. package/src/server/shell.ts +7 -22
  98. package/src/server/sourceMappings.ts +139 -0
  99. package/src/server/surfaceOf.ts +18 -3
  100. package/src/server/testBuild.ts +1 -19
  101. package/src/server/withAbideSources.ts +117 -0
  102. package/src/shared/ADDRESSES.ts +7 -18
  103. package/src/shared/APP_META.ts +7 -0
  104. package/src/shared/CopyOptions.ts +1 -1
  105. package/src/shared/DEBUG_META.ts +1 -1
  106. package/src/shared/GLOBAL_GETS.ts +9 -3
  107. package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
  108. package/src/shared/RPC_SERVER.ts +2 -2
  109. package/src/shared/cookies.ts +2 -2
  110. package/src/shared/csp.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/filledPath.ts +85 -0
  113. package/src/shared/framing.ts +79 -6
  114. package/src/shared/headKeyOf.ts +2 -0
  115. package/src/shared/index.ts +1 -1
  116. package/src/shared/log.ts +27 -13
  117. package/src/shared/matchRoute.ts +23 -4
  118. package/src/shared/menuItem.ts +3 -3
  119. package/src/shared/reactive/Cursor.ts +3 -9
  120. package/src/shared/reactive/Keyed.ts +4 -3
  121. package/src/shared/reactive/NotLanded.ts +1 -1
  122. package/src/shared/reactive/Reactive.ts +81 -60
  123. package/src/shared/reactive/argsContract.ts +4 -4
  124. package/src/shared/reactive/canonicalKey.ts +4 -4
  125. package/src/shared/reactive/chunksOfStream.ts +6 -0
  126. package/src/shared/reactive/index.ts +1 -1
  127. package/src/shared/reactive/recordWrites.ts +103 -5
  128. package/src/shared/reactive/structural.ts +1 -1
  129. package/src/shared/reactive/tracking.ts +155 -21
  130. package/src/shared/reactive/watch.ts +10 -6
  131. package/src/shared/refuse.ts +1 -1
  132. package/src/shared/routePattern.ts +82 -46
  133. package/src/shared/rpc.ts +47 -13
  134. package/src/shared/rpcRequest.ts +27 -3
  135. package/src/shared/url.ts +4 -44
  136. package/src/shared/writePath.ts +1 -1
  137. package/src/ui/LAYER_NAMES.ts +1 -1
  138. package/src/ui/List.ts +3 -3
  139. package/src/ui/MOUNTING_LEVEL.ts +18 -0
  140. package/src/ui/Mismatch.ts +1 -1
  141. package/src/ui/RawRun.ts +1 -1
  142. package/src/ui/Router.ts +38 -31
  143. package/src/ui/Scope.ts +1 -1
  144. package/src/ui/TryRegion.ts +2 -2
  145. package/src/ui/applyHead.ts +33 -0
  146. package/src/ui/index.ts +1 -1
  147. package/src/ui/menuBridge.ts +1 -1
  148. package/src/ui/moveNode.ts +1 -1
  149. package/src/ui/reconcile.ts +2 -2
  150. package/src/ui/reportMenu.ts +2 -2
  151. package/src/ui/routing.ts +2 -2
  152. package/src/ui/setAttribute.ts +1 -1
  153. package/src/ui/setText.ts +1 -1
  154. package/src/ui/socketWire.ts +9 -7
  155. package/src/ui/wire.ts +1 -1
  156. package/scaffold/src/server/app.ts +0 -6
  157. package/scaffold/src/shared/.gitkeep +0 -0
  158. package/scaffold/src/ui/app.html +0 -8
  159. package/src/server/componentImports.ts +0 -56
  160. /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
@@ -76,8 +76,8 @@ async function everyPresentMember(
76
76
  return present
77
77
  }
78
78
 
79
- // Where abide declares `Reactive`, `props`, `route` and `Selection`, found in the project the way
80
- // `METHODS_SOURCE` is.
79
+ // Where abide declares `Reactive`, `props`, `route`, `Selection` and `onConfig`, found in the project
80
+ // the way `METHODS_SOURCE` is.
81
81
  const REACTIVE_SOURCE = Bun.fileURLToPath(
82
82
  new URL('../shared/reactive/Reactive.ts', import.meta.url),
83
83
  )
@@ -90,16 +90,20 @@ const ROUTE_SOURCE = Bun.fileURLToPath(
90
90
  const SELECTION_SOURCE = Bun.fileURLToPath(
91
91
  new URL('../shared/reactive/index.ts', import.meta.url),
92
92
  )
93
+ const HOOKS_SOURCE = Bun.fileURLToPath(
94
+ new URL('../server/hooks.ts', import.meta.url),
95
+ )
93
96
 
94
97
  /**
95
- * Abide's own `Reactive`, `Selection`, `props` and `route` declarations, as this project's checker
96
- * names them.
98
+ * Abide's own `Reactive`, `Selection`, `props`, `route` and `onConfig` declarations, as this
99
+ * project's checker names them.
97
100
  *
98
101
  * 31.3 — A `Reactive` IS TOLD BY ITS DECLARATION, never by how it prints. Every instantiation of
99
102
  * abide's `Reactive` carries the symbol of the one type literal it is declared as, whatever alias
100
103
  * or re-export the app reached it through — so `type Counter = Reactive<number>` is one, and an
101
104
  * app's own type that happens to be spelled `Reactive` is not. `undefined` where the project never
102
- * reaches abide, and then nothing in it is one. `props` is abide's file as the checker names it.
105
+ * reaches abide, and then nothing in it is one. `props` and `onConfig` are abide's files as the
106
+ * checker names them.
103
107
  */
104
108
  export type Abide = {
105
109
  /** 23.9 — abide's `route`, as the file the checker names it. */
@@ -110,14 +114,17 @@ export type Abide = {
110
114
  */
111
115
  selection: CheckedSymbol | undefined
112
116
  props: string | undefined
117
+ /** 27.13 — abide's `onConfig`, as the file the checker names it. */
118
+ onConfig: string | undefined
113
119
  }
114
120
 
115
121
  export async function abideIn(project: Project): Promise<Abide> {
116
- const [declares, props, selects, route] = await Promise.all([
122
+ const [declares, props, selects, route, hooks] = await Promise.all([
117
123
  project.program.getSourceFile(REACTIVE_SOURCE),
118
124
  project.program.getSourceFile(PROPS_SOURCE),
119
125
  project.program.getSourceFile(SELECTION_SOURCE),
120
126
  project.program.getSourceFile(ROUTE_SOURCE),
127
+ project.program.getSourceFile(HOOKS_SOURCE),
121
128
  ])
122
129
  let selection: CheckedSymbol | undefined
123
130
  for (const statement of selects?.statements ?? [])
@@ -142,7 +149,13 @@ export async function abideIn(project: Project): Promise<Abide> {
142
149
  reactive = await literal?.getSymbol()
143
150
  break
144
151
  }
145
- return { route: route?.path, reactive, selection, props: props?.path }
152
+ return {
153
+ route: route?.path,
154
+ reactive,
155
+ selection,
156
+ props: props?.path,
157
+ onConfig: hooks?.path,
158
+ }
146
159
  }
147
160
 
148
161
  /**
@@ -13,6 +13,8 @@ export type ParsedSource = {
13
13
  text: string
14
14
  file: AbideFile
15
15
  quiet: boolean
16
+ /** 23.19 — where a mounted package's file stands in the app; `undefined` for one at its own path. */
17
+ position: string | undefined
16
18
  }
17
19
 
18
20
  /** One `.abide` as the emitters and the checked lowering take it: parsed, and answered. */
@@ -25,7 +27,12 @@ export type ReadSource = ParsedSource & { answer: CheckerAnswer }
25
27
  * parsed around it — a quiet one's is nobody's. Without it the refusal is thrown, as a build stops.
26
28
  */
27
29
  export function parseSources(
28
- sources: readonly { path: string; text: string; quiet?: boolean }[],
30
+ sources: readonly {
31
+ path: string
32
+ text: string
33
+ quiet?: boolean
34
+ position?: string
35
+ }[],
29
36
  refused?: Diagnosis[],
30
37
  ): ParsedSource[] {
31
38
  const parsed: ParsedSource[] = []
@@ -39,7 +46,13 @@ export function parseSources(
39
46
  if (!quiet) refused.push(refusal(source.path, thrown))
40
47
  continue
41
48
  }
42
- parsed.push({ path: source.path, text: source.text, file, quiet })
49
+ parsed.push({
50
+ path: source.path,
51
+ text: source.text,
52
+ file,
53
+ quiet,
54
+ position: source.position,
55
+ })
43
56
  }
44
57
  return parsed
45
58
  }
@@ -8,6 +8,7 @@ import {
8
8
  isCallExpression,
9
9
  isClassDeclaration,
10
10
  isEnumDeclaration,
11
+ isExpressionStatement,
11
12
  isFunctionDeclaration,
12
13
  isFunctionExpression,
13
14
  isIdentifier,
@@ -66,8 +67,9 @@ import { trackingWarnings } from './trackingWarnings.ts'
66
67
  export type ShadowSpans = {
67
68
  /** Where the shadow's scripts end and its template begins. */
68
69
  scriptsEnd: number
69
- /** The `<script module>` bodies, and the `<script>` bodies, in the shadow. */
70
+ /** The `<script module>`, `<script server>` and `<script>` bodies, in the shadow. */
70
71
  modules: Span
72
+ servers: Span
71
73
  scripts: Span
72
74
  positions: PositionTable
73
75
  }
@@ -123,7 +125,8 @@ export async function readsIn(
123
125
  sourceFile: SourceFile,
124
126
  shadowPath: string,
125
127
  spans: ShadowSpans,
126
- abidePath: string,
128
+ // The file below the app's root, which its route is read off (23.14).
129
+ routedPath: string,
127
130
  file: AbideFile,
128
131
  ): Promise<{ answer: CheckerAnswer; warnings: Finding[] }> {
129
132
  // A destructuring pattern binds names this does not see, and that is 31.12's case.
@@ -185,7 +188,14 @@ export async function readsIn(
185
188
  // Awaited after the round trips below, so a 33.14 refusal would reject with nothing
186
189
  // listening and be reported unhandled; `await props` still throws it.
187
190
  props.catch(() => {})
188
- const params = routeReads(checker, abide, sourceFile, abidePath, reads)
191
+ const params = routeReads(checker, abide, sourceFile, routedPath, reads)
192
+ const configs = configReads(
193
+ checker,
194
+ abide,
195
+ sourceFile,
196
+ spans.servers,
197
+ reads,
198
+ )
189
199
  // No name is no round trip to ask about none.
190
200
  const symbols =
191
201
  names.length === 0 ? [] : await checker.getSymbolAtLocation(names)
@@ -249,6 +259,7 @@ export async function readsIn(
249
259
  await Promise.all(holding)
250
260
  await props
251
261
  await params
262
+ await configs
252
263
  const statics = new Set<number>()
253
264
  const expressions = spans.positions.nodesIn(
254
265
  sourceFile,
@@ -683,7 +694,7 @@ const ROUTE_MEMBERS = new Set(['url', 'params', 'name', 'navigating'])
683
694
  * a name, and this read is spelled as a member.
684
695
  *
685
696
  * 23.9 — AND `route.params` IN A PAGE OR A LAYOUT IS WHAT THAT FILE'S OWN ROUTE PROMISES. The file
686
- * is the route: `ui/pages/media/[id]/page.abide` only ever renders where `id` matched, so its
697
+ * is the route: `media/[id]/page.abide` only ever renders where `id` matched, so its
687
698
  * `route.params` has an `id`, and a layout's has its own segments and whatever a page below adds.
688
699
  * Only the checker reads that — the running module reads the one `route` every page shares.
689
700
  */
@@ -691,11 +702,11 @@ async function routeReads(
691
702
  checker: Checker,
692
703
  abide: Abide,
693
704
  sourceFile: Node,
694
- abidePath: string,
705
+ routedPath: string,
695
706
  reads: Read[],
696
707
  ): Promise<void> {
697
708
  if (abide.route === undefined) return
698
- const routed = fileKind(abidePath)
709
+ const routed = fileKind(routedPath)
699
710
  const kind = routed === 'page' || routed === 'layout' ? routed : undefined
700
711
  const accesses: Node[] = []
701
712
  const owners: Node[] = []
@@ -714,7 +725,7 @@ async function routeReads(
714
725
  sourceFile.forEachChild(visit)
715
726
  if (owners.length === 0) return
716
727
  const symbols = await checker.getSymbolAtLocation(owners)
717
- const pattern = routeOf(abidePath)
728
+ const pattern = routeOf(routedPath)
718
729
  for (let at = 0; at < accesses.length; at += 1) {
719
730
  const symbol = symbols[at]
720
731
  if (symbol === undefined) continue
@@ -777,6 +788,49 @@ function refuseDeclared(
777
788
  )
778
789
  }
779
790
 
791
+ /**
792
+ * 27.13 — each `onConfig(…)` standing as a statement at the top of a `<script server>`, as a read of
793
+ * its callee, pushed onto `reads`.
794
+ *
795
+ * The callee says which are abide's, as `props`'s does. A call anywhere else — inside a function,
796
+ * or handing its unfiling back to a name — is filed all the same, and declares nothing.
797
+ */
798
+ async function configReads(
799
+ checker: Checker,
800
+ abide: Abide,
801
+ sourceFile: Node & { statements: readonly Node[] },
802
+ servers: Span,
803
+ reads: Read[],
804
+ ): Promise<void> {
805
+ if (abide.onConfig === undefined) return
806
+ const callees: Node[] = []
807
+ for (const statement of sourceFile.statements) {
808
+ const start = statement.getStart()
809
+ if (start < servers.start) continue
810
+ if (start >= servers.end) break
811
+ if (
812
+ isExpressionStatement(statement) &&
813
+ isCallExpression(statement.expression) &&
814
+ isIdentifier(statement.expression.expression)
815
+ )
816
+ callees.push(statement.expression.expression)
817
+ }
818
+ if (callees.length === 0) return
819
+ const named = await checker.getSymbolAtLocation(callees)
820
+ for (let at = 0; at < callees.length; at += 1) {
821
+ const symbol = named[at]
822
+ if (symbol === undefined) continue
823
+ const callee = await aliasedSymbol(checker, symbol)
824
+ if (
825
+ callee.name !== 'onConfig' ||
826
+ callee.valueDeclaration?.path !== abide.onConfig
827
+ )
828
+ continue
829
+ const node = callees[at] as Node
830
+ reads.push(newRead(node.getStart(), node.end, 'config'))
831
+ }
832
+ }
833
+
780
834
  /**
781
835
  * The reads a `props<Props>()` destructuring contributes, pushed onto `reads`.
782
836
  *
@@ -108,7 +108,7 @@ export const HEADS = '__heads'
108
108
  * EVERY MARKUP POSITION THAT EVALUATES AN AUTHOR EXPRESSION, rather than the two that were found
109
109
  * first. A GUARD HERE IS HALF A MECHANISM — it trades a throw for a HOLE, and something on the
110
110
  * client has to fill that hole once the value lands: a text slot has `runHere` and `textHere`, a
111
- * `{#for}` has `List.hydrate`'s count check, an attribute has its own binding re-running on the
111
+ * `{#for}` has `List.hydrate`'s count check, an attribute has its own binding rerunning on the
112
112
  * value that landed (32.3), and the `{#if}` chain has `isEmptyRegion` — which is why that one is
113
113
  * guarded at the REGION rather than per subject, and says so at its own arm. The ESCAPER STAYS
114
114
  * OUTSIDE: absence is `undefined` here and what it means is already decided where it lands (see
@@ -296,7 +296,7 @@ function renderPosition(
296
296
  `abide: a ${at.kind} position holds a ${node.kind} node.`,
297
297
  )
298
298
 
299
- // 32.17 — A CAPITALISED TAG IS AN INVOCATION, and the capital is the whole of the
299
+ // 32.17 — A CAPITALIZED TAG IS AN INVOCATION, and the capital is the whole of the
300
300
  // distinction. It is emitted as a CALL, so the child's own module renders it.
301
301
  if (isComponentTag(node.tag)) return invocationMarkup(node, at, lowering)
302
302
 
@@ -536,7 +536,7 @@ function serverComponent(
536
536
  // standing side by side are two instances, each creating what no ancestor holds (10.6).
537
537
  const { opened, closed } = shareScope(lowering, '')
538
538
  return `function ${component.name}(${PROPS} = {}) {
539
- ${opened}${head}${setup}${definitions(sink, lowering)}return { head: '', body: ${renderMarkup(sink, lowering, SERVER_LANE)} }${closed}
539
+ ${opened}${head}${setup}${definitions(sink, lowering)}return { head: '', body: ${renderMarkup(sink, lowering, SERVER_LANE)}, document: undefined }${closed}
540
540
  }`
541
541
  }
542
542
 
@@ -1,5 +1,6 @@
1
1
  // 38.1 — `abide scaffold <name>`: THE LAYOUT EVERY ABIDE APP HAS, written into `<name>`. There is
2
- // nothing to choose, because what is scaffolded is the three seams and the files the commands read.
2
+ // nothing to choose, because what is scaffolded is a route, the shell and the hooks — the files the
3
+ // commands read.
3
4
  // A file that is already there is left as it is and reported skipped, so a scaffold run over a
4
5
  // half-made project finishes it rather than overwriting it.
5
6
 
@@ -28,20 +29,26 @@ function filesFor(name: string): Record<string, string> {
28
29
  name,
29
30
  private: true,
30
31
  type: 'module',
32
+ // 34.24 — `prepare` runs on `bun install`, so a `.ts` importing a `.abide` is typed
33
+ // before any other abide command has run; and never fails it, as it would where
34
+ // `package.json` is installed before `src` is there to sync (38.26).
31
35
  scripts: {
36
+ prepare: 'abide sync || true',
37
+ // 38.103 — the checker over `.abide`, which `tsc` does not read.
38
+ check: 'abide check',
32
39
  dev: 'abide dev',
33
40
  build: 'abide build',
34
41
  start: 'abide start',
35
42
  },
36
- // TWO TARGETS A SEAM, because an extensionless specifier is how the seams are
37
- // written and only bun probes for the extension: TypeScript takes an `imports`
38
- // target as the exact file, so `#server/database` alone left every name it
39
- // imports `any` and the checker could not type one of them (31.3). The first
40
- // target is what bun resolves, and the one a `.ts` or `.abide` specifier needs.
43
+ // ONE ALIAS FOR THE SOURCE, and two targets for it, because an extensionless
44
+ // specifier is how an import is written and only bun probes for the extension:
45
+ // TypeScript takes an `imports` target as the exact file, so `#src/media/server/db`
46
+ // alone left every name it imports `any` and the checker could not type one of
47
+ // them (31.3). The first target is what bun resolves, and the one a `.ts` or
48
+ // `.abide` specifier needs. Which side a module is on is its directory's to say
49
+ // (36.10), not its alias's.
41
50
  imports: {
42
- '#ui/*': ['./src/ui/*', './src/ui/*.ts'],
43
- '#server/*': ['./src/server/*', './src/server/*.ts'],
44
- '#shared/*': ['./src/shared/*', './src/shared/*.ts'],
51
+ '#src/*': ['./src/*', './src/*.ts'],
45
52
  },
46
53
  dependencies: { '@abide/abide': ABIDE_DEPENDENCY },
47
54
  // `Bun`, `bun:sqlite` and the rest of the runtime a server module reaches for are
@@ -53,8 +60,21 @@ function filesFor(name: string): Record<string, string> {
53
60
  )}\n`,
54
61
  'tsconfig.json': `${JSON.stringify(
55
62
  // TypeScript 7 includes no `@types` package it is not named, so the runtime's is named.
63
+ // 34.24 — `rootDirs` and the `#src/*` path reach what abide writes to `.abide/types`,
64
+ // where a `.ts` importing a `.abide` finds the names it exports.
56
65
  {
57
- compilerOptions: { ...COMPILER_OPTIONS, types: ['bun'] },
66
+ compilerOptions: {
67
+ ...COMPILER_OPTIONS,
68
+ types: ['bun'],
69
+ rootDirs: ['.', '.abide/types'],
70
+ paths: {
71
+ '#src/*': [
72
+ './src/*',
73
+ './src/*.ts',
74
+ './.abide/types/src/*',
75
+ ],
76
+ },
77
+ },
58
78
  include: ['src'],
59
79
  },
60
80
  null,
@@ -81,7 +101,6 @@ export async function scaffold(
81
101
  const written: string[] = []
82
102
  const skipped: string[] = []
83
103
  const files: [string, string | Blob][] = Object.entries(filesFor(name))
84
- // The seam both sides import from is there from the first commit, `.gitkeep` and all.
85
104
  for (const path of new Bun.Glob('**/*').scanSync({
86
105
  cwd: STARTER,
87
106
  dot: true,
@@ -1,10 +1,15 @@
1
- import { type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher of its own
2
- import { basename, dirname } from 'node:path' // bun exposes no path helpers of its own
1
+ import { existsSync, type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher or sync existence check of its own
2
+ import { dirname } from 'node:path' // bun exposes no path helpers of its own
3
+ import { realOrAsGiven } from '#server/realOrAsGiven.ts'
3
4
  import { checkSource } from './checkApp.ts'
4
5
  import { type AppChecker, openChecker } from './compileApp.ts'
6
+ import { nearest } from './nearest.ts'
7
+ import { parseAbide } from './parseAbide.ts'
8
+ import { syncTypes } from './syncTypes.ts'
5
9
  import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
10
+ import { writeTypes } from './writeTypes.ts'
6
11
 
7
- // 38.12 — `abide lsp`: THE `.abide` LANGUAGE SERVER, OVER STDIO. It is `abide check` (38.3) asked
12
+ // 38.11 — `abide lsp`: THE `.abide` LANGUAGE SERVER, OVER STDIO. It is `abide check` (38.3) asked
8
13
  // about one open document at a time, so the squiggle an editor draws and the line CI fails on are
9
14
  // one finding rather than two implementations that agree most of the time.
10
15
  //
@@ -15,10 +20,14 @@ import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
15
20
  // same text.
16
21
  //
17
22
  // THE APP IS THE DOCUMENT'S. An editor starts this at the root of whatever it has open, which in a
18
- // monorepo is not an app at all, so each document is checked against the directory above the
19
- // OUTERMOST `ui/` it sits in (`appRootOf`) — with one checker per app, kept for the session.
23
+ // monorepo is not an app at all, so each document is checked against the root of the NEAREST
24
+ // package above it (`appRootOf`) — with one checker per app, kept for the session.
20
25
  // `--root` pins every document to one.
21
26
  //
27
+ // THE EDITOR'S SPELLING STOPS HERE. An app opened through a link (`/tmp` is one on macOS) is
28
+ // checked at its real path, the one bun resolves every import to (`openChecker`), so a path is
29
+ // made real on its way in (`placed`) and spelled under the link again on its way out (`spelled`).
30
+ //
22
31
  // ONE CHECK AT A TIME. The checker holds one snapshot, and two checks interleaving would each
23
32
  // register the other's text for a moment; a burst of keystrokes collapses onto the last one.
24
33
 
@@ -52,8 +61,33 @@ export async function serveLanguage(
52
61
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
53
62
  const checkers = new Map<string, AppChecker>()
54
63
  const watchers = new Map<string, FSWatcher>()
64
+ // Each app's real root, to the root the editor reached it by.
65
+ const links = new Map<string, string>()
55
66
  let queue: Promise<void> = Promise.resolve()
56
67
 
68
+ // The document at `uri`, in its app's real spelling; `undefined` where no app holds it.
69
+ const placed = (uri: string): { app: string; path: string } | undefined => {
70
+ const path = Bun.fileURLToPath(uri)
71
+ const given = root ?? appRootOf(path)
72
+ if (given === undefined) return undefined
73
+ const app = realOrAsGiven(given)
74
+ if (app !== given) links.set(app, given)
75
+ return {
76
+ app,
77
+ path: path.startsWith(`${given}/`)
78
+ ? `${app}${path.slice(given.length)}`
79
+ : path,
80
+ }
81
+ }
82
+
83
+ // `path`, real, under the root the editor reached its app by.
84
+ const spelled = (path: string): string => {
85
+ for (const [app, given] of links)
86
+ if (path.startsWith(`${app}/`))
87
+ return `${given}${path.slice(app.length)}`
88
+ return path
89
+ }
90
+
57
91
  const send = (message: object): void => {
58
92
  const body = ENCODER.encode(
59
93
  JSON.stringify({ jsonrpc: '2.0', ...message }),
@@ -118,8 +152,7 @@ export async function serveLanguage(
118
152
  )
119
153
  // What an open page says may be decided by the file that moved.
120
154
  for (const uri of documents.keys())
121
- if ((root ?? appRootOf(Bun.fileURLToPath(uri))) === app)
122
- check(uri)
155
+ if (placed(uri)?.app === app) check(uri)
123
156
  }),
124
157
  )
125
158
  return checker
@@ -129,18 +162,18 @@ export async function serveLanguage(
129
162
  const document = documents.get(uri)
130
163
  if (document === undefined) return
131
164
  const version = document.version
132
- const path = Bun.fileURLToPath(uri)
133
- const app = root ?? appRootOf(path)
134
165
  // A `.abide` belonging to no app has nothing to be checked against.
135
- if (app === undefined) return
166
+ const at = placed(uri)
167
+ if (at === undefined) return
168
+ const { app, path } = at
136
169
  let found: Diagnosis[]
137
170
  try {
138
171
  found = await checkSource(
139
172
  await checkerOf(app),
140
- app,
141
173
  path,
142
174
  document.text,
143
- (held) => documents.get(Bun.pathToFileURL(held).href)?.text,
175
+ (held) =>
176
+ documents.get(Bun.pathToFileURL(spelled(held)).href)?.text,
144
177
  )
145
178
  } catch (thrown) {
146
179
  // The checker's own failure, not the page's: the next check opens another.
@@ -164,6 +197,27 @@ export async function serveLanguage(
164
197
  // A newer text has arrived while this one was checked, and will be published.
165
198
  if (documents.get(uri)?.version !== version) return
166
199
  publish(uri, found, document.text)
200
+ await typesFor(app, path, document.text)
201
+ }
202
+
203
+ // 34.24 — THE TYPES A PLAIN `.ts` READS AN OPEN FILE BY, as the editor holds it: the whole app's
204
+ // the first time an app is seen, and the one file as it is edited after that.
205
+ const synced = new Set<string>()
206
+ const typesFor = async (
207
+ app: string,
208
+ path: string,
209
+ text: string,
210
+ ): Promise<void> => {
211
+ try {
212
+ if (synced.has(app))
213
+ await writeTypes(app, [{ path, file: parseAbide(text) }], false)
214
+ else {
215
+ synced.add(app)
216
+ await syncTypes(app, new Map([[path, text]]))
217
+ }
218
+ } catch {
219
+ // A file that does not parse keeps the types it last had; its check already says why.
220
+ }
167
221
  }
168
222
 
169
223
  const check = (uri: string): void => {
@@ -179,7 +233,20 @@ export async function serveLanguage(
179
233
 
180
234
  // A question about a document is about the text the editor shows, so a check still settling
181
235
  // runs now rather than after the answer; and it is answered in the queue, after that check.
182
- const inspect = (params: unknown): Promise<Inspection | undefined> => {
236
+ const inspect = (params: unknown): Promise<Inspection | undefined> =>
237
+ ask(params, (session, path, text, offset) =>
238
+ session.inspect(path, text, offset),
239
+ )
240
+
241
+ const ask = <Answer>(
242
+ params: unknown,
243
+ question: (
244
+ session: AppChecker['session'],
245
+ path: string,
246
+ text: string,
247
+ offset: number,
248
+ ) => Promise<Answer | undefined>,
249
+ ): Promise<Answer | undefined> => {
183
250
  const { textDocument, position } = params as {
184
251
  textDocument: { uri: string }
185
252
  position: { line: number; character: number }
@@ -192,12 +259,11 @@ export async function serveLanguage(
192
259
  }
193
260
  const answered = queue.then(async () => {
194
261
  const document = documents.get(uri)
195
- if (document === undefined) return undefined
196
- const path = Bun.fileURLToPath(uri)
197
- const app = root ?? appRootOf(path)
198
- if (app === undefined) return undefined
199
- return (await checkerOf(app)).session.inspect(
200
- path,
262
+ const at = placed(uri)
263
+ if (document === undefined || at === undefined) return undefined
264
+ return question(
265
+ (await checkerOf(at.app)).session,
266
+ at.path,
201
267
  document.text,
202
268
  offsetOf(document.text, position),
203
269
  )
@@ -220,6 +286,7 @@ export async function serveLanguage(
220
286
  textDocumentSync: 1,
221
287
  hoverProvider: true,
222
288
  definitionProvider: true,
289
+ completionProvider: { triggerCharacters: ['.'] },
223
290
  },
224
291
  serverInfo: { name: 'abide' },
225
292
  },
@@ -279,11 +346,39 @@ export async function serveLanguage(
279
346
  })
280
347
  return true
281
348
  }
349
+ // 38.98 — the protocol's `CompletionItemKind` is the checker's own numbering.
350
+ case 'textDocument/completion': {
351
+ const trigger = (
352
+ params?.context as { triggerCharacter?: string } | undefined
353
+ )?.triggerCharacter
354
+ const found = await ask(params, (session, path, text, offset) =>
355
+ session.complete(path, text, offset, trigger),
356
+ )
357
+ const items: object[] = []
358
+ for (const entry of found?.entries ?? [])
359
+ items.push({
360
+ label: entry.name,
361
+ kind: entry.kind,
362
+ sortText: entry.sortText,
363
+ filterText: entry.filterText,
364
+ insertText: entry.insertText,
365
+ detail: entry.detail,
366
+ labelDetails: entry.labelDetails,
367
+ })
368
+ send({
369
+ id: message.id,
370
+ result:
371
+ found === undefined
372
+ ? null
373
+ : { isIncomplete: found.isIncomplete, items },
374
+ })
375
+ return true
376
+ }
282
377
  case 'textDocument/definition': {
283
378
  const found = await inspect(params)
284
379
  const locations: object[] = []
285
380
  for (const place of found?.definitions ?? [])
286
- locations.push(locationOf(place))
381
+ locations.push(locationOf(spelled(place.path), place))
287
382
  send({ id: message.id, result: locations })
288
383
  return true
289
384
  }
@@ -348,20 +443,16 @@ async function closeAll(
348
443
  }
349
444
 
350
445
  /**
351
- * The app a file at `path` belongs to: the directory above its outermost `ui/`.
446
+ * The app a file at `path` belongs to: the root 38.25 finds from the nearest package above it.
352
447
  *
353
- * OUTERMOST, so a component folder that happens to be called `ui` inside an app is not taken for
354
- * the app. `undefined` where the file is under no `ui/` at all.
448
+ * That package's `src` where it has one, and the package itself where it does not — the same root
449
+ * `abide dev` run in that package would find. `undefined` where no package lies above the file.
355
450
  */
356
451
  function appRootOf(path: string): string | undefined {
357
- let found: string | undefined
358
- let at = dirname(path)
359
- for (;;) {
360
- if (basename(at) === 'ui') found = dirname(at)
361
- const up = dirname(at)
362
- if (up === at) return found
363
- at = up
364
- }
452
+ const manifest = nearest(dirname(path), 'package.json')
453
+ if (manifest === undefined) return undefined
454
+ const at = dirname(manifest)
455
+ return existsSync(`${at}/src`) ? `${at}/src` : at
365
456
  }
366
457
 
367
458
  /**
@@ -390,10 +481,10 @@ function offsetOf(
390
481
  return at + position.character
391
482
  }
392
483
 
393
- /** A `Place`, lines and columns from 1, as the protocol's `Location`, from 0. */
394
- function locationOf(place: Place): object {
484
+ /** A `Place` at `path`, lines and columns from 1, as the protocol's `Location`, from 0. */
485
+ function locationOf(path: string, place: Place): object {
395
486
  return {
396
- uri: Bun.pathToFileURL(place.path).href,
487
+ uri: Bun.pathToFileURL(path).href,
397
488
  range: {
398
489
  start: {
399
490
  line: place.start.line - 1,