@abide/abide 0.117.2 → 0.119.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.
- package/CHANGELOG.md +22 -0
- package/bin/abide.ts +74 -11
- package/docs/REGISTRY.md +18 -15
- package/docs/RULEBOOK.md +71 -10
- package/docs/abide.md +584 -274
- package/package.json +1 -1
- package/scaffold/AGENTS.md +1 -1
- package/scaffold/src/getGreeting.test.ts +2 -1
- package/scaffold/src/index.abide +5 -2
- package/scaffold/src/index.test.ts +2 -0
- package/scaffold/src/layout.abide +6 -6
- package/src/compiler/Lowering.ts +20 -2
- package/src/compiler/PositionTable.ts +1 -0
- package/src/compiler/Refusals.ts +1 -0
- package/src/compiler/TypeSession.ts +49 -13
- package/src/compiler/aliasedSymbol.ts +1 -1
- package/src/compiler/ambientReadsIn.ts +1 -0
- package/src/compiler/appLayout.ts +55 -3
- package/src/compiler/appMap.ts +15 -0
- package/src/compiler/argsDefaultsIn.ts +1 -0
- package/src/compiler/argsSchemaOf.ts +1 -0
- package/src/compiler/checkApp.ts +3 -1
- package/src/compiler/checkedShadow.ts +2 -1
- package/src/compiler/classifyRead.ts +1 -0
- package/src/compiler/classifyTemplate.ts +1 -0
- package/src/compiler/compileApp.ts +5 -3
- package/src/compiler/compiledEntry.ts +2 -1
- package/src/compiler/componentGraph.ts +1 -0
- package/src/compiler/declaredExports.ts +4 -2
- package/src/compiler/emitClient.ts +19 -14
- package/src/compiler/emitComponent.ts +1 -0
- package/src/compiler/emitSource.ts +2 -1
- package/src/compiler/formatAbide.ts +857 -0
- package/src/compiler/formatDiagnosis.ts +1 -0
- package/src/compiler/handlersUnder.ts +7 -3
- package/src/compiler/hookCalls.ts +1 -0
- package/src/compiler/lineOf.ts +17 -0
- package/src/compiler/matchersOf.ts +157 -0
- package/src/compiler/mountsOf.ts +5 -0
- package/src/compiler/paramFile.ts +125 -0
- package/src/compiler/parseAbide.ts +48 -28
- package/src/compiler/projectShadow.ts +11 -4
- package/src/compiler/reactiveOptions.ts +1 -0
- package/src/compiler/reactiveTypes.ts +2 -1
- package/src/compiler/readsIn.ts +3 -2
- package/src/compiler/renderMarkup.ts +3 -2
- package/src/compiler/resolverIn.ts +2 -0
- package/src/compiler/sameTemplate.ts +150 -0
- package/src/compiler/scaffold.ts +4 -11
- package/src/compiler/serveLanguage.ts +48 -21
- package/src/compiler/sharedModuleWrites.ts +2 -1
- package/src/compiler/sourceMapOf.ts +1 -0
- package/src/compiler/splitImports.ts +2 -0
- package/src/compiler/templateWarnings.ts +2 -2
- package/src/compiler/trackingWarnings.ts +1 -0
- package/src/compiler/writeDefaultProject.ts +5 -1
- package/src/server/BRIDGE_VERSION.ts +1 -1
- package/src/server/abideModules.ts +2 -0
- package/src/server/answerFailure.ts +1 -0
- package/src/server/answerRequest.ts +57 -19
- package/src/server/answerRpc.ts +6 -2
- package/src/server/answerSocket.ts +2 -1
- package/src/server/answerValue.ts +1 -0
- package/src/server/answerWorker.ts +1 -0
- package/src/server/appTree.ts +18 -9
- package/src/server/argsFor.ts +1 -0
- package/src/server/assistant.ts +2 -1
- package/src/server/banner.ts +2 -0
- package/src/server/bridgeRelay.ts +1 -0
- package/src/server/callerOf.ts +1 -0
- package/src/server/claudeApi.ts +1 -0
- package/src/server/claudeCode.ts +1 -0
- package/src/server/clientBundle.ts +1 -0
- package/src/server/coerceArgs.ts +1 -2
- package/src/server/commands.ts +9 -0
- package/src/server/compressAnswer.ts +2 -1
- package/src/server/config.ts +2 -0
- package/src/server/createApp.ts +4 -2
- package/src/server/crossingOrigin.ts +1 -0
- package/src/server/csp.ts +1 -0
- package/src/server/deferredBuilds.ts +1 -0
- package/src/server/derivedArgs.ts +1 -0
- package/src/server/devWorker.ts +2 -1
- package/src/server/faceOf.ts +2 -7
- package/src/server/generatedSurfaces.ts +79 -7
- package/src/server/hooks.ts +1 -0
- package/src/server/iconAccent.ts +1 -1
- package/src/server/installServer.ts +1 -0
- package/src/server/installSourceMaps.ts +1 -0
- package/src/server/launcherPage.ts +2 -2
- package/src/server/manifestOf.ts +1 -0
- package/src/server/muxUpgrade.ts +1 -0
- package/src/server/nativeMenu.ts +2 -0
- package/src/server/placement.ts +12 -2
- package/src/server/principal.ts +2 -1
- package/src/server/remote.ts +7 -2
- package/src/server/render.ts +3 -3
- package/src/server/renderComponent.ts +1 -0
- package/src/server/reportHealth.ts +1 -0
- package/src/server/requestScope.ts +3 -1
- package/src/server/runDesktop.ts +3 -2
- package/src/server/seedBuffer.ts +1 -0
- package/src/server/serveDev.ts +3 -2
- package/src/server/shell.ts +2 -0
- package/src/server/streamEvent.ts +1 -0
- package/src/server/surfaceOf.ts +6 -1
- package/src/server/testBuild.ts +1 -0
- package/src/server/testMount.ts +1 -0
- package/src/server/trace.ts +2 -0
- package/src/server/withAbideSources.ts +1 -0
- package/src/shared/Attributes.ts +12 -8
- package/src/shared/cookies.ts +1 -1
- package/src/shared/escapeText.ts +1 -1
- package/src/shared/filledPath.ts +16 -10
- package/src/shared/index.ts +1 -5
- package/src/shared/jsonSchema.ts +1 -3
- package/src/shared/matchRoute.ts +16 -4
- package/src/shared/menuBridge.ts +1 -2
- package/src/shared/outbox.ts +1 -1
- package/src/shared/reactive/Keyed.ts +2 -2
- package/src/shared/reactive/Reactive.ts +12 -16
- package/src/shared/reactive/index.ts +3 -4
- package/src/shared/reactive/isReactive.ts +1 -1
- package/src/shared/reactive/scoped.ts +2 -2
- package/src/shared/reactive/share.ts +1 -1
- package/src/shared/reactive/watch.ts +4 -4
- package/src/shared/refuse.ts +1 -1
- package/src/shared/routePattern.ts +66 -14
- package/src/shared/rpc.ts +8 -11
- package/src/shared/url.ts +24 -7
- package/src/shared/validateJson.ts +1 -1
- package/src/ui/ArmRegion.ts +1 -0
- package/src/ui/Level.ts +1 -0
- package/src/ui/List.ts +1 -0
- package/src/ui/RawRun.ts +1 -0
- package/src/ui/Router.ts +1 -0
- package/src/ui/TryRegion.ts +1 -0
- package/src/ui/adoptWalk.ts +2 -1
- package/src/ui/bindElement.ts +1 -0
- package/src/ui/bindFiles.ts +1 -0
- package/src/ui/bindGroup.ts +1 -0
- package/src/ui/bindProperty.ts +1 -0
- package/src/ui/bindSpread.ts +1 -0
- package/src/ui/index.ts +1 -6
- package/src/ui/outboxQueue.ts +1 -1
- package/src/ui/parseMarkup.ts +2 -1
- package/src/ui/reconcile.ts +2 -1
- package/src/ui/reconcileHead.ts +1 -0
- package/src/ui/regions.ts +2 -1
- package/src/ui/registerWorker.ts +1 -0
- package/src/ui/rows.ts +1 -0
- package/src/ui/setMarkup.ts +1 -0
- package/src/ui/setText.ts +1 -0
- package/src/ui/socketWire.ts +4 -3
- package/src/ui/wire.ts +2 -1
- /package/scaffold/src/{getGreeting.ts → getGreeting.handler.ts} +0 -0
|
@@ -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
|
+
}
|