@abide/abide 0.118.0 → 0.120.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/CHANGELOG.md +17 -0
  2. package/bin/abide.ts +74 -11
  3. package/docs/REGISTRY.md +5 -4
  4. package/docs/RULEBOOK.md +35 -0
  5. package/docs/abide.md +363 -127
  6. package/package.json +1 -1
  7. package/scaffold/src/getGreeting.test.ts +1 -0
  8. package/scaffold/src/index.abide +4 -1
  9. package/scaffold/src/index.test.ts +2 -0
  10. package/scaffold/src/layout.abide +6 -6
  11. package/src/compiler/Lowering.ts +20 -2
  12. package/src/compiler/PositionTable.ts +1 -0
  13. package/src/compiler/Refusals.ts +1 -0
  14. package/src/compiler/TypeSession.ts +11 -8
  15. package/src/compiler/aliasedSymbol.ts +1 -1
  16. package/src/compiler/ambientReadsIn.ts +1 -0
  17. package/src/compiler/appLayout.ts +4 -2
  18. package/src/compiler/argsDefaultsIn.ts +1 -0
  19. package/src/compiler/argsSchemaOf.ts +1 -0
  20. package/src/compiler/checkApp.ts +3 -1
  21. package/src/compiler/checkedShadow.ts +2 -1
  22. package/src/compiler/classifyRead.ts +1 -0
  23. package/src/compiler/classifyTemplate.ts +1 -0
  24. package/src/compiler/compileApp.ts +4 -2
  25. package/src/compiler/compiledEntry.ts +1 -0
  26. package/src/compiler/componentGraph.ts +1 -0
  27. package/src/compiler/declaredExports.ts +4 -2
  28. package/src/compiler/emitClient.ts +19 -14
  29. package/src/compiler/emitComponent.ts +1 -0
  30. package/src/compiler/emitSource.ts +2 -1
  31. package/src/compiler/formatAbide.ts +857 -0
  32. package/src/compiler/formatDiagnosis.ts +1 -0
  33. package/src/compiler/handlersUnder.ts +2 -1
  34. package/src/compiler/hookCalls.ts +1 -0
  35. package/src/compiler/lineOf.ts +17 -0
  36. package/src/compiler/matchersOf.ts +13 -3
  37. package/src/compiler/mountsOf.ts +2 -0
  38. package/src/compiler/paramFile.ts +1 -0
  39. package/src/compiler/parseAbide.ts +48 -28
  40. package/src/compiler/projectShadow.ts +10 -3
  41. package/src/compiler/reactiveOptions.ts +1 -0
  42. package/src/compiler/reactiveTypes.ts +2 -1
  43. package/src/compiler/readsIn.ts +3 -2
  44. package/src/compiler/renderMarkup.ts +3 -2
  45. package/src/compiler/resolverIn.ts +2 -0
  46. package/src/compiler/sameTemplate.ts +150 -0
  47. package/src/compiler/scaffold.ts +4 -11
  48. package/src/compiler/serveLanguage.ts +46 -19
  49. package/src/compiler/sharedModuleWrites.ts +2 -1
  50. package/src/compiler/sourceMapOf.ts +1 -0
  51. package/src/compiler/splitImports.ts +2 -0
  52. package/src/compiler/templateWarnings.ts +2 -2
  53. package/src/compiler/trackingWarnings.ts +1 -0
  54. package/src/compiler/writeDefaultProject.ts +5 -1
  55. package/src/server/BRIDGE_SCHEME.ts +3 -0
  56. package/src/server/BRIDGE_VERSION.ts +1 -1
  57. package/src/server/abideModules.ts +2 -0
  58. package/src/server/answerFailure.ts +1 -0
  59. package/src/server/answerRequest.ts +15 -14
  60. package/src/server/answerRpc.ts +3 -2
  61. package/src/server/answerSocket.ts +2 -1
  62. package/src/server/answerValue.ts +1 -0
  63. package/src/server/answerWorker.ts +1 -0
  64. package/src/server/appTree.ts +3 -1
  65. package/src/server/argsFor.ts +1 -0
  66. package/src/server/assistant.ts +2 -1
  67. package/src/server/banner.ts +2 -0
  68. package/src/server/bridgeRelay.ts +1 -0
  69. package/src/server/callerOf.ts +1 -0
  70. package/src/server/claudeApi.ts +1 -0
  71. package/src/server/claudeCode.ts +30 -14
  72. package/src/server/clientBundle.ts +1 -0
  73. package/src/server/coerceArgs.ts +1 -2
  74. package/src/server/commands.ts +9 -0
  75. package/src/server/compressAnswer.ts +2 -1
  76. package/src/server/config.ts +2 -0
  77. package/src/server/createApp.ts +4 -2
  78. package/src/server/crossingOrigin.ts +1 -0
  79. package/src/server/csp.ts +1 -0
  80. package/src/server/deferredBuilds.ts +1 -0
  81. package/src/server/derivedArgs.ts +1 -0
  82. package/src/server/devWorker.ts +2 -1
  83. package/src/server/faceOf.ts +2 -7
  84. package/src/server/generatedSurfaces.ts +5 -1
  85. package/src/server/hooks.ts +1 -0
  86. package/src/server/iconAccent.ts +1 -1
  87. package/src/server/installServer.ts +1 -0
  88. package/src/server/installSourceMaps.ts +1 -0
  89. package/src/server/launcherPage.ts +2 -2
  90. package/src/server/manifestOf.ts +1 -0
  91. package/src/server/muxUpgrade.ts +1 -0
  92. package/src/server/nativeMenu.ts +2 -0
  93. package/src/server/principal.ts +2 -1
  94. package/src/server/remote.ts +1 -0
  95. package/src/server/render.ts +3 -3
  96. package/src/server/renderComponent.ts +1 -0
  97. package/src/server/reportHealth.ts +1 -0
  98. package/src/server/requestScope.ts +3 -1
  99. package/src/server/runDesktop.ts +3 -2
  100. package/src/server/seedBuffer.ts +1 -0
  101. package/src/server/serveDev.ts +3 -2
  102. package/src/server/shell.ts +2 -0
  103. package/src/server/streamEvent.ts +1 -0
  104. package/src/server/surfaceOf.ts +1 -0
  105. package/src/server/testBuild.ts +1 -0
  106. package/src/server/testMount.ts +1 -0
  107. package/src/server/trace.ts +2 -0
  108. package/src/server/withAbideSources.ts +1 -0
  109. package/src/shared/Attributes.ts +12 -8
  110. package/src/shared/cookies.ts +1 -1
  111. package/src/shared/escapeText.ts +1 -1
  112. package/src/shared/index.ts +1 -5
  113. package/src/shared/jsonSchema.ts +1 -3
  114. package/src/shared/menuBridge.ts +1 -2
  115. package/src/shared/outbox.ts +1 -1
  116. package/src/shared/reactive/Keyed.ts +2 -2
  117. package/src/shared/reactive/Reactive.ts +12 -16
  118. package/src/shared/reactive/index.ts +3 -4
  119. package/src/shared/reactive/isReactive.ts +1 -1
  120. package/src/shared/reactive/scoped.ts +2 -2
  121. package/src/shared/reactive/share.ts +1 -1
  122. package/src/shared/reactive/watch.ts +4 -4
  123. package/src/shared/refuse.ts +1 -1
  124. package/src/shared/rpc.ts +8 -11
  125. package/src/shared/url.ts +1 -1
  126. package/src/shared/validateJson.ts +1 -1
  127. package/src/ui/ArmRegion.ts +1 -0
  128. package/src/ui/Level.ts +1 -0
  129. package/src/ui/List.ts +1 -0
  130. package/src/ui/RawRun.ts +1 -0
  131. package/src/ui/Router.ts +1 -0
  132. package/src/ui/TryRegion.ts +1 -0
  133. package/src/ui/adoptWalk.ts +2 -1
  134. package/src/ui/bindElement.ts +1 -0
  135. package/src/ui/bindFiles.ts +1 -0
  136. package/src/ui/bindGroup.ts +1 -0
  137. package/src/ui/bindProperty.ts +1 -0
  138. package/src/ui/bindSpread.ts +1 -0
  139. package/src/ui/index.ts +1 -6
  140. package/src/ui/outboxQueue.ts +1 -1
  141. package/src/ui/parseMarkup.ts +2 -1
  142. package/src/ui/reconcile.ts +2 -1
  143. package/src/ui/reconcileHead.ts +1 -0
  144. package/src/ui/regions.ts +2 -1
  145. package/src/ui/registerWorker.ts +1 -0
  146. package/src/ui/rows.ts +1 -0
  147. package/src/ui/setMarkup.ts +1 -0
  148. package/src/ui/setText.ts +1 -0
  149. package/src/ui/socketWire.ts +4 -3
  150. package/src/ui/wire.ts +2 -1
