@gotgenes/pi-autoformat 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/.github/workflows/ci.yml +35 -0
- package/.github/workflows/release-please.yml +22 -0
- package/.markdownlint-cli2.yaml +3 -0
- package/.pi/extensions/pi-autoformat/config.json +28 -0
- package/.release-please-manifest.json +3 -0
- package/AGENTS.md +71 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/biome.json +17 -0
- package/docs/configuration.md +177 -0
- package/docs/plans/0001-initial-implementation-plan.md +402 -0
- package/package.json +32 -0
- package/prek.toml +24 -0
- package/release-please-config.json +22 -0
- package/schemas/pi-autoformat.schema.json +87 -0
- package/src/config-loader.ts +520 -0
- package/src/extension.ts +374 -0
- package/src/formatter-config.ts +80 -0
- package/src/formatter-executor.ts +68 -0
- package/src/formatter-registry.ts +61 -0
- package/src/index.ts +42 -0
- package/src/prompt-autoformatter.ts +58 -0
- package/src/touched-files-queue.ts +46 -0
- package/test/config-loader.test.ts +199 -0
- package/test/extension.test.ts +364 -0
- package/test/formatter-config.test.ts +64 -0
- package/test/formatter-executor.test.ts +82 -0
- package/test/formatter-registry.test.ts +75 -0
- package/test/prompt-autoformatter.test.ts +93 -0
- package/test/smoke.test.ts +9 -0
- package/test/touched-files-queue.test.ts +46 -0
- package/tsconfig.json +13 -0
- package/vitest.config.ts +10 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AUTOFORMAT_EXTENSION_ID,
|
|
6
|
+
type ConfigValidationIssue,
|
|
7
|
+
type LoadConfigResult,
|
|
8
|
+
loadAutoformatConfig,
|
|
9
|
+
} from "./config-loader.js";
|
|
10
|
+
import type { AutoformatConfig } from "./formatter-config.js";
|
|
11
|
+
import type { CommandRunner, CommandRunResult } from "./formatter-executor.js";
|
|
12
|
+
import {
|
|
13
|
+
PromptAutoformatter,
|
|
14
|
+
type PromptAutoformatterResult,
|
|
15
|
+
} from "./prompt-autoformatter.js";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
const COMMAND_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
type NotificationType = "info" | "warning" | "error";
|
|
21
|
+
|
|
22
|
+
type ExtensionContextLike = {
|
|
23
|
+
cwd: string;
|
|
24
|
+
hasUI: boolean;
|
|
25
|
+
ui: {
|
|
26
|
+
notify(message: string, type?: NotificationType): void;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ToolResultEventLike = {
|
|
31
|
+
toolName: string;
|
|
32
|
+
input: unknown;
|
|
33
|
+
isError: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ExtensionHandler<TEvent> = (
|
|
37
|
+
event: TEvent,
|
|
38
|
+
ctx: ExtensionContextLike,
|
|
39
|
+
) => void | Promise<void>;
|
|
40
|
+
|
|
41
|
+
type ExtensionApiLike = {
|
|
42
|
+
on(eventName: "session_start", handler: ExtensionHandler<unknown>): void;
|
|
43
|
+
on(
|
|
44
|
+
eventName: "tool_result",
|
|
45
|
+
handler: ExtensionHandler<ToolResultEventLike>,
|
|
46
|
+
): void;
|
|
47
|
+
on(eventName: "agent_end", handler: ExtensionHandler<unknown>): void;
|
|
48
|
+
on(eventName: "session_shutdown", handler: ExtensionHandler<unknown>): void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type PromptAutoformatterLike = Pick<
|
|
52
|
+
PromptAutoformatter,
|
|
53
|
+
"recordToolResult" | "flushPrompt"
|
|
54
|
+
>;
|
|
55
|
+
|
|
56
|
+
type AutoformatExtensionDependencies = {
|
|
57
|
+
loadConfig?: (cwd: string) => LoadConfigResult;
|
|
58
|
+
createAutoformatter?: (
|
|
59
|
+
cwd: string,
|
|
60
|
+
config: AutoformatConfig,
|
|
61
|
+
) => PromptAutoformatterLike;
|
|
62
|
+
reportFlushResult?: (
|
|
63
|
+
result: PromptAutoformatterResult,
|
|
64
|
+
options: {
|
|
65
|
+
config: AutoformatConfig;
|
|
66
|
+
ctx: ExtensionContextLike;
|
|
67
|
+
},
|
|
68
|
+
) => void;
|
|
69
|
+
reportConfigIssues?: (
|
|
70
|
+
issues: ConfigValidationIssue[],
|
|
71
|
+
options: {
|
|
72
|
+
ctx: ExtensionContextLike;
|
|
73
|
+
},
|
|
74
|
+
) => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
type SessionState = {
|
|
78
|
+
cwd: string;
|
|
79
|
+
loadResult: LoadConfigResult;
|
|
80
|
+
autoformatter: PromptAutoformatterLike;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type ExecFileError = Error & {
|
|
84
|
+
code?: number | string;
|
|
85
|
+
stdout?: string | Buffer;
|
|
86
|
+
stderr?: string | Buffer;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function toOutputText(value: string | Buffer | undefined): string | undefined {
|
|
90
|
+
if (value === undefined) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return typeof value === "string" ? value : value.toString("utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeExecError(error: unknown): CommandRunResult {
|
|
98
|
+
if (!(error instanceof Error)) {
|
|
99
|
+
return {
|
|
100
|
+
exitCode: 1,
|
|
101
|
+
stderr: String(error),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const execError = error as ExecFileError;
|
|
106
|
+
return {
|
|
107
|
+
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
|
108
|
+
stdout: toOutputText(execError.stdout),
|
|
109
|
+
stderr: toOutputText(execError.stderr) ?? execError.message,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createCommandRunner(commandTimeoutMs: number): CommandRunner {
|
|
114
|
+
return async (
|
|
115
|
+
command: string,
|
|
116
|
+
args: string[],
|
|
117
|
+
options,
|
|
118
|
+
): Promise<CommandRunResult> => {
|
|
119
|
+
try {
|
|
120
|
+
const result = await execFileAsync(command, args, {
|
|
121
|
+
cwd: options?.cwd,
|
|
122
|
+
env: options?.env
|
|
123
|
+
? {
|
|
124
|
+
...process.env,
|
|
125
|
+
...options.env,
|
|
126
|
+
}
|
|
127
|
+
: process.env,
|
|
128
|
+
encoding: "utf-8",
|
|
129
|
+
maxBuffer: COMMAND_MAX_BUFFER_BYTES,
|
|
130
|
+
timeout: commandTimeoutMs,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
exitCode: 0,
|
|
135
|
+
stdout: result.stdout,
|
|
136
|
+
stderr: result.stderr,
|
|
137
|
+
};
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return normalizeExecError(error);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createDefaultAutoformatter(
|
|
145
|
+
cwd: string,
|
|
146
|
+
config: AutoformatConfig,
|
|
147
|
+
): PromptAutoformatterLike {
|
|
148
|
+
return new PromptAutoformatter(
|
|
149
|
+
cwd,
|
|
150
|
+
config,
|
|
151
|
+
createCommandRunner(config.commandTimeoutMs),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function reportMessage(
|
|
156
|
+
ctx: ExtensionContextLike,
|
|
157
|
+
message: string,
|
|
158
|
+
type: NotificationType,
|
|
159
|
+
): void {
|
|
160
|
+
if (ctx.hasUI) {
|
|
161
|
+
ctx.ui.notify(message, type);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const output = `[${AUTOFORMAT_EXTENSION_ID}] ${message}`;
|
|
166
|
+
if (type === "error" || type === "warning") {
|
|
167
|
+
console.warn(output);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(output);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type FailureSummary = {
|
|
175
|
+
lines: string[];
|
|
176
|
+
failedRunCount: number;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function summarizeFailures(result: PromptAutoformatterResult): FailureSummary {
|
|
180
|
+
const lines: string[] = [];
|
|
181
|
+
let failedRunCount = 0;
|
|
182
|
+
|
|
183
|
+
for (const file of result.files) {
|
|
184
|
+
const failures = file.runs.filter((run) => !run.success);
|
|
185
|
+
if (failures.length === 0) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
failedRunCount += failures.length;
|
|
190
|
+
const details = failures
|
|
191
|
+
.map((run) => `${run.formatterName} (exit ${run.exitCode})`)
|
|
192
|
+
.join(", ");
|
|
193
|
+
lines.push(`${file.path}: ${details}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
lines,
|
|
198
|
+
failedRunCount,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function summarizeSuccessPaths(
|
|
203
|
+
result: PromptAutoformatterResult,
|
|
204
|
+
): string | undefined {
|
|
205
|
+
if (result.files.length === 0 || result.files.length > 3) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return result.files.map((file) => file.path).join(", ");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function defaultReportFlushResult(
|
|
213
|
+
result: PromptAutoformatterResult,
|
|
214
|
+
options: {
|
|
215
|
+
config: AutoformatConfig;
|
|
216
|
+
ctx: ExtensionContextLike;
|
|
217
|
+
},
|
|
218
|
+
): void {
|
|
219
|
+
if (result.files.length === 0) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const failureSummary = summarizeFailures(result);
|
|
224
|
+
if (failureSummary.lines.length > 0) {
|
|
225
|
+
reportMessage(
|
|
226
|
+
options.ctx,
|
|
227
|
+
[
|
|
228
|
+
`Formatter failures in ${failureSummary.lines.length} file${failureSummary.lines.length === 1 ? "" : "s"} (${failureSummary.failedRunCount} failed run${failureSummary.failedRunCount === 1 ? "" : "s"}):`,
|
|
229
|
+
...failureSummary.lines,
|
|
230
|
+
].join("\n"),
|
|
231
|
+
"warning",
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (options.config.hideSummariesInTui && options.ctx.hasUI) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const successPaths = summarizeSuccessPaths(result);
|
|
241
|
+
const message = successPaths
|
|
242
|
+
? `Autoformatted ${result.files.length} file${result.files.length === 1 ? "" : "s"}: ${successPaths}`
|
|
243
|
+
: `Autoformatted ${result.files.length} file${result.files.length === 1 ? "" : "s"}.`;
|
|
244
|
+
|
|
245
|
+
reportMessage(options.ctx, message, "info");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function defaultReportConfigIssues(
|
|
249
|
+
issues: ConfigValidationIssue[],
|
|
250
|
+
options: {
|
|
251
|
+
ctx: ExtensionContextLike;
|
|
252
|
+
},
|
|
253
|
+
): void {
|
|
254
|
+
if (issues.length === 0) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lines = issues.slice(0, 3).map((issue) => {
|
|
259
|
+
if (issue.sourcePath) {
|
|
260
|
+
return `${issue.sourcePath} ${issue.path}: ${issue.message}`;
|
|
261
|
+
}
|
|
262
|
+
return `${issue.path}: ${issue.message}`;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const remainingCount = issues.length - lines.length;
|
|
266
|
+
if (remainingCount > 0) {
|
|
267
|
+
lines.push(
|
|
268
|
+
`...and ${remainingCount} more issue${remainingCount === 1 ? "" : "s"}.`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
reportMessage(
|
|
273
|
+
options.ctx,
|
|
274
|
+
["Configuration issues detected:", ...lines].join("\n"),
|
|
275
|
+
"warning",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createAutoformatExtension(
|
|
280
|
+
pi: ExtensionApiLike,
|
|
281
|
+
dependencies: AutoformatExtensionDependencies = {},
|
|
282
|
+
): void {
|
|
283
|
+
const loadConfig =
|
|
284
|
+
dependencies.loadConfig ?? ((cwd: string) => loadAutoformatConfig({ cwd }));
|
|
285
|
+
const createAutoformatter =
|
|
286
|
+
dependencies.createAutoformatter ?? createDefaultAutoformatter;
|
|
287
|
+
const reportFlushResult =
|
|
288
|
+
dependencies.reportFlushResult ?? defaultReportFlushResult;
|
|
289
|
+
const reportConfigIssues =
|
|
290
|
+
dependencies.reportConfigIssues ?? defaultReportConfigIssues;
|
|
291
|
+
|
|
292
|
+
let state: SessionState | undefined;
|
|
293
|
+
let pendingFlush = Promise.resolve();
|
|
294
|
+
|
|
295
|
+
function ensureState(cwd: string): SessionState {
|
|
296
|
+
if (state && state.cwd === cwd) {
|
|
297
|
+
return state;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const loadResult = loadConfig(cwd);
|
|
301
|
+
state = {
|
|
302
|
+
cwd,
|
|
303
|
+
loadResult,
|
|
304
|
+
autoformatter: createAutoformatter(cwd, loadResult.config),
|
|
305
|
+
};
|
|
306
|
+
return state;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function queueFlush(ctx: ExtensionContextLike): Promise<void> {
|
|
310
|
+
const sessionState = state;
|
|
311
|
+
if (!sessionState) {
|
|
312
|
+
return pendingFlush;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
pendingFlush = pendingFlush
|
|
316
|
+
.then(async () => {
|
|
317
|
+
const result = await sessionState.autoformatter.flushPrompt();
|
|
318
|
+
reportFlushResult(result, {
|
|
319
|
+
config: sessionState.loadResult.config,
|
|
320
|
+
ctx,
|
|
321
|
+
});
|
|
322
|
+
})
|
|
323
|
+
.catch((error: unknown) => {
|
|
324
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
325
|
+
reportMessage(ctx, `Unexpected runtime error: ${message}`, "warning");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return pendingFlush;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
332
|
+
const sessionState = ensureState(ctx.cwd);
|
|
333
|
+
reportConfigIssues(sessionState.loadResult.issues, { ctx });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
337
|
+
if (event.isError) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const sessionState = ensureState(ctx.cwd);
|
|
342
|
+
sessionState.autoformatter.recordToolResult(event.toolName, event.input);
|
|
343
|
+
|
|
344
|
+
if (sessionState.loadResult.config.formatMode === "tool") {
|
|
345
|
+
await queueFlush(ctx);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
350
|
+
const sessionState = ensureState(ctx.cwd);
|
|
351
|
+
if (sessionState.loadResult.config.formatMode !== "prompt") {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await queueFlush(ctx);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
359
|
+
const sessionState = state;
|
|
360
|
+
if (!sessionState) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (sessionState.loadResult.config.formatMode === "session") {
|
|
365
|
+
await queueFlush(ctx);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
state = undefined;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export default function autoformatExtension(pi: ExtensionApiLike): void {
|
|
373
|
+
createAutoformatExtension(pi);
|
|
374
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormatterConfig,
|
|
3
|
+
FormatterDefinition,
|
|
4
|
+
} from "./formatter-registry.js";
|
|
5
|
+
|
|
6
|
+
export type FormatMode = "tool" | "prompt" | "session";
|
|
7
|
+
|
|
8
|
+
export type UserFormatterConfig = {
|
|
9
|
+
formatMode?: FormatMode;
|
|
10
|
+
commandTimeoutMs?: number;
|
|
11
|
+
hideSummariesInTui?: boolean;
|
|
12
|
+
formatters?: Record<string, FormatterDefinition>;
|
|
13
|
+
chains?: Record<string, string[]>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AutoformatConfig = FormatterConfig & {
|
|
17
|
+
formatMode: FormatMode;
|
|
18
|
+
commandTimeoutMs: number;
|
|
19
|
+
hideSummariesInTui: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_FORMATTER_CONFIG: AutoformatConfig = {
|
|
23
|
+
formatMode: "prompt",
|
|
24
|
+
commandTimeoutMs: 10000,
|
|
25
|
+
hideSummariesInTui: false,
|
|
26
|
+
formatters: {
|
|
27
|
+
prettier: {
|
|
28
|
+
command: ["prettier", "--write", "$FILE"],
|
|
29
|
+
extensions: [
|
|
30
|
+
".js",
|
|
31
|
+
".cjs",
|
|
32
|
+
".mjs",
|
|
33
|
+
".jsx",
|
|
34
|
+
".ts",
|
|
35
|
+
".tsx",
|
|
36
|
+
".json",
|
|
37
|
+
".md",
|
|
38
|
+
".yaml",
|
|
39
|
+
".yml",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
"markdownlint-cli2": {
|
|
43
|
+
command: ["markdownlint-cli2", "--fix", "$FILE"],
|
|
44
|
+
extensions: [".md"],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
chains: {
|
|
48
|
+
".md": ["prettier", "markdownlint-cli2"],
|
|
49
|
+
".js": ["prettier"],
|
|
50
|
+
".cjs": ["prettier"],
|
|
51
|
+
".mjs": ["prettier"],
|
|
52
|
+
".jsx": ["prettier"],
|
|
53
|
+
".ts": ["prettier"],
|
|
54
|
+
".tsx": ["prettier"],
|
|
55
|
+
".json": ["prettier"],
|
|
56
|
+
".yaml": ["prettier"],
|
|
57
|
+
".yml": ["prettier"],
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function createFormatterConfig(
|
|
62
|
+
userConfig?: UserFormatterConfig,
|
|
63
|
+
): AutoformatConfig {
|
|
64
|
+
return {
|
|
65
|
+
formatMode: userConfig?.formatMode ?? DEFAULT_FORMATTER_CONFIG.formatMode,
|
|
66
|
+
commandTimeoutMs:
|
|
67
|
+
userConfig?.commandTimeoutMs ?? DEFAULT_FORMATTER_CONFIG.commandTimeoutMs,
|
|
68
|
+
hideSummariesInTui:
|
|
69
|
+
userConfig?.hideSummariesInTui ??
|
|
70
|
+
DEFAULT_FORMATTER_CONFIG.hideSummariesInTui,
|
|
71
|
+
formatters: {
|
|
72
|
+
...DEFAULT_FORMATTER_CONFIG.formatters,
|
|
73
|
+
...userConfig?.formatters,
|
|
74
|
+
},
|
|
75
|
+
chains: {
|
|
76
|
+
...DEFAULT_FORMATTER_CONFIG.chains,
|
|
77
|
+
...userConfig?.chains,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ResolvedFormatter } from "./formatter-registry.js";
|
|
2
|
+
|
|
3
|
+
export type CommandRunResult = {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stdout?: string;
|
|
6
|
+
stderr?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CommandRunnerOptions = {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type CommandRunner = (
|
|
15
|
+
command: string,
|
|
16
|
+
args: string[],
|
|
17
|
+
options?: CommandRunnerOptions,
|
|
18
|
+
) => Promise<CommandRunResult>;
|
|
19
|
+
|
|
20
|
+
export type FormatterExecutionResult = {
|
|
21
|
+
formatterName: string;
|
|
22
|
+
command: string[];
|
|
23
|
+
success: boolean;
|
|
24
|
+
exitCode: number;
|
|
25
|
+
stdout?: string;
|
|
26
|
+
stderr?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function executeFormatterChain(
|
|
30
|
+
chain: ResolvedFormatter[],
|
|
31
|
+
runner: CommandRunner,
|
|
32
|
+
options?: {
|
|
33
|
+
cwd?: string;
|
|
34
|
+
},
|
|
35
|
+
): Promise<FormatterExecutionResult[]> {
|
|
36
|
+
const results: FormatterExecutionResult[] = [];
|
|
37
|
+
|
|
38
|
+
for (const formatter of chain) {
|
|
39
|
+
const [command, ...args] = formatter.command;
|
|
40
|
+
|
|
41
|
+
if (!command) {
|
|
42
|
+
results.push({
|
|
43
|
+
formatterName: formatter.name,
|
|
44
|
+
command: formatter.command,
|
|
45
|
+
success: false,
|
|
46
|
+
exitCode: 1,
|
|
47
|
+
stderr: "Formatter command is empty",
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const runResult = await runner(command, args, {
|
|
53
|
+
cwd: options?.cwd,
|
|
54
|
+
env: formatter.environment,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
results.push({
|
|
58
|
+
formatterName: formatter.name,
|
|
59
|
+
command: formatter.command,
|
|
60
|
+
success: runResult.exitCode === 0,
|
|
61
|
+
exitCode: runResult.exitCode,
|
|
62
|
+
stdout: runResult.stdout,
|
|
63
|
+
stderr: runResult.stderr,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export type FormatterDefinition = {
|
|
4
|
+
command: string[];
|
|
5
|
+
extensions: string[];
|
|
6
|
+
environment?: Record<string, string>;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type FormatterConfig = {
|
|
11
|
+
formatters: Record<string, FormatterDefinition>;
|
|
12
|
+
chains?: Record<string, string[]>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ResolvedFormatter = {
|
|
16
|
+
name: string;
|
|
17
|
+
command: string[];
|
|
18
|
+
environment?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function resolveFormatterChainForFile(
|
|
22
|
+
filePath: string,
|
|
23
|
+
config: FormatterConfig,
|
|
24
|
+
): ResolvedFormatter[] {
|
|
25
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
26
|
+
if (!extension) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const chainNames = config.chains?.[extension];
|
|
31
|
+
if (!chainNames) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return chainNames
|
|
36
|
+
.map((formatterName) =>
|
|
37
|
+
resolveFormatterByName(formatterName, filePath, config),
|
|
38
|
+
)
|
|
39
|
+
.filter((formatter): formatter is ResolvedFormatter => formatter !== null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveFormatterByName(
|
|
43
|
+
formatterName: string,
|
|
44
|
+
filePath: string,
|
|
45
|
+
config: FormatterConfig,
|
|
46
|
+
): ResolvedFormatter | null {
|
|
47
|
+
const formatter = config.formatters[formatterName];
|
|
48
|
+
if (!formatter || formatter.disabled) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: formatterName,
|
|
54
|
+
command: substituteFileToken(formatter.command, filePath),
|
|
55
|
+
environment: formatter.environment,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function substituteFileToken(command: string[], filePath: string): string[] {
|
|
60
|
+
return command.map((arg) => arg.replaceAll("$FILE", filePath));
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const extensionName = "pi-autoformat";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
AUTOFORMAT_CONFIG_FILE_NAME,
|
|
5
|
+
AUTOFORMAT_EXTENSION_ID,
|
|
6
|
+
type ConfigValidationIssue,
|
|
7
|
+
getGlobalConfigPath,
|
|
8
|
+
getProjectConfigPath,
|
|
9
|
+
type LoadConfigResult,
|
|
10
|
+
loadAutoformatConfig,
|
|
11
|
+
type ValidateConfigResult,
|
|
12
|
+
validateUserFormatterConfig,
|
|
13
|
+
} from "./config-loader.js";
|
|
14
|
+
export {
|
|
15
|
+
createAutoformatExtension,
|
|
16
|
+
default as autoformatExtension,
|
|
17
|
+
} from "./extension.js";
|
|
18
|
+
export {
|
|
19
|
+
type AutoformatConfig,
|
|
20
|
+
createFormatterConfig,
|
|
21
|
+
DEFAULT_FORMATTER_CONFIG,
|
|
22
|
+
type FormatMode,
|
|
23
|
+
type UserFormatterConfig,
|
|
24
|
+
} from "./formatter-config.js";
|
|
25
|
+
export {
|
|
26
|
+
type CommandRunner,
|
|
27
|
+
type CommandRunnerOptions,
|
|
28
|
+
type CommandRunResult,
|
|
29
|
+
executeFormatterChain,
|
|
30
|
+
type FormatterExecutionResult,
|
|
31
|
+
} from "./formatter-executor.js";
|
|
32
|
+
export {
|
|
33
|
+
type FormatterConfig,
|
|
34
|
+
type FormatterDefinition,
|
|
35
|
+
type ResolvedFormatter,
|
|
36
|
+
resolveFormatterChainForFile,
|
|
37
|
+
} from "./formatter-registry.js";
|
|
38
|
+
export {
|
|
39
|
+
PromptAutoformatter,
|
|
40
|
+
type PromptAutoformatterResult,
|
|
41
|
+
} from "./prompt-autoformatter.js";
|
|
42
|
+
export { TouchedFilesQueue } from "./touched-files-queue.js";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CommandRunner,
|
|
3
|
+
executeFormatterChain,
|
|
4
|
+
type FormatterExecutionResult,
|
|
5
|
+
} from "./formatter-executor.js";
|
|
6
|
+
import {
|
|
7
|
+
type FormatterConfig,
|
|
8
|
+
resolveFormatterChainForFile,
|
|
9
|
+
} from "./formatter-registry.js";
|
|
10
|
+
import { TouchedFilesQueue } from "./touched-files-queue.js";
|
|
11
|
+
|
|
12
|
+
export type PromptAutoformatterResult = {
|
|
13
|
+
files: Array<{
|
|
14
|
+
path: string;
|
|
15
|
+
runs: FormatterExecutionResult[];
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class PromptAutoformatter {
|
|
20
|
+
private readonly queue: TouchedFilesQueue;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly cwd: string,
|
|
24
|
+
private readonly config: FormatterConfig,
|
|
25
|
+
private readonly runner: CommandRunner,
|
|
26
|
+
) {
|
|
27
|
+
this.queue = new TouchedFilesQueue(cwd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
recordToolResult(toolName: string, payload: unknown): void {
|
|
31
|
+
this.queue.recordToolResult(toolName, payload);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async flushPrompt(): Promise<PromptAutoformatterResult> {
|
|
35
|
+
const touchedFiles = this.queue.flush();
|
|
36
|
+
const fileResults: PromptAutoformatterResult["files"] = [];
|
|
37
|
+
|
|
38
|
+
for (const filePath of touchedFiles) {
|
|
39
|
+
const chain = resolveFormatterChainForFile(filePath, this.config);
|
|
40
|
+
if (chain.length === 0) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const runs = await executeFormatterChain(chain, this.runner, {
|
|
45
|
+
cwd: this.cwd,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
fileResults.push({
|
|
49
|
+
path: filePath,
|
|
50
|
+
runs,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
files: fileResults,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|