pi-shorthand 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # pi-shorthand
2
2
 
3
- A [Pi](https://github.com/earendil-works/pi) tool for token-efficient writes. The model writes a whole
4
- change as one small Bun program, in shorthand, instead of calling `read`, `edit` and `bash` over
5
- and over: fewer tokens, and fewer round trips.
3
+ ![A code call in Pi: the verdict, then the diff it applied, then the program's output](https://raw.githubusercontent.com/sebinsua/pi-shorthand/main/docs/screenshot.png?v=2)
6
4
 
7
- The program sees your repo as normal, but its writes are held back. If it succeeds, they're applied
8
- and the model gets the diff. If it fails, nothing changes.
5
+ A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs.
6
+ The model can combine ordinary JavaScript, text edits and structural transformations in one call.
7
+
8
+ The program sees your repo as normal, but its writes are held back. By default, they're applied only
9
+ if the program succeeds and destination files are unchanged, and the model gets the diff.
10
+ The program performs the edit; tests, type-checks and other verification run separately afterward.
9
11
 
10
12
  ## Install
11
13
 
@@ -15,8 +17,10 @@ pi install npm:pi-shorthand
15
17
 
16
18
  Or from GitHub (`pi install git:github.com/sebinsua/pi-shorthand`), or a local clone: `npm install`, then `pi install /path/to/pi-shorthand`.
17
19
 
20
+ A project install (`pi install -l`) only loads once you trust the project: Pi asks, or run `pi --approve`.
21
+
18
22
  You also need Bun, git, and either [bubblewrap](https://github.com/containers/bubblewrap) 0.9+
19
- (Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) (macOS:
23
+ (Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) and `clang` (macOS:
20
24
  `curl -fsSL https://agentfs.ai/install | bash`).
21
25
 
22
26
  ## What a program can use
@@ -24,28 +28,55 @@ You also need Bun, git, and either [bubblewrap](https://github.com/containers/bu
24
28
  Anything in Bun or Node, plus these globals (no imports):
25
29
 
26
30
  ```ts
27
- await $`bun test src/api.test.ts`; // Bun's shell (the only async one)
31
+ await $`git ls-files`.text(); // Bun's shell (the only async one)
28
32
  glob("src/**/*.ts"); // → ["src/a.ts", …]
29
33
  grep("oldApi(", "src"); // → [{ file, line, text }, …]
30
34
  sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
31
35
  sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src");
36
+ sg.insert("initialize();", { before: sg.one("run();", "src/app.ts") });
37
+ sg.move(sg.one("function helper() { $$$BODY }", "src/old.ts"), { endOf: sg.file("src/new.ts") });
38
+ sg.remove(sg.one("obsolete();", "src/app.ts"));
32
39
  sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
33
40
  grit("`console.log($x)` => `logger.info($x)`", "src");
34
41
  ```
35
42
 
43
+ `sg.find`, `sg.one`, `sg.rewrite` and `grit` also accept `sg.file("src/app.ts")` directly, including
44
+ in arrays mixed with paths. Search reads the file's current contents. Missing targets are valid insertion
45
+ destinations but cannot be searched. An explicit target can select an ignored file inside the workspace;
46
+ the tool still only applies Git-visible changes.
47
+
48
+ [api.d.ts](api.d.ts) exposes the actual injected helper types for editor completion and external TypeScript
49
+ checking of editing programs. Include it in the program's TypeScript project (or reference
50
+ `pi-shorthand/api` via `compilerOptions.types` when installed as a package). This supplies types, not runtime
51
+ globals. Bun execution does not automatically type-check programs or run application verification.
52
+
36
53
  ## Options
37
54
 
38
- - `rollback`: `"all"` (default) applies nothing if the program fails. `"file"` keeps the files it
39
- finished writing.
55
+ - `title`: a short description shown with the call (required).
56
+ - `rollback`: `"all"` (default) applies nothing if the program fails. After a timeout, `"file"`
57
+ keeps changed files that were no longer open for writing if writer inspection succeeds. Other
58
+ failures apply nothing because open writers cannot be identified after the process exits.
40
59
  - `timeout`: seconds before the program is killed. Default 2.
41
60
 
42
61
  ## Good to know
43
62
 
44
63
  - Only files git tracks, or would track, are applied.
45
- - On macOS your repo is briefly swapped for the overlay while a program runs (about 150 ms), so your
46
- editor may notice. On Linux nothing outside the program sees it (about 15 ms).
64
+ - Successful edits are formatted with detected installed project tools (Prettier, oxfmt, Biome, Ruff,
65
+ Black, gofmt or rustfmt) before the final diff. Ambiguous setups are skipped; formatter failures warn
66
+ without discarding completed edits. Set `PI_SHORTHAND_FORMAT=0` to disable. No project config is required.
67
+ - Each run snapshots the checkout first; reflinks make that cheap where supported, while other
68
+ filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
69
+ a private AgentFS mount, so use paths relative to its working directory for repository files.
70
+ - Runs against the same checkout are serialized. If another process edits a destination while a run
71
+ is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
72
+ A non-cooperating writer can still race the final filesystem rename or removal itself.
47
73
  - To try it with only `read` and `code`: `pi --tools read,code`.
48
- - To watch runs as they happen, including each command a program starts: `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
74
+ - Run history is stored in `~/.cache/pi-shorthand/runs.jsonl` with directory mode `0700` and file
75
+ mode `0600`. It records timestamps, opaque run IDs, lifecycle events, exit status, durations,
76
+ counts, helper names, and shell executable names. It does not record programs, output, errors,
77
+ arguments, repository paths, file paths, or diffs. The log rotates at 1 MiB and expires after
78
+ seven days. Set `PI_SHORTHAND_HISTORY=0` to disable it. To watch enabled history:
79
+ `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
49
80
 
50
81
  ## Developing
51
82
 
@@ -55,6 +86,8 @@ hook runs it; `npm run format` fixes formatting.
55
86
  `npm test` runs the tests against real overlays (it needs AgentFS on macOS, bubblewrap on Linux).
56
87
  `test/linux.sh` runs them on Linux in Docker.
57
88
 
58
- `bun e2e/run.ts --repo <path or git URL> --task "…" --setup baseline|code|read-code --check "…"` runs Pi
59
- with a real model on a fresh copy of a repo and summarises what it did (time, turns, tool calls,
60
- tokens, whether the check passed).
89
+ `bun e2e/suite.ts` previews the local benchmark suite without calling a model. Add `--execute` to run it.
90
+ The [comparison harness](e2e/README.md) supports stock, optional and replacement editing tools, controlled
91
+ documentation/skills, independent task checks, saved final changes and session reports.
92
+ `bun e2e/run.ts --repo <path or git URL> --task "…" --setups baseline,replace,code --check "…"` runs Pi
93
+ with a real model on fresh copies of your own repository.
package/api.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Types for programs executed by the code tool. Type-only: this does not install runtime globals. */
2
+ import type { ShorthandGlobals } from "./prelude.ts";
3
+
4
+ declare global {
5
+ const $: ShorthandGlobals["$"];
6
+ const edit: ShorthandGlobals["edit"];
7
+ const glob: ShorthandGlobals["glob"];
8
+ const grep: ShorthandGlobals["grep"];
9
+ const sg: ShorthandGlobals["sg"];
10
+ const grit: ShorthandGlobals["grit"];
11
+ }
12
+
13
+ export type { RewriteResult } from "./prelude.ts";
14
+ export type { ShorthandGlobals };
package/display.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  * on failure the output, then what would have changed; for an exploration, just the output.
8
8
  */
9
9
 
10
- import { keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
10
+ import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { FileChange, RunResult } from "./runner.ts";
12
12
 
13
+ type ToolBackground = "toolSuccessBg" | "toolErrorBg";
14
+
15
+ const ANSI_CUBE_VALUES = [0, 95, 135, 175, 215, 255];
16
+ const ANSI_GRAY_VALUES = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
17
+
13
18
  const OUTPUT_PREVIEW_LINES = 5; // like Pi's bash tool
14
19
  const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files…
15
20
  const LISTED_FILES = 8; // …showing this many, then "and N more files"
@@ -21,6 +26,19 @@ export function callLine(args: { title?: string; timeout?: number; rollback?: st
21
26
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
22
27
  }
23
28
 
29
+ /** Text to show when a completed tool result has no structured RunResult details. */
30
+ export function unstructuredResultText(content: readonly unknown[]): string {
31
+ const text = content
32
+ .flatMap((block) =>
33
+ typeof block === "object" && block !== null && "type" in block && block.type === "text" && "text" in block
34
+ ? [String(block.text)]
35
+ : [],
36
+ )
37
+ .join("\n")
38
+ .trim();
39
+ return text || "Code failed without result details";
40
+ }
41
+
24
42
  /** Lines that belong to the verdict line above them. */
25
43
  function indent(line: string): string {
26
44
  return ` ${line}`;
@@ -43,21 +61,28 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
43
61
  const error = run.exitCode !== 0 && !run.timedOut ? errorMessage(run.output) : undefined;
44
62
  if (error) lines.push(indent(theme.fg("error", error)));
45
63
  if (error && run.errorLine) lines.push(indent(theme.fg("muted", shorten(run.errorLine, 88))));
64
+ for (const file of run.conflicts) lines.push(indent(theme.fg("error", `changed while running: ${file}`)));
46
65
  // A command still running is what it was stuck on; otherwise the last step it logged is a clue.
47
66
  for (const command of run.stillRunning) lines.push(indent(theme.fg("warning", `stuck on $ ${command}`)));
48
67
  if (run.timedOut && run.stillRunning.length === 0 && run.lastStep) {
49
68
  lines.push(indent(theme.fg("warning", `last step: ${run.lastStep}`)));
50
69
  }
51
70
  for (const file of run.rolledBack) {
52
- lines.push(indent(theme.fg("warning", `rolled back ${file}: half-written when the program was killed`)));
71
+ const reason = run.writerInspectionFailed
72
+ ? "open writers could not be inspected at the timeout"
73
+ : run.timedOut
74
+ ? "half-written when the program was killed"
75
+ : "finished writes unknown after the program exited";
76
+ lines.push(indent(theme.fg("warning", `rolled back ${file}: ${reason}`)));
53
77
  }
54
78
  for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
55
79
 
56
80
  // The sections, each after a blank line.
57
81
  const sections: string[][] = [];
58
- if (applied.length > 0) sections.push(diffLines(applied, expanded, theme));
82
+ const background: ToolBackground = run.exitCode === 0 && run.conflicts.length === 0 ? "toolSuccessBg" : "toolErrorBg";
83
+ if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
59
84
  if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
60
- if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme));
85
+ if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
61
86
  for (const section of sections) lines.push("", ...section);
62
87
  return lines;
63
88
  }
@@ -69,6 +94,15 @@ function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
69
94
  const failure = run.timedOut ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
70
95
  const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
71
96
 
97
+ if (run.conflicts.length > 0) {
98
+ const program = run.timedOut
99
+ ? ` · timed out after ${run.timeoutMs / 1000}s`
100
+ : run.exitCode
101
+ ? ` · exit ${run.exitCode}`
102
+ : "";
103
+ const application = applied.length === 0 ? "nothing applied" : `${fileCount(applied)} applied`;
104
+ return theme.fg("error", `✕ Conflict · ${application}${program}`) + took;
105
+ }
72
106
  if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
73
107
  if (run.exitCode === 0) {
74
108
  return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
@@ -104,8 +138,8 @@ function outputLines(output: string, expanded: boolean, labelled: boolean, theme
104
138
  }
105
139
 
106
140
  /** Each file's diff under its name, in Pi's own diff style. A long diff collapses to a list of files. */
107
- function diffLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
108
- const files = changes.map((change) => [fileLine(change, theme), ...renderDiff(toPiDiff(change.patch)).split("\n")]);
141
+ function diffLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
142
+ const files = changes.map((change) => [fileLine(change, theme), ...renderFileDiff(change, theme, background)]);
109
143
  const total = files.reduce((sum, file) => sum + file.length, 0);
110
144
  if (!expanded && total > INLINE_DIFF_LINES) {
111
145
  return [...fileList(changes, theme), theme.fg("muted", `(${keyHint("app.tools.expand", "to see the diff")})`)];
@@ -117,9 +151,152 @@ function diffLines(changes: FileChange[], expanded: boolean, theme: Theme): stri
117
151
  return [...lines.slice(0, EXPANDED_DIFF_LINES), theme.fg("muted", `… ${more} more lines of diff`)];
118
152
  }
119
153
 
120
- function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
154
+ function renderFileDiff(change: FileChange, theme: Theme, background: ToolBackground): string[] {
155
+ const diff = toPiDiff(change.patch);
156
+ const language = getLanguageFromPath(change.path);
157
+ if (!language || diff === " binary file changed") return renderDiff(diff).split("\n");
158
+
159
+ const rendered: string[] = [];
160
+ let hunk: string[] = [];
161
+ const flush = () => {
162
+ if (hunk.length > 0) rendered.push(...renderSyntaxHunk(hunk, language, theme, background));
163
+ hunk = [];
164
+ };
165
+ for (const line of diff.split("\n")) {
166
+ if (/^\s+\.\.\.$/.test(line)) {
167
+ flush();
168
+ rendered.push(theme.fg("toolDiffContext", line));
169
+ } else {
170
+ hunk.push(line);
171
+ }
172
+ }
173
+ flush();
174
+ return rendered;
175
+ }
176
+
177
+ function renderSyntaxHunk(lines: string[], language: string, theme: Theme, background: ToolBackground): string[] {
178
+ const parsed = lines.map((line) => {
179
+ const match = line.match(/^([+\- ])(\s*\d*) (.*)$/);
180
+ return match ? { prefix: match[1], number: match[2], code: match[3] } : undefined;
181
+ });
182
+ const oldLines = parsed.flatMap((line) => (line && line.prefix !== "+" ? [line.code] : []));
183
+ const newLines = parsed.flatMap((line) => (line && line.prefix !== "-" ? [line.code] : []));
184
+ const oldHighlighted = highlightCode(oldLines.join("\n"), language);
185
+ const newHighlighted = highlightCode(newLines.join("\n"), language);
186
+ let oldIndex = 0;
187
+ let newIndex = 0;
188
+
189
+ return parsed.map((line, index) => {
190
+ if (!line) return theme.fg("toolDiffContext", lines[index]);
191
+ if (line.prefix === "-") {
192
+ const content = theme.fg("toolDiffRemoved", `-${line.number} `) + oldHighlighted[oldIndex++];
193
+ return tintedDiffLine(content, "toolDiffRemoved", background, theme);
194
+ }
195
+ if (line.prefix === "+") {
196
+ const content = theme.fg("toolDiffAdded", `+${line.number} `) + newHighlighted[newIndex++];
197
+ return tintedDiffLine(content, "toolDiffAdded", background, theme);
198
+ }
199
+ oldIndex++;
200
+ return theme.fg("toolDiffContext", ` ${line.number} `) + newHighlighted[newIndex++];
201
+ });
202
+ }
203
+
204
+ function tintedDiffLine(
205
+ line: string,
206
+ changeColor: "toolDiffAdded" | "toolDiffRemoved",
207
+ baseBackground: ToolBackground,
208
+ theme: Theme,
209
+ ): string {
210
+ const base = ansiRgb(theme.getBgAnsi(baseBackground));
211
+ const change = ansiRgb(theme.getFgAnsi(changeColor));
212
+ if (!base || !change) return line;
213
+ const [red, green, blue] = base.map((channel, index) => Math.round(channel * 0.86 + change[index] * 0.14));
214
+ const background =
215
+ theme.getColorMode() === "truecolor"
216
+ ? `\x1b[48;2;${red};${green};${blue}m`
217
+ : `\x1b[48;5;${ansi256(red, green, blue)}m`;
218
+ return `${background}${line}${theme.getBgAnsi(baseBackground)}`;
219
+ }
220
+
221
+ function ansiRgb(code: string): [number, number, number] | undefined {
222
+ const truecolor = code.match(/\[(?:38|48);2;(\d+);(\d+);(\d+)m/);
223
+ if (truecolor) return [Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3])];
224
+ const indexed = code.match(/\[(?:38|48);5;(\d+)m/);
225
+ return indexed ? rgbFromAnsi256(Number(indexed[1])) : undefined;
226
+ }
227
+
228
+ function rgbFromAnsi256(index: number): [number, number, number] | undefined {
229
+ if (index < 0 || index > 255) return undefined;
230
+ if (index < 16) {
231
+ const palette = [
232
+ [0, 0, 0],
233
+ [128, 0, 0],
234
+ [0, 128, 0],
235
+ [128, 128, 0],
236
+ [0, 0, 128],
237
+ [128, 0, 128],
238
+ [0, 128, 128],
239
+ [192, 192, 192],
240
+ [128, 128, 128],
241
+ [255, 0, 0],
242
+ [0, 255, 0],
243
+ [255, 255, 0],
244
+ [0, 0, 255],
245
+ [255, 0, 255],
246
+ [0, 255, 255],
247
+ [255, 255, 255],
248
+ ] as const;
249
+ return [...palette[index]];
250
+ }
251
+ if (index >= 232) {
252
+ const gray = 8 + (index - 232) * 10;
253
+ return [gray, gray, gray];
254
+ }
255
+ const cube = index - 16;
256
+ return [
257
+ ansiCubeChannel(Math.floor(cube / 36)),
258
+ ansiCubeChannel(Math.floor((cube % 36) / 6)),
259
+ ansiCubeChannel(cube % 6),
260
+ ];
261
+ }
262
+
263
+ function ansiCubeChannel(value: number): number {
264
+ return value === 0 ? 0 : 55 + value * 40;
265
+ }
266
+
267
+ function ansi256(red: number, green: number, blue: number): number {
268
+ const [redIndex, greenIndex, blueIndex] = [red, green, blue].map((channel) =>
269
+ closestIndex(channel, ANSI_CUBE_VALUES),
270
+ );
271
+ const cube = [ANSI_CUBE_VALUES[redIndex], ANSI_CUBE_VALUES[greenIndex], ANSI_CUBE_VALUES[blueIndex]];
272
+ const cubeIndex = 16 + 36 * redIndex + 6 * greenIndex + blueIndex;
273
+ const cubeDistance = colorDistance([red, green, blue], cube);
274
+
275
+ const luminance = Math.round(0.299 * red + 0.587 * green + 0.114 * blue);
276
+ const grayOffset = closestIndex(luminance, ANSI_GRAY_VALUES);
277
+ const gray = ANSI_GRAY_VALUES[grayOffset];
278
+ const grayDistance = colorDistance([red, green, blue], [gray, gray, gray]);
279
+ const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
280
+ return spread < 10 && grayDistance < cubeDistance ? 232 + grayOffset : cubeIndex;
281
+ }
282
+
283
+ function closestIndex(value: number, choices: number[]): number {
284
+ let closest = 0;
285
+ for (let index = 1; index < choices.length; index++) {
286
+ if (Math.abs(value - choices[index]) < Math.abs(value - choices[closest])) closest = index;
287
+ }
288
+ return closest;
289
+ }
290
+
291
+ function colorDistance(first: number[], second: number[]): number {
292
+ return (
293
+ (first[0] - second[0]) ** 2 * 0.299 + (first[1] - second[1]) ** 2 * 0.587 + (first[2] - second[2]) ** 2 * 0.114
294
+ );
295
+ }
296
+
297
+ function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
121
298
  const heading = theme.fg("muted", `Would have changed ${fileCount(changes)} · `) + stats(changes, theme);
122
- if (expanded) return [heading, "", ...diffLines(changes, true, theme)];
299
+ if (expanded) return [heading, "", ...diffLines(changes, true, theme, background)];
123
300
  return [heading + theme.fg("muted", ` (${keyHint("app.tools.expand", "to see the diff")})`)];
124
301
  }
125
302
 
@@ -133,7 +310,19 @@ function fileList(changes: FileChange[], theme: Theme): string[] {
133
310
  /** e.g. "src/a.ts +3 −1", or "src/new.ts (new) +12 −0" */
134
311
  function fileLine(change: FileChange, theme: Theme): string {
135
312
  const kind = change.kind === "modified" ? "" : theme.fg("muted", change.kind === "added" ? " (new)" : " (deleted)");
136
- return `${theme.fg("accent", change.path)}${kind} ${stats([change], theme)}`;
313
+ const metadata = fileMetadataSummary(change);
314
+ return `${theme.fg("accent", change.path)}${kind}${metadata ? theme.fg("muted", ` (${metadata})`) : ""} ${stats([change], theme)}`;
315
+ }
316
+
317
+ export function fileMetadataSummary(change: FileChange): string {
318
+ if (change.beforeType && change.afterType && change.beforeType !== change.afterType) {
319
+ return `${change.beforeType} → ${change.afterType}`;
320
+ }
321
+ if (change.afterType === "symlink" && change.beforeType !== "symlink") return "symlink";
322
+ if (change.beforeMode !== undefined && change.afterMode !== undefined && change.beforeMode !== change.afterMode) {
323
+ return `${change.beforeMode.toString(8)} → ${change.afterMode.toString(8)}`;
324
+ }
325
+ return "";
137
326
  }
138
327
 
139
328
  /** "+6 −2", in the diff colours. */
package/format.ts ADDED
@@ -0,0 +1,148 @@
1
+ /** Best-effort formatting using existing project tools. Runs inside the editing workspace. */
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, extname, join, relative, resolve } from "node:path";
4
+
5
+ type Command = { name: string; executable: string; args: string[]; cwd: string };
6
+
7
+ function text(file: string): string {
8
+ try {
9
+ return readFileSync(file, "utf8");
10
+ } catch {
11
+ return "";
12
+ }
13
+ }
14
+
15
+ function directories(start: string, root: string): string[] {
16
+ const result = [];
17
+ for (let dir = start; ; dir = dirname(dir)) {
18
+ result.push(dir);
19
+ if (dir === root || dirname(dir) === dir) return result;
20
+ }
21
+ }
22
+
23
+ export function formatterFor(file: string, root: string): Command | null {
24
+ root = resolve(root);
25
+ const extension = extname(file);
26
+ const js = /\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(extension);
27
+ for (const cwd of directories(dirname(resolve(root, file)), root)) {
28
+ const has = (...names: string[]) => names.some((name) => existsSync(join(cwd, name)));
29
+ let name: string | undefined;
30
+ let args: string[] = [];
31
+ if (js) {
32
+ let pkg: {
33
+ scripts?: Record<string, string>;
34
+ dependencies?: Record<string, string>;
35
+ devDependencies?: Record<string, string>;
36
+ prettier?: unknown;
37
+ };
38
+ try {
39
+ pkg = JSON.parse(text(join(cwd, "package.json")) || "{}");
40
+ } catch {
41
+ return null;
42
+ }
43
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
44
+ const choices = [
45
+ ...(deps.prettier ||
46
+ pkg.prettier !== undefined ||
47
+ has(
48
+ ".prettierrc",
49
+ ".prettierrc.json",
50
+ ".prettierrc.yaml",
51
+ ".prettierrc.yml",
52
+ ".prettierrc.js",
53
+ ".prettierrc.cjs",
54
+ ".prettierrc.mjs",
55
+ "prettier.config.js",
56
+ "prettier.config.cjs",
57
+ "prettier.config.mjs",
58
+ "prettier.config.ts",
59
+ )
60
+ ? ["prettier"]
61
+ : []),
62
+ ...(deps.oxfmt || has(".oxfmtrc.json", ".oxfmtrc.jsonc") ? ["oxfmt"] : []),
63
+ ...(deps["@biomejs/biome"] || has("biome.json", "biome.jsonc") ? ["biome"] : []),
64
+ ];
65
+ // Recognize the executable, never execute an arbitrary package script.
66
+ const script = pkg.scripts?.format;
67
+ const scripted = script?.match(/^(prettier|oxfmt|biome)(?:\s|$)/)?.[1];
68
+ // Custom wrappers/options may encode conventions we cannot reproduce. Leave them alone.
69
+ if (
70
+ script &&
71
+ (!scripted ||
72
+ /[;&|`$<>]/.test(script) ||
73
+ (script.match(/(?<!\S)--?[\w-]+(?:=\S+)?/g) ?? []).some(
74
+ (flag) => !["--write", "--check", "--ignore-unknown"].includes(flag),
75
+ ))
76
+ )
77
+ return null;
78
+ if (!scripted && choices.length > 1) return null;
79
+ name = scripted ?? choices[0];
80
+ args =
81
+ name === "prettier"
82
+ ? ["--write", "--ignore-unknown"]
83
+ : name === "biome"
84
+ ? ["format", "--write", "--files-ignore-unknown=true"]
85
+ : [];
86
+ } else if (extension === ".py" || extension === ".pyi") {
87
+ const config = text(join(cwd, "pyproject.toml"));
88
+ const ruff = has("ruff.toml", ".ruff.toml") || /\[tool\.ruff(?:\.|\])/.test(config);
89
+ const black = /\[tool\.black\]/.test(config);
90
+ if (ruff && black) return null;
91
+ name = ruff ? "ruff" : black ? "black" : undefined;
92
+ args = name === "ruff" ? ["format"] : [];
93
+ } else if (extension === ".go" && has("go.mod", "go.work")) {
94
+ name = "gofmt";
95
+ args = ["-w"];
96
+ } else if (extension === ".rs" && has("Cargo.toml")) {
97
+ const cargo = text(join(cwd, "Cargo.toml"));
98
+ if (/edition\s*\.\s*workspace\s*=/.test(cargo)) return null;
99
+ name = "rustfmt";
100
+ args = ["--edition", cargo.match(/^\s*edition\s*=\s*["'](\d+)["']/m)?.[1] ?? "2015"];
101
+ }
102
+ if (!name) continue;
103
+ const search = directories(cwd, root);
104
+ const candidates = search.map((dir) => join(dir, js ? "node_modules/.bin" : ".venv/bin", name!));
105
+ // JS formatters must belong to this project, not the extension's dependencies.
106
+ const executable = candidates.find((candidate) => Bun.which(candidate)) ?? (!js ? Bun.which(name) : null);
107
+ return executable ? { name, executable, args, cwd } : null;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ export async function formatChanged(
113
+ files: string[],
114
+ root: string,
115
+ ): Promise<{ messages: string[]; warnings: string[] }> {
116
+ const groups = new Map<string, { command: Command; files: string[] }>();
117
+ const result = { messages: [] as string[], warnings: [] as string[] };
118
+ for (const file of files) {
119
+ const command = formatterFor(file, root);
120
+ if (!command) continue;
121
+ const key = JSON.stringify(command);
122
+ const group = groups.get(key) ?? { command, files: [] };
123
+ group.files.push("./" + relative(command.cwd, resolve(root, file)));
124
+ groups.set(key, group);
125
+ }
126
+ for (const { command, files: targets } of groups.values()) {
127
+ try {
128
+ const child = Bun.spawn([command.executable, ...command.args, ...targets], {
129
+ cwd: command.cwd,
130
+ stdout: "pipe",
131
+ stderr: "pipe",
132
+ });
133
+ const [code, stdout, stderr] = await Promise.all([
134
+ child.exited,
135
+ new Response(child.stdout).text(),
136
+ new Response(child.stderr).text(),
137
+ ]);
138
+ if (code !== 0)
139
+ result.warnings.push(
140
+ `${command.name} formatting failed: ${(stderr || stdout).trim().slice(-2000) || `exit ${code}`}`,
141
+ );
142
+ else result.messages.push(`Formatted ${targets.length} file(s) with ${command.name}.`);
143
+ } catch (error) {
144
+ result.warnings.push(`${command.name} formatting failed: ${String(error)}`);
145
+ }
146
+ }
147
+ return result;
148
+ }