@@ -0,0 +1,857 @@
1
+ import { dirname } from 'node:path' // bun exposes no path helpers of its own
2
+
3
+ import { SyntaxKind } from 'typescript/unstable/ast'
4
+
5
+ import { diagnosisAt, offsetOf } from './lineOf.ts'
6
+ import { nearest } from './nearest.ts'
7
+ import {
8
+ type AbideFile,
9
+ closeTagAt,
10
+ ELSE_IF,
11
+ forSeparators,
12
+ parseAbide,
13
+ } from './parseAbide.ts'
14
+ import { sameTemplate } from './sameTemplate.ts'
15
+ import { isSpace, slotEnd, tokenize } from './tokenize.ts'
16
+ import type { Diagnosis } from './TypeSession.ts'
17
+ import { VOID_ELEMENTS } from './VOID_ELEMENTS.ts'
18
+
19
+ // 38.125, 38.126 — A `.abide` IS FORMATTED AS PIECES, and `bun fmt` formats every piece that is code: a
20
+ // script block as TypeScript, a style as CSS, and every expression in one batch of calls. What is
21
+ // left is the markup, which only abide can read, and abide only re-indents it. The scan here is
22
+ // LOSSLESS where `parseAbide`'s is not — it keeps the comments and the whitespace the parser drops
23
+ // — and it is held to the parser's reading by parsing the result and comparing the template it
24
+ // reads to the one it was (`sameTemplate`).
25
+
26
+ /** A run of the source as the emitter takes it. */
27
+ type Piece =
28
+ /** Characters written as they stand. */
29
+ | { kind: 'text'; text: string }
30
+ /**
31
+ * A whitespace run holding a newline, re-indented where it lands. `alone` where the run is a
32
+ * whole text node of the template, which 38.127 holds to the kind it was.
33
+ */
34
+ | {
35
+ kind: 'break'
36
+ text: string
37
+ place: 'markup' | 'tag' | 'tagEnd'
38
+ alone: boolean
39
+ }
40
+ /** An element or a block opening, and its close — a block's next arm is both. */
41
+ | { kind: 'open' }
42
+ | { kind: 'close' }
43
+ /**
44
+ * An expression `bun fmt` formats. `raw` is what the source holds, written back where the
45
+ * formatted text is refused; `fit` is where it stands — anywhere, inside an unquoted value, or
46
+ * inside a value quoted by that quote.
47
+ */
48
+ | {
49
+ kind: 'expression'
50
+ text: string
51
+ raw: string
52
+ at: number
53
+ fit: Fit
54
+ }
55
+ /** A `<script>` block's or a `<style>`'s body, and the close tag after it. */
56
+ | {
57
+ kind: 'code'
58
+ text: string
59
+ at: number
60
+ language: 'ts' | 'css'
61
+ close: string
62
+ }
63
+
64
+ type Fit = 'any' | 'unquoted' | '"' | "'"
65
+ type Expression = Extract<Piece, { kind: 'expression' }>
66
+
67
+ /** What one file formats to, and what kept part of it as written. */
68
+ export type Formatted = { text: string; findings: Diagnosis[] }
69
+
70
+ /** What `bun fmt` printed for a piece, or why it refused — `line` and `column` within the piece. */
71
+ type Answer =
72
+ | { text: string; error: undefined }
73
+ | {
74
+ text: undefined
75
+ error: { message: string; line: number; column: number }
76
+ }
77
+
78
+ /** Where an expression's call starts in the batch `bun fmt` is handed: `__abide_3(`. */
79
+ const ENTRY = /^__abide_(\d+|probe)\(/
80
+ const PROBE = '__abide_probe(() => {\nx\n})\n'
81
+ /** The elements whose body is not markup: code `bun fmt` formats, or text 38.127 keeps as written. */
82
+ const KEPT_BODY = new Set(['script', 'style', 'pre', 'textarea'])
83
+
84
+ /**
85
+ * `text`, the `.abide` at `path`, formatted — or as written, with the reason, where the parser
86
+ * refuses it or the result would read as a different template. 38.127, 38.128.
87
+ */
88
+ export async function formatAbide(
89
+ path: string,
90
+ text: string,
91
+ ): Promise<Formatted> {
92
+ if (await ignoredByConfig(path)) return { text, findings: [] }
93
+ const keptAsWritten = (message: string): Formatted => ({
94
+ text,
95
+ findings: [
96
+ diagnosisAt(
97
+ path,
98
+ undefined,
99
+ 0,
100
+ 'error',
101
+ `abide format: ${message}`,
102
+ ),
103
+ ],
104
+ })
105
+ let file: AbideFile
106
+ try {
107
+ file = parseAbide(text)
108
+ } catch (thrown) {
109
+ return keptAsWritten((thrown as Error).message)
110
+ }
111
+ // A `<script>` is TypeScript where the parser took it for a block rather than for markup.
112
+ const { origins } = file
113
+ const codeBlocks = new Set([
114
+ ...origins.scripts,
115
+ ...origins.modules,
116
+ ...origins.servers,
117
+ ...origins.setups,
118
+ ])
119
+ const pieces = piecesOf(text, codeBlocks)
120
+ const expressions: Expression[] = []
121
+ const codes: Extract<Piece, { kind: 'code' }>[] = []
122
+ for (const piece of pieces)
123
+ if (piece.kind === 'expression') expressions.push(piece)
124
+ else if (piece.kind === 'code') codes.push(piece)
125
+ const [batch, ...formattedCodes] = await Promise.all([
126
+ formatExpressions(path, expressions),
127
+ ...codes.map((code) =>
128
+ runFormatter(`${path}.${code.language}`, code.text),
129
+ ),
130
+ ])
131
+ const answered = [...expressions, ...codes]
132
+ const answers = [...batch.answers, ...formattedCodes]
133
+ const formatted = new Map<Piece, string>()
134
+ const findings: Diagnosis[] = []
135
+ for (const [index, piece] of answered.entries()) {
136
+ const answer = answers[index] as Answer
137
+ if (answer.text !== undefined) formatted.set(piece, answer.text)
138
+ else
139
+ findings.push(
140
+ diagnosisAt(
141
+ path,
142
+ text,
143
+ piece.at +
144
+ offsetOf(text.slice(piece.at), {
145
+ line: answer.error.line - 1,
146
+ character: answer.error.column - 1,
147
+ }),
148
+ 'error',
149
+ `bun fmt: ${answer.error.message}`,
150
+ ),
151
+ )
152
+ }
153
+ const result = emit(pieces, formatted, batch.unit)
154
+ if (result === text) return { text, findings }
155
+ let reread: AbideFile
156
+ try {
157
+ reread = parseAbide(result)
158
+ } catch (thrown) {
159
+ return keptAsWritten(
160
+ `the formatted file no longer parses, so it was left as written — ${(thrown as Error).message}`,
161
+ )
162
+ }
163
+ if (!sameTemplate(file, reread))
164
+ return keptAsWritten(
165
+ 'the formatted file would parse to a different template, so it was left as written. RULEBOOK 38.127.',
166
+ )
167
+ return { text: result, findings }
168
+ }
169
+
170
+ /**
171
+ * Whether the `ignorePatterns` of the nearest `bunfig.toml` holding a `[fmt]` names `path` — the
172
+ * patterns `bun fmt` skips a file by, held to a `.abide` too (38.125). A pattern ending in `/` is a
173
+ * directory at any depth; any other is a glob from the config's directory.
174
+ */
175
+ async function ignoredByConfig(path: string): Promise<boolean> {
176
+ let bunfig = nearest(dirname(path), 'bunfig.toml')
177
+ while (bunfig !== undefined) {
178
+ const directory = dirname(bunfig)
179
+ const options = Bun.TOML.parse(await Bun.file(bunfig).text()) as {
180
+ fmt?: { ignorePatterns?: string[] }
181
+ }
182
+ if (options.fmt !== undefined) {
183
+ const relative = path.slice(directory.length + 1)
184
+ for (const pattern of options.fmt.ignorePatterns ?? [])
185
+ if (
186
+ pattern.endsWith('/')
187
+ ? `/${relative}`.includes(`/${pattern}`)
188
+ : new Bun.Glob(pattern).match(relative)
189
+ )
190
+ return true
191
+ return false
192
+ }
193
+ if (directory === dirname(directory)) return false
194
+ bunfig = nearest(dirname(directory), 'bunfig.toml')
195
+ }
196
+ return false
197
+ }
198
+
199
+ /**
200
+ * The file as pieces, in source order. Mirrors `parseAbide`'s reading of a tag (`readTag`), a slot
201
+ * (`slotEnd`) and a raw-text body (`closeTagAt`), and keeps what that reading drops. `codeBlocks` is
202
+ * where the parser found each script block's body starting.
203
+ */
204
+ function piecesOf(source: string, codeBlocks: Set<number>): Piece[] {
205
+ const pieces: Piece[] = []
206
+ // The open elements and blocks, innermost last — a block as `null`, which no tag name is — so a
207
+ // close tag closes only what it names, as the parser's `open` does.
208
+ const open: (string | null)[] = []
209
+ let textFrom = 0
210
+ let at = 0
211
+
212
+ const flushText = (until: number): void => {
213
+ if (until > textFrom) pushText(pieces, source.slice(textFrom, until))
214
+ textFrom = until
215
+ }
216
+
217
+ while (at < source.length) {
218
+ const char = source[at]
219
+ if (char === '<' && source.startsWith('<!', at)) {
220
+ flushText(at)
221
+ const closer = source.startsWith('<!--', at) ? '-->' : '>'
222
+ const close = source.indexOf(closer, at + 2)
223
+ const end = close === -1 ? source.length : close + closer.length
224
+ pieces.push({ kind: 'text', text: source.slice(at, end) })
225
+ at = textFrom = end
226
+ continue
227
+ }
228
+ if (char === '<' && source[at + 1] === '/') {
229
+ const close = source.indexOf('>', at)
230
+ if (close === -1) break
231
+ flushText(at)
232
+ const name = source
233
+ .slice(at + 2, close)
234
+ .trim()
235
+ .toLowerCase()
236
+ if (open[open.length - 1] === name) {
237
+ open.pop()
238
+ pieces.push({ kind: 'close' })
239
+ }
240
+ pieces.push({ kind: 'text', text: source.slice(at, close + 1) })
241
+ at = textFrom = close + 1
242
+ continue
243
+ }
244
+ if (char === '<' && /[A-Za-z]/.test(source[at + 1] ?? '')) {
245
+ flushText(at)
246
+ const tag = tagPieces(source, at)
247
+ pieces.push(...tag.pieces)
248
+ at = textFrom = tag.end
249
+ const lower = tag.name.toLowerCase()
250
+ if (KEPT_BODY.has(lower)) {
251
+ if (tag.selfClosing) continue
252
+ const close = closeTagAt(source, lower, tag.end)
253
+ if (close === null) {
254
+ pieces.push({ kind: 'text', text: source.slice(tag.end) })
255
+ at = textFrom = source.length
256
+ continue
257
+ }
258
+ const body = source.slice(tag.end, close.start)
259
+ const closeTag = source.slice(close.start, close.end)
260
+ const language =
261
+ lower === 'style'
262
+ ? 'css'
263
+ : lower === 'script' && codeBlocks.has(tag.end)
264
+ ? 'ts'
265
+ : undefined
266
+ if (language === undefined)
267
+ pieces.push({ kind: 'text', text: body + closeTag })
268
+ else
269
+ pieces.push({
270
+ kind: 'code',
271
+ text: body,
272
+ at: tag.end,
273
+ language,
274
+ close: closeTag,
275
+ })
276
+ at = textFrom = close.end
277
+ continue
278
+ }
279
+ if (!tag.selfClosing && !VOID_ELEMENTS.has(lower)) {
280
+ open.push(lower)
281
+ pieces.push({ kind: 'open' })
282
+ }
283
+ continue
284
+ }
285
+ if (char === '{') {
286
+ flushText(at)
287
+ const close = slotEnd(source, at)
288
+ const raw = source.slice(at + 1, close)
289
+ const inner = raw.trim()
290
+ const innerAt = at + 1 + leadingSpace(raw)
291
+ if (inner.startsWith('#')) {
292
+ pieces.push(...headerPieces(inner, innerAt))
293
+ open.push(null)
294
+ pieces.push({ kind: 'open' })
295
+ } else if (inner.startsWith(':')) {
296
+ pieces.push(
297
+ { kind: 'close' },
298
+ ...headerPieces(inner, innerAt),
299
+ { kind: 'open' },
300
+ )
301
+ } else if (inner.startsWith('/')) {
302
+ if (open[open.length - 1] === null) {
303
+ open.pop()
304
+ pieces.push({ kind: 'close' })
305
+ }
306
+ pieces.push({
307
+ kind: 'text',
308
+ text: `{/${inner.slice(1).trim()}}`,
309
+ })
310
+ } else if (inner === '')
311
+ pieces.push({ kind: 'text', text: source.slice(at, close + 1) })
312
+ else pieces.push(...slotOf(raw, at + 1, 'any'))
313
+ at = textFrom = Math.min(close + 1, source.length)
314
+ continue
315
+ }
316
+ at += 1
317
+ }
318
+ flushText(source.length)
319
+ return pieces
320
+ }
321
+
322
+ /** A text run as pieces: its whitespace runs that hold a newline become breaks. */
323
+ function pushText(pieces: Piece[], text: string): void {
324
+ const alone = text.trim() === ''
325
+ let from = 0
326
+ for (const match of text.matchAll(/[ \t\r\n]*\n[ \t\r\n]*/g)) {
327
+ if (match.index > from)
328
+ pieces.push({ kind: 'text', text: text.slice(from, match.index) })
329
+ pieces.push({ kind: 'break', text: match[0], place: 'markup', alone })
330
+ from = match.index + match[0].length
331
+ }
332
+ if (from < text.length)
333
+ pieces.push({ kind: 'text', text: text.slice(from) })
334
+ }
335
+
336
+ /** An open tag as pieces, read the way `readTag` reads one. */
337
+ function tagPieces(
338
+ source: string,
339
+ open: number,
340
+ ): { name: string; end: number; selfClosing: boolean; pieces: Piece[] } {
341
+ let at = open + 1
342
+ while (at < source.length && /[\w:-]/.test(source[at] as string)) at += 1
343
+ const name = source.slice(open + 1, at)
344
+ const pieces: Piece[] = [{ kind: 'text', text: `<${name}` }]
345
+ while (at < source.length) {
346
+ const spaceFrom = at
347
+ while (at < source.length && isSpace(source[at] as string)) at += 1
348
+ const space = source.slice(spaceFrom, at)
349
+ const selfClosing = source[at] === '/' && source[at + 1] === '>'
350
+ const closing = selfClosing || source[at] === '>'
351
+ if (space.includes('\n'))
352
+ pieces.push({
353
+ kind: 'break',
354
+ text: space,
355
+ place: closing ? 'tagEnd' : 'tag',
356
+ alone: false,
357
+ })
358
+ else if (space !== '' && !closing)
359
+ pieces.push({ kind: 'text', text: ' ' })
360
+ if (closing) {
361
+ pieces.push({ kind: 'text', text: selfClosing ? ' />' : '>' })
362
+ return {
363
+ name,
364
+ end: at + (selfClosing ? 2 : 1),
365
+ selfClosing,
366
+ pieces,
367
+ }
368
+ }
369
+ if (source[at] === '{') {
370
+ const close = slotEnd(source, at)
371
+ const raw = source.slice(at + 1, close)
372
+ if (raw.trimStart().startsWith('...')) {
373
+ pieces.push(...slotOf(raw, at + 1, 'any'))
374
+ at = close + 1
375
+ continue
376
+ }
377
+ }
378
+ const nameFrom = at
379
+ while (
380
+ at < source.length &&
381
+ !isSpace(source[at] as string) &&
382
+ source[at] !== '=' &&
383
+ source[at] !== '/' &&
384
+ source[at] !== '>'
385
+ )
386
+ at += 1
387
+ if (at === nameFrom) {
388
+ pieces.push({ kind: 'text', text: source[at] ?? '' })
389
+ return { name, end: at + 1, selfClosing: false, pieces }
390
+ }
391
+ const attribute = source.slice(nameFrom, at)
392
+ pieces.push({ kind: 'text', text: attribute })
393
+ if (source[at] !== '=') continue
394
+ pieces.push({ kind: 'text', text: '=' })
395
+ at += 1
396
+ if (source[at] === '{') {
397
+ const close = slotEnd(source, at)
398
+ pieces.push(...slotOf(source.slice(at + 1, close), at + 1, 'any'))
399
+ at = close + 1
400
+ continue
401
+ }
402
+ const quote = source[at] as string
403
+ if (quote === '"' || quote === "'") {
404
+ const close = source.indexOf(quote, at + 1)
405
+ const end = close === -1 ? source.length : close
406
+ pieces.push({ kind: 'text', text: quote })
407
+ // `generics="T extends { id: string }"` is a type, and its braces are no slot (34.13).
408
+ if (attribute.toLowerCase() === 'generics')
409
+ pieces.push({ kind: 'text', text: source.slice(at + 1, end) })
410
+ else valuePieces(pieces, source, at + 1, end, quote)
411
+ pieces.push({ kind: 'text', text: close === -1 ? '' : quote })
412
+ at = close === -1 ? source.length : close + 1
413
+ continue
414
+ }
415
+ const valueFrom = at
416
+ while (
417
+ at < source.length &&
418
+ !isSpace(source[at] as string) &&
419
+ source[at] !== '>'
420
+ )
421
+ at += 1
422
+ valuePieces(pieces, source, valueFrom, at, 'unquoted')
423
+ }
424
+ return { name, end: at, selfClosing: false, pieces }
425
+ }
426
+
427
+ /** An attribute value's text and the slots in it, a slot that runs past the value kept as written. */
428
+ function valuePieces(
429
+ pieces: Piece[],
430
+ source: string,
431
+ from: number,
432
+ end: number,
433
+ fit: Exclude<Fit, 'any'>,
434
+ ): void {
435
+ let textFrom = from
436
+ let at = from
437
+ while (at < end) {
438
+ if (source[at] !== '{') {
439
+ at += 1
440
+ continue
441
+ }
442
+ const close = slotEnd(source, at)
443
+ if (close >= end) break
444
+ const raw = source.slice(at + 1, close)
445
+ if (raw.trim() === '') {
446
+ at = close + 1
447
+ continue
448
+ }
449
+ if (at > textFrom)
450
+ pieces.push({ kind: 'text', text: source.slice(textFrom, at) })
451
+ pieces.push(...slotOf(raw, at + 1, fit))
452
+ at = textFrom = close + 1
453
+ }
454
+ if (end > textFrom)
455
+ pieces.push({ kind: 'text', text: source.slice(textFrom, end) })
456
+ }
457
+
458
+ /**
459
+ * A block tag's pieces: `{#if}` and `{:else if}` with their condition an expression, `{#for}` with
460
+ * its subject and its key, and every other block tag with its spacing tidied (32.64).
461
+ */
462
+ function headerPieces(inner: string, at: number): Piece[] {
463
+ const sigil = inner[0] as string
464
+ const keyword = /^[#:]\s*([\w$]+)/.exec(inner)
465
+ if (keyword === null) return [{ kind: 'text', text: `{${inner}}` }]
466
+ const word = keyword[1] as string
467
+ let rest = inner.slice(keyword[0].length)
468
+ let restAt = at + keyword[0].length
469
+ let head = `${sigil}${word}`
470
+ const elseIf = word === 'else' ? ELSE_IF.exec(rest) : null
471
+ if (elseIf !== null) {
472
+ head = ':else if'
473
+ rest = rest.slice(elseIf[0].length)
474
+ restAt += elseIf[0].length
475
+ }
476
+ const subject = rest.trim()
477
+ const subjectAt = restAt + leadingSpace(rest)
478
+ if (subject === '') return [{ kind: 'text', text: `{${head}}` }]
479
+ if (head === '#if' || head === ':else if')
480
+ return [
481
+ { kind: 'text', text: `{${head} ` },
482
+ expressionOf(subject, subjectAt, 'any'),
483
+ { kind: 'text', text: '}' },
484
+ ]
485
+ if (head === '#for') {
486
+ const header = forPieces(subject, subjectAt)
487
+ if (header !== undefined)
488
+ return [
489
+ { kind: 'text', text: '{#for ' },
490
+ ...header,
491
+ { kind: 'text', text: '}' },
492
+ ]
493
+ }
494
+ return [{ kind: 'text', text: `{${head} ${subject}}` }]
495
+ }
496
+
497
+ /**
498
+ * `{#for item, index of subject by key}` split where `forBlock` splits it — at the `of`, the `,`
499
+ * and the `by` that stand at depth zero — or `undefined` where it has no `of`.
500
+ */
501
+ function forPieces(header: string, at: number): Piece[] | undefined {
502
+ const { of, comma, by } = forSeparators(header)
503
+ if (of === undefined) return undefined
504
+ const item = header.slice(0, (comma ?? of).start).trim()
505
+ const index =
506
+ comma === undefined ? '' : header.slice(comma.end, of.start).trim()
507
+ const subject = header.slice(
508
+ of.end,
509
+ by === undefined ? header.length : by.start,
510
+ )
511
+ const pieces: Piece[] = [
512
+ {
513
+ kind: 'text',
514
+ text: `${index === '' ? item : `${item}, ${index}`} of `,
515
+ },
516
+ trimmedExpression(subject, at + of.end),
517
+ ]
518
+ if (by !== undefined)
519
+ pieces.push(
520
+ { kind: 'text', text: ' by ' },
521
+ trimmedExpression(header.slice(by.end), at + by.end),
522
+ )
523
+ return pieces
524
+ }
525
+
526
+ /** How much whitespace `text` opens with. */
527
+ function leadingSpace(text: string): number {
528
+ return text.length - text.trimStart().length
529
+ }
530
+
531
+ /** An expression whose source `raw` starts at `rawAt`, written back as `raw` where it is refused. */
532
+ function expressionOf(raw: string, rawAt: number, fit: Fit): Expression {
533
+ return {
534
+ kind: 'expression',
535
+ text: raw.trim(),
536
+ raw,
537
+ at: rawAt + leadingSpace(raw),
538
+ fit,
539
+ }
540
+ }
541
+
542
+ /** An expression its surrounding text spaces, so one refused is written back trimmed. */
543
+ function trimmedExpression(raw: string, rawAt: number): Expression {
544
+ return expressionOf(raw.trim(), rawAt + leadingSpace(raw), 'any')
545
+ }
546
+
547
+ /** A slot's braces and the expression inside them, whose source `raw` starts at `rawAt`. */
548
+ function slotOf(raw: string, rawAt: number, fit: Fit): Piece[] {
549
+ return [
550
+ { kind: 'text', text: '{' },
551
+ expressionOf(raw, rawAt, fit),
552
+ { kind: 'text', text: '}' },
553
+ ]
554
+ }
555
+
556
+ /**
557
+ * Every expression of a file formatted in ONE call, each as the argument of a call of its own —
558
+ * `__abide_3(expr)` — so `bun fmt` prints it as an expression; and the indent its options print,
559
+ * read off a probe at the head of the batch. Where the batch is refused, each expression is asked
560
+ * alone, so one that does not parse is the only one left as written.
561
+ */
562
+ async function formatExpressions(
563
+ path: string,
564
+ expressions: Expression[],
565
+ ): Promise<{ answers: Answer[]; unit: string }> {
566
+ let batch = PROBE
567
+ for (const [index, expression] of expressions.entries())
568
+ batch += callOf(index, expression)
569
+ const answer = await runFormatter(`${path}.ts`, batch)
570
+ if (answer.text !== undefined) {
571
+ const entries = entriesOf(answer.text)
572
+ const unit = unitOf(entries.get('probe'))
573
+ const answers: Answer[] = []
574
+ for (const [index, expression] of expressions.entries())
575
+ answers.push(answerOf(entries.get(String(index)), expression, unit))
576
+ return { answers, unit }
577
+ }
578
+ const probe = await runFormatter(`${path}.ts`, PROBE)
579
+ const unit = unitOf(
580
+ probe.text === undefined
581
+ ? undefined
582
+ : entriesOf(probe.text).get('probe'),
583
+ )
584
+ const answers = await Promise.all(
585
+ expressions.map(async (expression): Promise<Answer> => {
586
+ const alone = await runFormatter(
587
+ `${path}.ts`,
588
+ callOf(0, expression),
589
+ )
590
+ if (alone.text !== undefined)
591
+ return answerOf(
592
+ entriesOf(alone.text).get('0'),
593
+ expression,
594
+ unit,
595
+ )
596
+ // The call's own name opens the first line, and is not the expression's; an error past
597
+ // its last line is where the parser ran out of it, at its end.
598
+ const lines = expression.text.split('\n')
599
+ const { message, line, column } = alone.error
600
+ if (line > lines.length)
601
+ return {
602
+ text: undefined,
603
+ error: {
604
+ message,
605
+ line: lines.length,
606
+ column: (lines[lines.length - 1] as string).length + 1,
607
+ },
608
+ }
609
+ const opening = line === 1 ? '__abide_0('.length : 0
610
+ return {
611
+ text: undefined,
612
+ error: { message, line, column: Math.max(column - opening, 1) },
613
+ }
614
+ }),
615
+ )
616
+ return { answers, unit }
617
+ }
618
+
619
+ /** `expression` as the argument of the batch's call number `index`. */
620
+ function callOf(index: number, expression: Expression): string {
621
+ return `__abide_${index}(${expression.text}\n)\n`
622
+ }
623
+
624
+ /** `expression` as `bun fmt` printed its call `entry`, or as it stands where none came back. */
625
+ function answerOf(
626
+ entry: string | undefined,
627
+ expression: Expression,
628
+ unit: string,
629
+ ): Answer {
630
+ return {
631
+ text: entry === undefined ? expression.text : unwrap(entry, unit),
632
+ error: undefined,
633
+ }
634
+ }
635
+
636
+ /** The batch `bun fmt` printed, split back into its calls by name. */
637
+ function entriesOf(printed: string): Map<string, string> {
638
+ const entries = new Map<string, string>()
639
+ let name: string | undefined
640
+ let lines: string[] = []
641
+ for (const line of printed.split('\n')) {
642
+ const entry = ENTRY.exec(line)
643
+ if (entry !== null) {
644
+ if (name !== undefined) entries.set(name, lines.join('\n'))
645
+ name = entry[1] as string
646
+ lines = [line]
647
+ } else lines.push(line)
648
+ }
649
+ if (name !== undefined) entries.set(name, lines.join('\n'))
650
+ return entries
651
+ }
652
+
653
+ /** The indent `bun fmt` printed the probe's body at — the unit the markup is indented by. */
654
+ function unitOf(probe: string | undefined): string {
655
+ const body = probe?.split('\n')[1]
656
+ const unit =
657
+ body === undefined
658
+ ? ''
659
+ : body.slice(0, body.length - body.trimStart().length)
660
+ return unit === '' ? ' ' : unit
661
+ }
662
+
663
+ /**
664
+ * One call's argument, as the expression it was: the call's name and its parentheses gone, and an
665
+ * argument printed on lines of its own brought back out by one indent, its trailing comma dropped.
666
+ */
667
+ function unwrap(entry: string, unit: string): string {
668
+ let inner = entry.trimEnd().replace(ENTRY, '')
669
+ if (inner.endsWith(';')) inner = inner.slice(0, -1)
670
+ if (inner.endsWith(')')) inner = inner.slice(0, -1)
671
+ if (!inner.startsWith('\n')) return inner.trimEnd()
672
+ inner = shiftLines(
673
+ inner.slice(1),
674
+ (line) =>
675
+ line.startsWith(unit) ? line.slice(unit.length) : line.trimStart(),
676
+ 'ts',
677
+ ).trimEnd()
678
+ return inner.endsWith(',') ? inner.slice(0, -1) : inner
679
+ }
680
+
681
+ /**
682
+ * `code` with `shift` applied to every line after the first — in TypeScript, every one that starts
683
+ * outside a template literal, since a line inside one is the string's own characters and moving it
684
+ * changes the value.
685
+ */
686
+ function shiftLines(
687
+ code: string,
688
+ shift: (line: string) => string,
689
+ language: 'ts' | 'css',
690
+ ): string {
691
+ const insideTemplate: [number, number][] = []
692
+ if (language === 'ts')
693
+ for (const token of tokenize(code))
694
+ if (
695
+ token.kind === SyntaxKind.NoSubstitutionTemplateLiteral ||
696
+ token.kind === SyntaxKind.TemplateHead ||
697
+ token.kind === SyntaxKind.TemplateMiddle ||
698
+ token.kind === SyntaxKind.TemplateTail
699
+ )
700
+ insideTemplate.push([token.start, token.end])
701
+ const lines = code.split('\n')
702
+ let offset = (lines[0] as string).length + 1
703
+ // The ranges are in source order and so are the lines, so one cursor walks both.
704
+ let template = 0
705
+ for (let index = 1; index < lines.length; index += 1) {
706
+ const line = lines[index] as string
707
+ while (
708
+ template < insideTemplate.length &&
709
+ (insideTemplate[template] as [number, number])[1] <= offset
710
+ )
711
+ template += 1
712
+ const range = insideTemplate[template]
713
+ if (range === undefined || offset <= range[0])
714
+ lines[index] = shift(line)
715
+ offset += line.length + 1
716
+ }
717
+ return lines.join('\n')
718
+ }
719
+
720
+ /** `code` with every line after the first moved right by `indent`, a blank line left blank. */
721
+ function indented(
722
+ code: string,
723
+ indent: string,
724
+ language: 'ts' | 'css',
725
+ ): string {
726
+ if (indent === '') return code
727
+ return shiftLines(
728
+ code,
729
+ (line) => (line === '' ? line : indent + line),
730
+ language,
731
+ )
732
+ }
733
+
734
+ /**
735
+ * The pieces written back out: each break re-indented by the nesting where it lands, and each
736
+ * piece of code as `bun fmt` printed it where it fits where it stands.
737
+ */
738
+ function emit(
739
+ pieces: Piece[],
740
+ formatted: Map<Piece, string>,
741
+ unit: string,
742
+ ): string {
743
+ let out = ''
744
+ let depth = 0
745
+ let pending: Extract<Piece, { kind: 'break' }> | undefined
746
+ // Whether the pending break stands at an end of a body: after what opened it, or before what
747
+ // closes it — where 33.17 and 33.18 drop a run of either kind.
748
+ let opened = true
749
+ let closing = false
750
+
751
+ const lineIndent = (): string => {
752
+ const line = out.slice(out.lastIndexOf('\n') + 1)
753
+ return line.slice(0, line.length - line.trimStart().length)
754
+ }
755
+ const resolve = (): void => {
756
+ if (pending === undefined) return
757
+ const level = pending.place === 'tag' ? depth + 1 : depth
758
+ const newlines = Math.min(pending.text.split('\n').length - 1, 2)
759
+ const written =
760
+ out === '' ? '' : '\n'.repeat(newlines) + unit.repeat(level)
761
+ // 38.127 — a run that is a whole text node between two nodes keeps its kind: a newline
762
+ // alone stays a newline alone (33.17), and one carrying indentation keeps carrying some.
763
+ const keepsKind =
764
+ !pending.alone ||
765
+ opened ||
766
+ closing ||
767
+ /[ \t]/.test(pending.text) === /[ \t]/.test(written)
768
+ out += keepsKind ? written : pending.text
769
+ pending = undefined
770
+ }
771
+ const printable = (): void => {
772
+ resolve()
773
+ opened = false
774
+ closing = false
775
+ }
776
+
777
+ for (const piece of pieces) {
778
+ switch (piece.kind) {
779
+ case 'open':
780
+ depth += 1
781
+ opened = true
782
+ continue
783
+ case 'close':
784
+ depth = Math.max(depth - 1, 0)
785
+ closing = true
786
+ continue
787
+ case 'break':
788
+ pending = piece
789
+ continue
790
+ case 'text':
791
+ printable()
792
+ out += piece.text
793
+ continue
794
+ case 'expression': {
795
+ printable()
796
+ const printed = formatted.get(piece)
797
+ const fits =
798
+ printed !== undefined &&
799
+ (piece.fit === 'any' ||
800
+ (piece.fit === 'unquoted'
801
+ ? !/[\s>"'`=<]/.test(printed)
802
+ : !printed.includes('\n') &&
803
+ !printed.includes(piece.fit)))
804
+ out += fits ? indented(printed, lineIndent(), 'ts') : piece.raw
805
+ continue
806
+ }
807
+ case 'code': {
808
+ printable()
809
+ const printed = formatted.get(piece)
810
+ if (printed === undefined) {
811
+ out += piece.text + piece.close
812
+ continue
813
+ }
814
+ const indent = lineIndent()
815
+ const body = printed.trim()
816
+ out +=
817
+ body === ''
818
+ ? piece.close
819
+ : `\n${indent}${indented(body, indent, piece.language)}\n${indent}${piece.close}`
820
+ continue
821
+ }
822
+ }
823
+ }
824
+ return `${out.trimEnd()}\n`
825
+ }
826
+
827
+ /** What `bun fmt` prints for `text` read as the file at `path`, or why it refused. */
828
+ async function runFormatter(path: string, text: string): Promise<Answer> {
829
+ // `--no-script`: a project whose `fmt` script runs `abide format` would otherwise run this again.
830
+ const child = Bun.spawn(
831
+ [process.execPath, 'fmt', '--no-script', `--stdin-filepath=${path}`],
832
+ {
833
+ cwd: dirname(path),
834
+ stdin: new Blob([text]),
835
+ stdout: 'pipe',
836
+ stderr: 'pipe',
837
+ },
838
+ )
839
+ const [printed, complaint, code] = await Promise.all([
840
+ new Response(child.stdout).text(),
841
+ new Response(child.stderr).text(),
842
+ child.exited,
843
+ ])
844
+ if (code === 0) return { text: printed, error: undefined }
845
+ const message =
846
+ /error: (.*)/.exec(complaint)?.[1] ??
847
+ (complaint.trim() || `bun fmt exited ${code}`)
848
+ const where = /:(\d+):(\d+)\s*$/m.exec(complaint)
849
+ return {
850
+ text: undefined,
851
+ error: {
852
+ message,
853
+ line: where === null ? 1 : Number(where[1]),
854
+ column: where === null ? 1 : Number(where[2]),
855
+ },
856
+ }
857
+ }