pi-shorthand 0.1.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/runner.ts ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Runs a Bun program against a git repository at its real path. The program's writes go to a
3
+ * copy-on-write overlay; afterwards they're diffed, then applied to the repository or discarded.
4
+ *
5
+ * Usage: echo '<RunOptions as JSON>' | bun runner.ts → prints a RunResult as JSON
6
+ * SIGTERM aborts: the program is killed, the overlay is closed and nothing is applied.
7
+ * Each step is logged to ~/.cache/pi-shorthand/runs.jsonl, so `tail -f` shows what a run is doing.
8
+ *
9
+ * If the program fails:
10
+ * - rollback "all": nothing is applied;
11
+ * - rollback "file": every file it finished writing is applied. Files it (or a subprocess) still
12
+ * had open for writing when it was killed may be half-written, so they're rolled back.
13
+ */
14
+
15
+ import { type ChildProcess, spawn } from "node:child_process";
16
+ import { appendFileSync, mkdirSync } from "node:fs";
17
+ import * as fs from "node:fs/promises";
18
+ import { homedir, tmpdir } from "node:os";
19
+ import * as path from "node:path";
20
+ import { Lang, parse } from "@ast-grep/napi";
21
+ import { $ } from "bun";
22
+ import { structuredPatch } from "diff";
23
+ import { openLinuxOverlay } from "./overlay-linux.ts";
24
+ import { openMacOverlay } from "./overlay-macos.ts";
25
+
26
+ export interface RunOptions {
27
+ runId: string; // identifies this run's events in the log
28
+ cwd: string;
29
+ program: string;
30
+ timeoutMs: number;
31
+ rollback: "all" | "file";
32
+ }
33
+
34
+ export interface RunResult {
35
+ exitCode: number | null; // null if it was killed
36
+ timedOut: boolean;
37
+ durationMs: number;
38
+ output: string; // stdout and stderr
39
+ warnings: string[]; // likely mistakes spotted in the program before it ran
40
+ changes: FileChange[]; // everything the program changed
41
+ applied: string[]; // the changed files that were applied
42
+ rolledBack: string[]; // rollback "file": changed files left half-written, so not applied
43
+ stillRunning: string[]; // on timeout: commands the program was still running, e.g. "find / -name x (for 58s)"
44
+ lastStep?: string; // on timeout: the last step the program logged, e.g. "$ find / -name x" or "grep (18 ms)"
45
+ errorLine?: string; // on failure: the program's line the error came from, e.g. "line 3: throw new Error(…)"
46
+ timeoutMs: number;
47
+ rollback: RunOptions["rollback"];
48
+ }
49
+
50
+ export interface FileChange {
51
+ path: string; // relative to the tool's working directory
52
+ kind: "added" | "modified" | "deleted";
53
+ patch: string;
54
+ }
55
+
56
+ /** A copy-on-write view of the repository at its real path. */
57
+ export interface Overlay {
58
+ originalDir: string; // where the original files are while the overlay is open
59
+ writableDir: string; // writing a file here puts it into the overlay
60
+ gitExcludes: string[]; // extra patterns git should ignore inside the overlay
61
+ wrap(command: string[], cwd: string): string[]; // makes a command run inside the overlay
62
+ changes(): Promise<{ file: string; contents: Uint8Array | null }[]>; // may include files only read
63
+ close(): Promise<void>;
64
+ }
65
+
66
+ /** A changed file, relative to the repository root, with its new contents (null if deleted). */
67
+ interface Change {
68
+ file: string;
69
+ before: Uint8Array | null;
70
+ after: Uint8Array | null;
71
+ }
72
+
73
+ const PROGRAM_FILE = ".pi-shorthand-program.ts";
74
+ const PRELUDE = path.join(import.meta.dir, "prelude.ts");
75
+ // Our dependencies are in the nearest node_modules that has them: our own, or, when npm hoisted them
76
+ // (e.g. Pi's project installs), one further up. Like `npm run`, look in every one from here up.
77
+ const NODE_MODULES = ancestors(import.meta.dir).map((dir) => path.join(dir, "node_modules"));
78
+ const MAX_OUTPUT_CHARS = 1024 * 1024; // a safety cap; index.ts decides how much the model sees
79
+ const LOG_FILE = path.join(homedir(), ".cache", "pi-shorthand", "runs.jsonl");
80
+ let RUN_ID = ""; // set from RunOptions when the runner starts
81
+
82
+ /** Appends one event to the log, e.g. log("program exited", { exitCode: 0 }). */
83
+ function log(event: string, details: Record<string, unknown> = {}) {
84
+ appendFileSync(LOG_FILE, `${JSON.stringify({ time: new Date().toISOString(), run: RUN_ID, event, ...details })}\n`);
85
+ }
86
+
87
+ async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult> {
88
+ const startedAt = performance.now();
89
+ const cwd = await fs.realpath(options.cwd);
90
+ const repo = await findRepository(cwd);
91
+ const tempDir = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-"));
92
+
93
+ try {
94
+ log("started", { repo, cwd, timeoutMs: options.timeoutMs, rollback: options.rollback });
95
+ const open = process.platform === "darwin" ? openMacOverlay : openLinuxOverlay;
96
+ const overlay = await open(repo, tempDir);
97
+ log("overlay opened");
98
+ let program: ProgramRun;
99
+ let changes: Change[];
100
+ try {
101
+ program = await runProgram({ ...options, cwd }, abort, overlay, repo, tempDir);
102
+ changes = await findChanges(overlay);
103
+ } finally {
104
+ await overlay.close();
105
+ }
106
+
107
+ const { applied, rolledBack } = whatToApply(changes, program, options.rollback, abort.aborted);
108
+ await applyChanges(repo, applied);
109
+ log("finished", { changed: changes.map((change) => change.file), applied: applied.map((change) => change.file) });
110
+
111
+ const shown = (file: string) => path.relative(cwd, path.join(repo, file));
112
+ return {
113
+ exitCode: program.exitCode,
114
+ timedOut: program.timedOut,
115
+ durationMs: Math.round(performance.now() - startedAt),
116
+ output: program.output,
117
+ warnings: lint(options.program),
118
+ changes: changes.map((change) => describe(shown(change.file), change)),
119
+ applied: applied.map((change) => shown(change.file)),
120
+ rolledBack: rolledBack.map((change) => shown(change.file)),
121
+ stillRunning: program.stillRunning,
122
+ lastStep: program.timedOut ? await lastLoggedStep() : undefined,
123
+ errorLine: program.exitCode !== 0 ? failingLine(options.program, program.output) : undefined,
124
+ timeoutMs: options.timeoutMs,
125
+ rollback: options.rollback,
126
+ };
127
+ } finally {
128
+ await fs.rm(tempDir, { recursive: true, force: true });
129
+ }
130
+ }
131
+
132
+ /** Nothing if aborted. Everything if the program succeeded. If it failed, nothing, unless rollback is "file". */
133
+ function whatToApply(changes: Change[], program: ProgramRun, rollback: RunOptions["rollback"], aborted: boolean) {
134
+ if (aborted) return { applied: [], rolledBack: [] };
135
+ if (program.exitCode === 0) return { applied: changes, rolledBack: [] };
136
+ if (rollback === "all") return { applied: [], rolledBack: [] };
137
+
138
+ const halfWritten = (change: Change) => program.openForWriting.includes(change.file);
139
+ return { applied: changes.filter((change) => !halfWritten(change)), rolledBack: changes.filter(halfWritten) };
140
+ }
141
+
142
+ /** A `$` command that isn't awaited never runs: Bun's shell starts a command when it's awaited. */
143
+ const UNAWAITED_SHELL = {
144
+ rule: {
145
+ kind: "call_expression",
146
+ has: { field: "function", regex: "^\\$$" },
147
+ not: {
148
+ inside: {
149
+ any: [{ kind: "await_expression" }, { kind: "return_statement" }],
150
+ stopBy: { kind: "statement_block" },
151
+ },
152
+ },
153
+ },
154
+ };
155
+
156
+ /** Likely mistakes in the program, found without running it. */
157
+ function lint(program: string): string[] {
158
+ const root = parse(Lang.TypeScript, program).root();
159
+ return root.findAll(UNAWAITED_SHELL).map((node) => {
160
+ const line = node.range().start.line + 1;
161
+ return `line ${line}: ${node.text().split("\n")[0]} isn't awaited, so the command may not have run`;
162
+ });
163
+ }
164
+
165
+ async function findRepository(cwd: string): Promise<string> {
166
+ const result = await $`git rev-parse --show-toplevel`.cwd(cwd).nothrow().quiet();
167
+ if (result.exitCode !== 0) throw new Error("The code tool only works inside a git repository.");
168
+ return fs.realpath(result.text().trim());
169
+ }
170
+
171
+ // ── Running the program ───────────────────────────────────────────────────────────
172
+
173
+ interface ProgramRun {
174
+ exitCode: number | null;
175
+ timedOut: boolean;
176
+ output: string;
177
+ openForWriting: string[]; // on timeout: files it still had open for writing
178
+ stillRunning: string[]; // on timeout: the commands it was still running
179
+ }
180
+
181
+ async function runProgram(
182
+ options: RunOptions,
183
+ abort: AbortSignal,
184
+ overlay: Overlay,
185
+ repo: string,
186
+ tempDir: string,
187
+ ): Promise<ProgramRun> {
188
+ // The program file goes into the working directory, so its relative imports resolve as usual.
189
+ const programPath = path.join(options.cwd, PROGRAM_FILE);
190
+ const programFile = path.join(overlay.writableDir, path.relative(repo, programPath));
191
+ await Bun.write(programFile, options.program);
192
+
193
+ const excludesFile = path.join(tempDir, "exclude");
194
+ await Bun.write(excludesFile, [PROGRAM_FILE, ...overlay.gitExcludes, await globalGitExcludes()].join("\n"));
195
+
196
+ // Output goes to a file rather than a pipe, so a process the program leaves running can't hold it open.
197
+ // detached: the program gets its own process group, so killing the group kills anything it started too.
198
+ const outputFile = path.join(tempDir, "output");
199
+ const output = await fs.open(outputFile, "w");
200
+ const [command, ...args] = overlay.wrap([process.execPath, "--preload", PRELUDE, programPath], options.cwd);
201
+ log("program started", { output: outputFile, timeoutMs: options.timeoutMs });
202
+ const child = spawn(command, args, {
203
+ cwd: options.cwd,
204
+ detached: true,
205
+ stdio: ["ignore", output.fd, output.fd],
206
+ env: { ...process.env, ...programEnvironment(excludesFile) },
207
+ });
208
+ const killAll = () => killGroup(child);
209
+ abort.addEventListener("abort", killAll);
210
+
211
+ const { exitCode, timedOut, openForWriting, stillRunning } = await waitWithTimeout(child, options.timeoutMs, repo);
212
+ log("program exited", { exitCode, timedOut, aborted: abort.aborted, stillRunning });
213
+ killAll(); // anything it left running
214
+ abort.removeEventListener("abort", killAll);
215
+ await output.close();
216
+ await fs.rm(programFile, { force: true });
217
+
218
+ // Keep the tail, where errors are. Show stack traces as "program.ts:3:11", and drop Bun's version footer.
219
+ let text = await Bun.file(outputFile).text();
220
+ if (text.length > MAX_OUTPUT_CHARS) {
221
+ text = `[${text.length - MAX_OUTPUT_CHARS} earlier characters dropped]\n${text.slice(-MAX_OUTPUT_CHARS)}`;
222
+ }
223
+ text = text.replaceAll(programPath, "program.ts").replace(/\nBun v[\d.]+ \([^)]*\)\n?$/, "\n");
224
+
225
+ return { exitCode, timedOut, output: text, openForWriting, stillRunning };
226
+ }
227
+
228
+ /**
229
+ * Waits for the program to exit, or kills it after timeoutMs. Before killing it, notes which files
230
+ * it (or anything it started) still has open for writing, since they may be half-written, and which
231
+ * commands it was still running, since one of them is probably why it timed out.
232
+ */
233
+ async function waitWithTimeout(child: ChildProcess, timeoutMs: number, repo: string) {
234
+ const exited = new Promise<number | null>((resolve) => child.on("exit", (code) => resolve(code)));
235
+ let timer: Timer | undefined;
236
+ const timeout = new Promise<"timeout">((resolve) => {
237
+ timer = setTimeout(() => resolve("timeout"), timeoutMs);
238
+ });
239
+ const winner = await Promise.race([exited, timeout]);
240
+ clearTimeout(timer);
241
+ if (winner !== "timeout") return { exitCode: winner, timedOut: false, openForWriting: [], stillRunning: [] };
242
+
243
+ const [openForWriting, stillRunning] = await Promise.all([
244
+ filesOpenForWriting(repo, child.pid!),
245
+ commandsRunning(child.pid!),
246
+ ]);
247
+ killGroup(child);
248
+ await exited;
249
+ return { exitCode: null, timedOut: true, openForWriting, stillRunning };
250
+ }
251
+
252
+ /**
253
+ * The commands running in a process group, with how long each has been running, e.g.
254
+ * "find / -name x (running 58s)". Leaves out the program itself and bubblewrap, which wraps it.
255
+ */
256
+ async function commandsRunning(processGroup: number): Promise<string[]> {
257
+ const pids = (await $`pgrep -g ${processGroup}`.nothrow().quiet().text()).split("\n").filter(Boolean);
258
+ if (pids.length === 0) return [];
259
+ const output = await $`ps -o etime=,command= -p ${pids.join(",")}`.nothrow().quiet().text();
260
+
261
+ const commands: string[] = [];
262
+ for (const line of output.split("\n")) {
263
+ const match = line.trim().match(/^(\S+)\s+(.+)$/);
264
+ if (!match) continue;
265
+ const [, elapsed, command] = match;
266
+ if (command.includes(PROGRAM_FILE) || /^\S*bwrap /.test(command)) continue;
267
+ commands.push(`${command.slice(0, 200)} (for ${seconds(elapsed)}s)`);
268
+ }
269
+ return commands;
270
+ }
271
+
272
+ /**
273
+ * The line of the program an error came from, found from Bun's "at program.ts:3:11" rather than its
274
+ * source excerpt, which leaves out long lines and then mislabels the ones around them.
275
+ */
276
+ function failingLine(program: string, output: string): string | undefined {
277
+ const location = output.match(/\bprogram\.ts:(\d+):\d+/);
278
+ if (!location) return undefined;
279
+ const text = program.split("\n")[Number(location[1]) - 1]?.trim();
280
+ return text ? `line ${location[1]}: ${text}`.slice(0, 160) : undefined;
281
+ }
282
+
283
+ /** ps's elapsed time, "[[dd-]hh:]mm:ss", in seconds. */
284
+ function seconds(elapsed: string): number {
285
+ const [days, clock] = elapsed.includes("-") ? elapsed.split("-") : ["0", elapsed];
286
+ const parts = clock.split(":").map(Number);
287
+ const [hours, minutes, secs] = [0, 0, ...parts].slice(-3);
288
+ return Number(days) * 86400 + hours * 3600 + minutes * 60 + secs;
289
+ }
290
+
291
+ /** The last command or helper call this run's program logged, e.g. "$ find / -name x". */
292
+ async function lastLoggedStep(): Promise<string | undefined> {
293
+ const lines = (await Bun.file(LOG_FILE).text()).trimEnd().split("\n").slice(-500);
294
+ for (const line of lines.toReversed()) {
295
+ const event = JSON.parse(line);
296
+ if (event.run !== RUN_ID) continue;
297
+ if (event.event === "command") return `$ ${event.command}`;
298
+ if (event.event === "helper") return `${event.helper}(${event.args.slice(1, -1)}) (${event.ms} ms)`;
299
+ }
300
+ return undefined;
301
+ }
302
+
303
+ function killGroup(child: ChildProcess) {
304
+ try {
305
+ process.kill(-child.pid!, "SIGKILL");
306
+ } catch {
307
+ // already gone
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Adds our git excludes on top of any GIT_CONFIG_* the user already set. core.excludesFile
313
+ * replaces the user's global excludes file, so globalGitExcludes() copies that file's patterns in.
314
+ */
315
+ function programEnvironment(excludesFile: string) {
316
+ const count = Number(process.env.GIT_CONFIG_COUNT ?? 0);
317
+ return {
318
+ PATH: [...NODE_MODULES.map((dir) => path.join(dir, ".bin")), process.env.PATH].join(path.delimiter),
319
+ NO_COLOR: "1",
320
+ // So programs can import the extension's own packages, e.g. "@ast-grep/napi". A repository's own
321
+ // node_modules still wins: NODE_PATH is only a fallback.
322
+ NODE_PATH: [...NODE_MODULES, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
323
+ PI_SHORTHAND_LOG: LOG_FILE, // the prelude logs each command and helper call here
324
+ PI_SHORTHAND_RUN: RUN_ID,
325
+ GIT_OPTIONAL_LOCKS: "0", // on macOS .git is the real one: don't let `git status` write to it
326
+ GIT_CONFIG_COUNT: String(count + 1),
327
+ [`GIT_CONFIG_KEY_${count}`]: "core.excludesFile",
328
+ [`GIT_CONFIG_VALUE_${count}`]: excludesFile,
329
+ };
330
+ }
331
+
332
+ /** The patterns in the user's global git excludes file, if they have one. */
333
+ async function globalGitExcludes(): Promise<string> {
334
+ const configured = (await $`git config --global --path --get core.excludesFile`.nothrow().quiet().text()).trim();
335
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config");
336
+ const file = Bun.file(configured || path.join(xdgConfig, "git", "ignore"));
337
+ return (await file.exists()) ? file.text() : "";
338
+ }
339
+
340
+ /** Files under dir that any process in a process group has open for writing, relative to dir. */
341
+ async function filesOpenForWriting(dir: string, processGroup: number): Promise<string[]> {
342
+ // -F an: one field per line. "a" is the access mode (r, w, or u for read/write), "n" the file name.
343
+ const output = await $`lsof -n -P -F an -g ${processGroup}`.nothrow().quiet().text();
344
+
345
+ const files: string[] = [];
346
+ let access = "";
347
+ for (const line of output.split("\n")) {
348
+ if (line.startsWith("a")) access = line.slice(1);
349
+ if (line.startsWith(`n${dir}/`) && access !== "r") files.push(path.relative(dir, line.slice(1)));
350
+ }
351
+ return files;
352
+ }
353
+
354
+ // ── Finding, describing and applying changes ──────────────────────────────────────
355
+
356
+ /** Compares what the overlay reports with the originals. Only files git sees count. */
357
+ async function findChanges(overlay: Overlay): Promise<Change[]> {
358
+ const candidates = (await overlay.changes()).filter(({ file }) => path.basename(file) !== PROGRAM_FILE);
359
+ const ignored = await gitIgnored(
360
+ overlay.originalDir,
361
+ candidates.map(({ file }) => file),
362
+ );
363
+
364
+ const changes: Change[] = [];
365
+ const seen = new Set<string>(); // an overlay may report a file twice, e.g. a deleted directory and the files in it
366
+ for (const { file, contents: after } of candidates) {
367
+ if (ignored.has(file) || seen.has(file)) continue;
368
+ seen.add(file);
369
+ const before = await readFile(path.join(overlay.originalDir, file));
370
+ if (!before && !after) continue;
371
+ if (before && after && Buffer.from(before).equals(after)) continue; // read, not changed
372
+ changes.push({ file, before, after });
373
+ }
374
+ return changes.toSorted((a, b) => a.file.localeCompare(b.file));
375
+ }
376
+
377
+ async function gitIgnored(dir: string, files: string[]): Promise<Set<string>> {
378
+ if (files.length === 0) return new Set();
379
+ const input = new Response(`${files.join("\0")}\0`);
380
+ const output = await $`git check-ignore -z --stdin < ${input}`.cwd(dir).nothrow().quiet().text();
381
+ return new Set(output.split("\0").filter(Boolean));
382
+ }
383
+
384
+ async function applyChanges(repo: string, changes: Change[]) {
385
+ for (const { file, after } of changes) {
386
+ const target = path.join(repo, file);
387
+ if (!after) {
388
+ await fs.rm(target, { force: true });
389
+ continue;
390
+ }
391
+ // Write next to the target, then rename, so each file is replaced atomically.
392
+ const temp = `${target}.pi-shorthand.tmp`;
393
+ await Bun.write(temp, after);
394
+ const original = await fs.stat(target).catch(() => null);
395
+ if (original) await fs.chmod(temp, original.mode);
396
+ await fs.rename(temp, target);
397
+ }
398
+ }
399
+
400
+ /** A change as a git-style patch. */
401
+ function describe(file: string, { before, after }: Change): FileChange {
402
+ const kind = !before ? "added" : !after ? "deleted" : "modified";
403
+ const lines = [`diff --git a/${file} b/${file}`];
404
+ if (kind !== "modified") lines.push(`${kind === "added" ? "new" : "deleted"} file`);
405
+
406
+ const oldBytes = before ?? new Uint8Array();
407
+ const newBytes = after ?? new Uint8Array();
408
+ if (isBinary(oldBytes) || isBinary(newBytes)) {
409
+ lines.push("Binary file changed");
410
+ return { path: file, kind, patch: lines.join("\n") };
411
+ }
412
+
413
+ lines.push(kind === "added" ? "--- /dev/null" : `--- a/${file}`);
414
+ lines.push(kind === "deleted" ? "+++ /dev/null" : `+++ b/${file}`);
415
+ const oldText = new TextDecoder().decode(oldBytes);
416
+ const newText = new TextDecoder().decode(newBytes);
417
+ for (const hunk of structuredPatch(file, file, oldText, newText, "", "", { context: 3 }).hunks) {
418
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines);
419
+ }
420
+ return { path: file, kind, patch: lines.join("\n") };
421
+ }
422
+
423
+ function isBinary(bytes: Uint8Array): boolean {
424
+ return bytes.subarray(0, 8000).includes(0);
425
+ }
426
+
427
+ /** dir and each directory above it, up to the root. */
428
+ function ancestors(dir: string): string[] {
429
+ const parent = path.dirname(dir);
430
+ return parent === dir ? [dir] : [dir, ...ancestors(parent)];
431
+ }
432
+
433
+ /** A regular file's contents, or null if there's no regular file there. */
434
+ async function readFile(file: string): Promise<Uint8Array | null> {
435
+ const stats = await fs.lstat(file).catch(() => null);
436
+ return stats?.isFile() ? Bun.file(file).bytes() : null;
437
+ }
438
+
439
+ if (import.meta.main) {
440
+ mkdirSync(path.dirname(LOG_FILE), { recursive: true });
441
+ const abort = new AbortController();
442
+ process.on("SIGTERM", () => abort.abort());
443
+ const options: RunOptions = await Bun.stdin.json();
444
+ RUN_ID = options.runId;
445
+ try {
446
+ console.log(JSON.stringify(await run(options, abort.signal)));
447
+ } catch (error) {
448
+ log("failed", { error: String(error) });
449
+ throw error;
450
+ }
451
+ }
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: shorthand
3
+ description: How to write a whole change as one code program, with Bun's file and shell APIs, checks, and ast-grep or GritQL rewrites. Use when a code program edits several files, rewrites code by its structure, or checks its own change.
4
+ ---
5
+
6
+ # Shorthand
7
+
8
+ A `code` program is a transaction: Bun runs it against the repository, and its writes are applied only
9
+ if it exits successfully. So write the whole change as one program: find what to change, change it,
10
+ check it, and print a short summary.
11
+
12
+ - [writing.md](writing.md): the everyday part. The environment, Bun's file and shell APIs, and the
13
+ shape of a program that edits and checks many files.
14
+ - [ast-grep.md](ast-grep.md): when a text replace isn't safe. Structural search and rewrite, rule
15
+ objects, and ast-grep's own API.
16
+ - [gritql.md](gritql.md): GritQL rewrites, including other languages.
17
+
18
+ A failed program changes nothing (unless you passed `rollback: "file"`), so fix it and run it again
19
+ without asking. It's done when its own checks pass (no leftover matches, and `tsc` or the relevant
20
+ tests where the change could break them) and the diff shows what you meant.
@@ -0,0 +1,75 @@
1
+ # ast-grep patterns
2
+
3
+ A pattern is ordinary code with metavariables. It has to parse on its own, so match a whole
4
+ expression or statement (`oldApi($$$A)`, `const $X = $Y`), not a fragment.
5
+
6
+ - `$X` matches one node and captures its text, as `m.X` (or `m.vars.X`).
7
+ - `$$$X` matches zero or more nodes, e.g. all the arguments, keeping the original text, commas
8
+ included.
9
+ - `$_` matches one node without capturing it.
10
+
11
+ ```ts
12
+ sg.find("oldApi($$$ARGS)", "src"); // [{ file, line, text, vars, node }]
13
+ sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src"); // template
14
+ sg.rewrite("oldApi($A)", (m) => m.A !== "0" && `newApi(${m.A})`, "src"); // function
15
+ ```
16
+
17
+ A function returns the new text; returning anything else (`undefined`, `null`, `false`) leaves that
18
+ match alone.
19
+
20
+ ## Renaming a name
21
+
22
+ A bare name as the pattern (`sg.rewrite("oldName", "newName")`) only matches plain identifiers, not
23
+ property names (`obj.oldName`, `{ oldName: 1 }`, interface fields). To rename a name everywhere it
24
+ appears in code (but not in strings), match it by kind:
25
+
26
+ ```ts
27
+ const anyName = [
28
+ "identifier",
29
+ "property_identifier",
30
+ "shorthand_property_identifier",
31
+ "shorthand_property_identifier_pattern",
32
+ "type_identifier",
33
+ ];
34
+ sg.rewrite({ rule: { regex: "^oldName$", any: anyName.map((kind) => ({ kind })) } }, "newName", "src");
35
+ ```
36
+
37
+ ## Rule objects
38
+
39
+ When a pattern alone can't say it, pass a rule instead:
40
+
41
+ ```ts
42
+ sg.find({ rule: { kind: "import_statement" } }, "src");
43
+ sg.find({ rule: { pattern: "console.log($$$A)", inside: { kind: "function_declaration", stopBy: "end" } } }, "src");
44
+ sg.find(
45
+ { rule: { pattern: "console.log($$$A)", not: { inside: { kind: "function_declaration", stopBy: "end" } } } },
46
+ "src",
47
+ );
48
+ ```
49
+
50
+ `kind` names come from tree-sitter (`function_declaration`, `call_expression`, `import_statement`,
51
+ …). `stopBy: "end"` searches all ancestors, not just the parent.
52
+
53
+ ## ast-grep's own API
54
+
55
+ When an edit depends on context a pattern can't express, use ast-grep's JavaScript API directly.
56
+ It's on `sg` under its usual names, and `import { parse, Lang } from "@ast-grep/napi"` works too:
57
+
58
+ ```ts
59
+ const file = "src/app.ts";
60
+ const root = sg.parse(sg.Lang.TypeScript, await Bun.file(file).text()).root();
61
+ const edits = root.findAll("app.get($PATH, $$$HANDLERS)").map((node) => {
62
+ const path = node.getMatch("PATH")!; // edit just the captured node
63
+ return path.replace(path.text().toLowerCase());
64
+ });
65
+ await Bun.write(file, root.commitEdits(edits));
66
+ ```
67
+
68
+ ## Other languages
69
+
70
+ `sg.find` and `sg.rewrite` handle JS, TS, TSX, HTML and CSS. For anything else, use the CLI:
71
+
72
+ ```ts
73
+ const matches = await $`ast-grep run -p 'print($A)' -l python --json=compact src`.json();
74
+ await $`ast-grep run -p 'print($A)' -r 'log($A)' -l python -U src`; // -U applies the rewrite
75
+ ```
@@ -0,0 +1,20 @@
1
+ # GritQL patterns
2
+
3
+ Code goes in backticks. `$x` captures a node, `=>` rewrites, and `where` adds conditions.
4
+
5
+ ```ts
6
+ grit("`oldApi($args)`", "src", { dryRun: true }); // find: [{ file, matches }]
7
+ grit("`console.log($m)` => `logger.info($m)`", "src"); // rewrite in place
8
+ grit("`$f($x)` where { $f <: `oldApi` }", "src", { dryRun: true }); // conditions
9
+ grit("`print($x)` => `log($x)`", "src", { lang: "python" }); // other languages
10
+ ```
11
+
12
+ It defaults to JavaScript/TypeScript. Other languages include python, go, rust, java, ruby, css,
13
+ json and yaml.
14
+
15
+ Worth knowing:
16
+
17
+ - Each `grit` call takes about a second to start, so pass a longer `timeout` to the code tool when
18
+ a program calls it.
19
+ - A rewrite can drop a statement's trailing semicolon. Check the diff, or use `sg.rewrite` for
20
+ simple JS/TS rewrites.
@@ -0,0 +1,65 @@
1
+ # Writing a shorthand program
2
+
3
+ ## The environment
4
+
5
+ TypeScript, run by Bun in the repository's working directory, at its usual path. Top-level `await`
6
+ works. `glob`, `grep`, `sg` and `grit` are globals and synchronous (no `await`); Bun's `$` is async.
7
+
8
+ ## Files
9
+
10
+ ```ts
11
+ const source = await Bun.file("src/app.ts").text(); // .json() for JSON
12
+ await Bun.write("src/app.ts", source.replace("a", "b")); // creates missing directories
13
+ await Bun.file("src/old.ts").exists();
14
+ await Bun.file("src/old.ts").delete();
15
+ ```
16
+
17
+ `node:fs` works too, including its sync API (`readFileSync`, `writeFileSync`, `renameSync`, `rmSync`).
18
+
19
+ ## Finding what to change
20
+
21
+ ```ts
22
+ glob("src/**/*.ts"); // files git sees, sorted
23
+ grep("oldApi(", "src"); // [{ file, line, text }]; pass a RegExp for a pattern
24
+ sg.find("oldApi($$$ARGS)", "src"); // by syntax, when matching text isn't enough
25
+ ```
26
+
27
+ Work out the files to change here, in the program, rather than copying a list from earlier output:
28
+ then it can't miss one.
29
+
30
+ ## Commands
31
+
32
+ ```ts
33
+ await $`bun test src/api.test.ts`; // throws if it fails, so a failing check fails the program
34
+ const count = await $`git grep -c oldApi`.nothrow().text(); // .nothrow(): check the exit code yourself
35
+ await $`npx tsc --noEmit -p .`.quiet(); // .quiet(): keep its output out of yours
36
+ ```
37
+
38
+ Interpolated values become single, safely quoted arguments; an array becomes several.
39
+
40
+ ## The shape of a program
41
+
42
+ 1. Find what to change.
43
+ 2. Change it: read, transform in memory, write.
44
+ 3. Check it: look for leftovers, run the type-checker or the tests that cover it, and throw if
45
+ something's wrong. Then nothing is applied, and you get the error.
46
+ 4. Print a summary (counts, anything surprising), not whole files. The diff comes back anyway.
47
+
48
+ ```ts
49
+ const files = [...new Set(grep("oldApi", "src").map((match) => match.file))];
50
+ for (const file of files) {
51
+ const source = await Bun.file(file).text();
52
+ await Bun.write(file, source.replaceAll("oldApi", "newApi"));
53
+ }
54
+
55
+ const left = grep("oldApi", "src");
56
+ if (left.length > 0) throw new Error(`oldApi is still used at ${left.map((m) => `${m.file}:${m.line}`).join(", ")}`);
57
+ await $`npx tsc --noEmit -p .`.quiet();
58
+ console.log(`updated ${files.length} files`);
59
+ ```
60
+
61
+ ## Options
62
+
63
+ - `timeout`: 2 seconds unless you pass more. Pass more when the program runs `tsc` or tests.
64
+ - `rollback: "file"`: keep the files the program finished writing, even if it then fails. Useful when
65
+ each file's change stands on its own; otherwise keep the default, which applies nothing on failure.