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 +48 -15
- package/api.d.ts +14 -0
- package/display.ts +198 -9
- package/format.ts +148 -0
- package/history.ts +252 -0
- package/index.ts +53 -48
- package/macos-process-cleanup.c +55 -0
- package/overlay-linux.ts +180 -26
- package/overlay-macos.ts +354 -183
- package/package.json +6 -3
- package/placement.ts +333 -0
- package/prelude.ts +361 -80
- package/program-lint.ts +112 -0
- package/runner.ts +667 -84
- package/skills/shorthand/SKILL.md +84 -12
- package/skills/shorthand/advanced-refactors.md +167 -0
- package/text-format.ts +22 -0
- package/skills/shorthand/ast-grep.md +0 -75
- package/skills/shorthand/gritql.md +0 -20
- package/skills/shorthand/writing.md +0 -65
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# pi-shorthand
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
+

|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
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 $`
|
|
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
|
-
- `
|
|
39
|
-
|
|
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
|
-
-
|
|
46
|
-
|
|
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
|
-
-
|
|
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/
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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), ...
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|