pi-formatter 1.0.1 → 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 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
- Supported file types:
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
@@ -27,7 +27,7 @@ function getPatternRegex(pattern: string): RegExp {
27
27
  return regex;
28
28
  }
29
29
 
30
- async function findConfigFileFromPath(
30
+ export async function findConfigFileFromPath(
31
31
  filePath: string,
32
32
  patterns: readonly string[],
33
33
  rootDirectory: string,
@@ -1,8 +1,18 @@
1
+ import { dirname, join } from "node:path";
1
2
  import type { ExecResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { FormatRunContext, type FormatWarningReporter } from "./context.js";
3
- import { detectFileKind } from "./path.js";
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;
@@ -135,12 +135,27 @@ export default function (pi: ExtensionAPI) {
135
135
  await next;
136
136
  };
137
137
 
138
+ const emitSummaryMessages = (
139
+ messages: string[],
140
+ ctx: FormatterContext,
141
+ ): void => {
142
+ if (
143
+ !ctx.hasUI ||
144
+ formatterConfig.hideCallSummariesInTui ||
145
+ messages.length === 0
146
+ ) {
147
+ return;
148
+ }
149
+
150
+ ctx.ui.notify(messages.join("\n"), "info");
151
+ };
152
+
138
153
  const formatResolvedPath = async (
139
154
  filePath: string,
140
155
  ctx: FormatterContext,
141
- ): Promise<void> => {
156
+ ): Promise<string[]> => {
142
157
  if (!(await pathExists(filePath))) {
143
- return;
158
+ return [];
144
159
  }
145
160
 
146
161
  const showSummaries = !formatterConfig.hideCallSummariesInTui && ctx.hasUI;
@@ -155,6 +170,8 @@ export default function (pi: ExtensionAPI) {
155
170
  console.warn(normalizedMessage);
156
171
  };
157
172
 
173
+ let summaryMessages: string[] = [];
174
+
158
175
  await enqueueFormat(filePath, async () => {
159
176
  const summaries: FormatCallSummary[] = [];
160
177
  const summaryReporter = showSummaries
@@ -188,28 +205,37 @@ export default function (pi: ExtensionAPI) {
188
205
  }
189
206
 
190
207
  const fileLabel = getRelativePathOrAbsolute(filePath, ctx.cwd);
191
-
192
- for (const summary of summaries) {
193
- ctx.ui.notify(formatCallSummary(summary, fileLabel), "info");
194
- }
208
+ summaryMessages = summaries.map((summary) =>
209
+ formatCallSummary(summary, fileLabel),
210
+ );
195
211
  });
212
+
213
+ return summaryMessages;
196
214
  };
197
215
 
198
216
  const flushPaths = async (
199
217
  paths: Set<string>,
200
218
  ctx: FormatterContext,
201
- ): Promise<void> => {
219
+ ): Promise<string[]> => {
202
220
  const batch = [...paths];
203
221
  paths.clear();
204
222
 
223
+ const summaryMessages: string[] = [];
224
+
205
225
  for (const filePath of batch) {
206
- await formatResolvedPath(filePath, ctx);
226
+ summaryMessages.push(...(await formatResolvedPath(filePath, ctx)));
207
227
  }
228
+
229
+ return summaryMessages;
208
230
  };
209
231
 
210
232
  const flushPendingPaths = async (ctx: FormatterContext): Promise<void> => {
211
- await flushPaths(pendingPromptPaths, ctx);
212
- await flushPaths(pendingSessionPaths, ctx);
233
+ const summaryMessages = [
234
+ ...(await flushPaths(pendingPromptPaths, ctx)),
235
+ ...(await flushPaths(pendingSessionPaths, ctx)),
236
+ ];
237
+
238
+ emitSummaryMessages(summaryMessages, ctx);
213
239
  };
214
240
 
215
241
  const reloadFormatterConfig = () => {
@@ -231,7 +257,7 @@ export default function (pi: ExtensionAPI) {
231
257
  }
232
258
 
233
259
  if (formatterConfig.formatMode === "tool") {
234
- await formatResolvedPath(filePath, ctx);
260
+ emitSummaryMessages(await formatResolvedPath(filePath, ctx), ctx);
235
261
  return;
236
262
  }
237
263
 
@@ -248,7 +274,7 @@ export default function (pi: ExtensionAPI) {
248
274
  return;
249
275
  }
250
276
 
251
- await flushPaths(pendingPromptPaths, ctx);
277
+ emitSummaryMessages(await flushPaths(pendingPromptPaths, ctx), ctx);
252
278
  });
253
279
 
254
280
  pi.on("session_switch", async (_event, ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-formatter",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Pi extension that auto-formats files.",
5
5
  "type": "module",
6
6
  "files": [