pi-formatter 0.1.1 → 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.
Files changed (26) hide show
  1. package/README.md +55 -10
  2. package/extensions/formatter/config.ts +140 -0
  3. package/extensions/{format → formatter}/context.ts +58 -37
  4. package/extensions/{format → formatter}/dispatch.ts +114 -32
  5. package/extensions/{format → formatter}/path.ts +19 -3
  6. package/extensions/{format → formatter}/plan.ts +5 -5
  7. package/extensions/{format/runners/biome-check-write.ts → formatter/runners/biome.ts} +4 -4
  8. package/extensions/formatter/runners/config-patterns.ts +5 -0
  9. package/extensions/{format/runners/eslint-fix.ts → formatter/runners/eslint.ts} +3 -3
  10. package/extensions/{format → formatter}/runners/index.ts +10 -12
  11. package/extensions/{format/runners/markdownlint-fix.ts → formatter/runners/markdownlint.ts} +3 -3
  12. package/extensions/{format/runners/prettier-markdown.ts → formatter/runners/prettier.ts} +3 -3
  13. package/extensions/{format/runners/ruff-check-fix.ts → formatter/runners/ruff-check.ts} +3 -3
  14. package/extensions/{format → formatter}/types.ts +4 -6
  15. package/extensions/index.ts +332 -20
  16. package/package.json +1 -2
  17. package/DOCUMENTATION.md +0 -120
  18. package/extensions/format/config.ts +0 -11
  19. package/extensions/format/runners/config-patterns.ts +0 -6
  20. package/extensions/format/runners/prettier-config-write.ts +0 -11
  21. /package/extensions/{format → formatter}/runners/clang-format.ts +0 -0
  22. /package/extensions/{format → formatter}/runners/cmake-format.ts +0 -0
  23. /package/extensions/{format → formatter}/runners/helpers.ts +0 -0
  24. /package/extensions/{format → formatter}/runners/ruff-format.ts +0 -0
  25. /package/extensions/{format → formatter}/runners/shfmt.ts +0 -0
  26. /package/extensions/{format → formatter}/system.ts +0 -0
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # 🎨 pi-formatter
2
2
 
