@stacksjs/defaults 0.75.62 → 0.75.64

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.
@@ -1,433 +0,0 @@
1
- /**
2
- * Pickier, built in: lint diagnostics, a formatter, `source.fixAll.pickier`
3
- * and quick fixes, all from the project's own pickier and config - the same
4
- * ones `./buddy lint` and `./buddy format` use.
5
- *
6
- * Pickier itself runs in a Bun process per project (./pickier-worker.ts), so
7
- * this module only manages those processes and turns their answers into
8
- * editor features. Everything it needs from the machine comes through
9
- * `PickierHost`, so tests drive it with fakes.
10
- */
11
- import type * as vscode from 'vscode'
12
- import type { PickierIssue, PickierOperation, PickierReady, PickierRequest, PickierResponse, PickierResult } from './pickier-protocol'
13
- import { join, relative, sep } from 'node:path'
14
- import { isLintablePath } from '../../../../core/actions/src/lint/files'
15
-
16
- type Vscode = typeof vscode
17
-
18
- export interface PickierProcess {
19
- write: (line: string) => void
20
- onLine: (listener: (line: string) => void) => void
21
- onExit: (listener: () => void) => void
22
- kill: () => void
23
- }
24
-
25
- export interface PickierHost {
26
- exists: (path: string) => boolean
27
- /** Start `bun <script>` in `cwd`; stdout is delivered line by line. */
28
- spawnBun: (bun: string, script: string, cwd: string) => PickierProcess
29
- }
30
-
31
- export interface PickierOptions {
32
- /** Absolute path of dist/pickier-worker.js. */
33
- workerPath: string
34
- lintDelayMs?: number
35
- }
36
-
37
- export const PICKIER_COMMANDS = {
38
- fixAll: 'stacks.pickier.fixAll',
39
- restart: 'stacks.pickier.restart',
40
- } as const
41
-
42
- /** The language ids of the files `buddy lint` covers (core/actions/src/lint/files.ts). */
43
- export const PICKIER_LANGUAGES = ['typescript', 'javascript', 'json', 'jsonc', 'markdown', 'yaml'] as const
44
-
45
- /** Comment syntax for `pickier-disable-next-line`, where the language has line comments. */
46
- const LINE_COMMENT: Record<string, string> = { typescript: '//', javascript: '//', jsonc: '//' }
47
-
48
- /**
49
- * The Bun that runs pickier: the setting, then the project's pinned Bun
50
- * (`pantry/.bin/bun`, the one `./buddy` itself runs on), then `bun` on the PATH.
51
- */
52
- export function chooseBun(setting: string, root: string, exists: (path: string) => boolean): string {
53
- if (setting.trim())
54
- return setting.trim()
55
- const pinned = join(root, 'pantry', '.bin', 'bun')
56
- return exists(pinned) ? pinned : 'bun'
57
- }
58
-
59
- /** Whether saving this file changes the config a running pickier has loaded. */
60
- export function isPickierConfigPath(path: string): boolean {
61
- return /(?:^|[/\\])(?:pickier\.config|\.config[/\\]pickier|config[/\\]code-style)\.(?:[cm]?[jt]s|json)$/.test(path)
62
- }
63
-
64
- /** The comment that silences `ruleId` on the line below, indented to match it. */
65
- export function disableNextLineComment(lineText: string, ruleId: string, comment: string): string {
66
- const indent = lineText.match(/^\s*/)?.[0] ?? ''
67
- return `${indent}${comment} pickier-disable-next-line ${ruleId}\n`
68
- }
69
-
70
- /** Whether `buddy lint` would lint `file`, given the project it belongs to. */
71
- export function isLintedFile(root: string, file: string): boolean {
72
- const rel = relative(root, file)
73
- if (!rel || rel.startsWith('..'))
74
- return false
75
- return isLintablePath(rel.split(sep).join('/'))
76
- }
77
-
78
- /** One pickier worker process, for one project root. */
79
- export class PickierClient {
80
- private process: PickierProcess | undefined
81
- private nextId = 1
82
- private pending = new Map<number, (response: PickierResponse | undefined) => void>()
83
- private readyWaiters: Array<(ready: PickierReady | undefined) => void> = []
84
- /** Set once the worker has said whether it found pickier. */
85
- status: 'starting' | 'ready' | 'unavailable' = 'starting'
86
- info: PickierReady | undefined
87
- unavailableReason = ''
88
- private exits = 0
89
-
90
- constructor(
91
- readonly root: string,
92
- private readonly start: () => PickierProcess,
93
- private readonly log: (message: string) => void,
94
- ) {}
95
-
96
- private ensureProcess(): PickierProcess | undefined {
97
- if (this.status === 'unavailable')
98
- return undefined
99
- if (this.process)
100
- return this.process
101
-
102
- const child = this.start()
103
- this.process = child
104
- this.status = 'starting'
105
-
106
- child.onLine((line) => {
107
- let message: any
108
- try {
109
- message = JSON.parse(line)
110
- }
111
- catch {
112
- return
113
- }
114
-
115
- if ('ready' in message) {
116
- if (message.ready) {
117
- this.status = 'ready'
118
- this.info = message as PickierReady
119
- this.log(`pickier ${this.info.version} ready for ${this.root}`)
120
- }
121
- else {
122
- this.status = 'unavailable'
123
- this.unavailableReason = message.error
124
- this.log(`pickier is not available in ${this.root}, so it is not linted: ${message.error}`)
125
- }
126
- for (const waiter of this.readyWaiters.splice(0))
127
- waiter(this.info)
128
- return
129
- }
130
-
131
- const resolve = this.pending.get(message.id)
132
- if (resolve) {
133
- this.pending.delete(message.id)
134
- resolve(message as PickierResponse)
135
- }
136
- })
137
-
138
- child.onExit(() => {
139
- if (this.process !== child)
140
- return
141
- this.process = undefined
142
- for (const resolve of this.pending.values())
143
- resolve(undefined)
144
- this.pending.clear()
145
- for (const waiter of this.readyWaiters.splice(0))
146
- waiter(undefined)
147
-
148
- // A worker that keeps dying (a broken Bun, a config that throws) would
149
- // otherwise be restarted on every keystroke.
150
- if (this.status !== 'unavailable' && ++this.exits >= 3) {
151
- this.status = 'unavailable'
152
- this.unavailableReason = 'the pickier process exited three times'
153
- this.log(`pickier keeps exiting in ${this.root}; run "Stacks: Restart Pickier" after fixing it`)
154
- }
155
- })
156
-
157
- return child
158
- }
159
-
160
- private whenReady(): Promise<PickierReady | undefined> {
161
- if (this.status === 'ready')
162
- return Promise.resolve(this.info)
163
- if (this.status === 'unavailable')
164
- return Promise.resolve(undefined)
165
- return new Promise(resolve => this.readyWaiters.push(resolve))
166
- }
167
-
168
- async request(op: PickierOperation, file: string, text: string): Promise<PickierResult | undefined> {
169
- const child = this.ensureProcess()
170
- if (!child || !(await this.whenReady()) || !this.process)
171
- return undefined
172
-
173
- const id = this.nextId++
174
- const request: PickierRequest = { id, op, file, text }
175
- const response = await new Promise<PickierResponse | undefined>((resolve) => {
176
- this.pending.set(id, resolve)
177
- this.process!.write(`${JSON.stringify(request)}\n`)
178
- })
179
-
180
- if (!response)
181
- return undefined
182
- if (!response.ok) {
183
- this.log(`pickier could not ${op} ${file}: ${response.error}`)
184
- return undefined
185
- }
186
- return response.result
187
- }
188
-
189
- dispose(): void {
190
- const child = this.process
191
- this.process = undefined
192
- child?.kill()
193
- for (const resolve of this.pending.values())
194
- resolve(undefined)
195
- this.pending.clear()
196
- }
197
- }
198
-
199
- export function createPickierSupport(api: Vscode, host: PickierHost, options: PickierOptions): {
200
- activate: (context: { subscriptions: Array<{ dispose: () => unknown }> }) => void
201
- deactivate: () => void
202
- } {
203
- const clients = new Map<string, PickierClient>()
204
- const timers = new Map<string, ReturnType<typeof setTimeout>>()
205
- const output = api.window.createOutputChannel('Stacks: Pickier')
206
- const diagnostics = api.languages.createDiagnosticCollection('pickier')
207
- const fixAllKind = api.CodeActionKind.SourceFixAll.append('pickier')
208
- const settings = () => api.workspace.getConfiguration('stacks')
209
-
210
- const enabled = () => settings().get<boolean>('pickier.enable', true)
211
-
212
- function rootOf(document: vscode.TextDocument): string | undefined {
213
- if (document.uri.scheme !== 'file')
214
- return undefined
215
- const folder = api.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath
216
- if (!folder || !(host.exists(join(folder, 'buddy')) || host.exists(join(folder, 'config', 'app.ts'))))
217
- return undefined
218
- return isLintedFile(folder, document.uri.fsPath) ? folder : undefined
219
- }
220
-
221
- function client(root: string): PickierClient {
222
- let existing = clients.get(root)
223
- if (!existing) {
224
- existing = new PickierClient(
225
- root,
226
- () => host.spawnBun(chooseBun(settings().get<string>('bunPath', ''), root, host.exists), options.workerPath, root),
227
- message => output.appendLine(message),
228
- )
229
- clients.set(root, existing)
230
- }
231
- return existing
232
- }
233
-
234
- async function run(document: vscode.TextDocument, op: PickierOperation): Promise<PickierResult | undefined> {
235
- const root = enabled() ? rootOf(document) : undefined
236
- if (!root)
237
- return undefined
238
- return client(root).request(op, document.uri.fsPath, document.getText())
239
- }
240
-
241
- function toDiagnostic(document: vscode.TextDocument, issue: PickierIssue): vscode.Diagnostic {
242
- const line = Math.min(Math.max(issue.line - 1, 0), Math.max(document.lineCount - 1, 0))
243
- const start = new api.Position(line, Math.max(issue.column - 1, 0))
244
- const range = document.getWordRangeAtPosition(start) ?? new api.Range(start, start.translate(0, 1))
245
- const diagnostic = new api.Diagnostic(
246
- range,
247
- issue.help ? `${issue.message}\n${issue.help}` : issue.message,
248
- issue.severity === 'error' ? api.DiagnosticSeverity.Error : api.DiagnosticSeverity.Warning,
249
- )
250
- diagnostic.source = 'pickier'
251
- diagnostic.code = issue.ruleId
252
- return diagnostic
253
- }
254
-
255
- async function lint(document: vscode.TextDocument): Promise<void> {
256
- const version = document.version
257
- const result = await run(document, 'lint')
258
- if (document.isClosed || document.version !== version)
259
- return
260
- if (!result || result.op !== 'lint') {
261
- diagnostics.delete(document.uri)
262
- return
263
- }
264
- diagnostics.set(document.uri, result.issues.map(issue => toDiagnostic(document, issue)))
265
- }
266
-
267
- function scheduleLint(document: vscode.TextDocument, delay = options.lintDelayMs ?? 300): void {
268
- const key = document.uri.toString()
269
- clearTimeout(timers.get(key))
270
- timers.set(key, setTimeout(() => {
271
- timers.delete(key)
272
- void lint(document)
273
- }, delay))
274
- }
275
-
276
- function wholeDocument(document: vscode.TextDocument): vscode.Range {
277
- return new api.Range(new api.Position(0, 0), document.lineAt(Math.max(document.lineCount - 1, 0)).range.end)
278
- }
279
-
280
- async function fixedText(document: vscode.TextDocument): Promise<string | undefined> {
281
- const result = await run(document, 'fix')
282
- if (!result || result.op === 'lint' || result.text === null || result.text === document.getText())
283
- return undefined
284
- return result.text
285
- }
286
-
287
- async function fixAllEdit(document: vscode.TextDocument): Promise<vscode.WorkspaceEdit | undefined> {
288
- const text = await fixedText(document)
289
- if (text === undefined)
290
- return undefined
291
- const edit = new api.WorkspaceEdit()
292
- edit.replace(document.uri, wholeDocument(document), text)
293
- return edit
294
- }
295
-
296
- function relintOpenDocuments(): void {
297
- diagnostics.clear()
298
- for (const document of api.workspace.textDocuments)
299
- scheduleLint(document, 0)
300
- }
301
-
302
- function stopClients(root?: string): void {
303
- for (const [key, existing] of clients) {
304
- if (root && key !== root)
305
- continue
306
- existing.dispose()
307
- clients.delete(key)
308
- }
309
- }
310
-
311
- function restart(root?: string): void {
312
- stopClients(root)
313
- relintOpenDocuments()
314
- }
315
-
316
- function stop(): void {
317
- for (const timer of timers.values())
318
- clearTimeout(timer)
319
- timers.clear()
320
- stopClients()
321
- }
322
-
323
- const codeActions: vscode.CodeActionProvider = {
324
- async provideCodeActions(document, range, context) {
325
- const actions: vscode.CodeAction[] = []
326
- const only = context.only
327
-
328
- if (only && (only.contains(fixAllKind) || fixAllKind.contains(only))) {
329
- const edit = await fixAllEdit(document)
330
- if (edit) {
331
- const action = new api.CodeAction('Fix all pickier problems', fixAllKind)
332
- action.edit = edit
333
- actions.push(action)
334
- }
335
- return actions
336
- }
337
-
338
- if (only && !only.contains(api.CodeActionKind.QuickFix))
339
- return actions
340
-
341
- const ours = context.diagnostics.filter(diagnostic => diagnostic.source === 'pickier')
342
- if (!ours.length)
343
- return actions
344
-
345
- const edit = await fixAllEdit(document)
346
- if (edit) {
347
- const action = new api.CodeAction('Fix all pickier problems', api.CodeActionKind.QuickFix)
348
- action.edit = edit
349
- action.diagnostics = ours
350
- actions.push(action)
351
- }
352
-
353
- const comment = LINE_COMMENT[document.languageId]
354
- if (comment) {
355
- for (const diagnostic of ours) {
356
- const ruleId = String(diagnostic.code ?? '')
357
- if (!ruleId)
358
- continue
359
- const line = document.lineAt(diagnostic.range.start.line)
360
- const disable = new api.CodeAction(`Disable ${ruleId} for this line`, api.CodeActionKind.QuickFix)
361
- disable.edit = new api.WorkspaceEdit()
362
- disable.edit.insert(document.uri, new api.Position(line.lineNumber, 0), disableNextLineComment(line.text, ruleId, comment))
363
- disable.diagnostics = [diagnostic]
364
- actions.push(disable)
365
- }
366
- }
367
-
368
- return actions
369
- },
370
- }
371
-
372
- const formatter: vscode.DocumentFormattingEditProvider = {
373
- async provideDocumentFormattingEdits(document) {
374
- const result = await run(document, 'format')
375
- if (!result || result.op === 'lint' || result.text === null || result.text === document.getText())
376
- return []
377
- return [api.TextEdit.replace(wholeDocument(document), result.text)]
378
- },
379
- }
380
-
381
- return {
382
- activate(context) {
383
- const selector = PICKIER_LANGUAGES.map(language => ({ language, scheme: 'file' }))
384
-
385
- context.subscriptions.push(
386
- output,
387
- diagnostics,
388
- api.languages.registerCodeActionsProvider(selector, codeActions, {
389
- providedCodeActionKinds: [fixAllKind, api.CodeActionKind.QuickFix],
390
- }),
391
- api.languages.registerDocumentFormattingEditProvider(selector, formatter),
392
- api.commands.registerCommand(PICKIER_COMMANDS.fixAll, async () => {
393
- const document = api.window.activeTextEditor?.document
394
- if (!document)
395
- return
396
- const edit = await fixAllEdit(document)
397
- if (edit)
398
- await api.workspace.applyEdit(edit)
399
- else
400
- void api.window.showInformationMessage('Stacks: pickier has nothing to fix in this file.')
401
- }),
402
- api.commands.registerCommand(PICKIER_COMMANDS.restart, () => restart()),
403
- api.workspace.onDidOpenTextDocument(document => scheduleLint(document, 0)),
404
- api.workspace.onDidChangeTextDocument((event) => {
405
- if (settings().get<string>('pickier.run', 'onType') === 'onType')
406
- scheduleLint(event.document)
407
- }),
408
- api.workspace.onDidSaveTextDocument((document) => {
409
- if (isPickierConfigPath(document.uri.fsPath)) {
410
- const folder = api.workspace.getWorkspaceFolder(document.uri)?.uri.fsPath
411
- restart(folder)
412
- return
413
- }
414
- scheduleLint(document, 0)
415
- }),
416
- api.workspace.onDidCloseTextDocument((document) => {
417
- clearTimeout(timers.get(document.uri.toString()))
418
- diagnostics.delete(document.uri)
419
- }),
420
- api.workspace.onDidChangeConfiguration((event) => {
421
- if (event.affectsConfiguration('stacks.pickier') || event.affectsConfiguration('stacks.bunPath'))
422
- restart()
423
- }),
424
- { dispose: stop },
425
- )
426
-
427
- for (const document of api.workspace.textDocuments)
428
- scheduleLint(document, 0)
429
- },
430
-
431
- deactivate: stop,
432
- }
433
- }