pi-formatter 1.0.2 → 1.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/README.md +17 -1
- package/extensions/formatter/context.ts +1 -1
- package/extensions/formatter/dispatch.ts +351 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Formatting modes:
|
|
|
34
34
|
Use this mode when you want the fewest interruptions and are okay with
|
|
35
35
|
formatting only when the session ends.
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Built-in supported file types:
|
|
38
38
|
|
|
39
39
|
- C/C++
|
|
40
40
|
- CMake
|
|
@@ -47,6 +47,22 @@ Supported file types:
|
|
|
47
47
|
For JS/TS and JSON, project-configured tools are preferred first (Biome,
|
|
48
48
|
ESLint), with Prettier as a fallback.
|
|
49
49
|
|
|
50
|
+
When a project contains `treefmt.toml` or `.treefmt.toml` and `treefmt` is
|
|
51
|
+
installed, `pi-formatter` prefers `treefmt` before the built-in file-type
|
|
52
|
+
runners. This can add support for additional file types declared in the
|
|
53
|
+
project's treefmt config. If treefmt reports that no formatter matches a path,
|
|
54
|
+
`pi-formatter` falls back to the built-in runners.
|
|
55
|
+
|
|
56
|
+
For flake-based `treefmt-nix` setups, `pi-formatter` detects flake roots that
|
|
57
|
+
contain `treefmt.nix` or `nix/treefmt.nix` and then tries `nix fmt -- <path>`
|
|
58
|
+
before falling back to the built-in runners. These `nix fmt` calls are run with
|
|
59
|
+
`--no-update-lock-file` and `--no-write-lock-file` so formatting does not
|
|
60
|
+
rewrite flake lock files.
|
|
61
|
+
|
|
62
|
+
When multiple project formatter configs apply, `pi-formatter` uses the nearest
|
|
63
|
+
config root. If `treefmt` and `treefmt-nix` share the same root, `treefmt-nix`
|
|
64
|
+
is tried first.
|
|
65
|
+
|
|
50
66
|
## 🎮 Commands
|
|
51
67
|
|
|
52
68
|
- `/formatter`: open the interactive formatter settings editor and save changes
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
1
2
|
import type { ExecResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
3
|
+
import {
|
|
4
|
+
FormatRunContext,
|
|
5
|
+
type FormatWarningReporter,
|
|
6
|
+
findConfigFileFromPath,
|
|
7
|
+
} from "./context.js";
|
|
8
|
+
import {
|
|
9
|
+
detectFileKind,
|
|
10
|
+
getRelativePathOrAbsolute,
|
|
11
|
+
pathExists,
|
|
12
|
+
} from "./path.js";
|
|
4
13
|
import { FORMAT_PLAN } from "./plan.js";
|
|
5
14
|
import { RUNNERS } from "./runners/index.js";
|
|
15
|
+
import { hasCommand } from "./system.js";
|
|
6
16
|
import {
|
|
7
17
|
isDynamicRunner,
|
|
8
18
|
type ResolvedLauncher,
|
|
@@ -12,6 +22,23 @@ import {
|
|
|
12
22
|
type RunnerLauncher,
|
|
13
23
|
} from "./types.js";
|
|
14
24
|
|
|
25
|
+
const TREEFMT_CONFIG_PATTERNS = ["treefmt.toml", ".treefmt.toml"] as const;
|
|
26
|
+
const TREEFMT_NIX_CONFIG_PATTERNS = ["treefmt.nix", "nix/treefmt.nix"] as const;
|
|
27
|
+
const FLAKE_CONFIG_PATTERNS = ["flake.nix"] as const;
|
|
28
|
+
|
|
29
|
+
type ProjectFormatterCandidate =
|
|
30
|
+
| {
|
|
31
|
+
kind: "treefmt";
|
|
32
|
+
runnerId: "treefmt";
|
|
33
|
+
rootPath: string;
|
|
34
|
+
configPath: string;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
kind: "treefmt-nix";
|
|
38
|
+
runnerId: "treefmt-nix";
|
|
39
|
+
rootPath: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
15
42
|
async function resolveLauncher(
|
|
16
43
|
launcher: RunnerLauncher,
|
|
17
44
|
ctx: RunnerContext,
|
|
@@ -172,6 +199,315 @@ function summarizeFailureMessage(result: ExecResult): string | undefined {
|
|
|
172
199
|
: `${message.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…`;
|
|
173
200
|
}
|
|
174
201
|
|
|
202
|
+
function isTreefmtUnmatchedPathFailure(result: ExecResult): boolean {
|
|
203
|
+
return /\bno formatter for path:/i.test(`${result.stderr}\n${result.stdout}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function shouldFallbackFromTreefmtNixFailure(result: ExecResult): boolean {
|
|
207
|
+
const output = `${result.stderr}\n${result.stdout}`;
|
|
208
|
+
return (
|
|
209
|
+
/cannot connect to socket at '.*daemon-socket\/socket'/i.test(output) ||
|
|
210
|
+
/Refusing to evaluate package .* because it is not available on the requested hostPlatform/i.test(
|
|
211
|
+
output,
|
|
212
|
+
) ||
|
|
213
|
+
/failed to create walker: error resolving path/i.test(output) ||
|
|
214
|
+
/\bpath .* not inside the tree root\b/i.test(output)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function findConfigFileAtRoot(
|
|
219
|
+
rootPath: string,
|
|
220
|
+
patterns: readonly string[],
|
|
221
|
+
): Promise<string | undefined> {
|
|
222
|
+
for (const pattern of patterns) {
|
|
223
|
+
const candidatePath = join(rootPath, pattern);
|
|
224
|
+
if (await pathExists(candidatePath)) {
|
|
225
|
+
return candidatePath;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function resolveTreefmtCandidate(
|
|
233
|
+
filePath: string,
|
|
234
|
+
cwd: string,
|
|
235
|
+
): Promise<ProjectFormatterCandidate | undefined> {
|
|
236
|
+
const configPath = await findConfigFileFromPath(
|
|
237
|
+
filePath,
|
|
238
|
+
TREEFMT_CONFIG_PATTERNS,
|
|
239
|
+
cwd,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (!configPath) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
kind: "treefmt",
|
|
248
|
+
runnerId: "treefmt",
|
|
249
|
+
rootPath: dirname(configPath),
|
|
250
|
+
configPath,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function resolveTreefmtNixCandidate(
|
|
255
|
+
filePath: string,
|
|
256
|
+
cwd: string,
|
|
257
|
+
): Promise<ProjectFormatterCandidate | undefined> {
|
|
258
|
+
const flakePath = await findConfigFileFromPath(
|
|
259
|
+
filePath,
|
|
260
|
+
FLAKE_CONFIG_PATTERNS,
|
|
261
|
+
cwd,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (!flakePath) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const flakeRoot = dirname(flakePath);
|
|
269
|
+
const configPath = await findConfigFileAtRoot(
|
|
270
|
+
flakeRoot,
|
|
271
|
+
TREEFMT_NIX_CONFIG_PATTERNS,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (!configPath) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
kind: "treefmt-nix",
|
|
280
|
+
runnerId: "treefmt-nix",
|
|
281
|
+
rootPath: flakeRoot,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function compareProjectFormatterCandidates(
|
|
286
|
+
a: ProjectFormatterCandidate,
|
|
287
|
+
b: ProjectFormatterCandidate,
|
|
288
|
+
): number {
|
|
289
|
+
const rootPathLengthDifference = b.rootPath.length - a.rootPath.length;
|
|
290
|
+
if (rootPathLengthDifference !== 0) {
|
|
291
|
+
return rootPathLengthDifference;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (a.kind === b.kind) {
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return a.kind === "treefmt-nix" ? -1 : 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function resolveProjectFormatterCandidates(
|
|
302
|
+
filePath: string,
|
|
303
|
+
cwd: string,
|
|
304
|
+
): Promise<ProjectFormatterCandidate[]> {
|
|
305
|
+
const candidates = [
|
|
306
|
+
await resolveTreefmtCandidate(filePath, cwd),
|
|
307
|
+
await resolveTreefmtNixCandidate(filePath, cwd),
|
|
308
|
+
].filter(
|
|
309
|
+
(candidate): candidate is ProjectFormatterCandidate => candidate !== undefined,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
return candidates.sort(compareProjectFormatterCandidates);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function reportProjectFormatterFailure(
|
|
316
|
+
runnerId: string,
|
|
317
|
+
result: ExecResult,
|
|
318
|
+
summaryReporter?: FormatCallSummaryReporter,
|
|
319
|
+
warningReporter?: FormatWarningReporter,
|
|
320
|
+
): void {
|
|
321
|
+
const failureMessage = summarizeFailureMessage(result);
|
|
322
|
+
summaryReporter?.({
|
|
323
|
+
runnerId,
|
|
324
|
+
status: "failed",
|
|
325
|
+
exitCode: result.code,
|
|
326
|
+
failureMessage,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const warningMessage = `${runnerId} failed (${result.code})${
|
|
330
|
+
failureMessage ? `: ${failureMessage}` : ""
|
|
331
|
+
}`;
|
|
332
|
+
if (warningReporter) {
|
|
333
|
+
warningReporter(warningMessage);
|
|
334
|
+
} else {
|
|
335
|
+
console.warn(warningMessage);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function reportProjectFormatterFallback(
|
|
340
|
+
runnerId: string,
|
|
341
|
+
result: ExecResult,
|
|
342
|
+
warningReporter?: FormatWarningReporter,
|
|
343
|
+
): void {
|
|
344
|
+
const failureMessage = summarizeFailureMessage(result);
|
|
345
|
+
const warningMessage = `${runnerId} unavailable, falling back to other formatters${
|
|
346
|
+
failureMessage ? `: ${failureMessage}` : ""
|
|
347
|
+
}`;
|
|
348
|
+
|
|
349
|
+
if (warningReporter) {
|
|
350
|
+
warningReporter(warningMessage);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.warn(warningMessage);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function executeTreefmtCandidate(
|
|
358
|
+
pi: ExtensionAPI,
|
|
359
|
+
candidate: Extract<ProjectFormatterCandidate, { kind: "treefmt" }>,
|
|
360
|
+
cwd: string,
|
|
361
|
+
filePath: string,
|
|
362
|
+
timeoutMs: number,
|
|
363
|
+
summaryReporter?: FormatCallSummaryReporter,
|
|
364
|
+
warningReporter?: FormatWarningReporter,
|
|
365
|
+
): Promise<RunnerOutcome> {
|
|
366
|
+
if (!(await hasCommand("treefmt"))) {
|
|
367
|
+
return "skipped";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const targetPath = getRelativePathOrAbsolute(filePath, cwd);
|
|
371
|
+
const result = await pi.exec(
|
|
372
|
+
"treefmt",
|
|
373
|
+
[
|
|
374
|
+
"--quiet",
|
|
375
|
+
"--no-cache",
|
|
376
|
+
"--on-unmatched",
|
|
377
|
+
"fatal",
|
|
378
|
+
"--config-file",
|
|
379
|
+
candidate.configPath,
|
|
380
|
+
targetPath,
|
|
381
|
+
],
|
|
382
|
+
{
|
|
383
|
+
cwd,
|
|
384
|
+
timeout: timeoutMs,
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (result.code === 0) {
|
|
389
|
+
summaryReporter?.({
|
|
390
|
+
runnerId: candidate.runnerId,
|
|
391
|
+
status: "succeeded",
|
|
392
|
+
});
|
|
393
|
+
return "succeeded";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Treefmt currently reports excluded files as "no formatter for path" too, so
|
|
397
|
+
// we cannot distinguish an explicit exclude from a genuinely unmatched path
|
|
398
|
+
// here. In both cases, fall back to the built-in per-language runners.
|
|
399
|
+
if (isTreefmtUnmatchedPathFailure(result)) {
|
|
400
|
+
return "skipped";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
reportProjectFormatterFailure(
|
|
404
|
+
candidate.runnerId,
|
|
405
|
+
result,
|
|
406
|
+
summaryReporter,
|
|
407
|
+
warningReporter,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
return "failed";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function executeTreefmtNixCandidate(
|
|
414
|
+
pi: ExtensionAPI,
|
|
415
|
+
candidate: Extract<ProjectFormatterCandidate, { kind: "treefmt-nix" }>,
|
|
416
|
+
filePath: string,
|
|
417
|
+
timeoutMs: number,
|
|
418
|
+
summaryReporter?: FormatCallSummaryReporter,
|
|
419
|
+
warningReporter?: FormatWarningReporter,
|
|
420
|
+
): Promise<RunnerOutcome> {
|
|
421
|
+
if (!(await hasCommand("nix"))) {
|
|
422
|
+
return "skipped";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const flakeRoot = candidate.rootPath;
|
|
426
|
+
const targetPath = getRelativePathOrAbsolute(filePath, flakeRoot);
|
|
427
|
+
const result = await pi.exec(
|
|
428
|
+
"nix",
|
|
429
|
+
[
|
|
430
|
+
"--extra-experimental-features",
|
|
431
|
+
"nix-command flakes",
|
|
432
|
+
"fmt",
|
|
433
|
+
"--no-update-lock-file",
|
|
434
|
+
"--no-write-lock-file",
|
|
435
|
+
"--",
|
|
436
|
+
targetPath,
|
|
437
|
+
],
|
|
438
|
+
{
|
|
439
|
+
cwd: flakeRoot,
|
|
440
|
+
timeout: timeoutMs,
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
if (result.code === 0) {
|
|
445
|
+
summaryReporter?.({
|
|
446
|
+
runnerId: candidate.runnerId,
|
|
447
|
+
status: "succeeded",
|
|
448
|
+
});
|
|
449
|
+
return "succeeded";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (isTreefmtUnmatchedPathFailure(result)) {
|
|
453
|
+
return "skipped";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (shouldFallbackFromTreefmtNixFailure(result)) {
|
|
457
|
+
reportProjectFormatterFallback(candidate.runnerId, result, warningReporter);
|
|
458
|
+
return "skipped";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
reportProjectFormatterFailure(
|
|
462
|
+
candidate.runnerId,
|
|
463
|
+
result,
|
|
464
|
+
summaryReporter,
|
|
465
|
+
warningReporter,
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
return "failed";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function tryProjectFormatters(
|
|
472
|
+
pi: ExtensionAPI,
|
|
473
|
+
cwd: string,
|
|
474
|
+
filePath: string,
|
|
475
|
+
timeoutMs: number,
|
|
476
|
+
summaryReporter?: FormatCallSummaryReporter,
|
|
477
|
+
warningReporter?: FormatWarningReporter,
|
|
478
|
+
): Promise<RunnerOutcome> {
|
|
479
|
+
for (const candidate of await resolveProjectFormatterCandidates(
|
|
480
|
+
filePath,
|
|
481
|
+
cwd,
|
|
482
|
+
)) {
|
|
483
|
+
const outcome =
|
|
484
|
+
candidate.kind === "treefmt"
|
|
485
|
+
? await executeTreefmtCandidate(
|
|
486
|
+
pi,
|
|
487
|
+
candidate,
|
|
488
|
+
cwd,
|
|
489
|
+
filePath,
|
|
490
|
+
timeoutMs,
|
|
491
|
+
summaryReporter,
|
|
492
|
+
warningReporter,
|
|
493
|
+
)
|
|
494
|
+
: await executeTreefmtNixCandidate(
|
|
495
|
+
pi,
|
|
496
|
+
candidate,
|
|
497
|
+
filePath,
|
|
498
|
+
timeoutMs,
|
|
499
|
+
summaryReporter,
|
|
500
|
+
warningReporter,
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
if (outcome !== "skipped") {
|
|
504
|
+
return outcome;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return "skipped";
|
|
509
|
+
}
|
|
510
|
+
|
|
175
511
|
async function runRunner(
|
|
176
512
|
ctx: RunnerContext,
|
|
177
513
|
runner: RunnerDefinition,
|
|
@@ -294,6 +630,19 @@ export async function formatFile(
|
|
|
294
630
|
summaryReporter?: FormatCallSummaryReporter,
|
|
295
631
|
warningReporter?: FormatWarningReporter,
|
|
296
632
|
): Promise<void> {
|
|
633
|
+
if (
|
|
634
|
+
(await tryProjectFormatters(
|
|
635
|
+
pi,
|
|
636
|
+
cwd,
|
|
637
|
+
filePath,
|
|
638
|
+
timeoutMs,
|
|
639
|
+
summaryReporter,
|
|
640
|
+
warningReporter,
|
|
641
|
+
)) !== "skipped"
|
|
642
|
+
) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
297
646
|
const kind = detectFileKind(filePath);
|
|
298
647
|
if (!kind) {
|
|
299
648
|
return;
|