3
- A [pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)
4
- extension that auto-formats files after every `write` and `edit` tool call.
3
+ A [pi](https://pi.dev) extension that auto-formats files after `write` and
4
+ `edit` tool calls.
5
5
 
6
- The extension hooks into successful tool results, detects the file type, and
7
- runs the appropriate formatter. Failures never block the tool result, so
8
- formatting is always best-effort.
6
+ By default, formatting runs after each successful tool result. It can also be
7
+ configured to defer formatting until the agent stops and yields back to the
8
+ user.
9
9
 
10
10
  ## 📦 Install
11
11
 
@@ -15,8 +15,18 @@ pi install npm:pi-formatter
15
15
 
16
16
  ## ⚙️ What it does
17
17
 
18
- `pi-formatter` listens to successful `write` and `edit` tool calls and applies
19
- best-effort formatting. Formatter failures never block tool results.
18
+ `pi-formatter` detects file types and runs the appropriate formatter as
19
+ best-effort post-processing. Formatter failures never block tool results.
20
+
21
+ Formatting modes:
22
+
23
+ - `afterEachToolCall`: format immediately after each successful `write` or
24
+ `edit` tool result. This is the default.
25
+ - `afterAgentStop`: collect touched files during the run and format them once
26
+ the agent stops. This avoids mid-run model drift from formatter edits.
27
+
28
+ When `afterAgentStop` is active, interrupted or canceled runs are not formatted
29
+ unless `formatOnAbort` is enabled.
20
30
 
21
31
  Supported file types:
22
32
 
@@ -28,13 +38,48 @@ Supported file types:
28
38
  - Python
29
39
  - JavaScript/TypeScript
30
40
 
41
+ For JS/TS and JSON, project-configured tools are preferred first (Biome,
42
+ ESLint), with Prettier as a fallback.
43
+
44
+ ## 🎮 Commands
45
+
46
+ - `/formatter`: open the interactive formatter settings editor and save changes
47
+ to `formatter.json`
48
+
31
49
  ## 🔧 Configuration
32
50
 
33
- - `PI_FORMAT_COMMAND_TIMEOUT_MS`: timeout (ms) per formatter command (default: `10000`)
51
+ Create `<agent-dir>/formatter.json`, where `<agent-dir>` is pi's agent config
52
+ folder (default: `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`):
53
+
54
+ ```json
55
+ {
56
+ "formatMode": "afterEachToolCall",
57
+ "formatOnAbort": false,
58
+ "commandTimeoutMs": 10000,
59
+ "hideCallSummariesInTui": false
60
+ }
61
+ ```
62
+
63
+ - `formatMode`: formatting strategy
64
+ (`"afterEachToolCall"` | `"afterAgentStop"`, default: `"afterEachToolCall"`)
65
+ - `formatOnAbort`: in deferred mode, also format files after an interrupted or
66
+ canceled run (default: `false`)
67
+ - `commandTimeoutMs`: timeout (ms) per formatter command (default: `10000`)
68
+ - `hideCallSummariesInTui`: hide formatter pass/fail summaries in the TUI
69
+ (default: `false`)
70
+
71
+ ## 🧩 Adding formatters
72
+
73
+ Each formatter is a _runner_ that wraps a CLI tool behind a common interface.
74
+ To add one:
34
75
 
35
- ## 🧩 Contributor docs
76
+ 1. Create a file in `extensions/formatter/runners/` using `defineRunner` and a
77
+ launcher helper (`direct`, `pypi`, or `goTool`).
78
+ 2. Register it in `extensions/formatter/runners/index.ts`.
79
+ 3. Add its id to a group in `extensions/formatter/plan.ts`.
36
80
 
37
- See the [runner API contract](DOCUMENTATION.md) for how to add new formatters.
81
+ The format plan maps file kinds to ordered runner groups. Each group runs in
82
+ `"all"` mode (every runner) or `"fallback"` mode (first match wins).
38
83
 
39
84
  ## 📄 License
40
85
 
@@ -0,0 +1,140 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
9
+
10
+ export type FormatMode = "afterEachToolCall" | "afterAgentStop";
11
+
12
+ const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
13
+ const DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI = false;
14
+ const DEFAULT_FORMAT_MODE: FormatMode = "afterEachToolCall";
15
+ const DEFAULT_FORMAT_ON_ABORT = false;
16
+ const FORMATTER_CONFIG_FILE = "formatter.json";
17
+
18
+ export type FormatterConfigSnapshot = {
19
+ commandTimeoutMs: number;
20
+ hideCallSummariesInTui: boolean;
21
+ formatMode: FormatMode;
22
+ formatOnAbort: boolean;
23
+ };
24
+
25
+ export const DEFAULT_FORMATTER_CONFIG: FormatterConfigSnapshot = {
26
+ commandTimeoutMs: DEFAULT_COMMAND_TIMEOUT_MS,
27
+ hideCallSummariesInTui: DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
28
+ formatMode: DEFAULT_FORMAT_MODE,
29
+ formatOnAbort: DEFAULT_FORMAT_ON_ABORT,
30
+ };
31
+
32
+ export function getFormatterConfigPath(): string {
33
+ return join(getAgentDir(), FORMATTER_CONFIG_FILE);
34
+ }
35
+
36
+ function readJsonObject(filePath: string): Record<string, unknown> | undefined {
37
+ try {
38
+ if (!existsSync(filePath)) {
39
+ return undefined;
40
+ }
41
+
42
+ const content = readFileSync(filePath, "utf8");
43
+ const parsed = JSON.parse(content);
44
+
45
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
46
+ return undefined;
47
+ }
48
+
49
+ return parsed as Record<string, unknown>;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ function parsePositiveInt(value: unknown, defaultValue: number): number {
56
+ if (typeof value !== "number") {
57
+ return defaultValue;
58
+ }
59
+
60
+ if (!Number.isInteger(value) || value <= 0) {
61
+ return defaultValue;
62
+ }
63
+
64
+ return value;
65
+ }
66
+
67
+ function parseBooleanValue(value: unknown, defaultValue: boolean): boolean {
68
+ if (typeof value !== "boolean") {
69
+ return defaultValue;
70
+ }
71
+
72
+ return value;
73
+ }
74
+
75
+ function parseFormatMode(value: unknown, defaultValue: FormatMode): FormatMode {
76
+ if (value === "afterEachToolCall" || value === "afterAgentStop") {
77
+ return value;
78
+ }
79
+
80
+ return defaultValue;
81
+ }
82
+
83
+ function toFormatterConfigObject(
84
+ config: FormatterConfigSnapshot,
85
+ ): Record<string, unknown> {
86
+ return {
87
+ commandTimeoutMs: config.commandTimeoutMs,
88
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
89
+ formatMode: config.formatMode,
90
+ formatOnAbort: config.formatOnAbort,
91
+ };
92
+ }
93
+
94
+ function writeFormatterConfigFile(content: string): void {
95
+ mkdirSync(getAgentDir(), { recursive: true });
96
+ writeFileSync(getFormatterConfigPath(), content, "utf8");
97
+ }
98
+
99
+ export function loadFormatterConfig(): FormatterConfigSnapshot {
100
+ const config = readJsonObject(getFormatterConfigPath());
101
+
102
+ if (!config) {
103
+ return { ...DEFAULT_FORMATTER_CONFIG };
104
+ }
105
+
106
+ return {
107
+ commandTimeoutMs: parsePositiveInt(
108
+ config.commandTimeoutMs,
109
+ DEFAULT_COMMAND_TIMEOUT_MS,
110
+ ),
111
+ hideCallSummariesInTui: parseBooleanValue(
112
+ config.hideCallSummariesInTui,
113
+ DEFAULT_HIDE_CALL_SUMMARIES_IN_TUI,
114
+ ),
115
+ formatMode: parseFormatMode(config.formatMode, DEFAULT_FORMAT_MODE),
116
+ formatOnAbort: parseBooleanValue(
117
+ config.formatOnAbort,
118
+ DEFAULT_FORMAT_ON_ABORT,
119
+ ),
120
+ };
121
+ }
122
+
123
+ export function cloneFormatterConfig(
124
+ config: FormatterConfigSnapshot,
125
+ ): FormatterConfigSnapshot {
126
+ return {
127
+ commandTimeoutMs: config.commandTimeoutMs,
128
+ hideCallSummariesInTui: config.hideCallSummariesInTui,
129
+ formatMode: config.formatMode,
130
+ formatOnAbort: config.formatOnAbort,
131
+ };
132
+ }
133
+
134
+ export function writeFormatterConfigSnapshot(
135
+ config: FormatterConfigSnapshot,
136
+ ): void {
137
+ writeFormatterConfigFile(
138
+ `${JSON.stringify(toFormatterConfigObject(config), null, 2)}\n`,
139
+ );
140
+ }
@@ -3,7 +3,7 @@ import { dirname, join, resolve } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { getPathForGit, isWithinDirectory, pathExists } from "./path.js";
5
5
  import { hasCommand } from "./system.js";
6
- import type { FileKind, RequiredMajorVersion, RunnerContext, SourceTool } from "./types.js";
6
+ import type { FileKind, RequiredMajorVersion, RunnerContext } from "./types.js";
7
7
 
8
8
  const globRegexCache = new Map<string, RegExp>();
9
9
 
@@ -66,14 +66,44 @@ async function findConfigFileFromPath(
66
66
  return undefined;
67
67
  }
68
68
 
69
+ function getLineRangesFromDiff(diffOutput: string): string[] {
70
+ const ranges: string[] = [];
71
+
72
+ for (const line of diffOutput.split(/\r?\n/)) {
73
+ const match = line.match(/^@@ .* \+([0-9]+)(?:,([0-9]+))? @@/);
74
+ if (!match) {
75
+ continue;
76
+ }
77
+
78
+ const start = Number.parseInt(match[1], 10);
79
+ const count = match[2] ? Number.parseInt(match[2], 10) : 1;
80
+
81
+ if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
82
+ continue;
83
+ }
84
+
85
+ ranges.push(`${start}:${start + count - 1}`);
86
+ }
87
+
88
+ return ranges;
89
+ }
90
+
91
+ function compareLineRanges(a: string, b: string): number {
92
+ const [aStart] = a.split(":", 1);
93
+ const [bStart] = b.split(":", 1);
94
+ return Number.parseInt(aStart, 10) - Number.parseInt(bStart, 10);
95
+ }
96
+
97
+ export type FormatWarningReporter = (message: string) => void;
98
+
69
99
  export class FormatRunContext implements RunnerContext {
70
100
  readonly filePath: string;
71
101
  readonly cwd: string;
72
- readonly sourceTool: SourceTool;
73
102
  readonly kind: FileKind;
74
103
 
75
104
  private readonly pi: ExtensionAPI;
76
105
  private readonly timeoutMs: number;
106
+ private readonly warningReporter?: FormatWarningReporter;
77
107
 
78
108
  private readonly configLookupCache = new Map<
79
109
  string,
@@ -95,23 +125,25 @@ export class FormatRunContext implements RunnerContext {
95
125
  pi: ExtensionAPI,
96
126
  cwd: string,
97
127
  filePath: string,
98
- sourceTool: SourceTool,
99
128
  kind: FileKind,
100
129
  timeoutMs: number,
130
+ warningReporter?: FormatWarningReporter,
101
131
  ) {
102
132
  this.pi = pi;
103
133
  this.cwd = cwd;
104
134
  this.filePath = filePath;
105
- this.sourceTool = sourceTool;
106
135
  this.kind = kind;
107
136
  this.timeoutMs = timeoutMs;
137
+ this.warningReporter = warningReporter;
108
138
  }
109
139
 
110
140
  async hasCommand(command: string): Promise<boolean> {
111
141
  return hasCommand(command);
112
142
  }
113
143
 
114
- async findConfigFile(patterns: readonly string[]): Promise<string | undefined> {
144
+ async findConfigFile(
145
+ patterns: readonly string[],
146
+ ): Promise<string | undefined> {
115
147
  const key = patterns.join("\u0000");
116
148
  let cached = this.configLookupCache.get(key);
117
149
 
@@ -136,14 +168,10 @@ export class FormatRunContext implements RunnerContext {
136
168
  }
137
169
 
138
170
  async exec(command: string, args: string[]) {
139
- try {
140
- return await this.pi.exec(command, args, {
141
- cwd: this.cwd,
142
- timeout: this.timeoutMs,
143
- });
144
- } catch {
145
- return undefined;
146
- }
171
+ return this.pi.exec(command, args, {
172
+ cwd: this.cwd,
173
+ timeout: this.timeoutMs,
174
+ });
147
175
  }
148
176
 
149
177
  async getChangedLines(): Promise<string[]> {
@@ -182,6 +210,11 @@ export class FormatRunContext implements RunnerContext {
182
210
  }
183
211
 
184
212
  warn(message: string): void {
213
+ if (this.warningReporter) {
214
+ this.warningReporter(message);
215
+ return;
216
+ }
217
+
185
218
  console.warn(message);
186
219
  }
187
220
 
@@ -207,37 +240,25 @@ export class FormatRunContext implements RunnerContext {
207
240
 
208
241
  private async resolveChangedLines(): Promise<string[]> {
209
242
  const gitPath = getPathForGit(this.filePath, this.cwd);
210
- const diffResult = await this.exec("git", [
211
- "diff",
212
- "--cached",
213
- "--unified=0",
214
- "--",
215
- gitPath,
216
- ]);
217
-
218
- if (!diffResult || diffResult.code !== 0) {
219
- return [];
220
- }
243
+ const rangeSet = new Set<string>();
221
244
 
222
- const ranges: string[] = [];
245
+ const diffArgSets = [
246
+ ["diff", "--unified=0", "--", gitPath],
247
+ ["diff", "--cached", "--unified=0", "--", gitPath],
248
+ ];
223
249
 
224
- for (const line of diffResult.stdout.split(/\r?\n/)) {
225
- const match = line.match(/^@@ .* \+([0-9]+)(?:,([0-9]+))? @@/);
226
- if (!match) {
250
+ for (const args of diffArgSets) {
251
+ const diffResult = await this.exec("git", args);
252
+ if (diffResult.code !== 0) {
227
253
  continue;
228
254
  }
229
255
 
230
- const start = Number.parseInt(match[1], 10);
231
- const count = match[2] ? Number.parseInt(match[2], 10) : 1;
232
-
233
- if (!Number.isFinite(start) || !Number.isFinite(count) || count <= 0) {
234
- continue;
256
+ for (const range of getLineRangesFromDiff(diffResult.stdout)) {
257
+ rangeSet.add(range);
235
258
  }
236
-
237
- ranges.push(`${start}:${start + count - 1}`);
238
259
  }
239
260
 
240
- return ranges;
261
+ return [...rangeSet].sort(compareLineRanges);
241
262
  }
242
263
 
243
264
  private async resolveRequiredMajorVersionFromConfig(
@@ -266,7 +287,7 @@ export class FormatRunContext implements RunnerContext {
266
287
  command: string,
267
288
  ): Promise<string | undefined> {
268
289
  const result = await this.exec(command, ["--version"]);
269
- if (!result || result.code !== 0) {
290
+ if (result.code !== 0) {
270
291
  return undefined;
271
292
  }
272
293
 
@@ -1,28 +1,17 @@
1
- import type { ExtensionAPI, ExecResult } from "@mariozechner/pi-coding-agent";
2
- import { FormatRunContext } from "./context.js";
1
+ import type { ExecResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { FormatRunContext, type FormatWarningReporter } from "./context.js";
3
3
  import { detectFileKind } from "./path.js";
4
4
  import { FORMAT_PLAN } from "./plan.js";
5
5
  import { RUNNERS } from "./runners/index.js";
6
6
  import {
7
7
  isDynamicRunner,
8
8
  type ResolvedLauncher,
9
+ type RunnerContext,
9
10
  type RunnerDefinition,
10
11
  type RunnerGroup,
11
12
  type RunnerLauncher,
12
- type RunnerContext,
13
- type SourceTool,
14
13
  } from "./types.js";
15
14
 
16
- function summarizeExecResult(result: ExecResult): string {
17
- const output = `${result.stderr}\n${result.stdout}`.trim();
18
- if (!output) {
19
- return "";
20
- }
21
-
22
- const firstLine = output.split(/\r?\n/, 1)[0];
23
- return `: ${firstLine}`;
24
- }
25
-
26
15
  async function resolveLauncher(
27
16
  launcher: RunnerLauncher,
28
17
  ctx: RunnerContext,
@@ -93,15 +82,17 @@ async function satisfiesRunnerRequirements(
93
82
  const onInvalid = requirement.onInvalid ?? "warn-skip";
94
83
  if (onInvalid === "warn-skip") {
95
84
  ctx.warn(
96
- `[pi-formatter] ${runner.id} skipped: invalid version requirement in ${requirement.patterns.join(", ")}`,
85
+ `${runner.id} skipped: invalid version requirement in ${requirement.patterns.join(", ")}`,
97
86
  );
98
87
  }
99
88
 
100
89
  return false;
101
90
  }
102
91
 
103
- const versionCommand = requirement.command ?? defaultVersionCommand(runner.launcher);
104
- const installedVersion = await ctx.getInstalledToolMajorVersion(versionCommand);
92
+ const versionCommand =
93
+ requirement.command ?? defaultVersionCommand(runner.launcher);
94
+ const installedVersion =
95
+ await ctx.getInstalledToolMajorVersion(versionCommand);
105
96
 
106
97
  if (installedVersion === requiredVersion) {
107
98
  return true;
@@ -110,7 +101,7 @@ async function satisfiesRunnerRequirements(
110
101
  const onMismatch = requirement.onMismatch ?? "warn-skip";
111
102
  if (onMismatch === "warn-skip") {
112
103
  ctx.warn(
113
- `[pi-formatter] ${runner.id} skipped: ${versionCommand} version mismatch (have ${installedVersion ?? "unknown"}, need ${requiredVersion})`,
104
+ `${runner.id} skipped: ${versionCommand} version mismatch (have ${installedVersion ?? "unknown"}, need ${requiredVersion})`,
114
105
  );
115
106
  }
116
107
 
@@ -135,9 +126,56 @@ async function resolveRunnerArgs(
135
126
 
136
127
  type RunnerOutcome = "skipped" | "failed" | "succeeded";
137
128
 
129
+ export interface FormatCallSummary {
130
+ runnerId: string;
131
+ status: "succeeded" | "failed";
132
+ exitCode?: number;
133
+ failureMessage?: string;
134
+ }
135
+
136
+ export type FormatCallSummaryReporter = (summary: FormatCallSummary) => void;
137
+
138
+ const MAX_FAILURE_MESSAGE_LENGTH = 140;
139
+ const ANSI_ESCAPE = String.fromCharCode(27);
140
+ const ANSI_COLOR_SEQUENCE_PATTERN = new RegExp(
141
+ `${ANSI_ESCAPE}\\[[0-9;]*m`,
142
+ "g",
143
+ );
144
+
145
+ function normalizeFailureLine(line: string): string {
146
+ return line
147
+ .replace(ANSI_COLOR_SEQUENCE_PATTERN, "")
148
+ .replace(/^\s*\[error\]\s*/i, "")
149
+ .replace(/^\s*error:\s*/i, "")
150
+ .replace(/^\s*[×✖✘]\s*/u, "")
151
+ .replace(/\s+/g, " ")
152
+ .trim();
153
+ }
154
+
155
+ function summarizeFailureMessage(result: ExecResult): string | undefined {
156
+ const lines = `${result.stderr}\n${result.stdout}`
157
+ .split(/\r?\n/)
158
+ .map((line) => normalizeFailureLine(line))
159
+ .filter((line) => line.length > 0);
160
+
161
+ if (lines.length === 0) {
162
+ return undefined;
163
+ }
164
+
165
+ const withMarker = lines.find((line) =>
166
+ /\b(error|failed|invalid|unexpected|expected|syntax)\b/i.test(line),
167
+ );
168
+ const message = withMarker ?? lines[0];
169
+
170
+ return message.length <= MAX_FAILURE_MESSAGE_LENGTH
171
+ ? message
172
+ : `${message.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…`;
173
+ }
174
+
138
175
  async function runRunner(
139
176
  ctx: RunnerContext,
140
177
  runner: RunnerDefinition,
178
+ summaryReporter?: FormatCallSummaryReporter,
141
179
  ): Promise<RunnerOutcome> {
142
180
  const launcher = await resolveLauncher(runner.launcher, ctx);
143
181
  if (!launcher) {
@@ -157,60 +195,104 @@ async function runRunner(
157
195
  return "skipped";
158
196
  }
159
197
 
160
- const result = await ctx.exec(launcher.command, [...launcher.argsPrefix, ...args]);
161
- if (!result) {
162
- ctx.warn(`[pi-formatter] ${runner.id} failed to execute`);
163
- return "failed";
164
- }
198
+ const commandArgs = [...launcher.argsPrefix, ...args];
199
+
200
+ const result = await ctx.exec(launcher.command, commandArgs);
165
201
 
166
202
  if (result.code !== 0) {
203
+ const failureMessage = summarizeFailureMessage(result);
204
+
205
+ summaryReporter?.({
206
+ runnerId: runner.id,
207
+ status: "failed",
208
+ exitCode: result.code,
209
+ failureMessage,
210
+ });
211
+
167
212
  ctx.warn(
168
- `[pi-formatter] ${runner.id} exited with code ${result.code}${summarizeExecResult(result)}`,
213
+ `${runner.id} failed (${result.code})${failureMessage ? `: ${failureMessage}` : ""}`,
169
214
  );
170
215
  return "failed";
171
216
  }
172
217
 
218
+ summaryReporter?.({
219
+ runnerId: runner.id,
220
+ status: "succeeded",
221
+ });
222
+
173
223
  return "succeeded";
174
224
  }
175
225
 
176
226
  async function runRunnerGroup(
177
227
  ctx: RunnerContext,
178
228
  group: RunnerGroup,
229
+ summaryReporter?: FormatCallSummaryReporter,
179
230
  ): Promise<void> {
180
231
  if (group.mode === "all") {
181
232
  for (const runnerId of group.runnerIds) {
182
233
  const runner = RUNNERS.get(runnerId);
183
234
  if (!runner) {
184
- ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
235
+ ctx.warn(`unknown runner in format plan: ${runnerId}`);
185
236
  continue;
186
237
  }
187
238
 
188
- await runRunner(ctx, runner);
239
+ await runRunner(ctx, runner, summaryReporter);
189
240
  }
190
241
 
191
242
  return;
192
243
  }
193
244
 
245
+ const fallbackSummaries: FormatCallSummary[] = [];
246
+ const fallbackSummaryReporter = summaryReporter
247
+ ? (summary: FormatCallSummary) => {
248
+ fallbackSummaries.push(summary);
249
+ }
250
+ : undefined;
251
+
194
252
  for (const runnerId of group.runnerIds) {
195
253
  const runner = RUNNERS.get(runnerId);
196
254
  if (!runner) {
197
- ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
255
+ ctx.warn(`unknown runner in format plan: ${runnerId}`);
198
256
  continue;
199
257
  }
200
258
 
201
- const outcome = await runRunner(ctx, runner);
259
+ const outcome = await runRunner(ctx, runner, fallbackSummaryReporter);
202
260
  if (outcome === "succeeded") {
203
- break;
261
+ if (!summaryReporter) {
262
+ return;
263
+ }
264
+
265
+ const successSummary = [...fallbackSummaries]
266
+ .reverse()
267
+ .find((summary) => summary.status === "succeeded");
268
+ if (successSummary) {
269
+ summaryReporter(successSummary);
270
+ }
271
+
272
+ return;
204
273
  }
205
274
  }
275
+
276
+ if (!summaryReporter) {
277
+ return;
278
+ }
279
+
280
+ const lastFailureSummary = [...fallbackSummaries]
281
+ .reverse()
282
+ .find((summary) => summary.status === "failed");
283
+
284
+ if (lastFailureSummary) {
285
+ summaryReporter(lastFailureSummary);
286
+ }
206
287
  }
207
288
 
208
289
  export async function formatFile(
209
290
  pi: ExtensionAPI,
210
291
  cwd: string,
211
- sourceTool: SourceTool,
212
292
  filePath: string,
213
293
  timeoutMs: number,
294
+ summaryReporter?: FormatCallSummaryReporter,
295
+ warningReporter?: FormatWarningReporter,
214
296
  ): Promise<void> {
215
297
  const kind = detectFileKind(filePath);
216
298
  if (!kind) {
@@ -226,12 +308,12 @@ export async function formatFile(
226
308
  pi,
227
309
  cwd,
228
310
  filePath,
229
- sourceTool,
230
311
  kind,
231
312
  timeoutMs,
313
+ warningReporter,
232
314
  );
233
315
 
234
316
  for (const group of groups) {
235
- await runRunnerGroup(runContext, group);
317
+ await runRunnerGroup(runContext, group, summaryReporter);
236
318
  }
237
319
  }
@@ -3,8 +3,17 @@ import { homedir } from "node:os";
3
3
  import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import type { FileKind } from "./types.js";
5
5
 
6
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
7
+
8
+ function normalizeUnicodeSpaces(value: string): string {
9
+ return value.replace(UNICODE_SPACES, " ");
10
+ }
11
+
6
12
  export function normalizeToolPath(filePath: string): string {
7
- const normalizedAt = filePath.startsWith("@") ? filePath.slice(1) : filePath;
13
+ const normalizedInput = normalizeUnicodeSpaces(filePath);
14
+ const normalizedAt = normalizedInput.startsWith("@")
15
+ ? normalizedInput.slice(1)
16
+ : normalizedInput;
8
17
 
9
18
  if (normalizedAt === "~") {
10
19
  return homedir();
@@ -33,7 +42,10 @@ export async function pathExists(path: string): Promise<boolean> {
33
42
  }
34
43
  }
35
44
 
36
- export function isWithinDirectory(pathToCheck: string, directory: string): boolean {
45
+ export function isWithinDirectory(
46
+ pathToCheck: string,
47
+ directory: string,
48
+ ): boolean {
37
49
  const relPath = relative(directory, pathToCheck);
38
50
  return (
39
51
  relPath === "" ||
@@ -57,7 +69,11 @@ export function getPathForGit(filePath: string, cwd: string): string {
57
69
  }
58
70
 
59
71
  export function detectFileKind(filePath: string): FileKind | undefined {
60
- if (/\.(cpp|hpp|cpp\.in|hpp\.in)$/.test(filePath)) {
72
+ if (
73
+ /(\.(c|h|cc|hh|cpp|hpp|cxx|hxx|ixx|ipp|inl|tpp)|\.(c|h|cc|hh|cpp|hpp|cxx|hxx)\.in)$/i.test(
74
+ filePath,
75
+ )
76
+ ) {
61
77
  return "cxx";
62
78
  }
63
79