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.
- package/README.md +55 -10
- package/extensions/formatter/config.ts +140 -0
- package/extensions/{format → formatter}/context.ts +58 -37
- package/extensions/{format → formatter}/dispatch.ts +114 -32
- package/extensions/{format → formatter}/path.ts +19 -3
- package/extensions/{format → formatter}/plan.ts +5 -5
- package/extensions/{format/runners/biome-check-write.ts → formatter/runners/biome.ts} +4 -4
- package/extensions/formatter/runners/config-patterns.ts +5 -0
- package/extensions/{format/runners/eslint-fix.ts → formatter/runners/eslint.ts} +3 -3
- package/extensions/{format → formatter}/runners/index.ts +10 -12
- package/extensions/{format/runners/markdownlint-fix.ts → formatter/runners/markdownlint.ts} +3 -3
- package/extensions/{format/runners/prettier-markdown.ts → formatter/runners/prettier.ts} +3 -3
- package/extensions/{format/runners/ruff-check-fix.ts → formatter/runners/ruff-check.ts} +3 -3
- package/extensions/{format → formatter}/types.ts +4 -6
- package/extensions/index.ts +332 -20
- package/package.json +1 -2
- package/DOCUMENTATION.md +0 -120
- package/extensions/format/config.ts +0 -11
- package/extensions/format/runners/config-patterns.ts +0 -6
- package/extensions/format/runners/prettier-config-write.ts +0 -11
- /package/extensions/{format → formatter}/runners/clang-format.ts +0 -0
- /package/extensions/{format → formatter}/runners/cmake-format.ts +0 -0
- /package/extensions/{format → formatter}/runners/helpers.ts +0 -0
- /package/extensions/{format → formatter}/runners/ruff-format.ts +0 -0
- /package/extensions/{format → formatter}/runners/shfmt.ts +0 -0
- /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://
|
|
4
|
-
|
|
3
|
+
A [pi](https://pi.dev) extension that auto-formats files after `write` and
|
|
4
|
+
`edit` tool calls.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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`
|
|
19
|
-
best-effort
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
|
245
|
+
const diffArgSets = [
|
|
246
|
+
["diff", "--unified=0", "--", gitPath],
|
|
247
|
+
["diff", "--cached", "--unified=0", "--", gitPath],
|
|
248
|
+
];
|
|
223
249
|
|
|
224
|
-
for (const
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
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
|
|
231
|
-
|
|
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
|
|
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 (
|
|
290
|
+
if (result.code !== 0) {
|
|
270
291
|
return undefined;
|
|
271
292
|
}
|
|
272
293
|
|
|
@@ -1,28 +1,17 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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 =
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
|