pi-formatter 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.
@@ -0,0 +1,237 @@
1
+ import type { ExtensionAPI, ExecResult } from "@mariozechner/pi-coding-agent";
2
+ import { FormatRunContext } from "./context.js";
3
+ import { detectFileKind } from "./path.js";
4
+ import { FORMAT_PLAN } from "./plan.js";
5
+ import { RUNNERS } from "./runners/index.js";
6
+ import {
7
+ isDynamicRunner,
8
+ type ResolvedLauncher,
9
+ type RunnerDefinition,
10
+ type RunnerGroup,
11
+ type RunnerLauncher,
12
+ type RunnerContext,
13
+ type SourceTool,
14
+ } from "./types.js";
15
+
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
+ async function resolveLauncher(
27
+ launcher: RunnerLauncher,
28
+ ctx: RunnerContext,
29
+ ): Promise<ResolvedLauncher | undefined> {
30
+ if (launcher.type === "direct") {
31
+ if (await ctx.hasCommand(launcher.command)) {
32
+ return { command: launcher.command, argsPrefix: [] };
33
+ }
34
+
35
+ return undefined;
36
+ }
37
+
38
+ if (launcher.type === "pypi") {
39
+ if (await ctx.hasCommand(launcher.tool)) {
40
+ return { command: launcher.tool, argsPrefix: [] };
41
+ }
42
+
43
+ if (await ctx.hasCommand("uv")) {
44
+ return {
45
+ command: "uv",
46
+ argsPrefix: ["tool", "run", launcher.tool],
47
+ };
48
+ }
49
+
50
+ return undefined;
51
+ }
52
+
53
+ if (await ctx.hasCommand(launcher.tool)) {
54
+ return { command: launcher.tool, argsPrefix: [] };
55
+ }
56
+
57
+ if (await ctx.hasCommand("go")) {
58
+ return {
59
+ command: "go",
60
+ argsPrefix: ["run", launcher.module],
61
+ };
62
+ }
63
+
64
+ return undefined;
65
+ }
66
+
67
+ function defaultVersionCommand(launcher: RunnerLauncher): string {
68
+ if (launcher.type === "direct") {
69
+ return launcher.command;
70
+ }
71
+
72
+ return launcher.tool;
73
+ }
74
+
75
+ async function satisfiesRunnerRequirements(
76
+ ctx: RunnerContext,
77
+ runner: RunnerDefinition,
78
+ ): Promise<boolean> {
79
+ const requirement = runner.requires?.majorVersionFromConfig;
80
+ if (!requirement) {
81
+ return true;
82
+ }
83
+
84
+ const requiredVersion = await ctx.getRequiredMajorVersionFromConfig(
85
+ requirement.patterns,
86
+ );
87
+
88
+ if (requiredVersion === undefined) {
89
+ return true;
90
+ }
91
+
92
+ if (requiredVersion === "invalid") {
93
+ const onInvalid = requirement.onInvalid ?? "warn-skip";
94
+ if (onInvalid === "warn-skip") {
95
+ ctx.warn(
96
+ `[pi-formatter] ${runner.id} skipped: invalid version requirement in ${requirement.patterns.join(", ")}`,
97
+ );
98
+ }
99
+
100
+ return false;
101
+ }
102
+
103
+ const versionCommand = requirement.command ?? defaultVersionCommand(runner.launcher);
104
+ const installedVersion = await ctx.getInstalledToolMajorVersion(versionCommand);
105
+
106
+ if (installedVersion === requiredVersion) {
107
+ return true;
108
+ }
109
+
110
+ const onMismatch = requirement.onMismatch ?? "warn-skip";
111
+ if (onMismatch === "warn-skip") {
112
+ ctx.warn(
113
+ `[pi-formatter] ${runner.id} skipped: ${versionCommand} version mismatch (have ${installedVersion ?? "unknown"}, need ${requiredVersion})`,
114
+ );
115
+ }
116
+
117
+ return false;
118
+ }
119
+
120
+ async function resolveRunnerArgs(
121
+ ctx: RunnerContext,
122
+ runner: RunnerDefinition,
123
+ ): Promise<string[] | undefined> {
124
+ if (isDynamicRunner(runner)) {
125
+ return runner.buildArgs(ctx);
126
+ }
127
+
128
+ const args = [...runner.args];
129
+ if (runner.appendFile !== false) {
130
+ args.push(ctx.filePath);
131
+ }
132
+
133
+ return args;
134
+ }
135
+
136
+ type RunnerOutcome = "skipped" | "failed" | "succeeded";
137
+
138
+ async function runRunner(
139
+ ctx: RunnerContext,
140
+ runner: RunnerDefinition,
141
+ ): Promise<RunnerOutcome> {
142
+ const launcher = await resolveLauncher(runner.launcher, ctx);
143
+ if (!launcher) {
144
+ return "skipped";
145
+ }
146
+
147
+ if (runner.when && !(await runner.when(ctx))) {
148
+ return "skipped";
149
+ }
150
+
151
+ if (!(await satisfiesRunnerRequirements(ctx, runner))) {
152
+ return "skipped";
153
+ }
154
+
155
+ const args = await resolveRunnerArgs(ctx, runner);
156
+ if (!args) {
157
+ return "skipped";
158
+ }
159
+
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
+ }
165
+
166
+ if (result.code !== 0) {
167
+ ctx.warn(
168
+ `[pi-formatter] ${runner.id} exited with code ${result.code}${summarizeExecResult(result)}`,
169
+ );
170
+ return "failed";
171
+ }
172
+
173
+ return "succeeded";
174
+ }
175
+
176
+ async function runRunnerGroup(
177
+ ctx: RunnerContext,
178
+ group: RunnerGroup,
179
+ ): Promise<void> {
180
+ if (group.mode === "all") {
181
+ for (const runnerId of group.runnerIds) {
182
+ const runner = RUNNERS.get(runnerId);
183
+ if (!runner) {
184
+ ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
185
+ continue;
186
+ }
187
+
188
+ await runRunner(ctx, runner);
189
+ }
190
+
191
+ return;
192
+ }
193
+
194
+ for (const runnerId of group.runnerIds) {
195
+ const runner = RUNNERS.get(runnerId);
196
+ if (!runner) {
197
+ ctx.warn(`[pi-formatter] unknown runner in format plan: ${runnerId}`);
198
+ continue;
199
+ }
200
+
201
+ const outcome = await runRunner(ctx, runner);
202
+ if (outcome === "succeeded") {
203
+ break;
204
+ }
205
+ }
206
+ }
207
+
208
+ export async function formatFile(
209
+ pi: ExtensionAPI,
210
+ cwd: string,
211
+ sourceTool: SourceTool,
212
+ filePath: string,
213
+ timeoutMs: number,
214
+ ): Promise<void> {
215
+ const kind = detectFileKind(filePath);
216
+ if (!kind) {
217
+ return;
218
+ }
219
+
220
+ const groups = FORMAT_PLAN[kind];
221
+ if (!groups || groups.length === 0) {
222
+ return;
223
+ }
224
+
225
+ const runContext = new FormatRunContext(
226
+ pi,
227
+ cwd,
228
+ filePath,
229
+ sourceTool,
230
+ kind,
231
+ timeoutMs,
232
+ );
233
+
234
+ for (const group of groups) {
235
+ await runRunnerGroup(runContext, group);
236
+ }
237
+ }
@@ -0,0 +1,89 @@
1
+ import { access } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
+ import type { FileKind } from "./types.js";
5
+
6
+ export function normalizeToolPath(filePath: string): string {
7
+ const normalizedAt = filePath.startsWith("@") ? filePath.slice(1) : filePath;
8
+
9
+ if (normalizedAt === "~") {
10
+ return homedir();
11
+ }
12
+
13
+ if (normalizedAt.startsWith("~/")) {
14
+ return join(homedir(), normalizedAt.slice(2));
15
+ }
16
+
17
+ return normalizedAt;
18
+ }
19
+
20
+ export function resolveToolPath(filePath: string, cwd: string): string {
21
+ const normalizedPath = normalizeToolPath(filePath);
22
+ return isAbsolute(normalizedPath)
23
+ ? normalizedPath
24
+ : resolve(cwd, normalizedPath);
25
+ }
26
+
27
+ export async function pathExists(path: string): Promise<boolean> {
28
+ try {
29
+ await access(path);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export function isWithinDirectory(pathToCheck: string, directory: string): boolean {
37
+ const relPath = relative(directory, pathToCheck);
38
+ return (
39
+ relPath === "" ||
40
+ relPath === "." ||
41
+ (!relPath.startsWith("..") && !isAbsolute(relPath))
42
+ );
43
+ }
44
+
45
+ export function getPathForGit(filePath: string, cwd: string): string {
46
+ const relPath = relative(cwd, filePath);
47
+ if (
48
+ !relPath ||
49
+ relPath === "." ||
50
+ relPath.startsWith("..") ||
51
+ isAbsolute(relPath)
52
+ ) {
53
+ return filePath;
54
+ }
55
+
56
+ return relPath;
57
+ }
58
+
59
+ export function detectFileKind(filePath: string): FileKind | undefined {
60
+ if (/\.(cpp|hpp|cpp\.in|hpp\.in)$/.test(filePath)) {
61
+ return "cxx";
62
+ }
63
+
64
+ if (/\.cmake$/.test(filePath) || basename(filePath) === "CMakeLists.txt") {
65
+ return "cmake";
66
+ }
67
+
68
+ if (/\.(md|mdx)$/.test(filePath)) {
69
+ return "markdown";
70
+ }
71
+
72
+ if (/\.json$/.test(filePath)) {
73
+ return "json";
74
+ }
75
+
76
+ if (/\.(sh|bash)$/.test(filePath)) {
77
+ return "shell";
78
+ }
79
+
80
+ if (/\.py$/.test(filePath)) {
81
+ return "python";
82
+ }
83
+
84
+ if (/\.(js|jsx|ts|tsx|mjs|cjs)$/.test(filePath)) {
85
+ return "jsts";
86
+ }
87
+
88
+ return undefined;
89
+ }
@@ -0,0 +1,26 @@
1
+ import type { FileKind, RunnerGroup } from "./types.js";
2
+
3
+ export const FORMAT_PLAN: Record<FileKind, RunnerGroup[]> = {
4
+ cxx: [{ mode: "all", runnerIds: ["clang-format"] }],
5
+ cmake: [{ mode: "all", runnerIds: ["cmake-format"] }],
6
+ markdown: [
7
+ {
8
+ mode: "all",
9
+ runnerIds: ["markdownlint-fix", "prettier-markdown"],
10
+ },
11
+ ],
12
+ json: [
13
+ {
14
+ mode: "fallback",
15
+ runnerIds: ["biome-check-write", "prettier-config-write"],
16
+ },
17
+ ],
18
+ shell: [{ mode: "all", runnerIds: ["shfmt"] }],
19
+ python: [{ mode: "all", runnerIds: ["ruff-format", "ruff-check-fix"] }],
20
+ jsts: [
21
+ {
22
+ mode: "fallback",
23
+ runnerIds: ["biome-check-write", "eslint-fix", "prettier-config-write"],
24
+ },
25
+ ],
26
+ };
@@ -0,0 +1,11 @@
1
+ import { defineRunner, direct } from "./helpers.js";
2
+ import { BIOME_CONFIG_PATTERNS } from "./config-patterns.js";
3
+
4
+ const biomeCheckWriteRunner = defineRunner({
5
+ id: "biome-check-write",
6
+ launcher: direct("biome"),
7
+ when: (ctx) => ctx.hasConfig(BIOME_CONFIG_PATTERNS),
8
+ args: ["check", "--write"],
9
+ });
10
+
11
+ export default biomeCheckWriteRunner;
@@ -0,0 +1,27 @@
1
+ import { defineRunner, direct } from "./helpers.js";
2
+
3
+ const clangFormatRunner = defineRunner({
4
+ id: "clang-format",
5
+ launcher: direct("clang-format"),
6
+ requires: {
7
+ majorVersionFromConfig: {
8
+ patterns: [".clang-format-version"],
9
+ onInvalid: "warn-skip",
10
+ onMismatch: "warn-skip",
11
+ },
12
+ },
13
+ async buildArgs(ctx) {
14
+ const changedLines = await ctx.getChangedLines();
15
+ if (changedLines.length > 0) {
16
+ return [
17
+ ...changedLines.map((line) => `--lines=${line}`),
18
+ "-i",
19
+ ctx.filePath,
20
+ ];
21
+ }
22
+
23
+ return ["-i", ctx.filePath];
24
+ },
25
+ });
26
+
27
+ export default clangFormatRunner;
@@ -0,0 +1,9 @@
1
+ import { defineRunner, pypi } from "./helpers.js";
2
+
3
+ const cmakeFormatRunner = defineRunner({
4
+ id: "cmake-format",
5
+ launcher: pypi("cmake-format"),
6
+ args: ["--in-place"],
7
+ });
8
+
9
+ export default cmakeFormatRunner;
@@ -0,0 +1,6 @@
1
+ export const BIOME_CONFIG_PATTERNS = ["biome.json", "biome.jsonc"] as const;
2
+ export const ESLINT_CONFIG_PATTERNS = ["eslint.config.*", ".eslintrc*"] as const;
3
+ export const PRETTIER_CONFIG_PATTERNS = [
4
+ ".prettierrc*",
5
+ "prettier.config.*",
6
+ ] as const;
@@ -0,0 +1,11 @@
1
+ import { ESLINT_CONFIG_PATTERNS } from "./config-patterns.js";
2
+ import { defineRunner, direct } from "./helpers.js";
3
+
4
+ const eslintFixRunner = defineRunner({
5
+ id: "eslint-fix",
6
+ launcher: direct("eslint"),
7
+ when: (ctx) => ctx.hasConfig(ESLINT_CONFIG_PATTERNS),
8
+ args: ["--fix"],
9
+ });
10
+
11
+ export default eslintFixRunner;
@@ -0,0 +1,22 @@
1
+ import type {
2
+ DirectLauncher,
3
+ GoLauncher,
4
+ PypiLauncher,
5
+ RunnerDefinition,
6
+ } from "../types.js";
7
+
8
+ export function direct(command: string): DirectLauncher {
9
+ return { type: "direct", command };
10
+ }
11
+
12
+ export function pypi(tool: string): PypiLauncher {
13
+ return { type: "pypi", tool };
14
+ }
15
+
16
+ export function goTool(tool: string, module: string): GoLauncher {
17
+ return { type: "go", tool, module };
18
+ }
19
+
20
+ export function defineRunner<T extends RunnerDefinition>(runner: T): T {
21
+ return runner;
22
+ }
@@ -0,0 +1,42 @@
1
+ import type { RunnerDefinition } from "../types.js";
2
+ import biomeCheckWriteRunner from "./biome-check-write.js";
3
+ import clangFormatRunner from "./clang-format.js";
4
+ import cmakeFormatRunner from "./cmake-format.js";
5
+ import eslintFixRunner from "./eslint-fix.js";
6
+ import markdownlintFixRunner from "./markdownlint-fix.js";
7
+ import prettierConfigWriteRunner from "./prettier-config-write.js";
8
+ import prettierMarkdownRunner from "./prettier-markdown.js";
9
+ import ruffCheckFixRunner from "./ruff-check-fix.js";
10
+ import ruffFormatRunner from "./ruff-format.js";
11
+ import shfmtRunner from "./shfmt.js";
12
+
13
+ export const RUNNER_DEFINITIONS: RunnerDefinition[] = [
14
+ clangFormatRunner,
15
+ cmakeFormatRunner,
16
+ markdownlintFixRunner,
17
+ prettierMarkdownRunner,
18
+ biomeCheckWriteRunner,
19
+ eslintFixRunner,
20
+ prettierConfigWriteRunner,
21
+ shfmtRunner,
22
+ ruffFormatRunner,
23
+ ruffCheckFixRunner,
24
+ ];
25
+
26
+ function buildRunnerRegistry(
27
+ definitions: RunnerDefinition[],
28
+ ): Map<string, RunnerDefinition> {
29
+ const registry = new Map<string, RunnerDefinition>();
30
+
31
+ for (const runner of definitions) {
32
+ if (registry.has(runner.id)) {
33
+ throw new Error(`Duplicate runner registration: ${runner.id}`);
34
+ }
35
+
36
+ registry.set(runner.id, runner);
37
+ }
38
+
39
+ return registry;
40
+ }
41
+
42
+ export const RUNNERS = buildRunnerRegistry(RUNNER_DEFINITIONS);
@@ -0,0 +1,9 @@
1
+ import { defineRunner, direct } from "./helpers.js";
2
+
3
+ const markdownlintFixRunner = defineRunner({
4
+ id: "markdownlint-fix",
5
+ launcher: direct("markdownlint"),
6
+ args: ["--fix"],
7
+ });
8
+
9
+ export default markdownlintFixRunner;
@@ -0,0 +1,11 @@
1
+ import { PRETTIER_CONFIG_PATTERNS } from "./config-patterns.js";
2
+ import { defineRunner, direct } from "./helpers.js";
3
+
4
+ const prettierConfigWriteRunner = defineRunner({
5
+ id: "prettier-config-write",
6
+ launcher: direct("prettier"),
7
+ when: (ctx) => ctx.hasConfig(PRETTIER_CONFIG_PATTERNS),
8
+ args: ["--write"],
9
+ });
10
+
11
+ export default prettierConfigWriteRunner;
@@ -0,0 +1,9 @@
1
+ import { defineRunner, direct } from "./helpers.js";
2
+
3
+ const prettierMarkdownRunner = defineRunner({
4
+ id: "prettier-markdown",
5
+ launcher: direct("prettier"),
6
+ args: ["--write"],
7
+ });
8
+
9
+ export default prettierMarkdownRunner;
@@ -0,0 +1,9 @@
1
+ import { defineRunner, pypi } from "./helpers.js";
2
+
3
+ const ruffCheckFixRunner = defineRunner({
4
+ id: "ruff-check-fix",
5
+ launcher: pypi("ruff"),
6
+ args: ["check", "--fix"],
7
+ });
8
+
9
+ export default ruffCheckFixRunner;
@@ -0,0 +1,9 @@
1
+ import { defineRunner, pypi } from "./helpers.js";
2
+
3
+ const ruffFormatRunner = defineRunner({
4
+ id: "ruff-format",
5
+ launcher: pypi("ruff"),
6
+ args: ["format"],
7
+ });
8
+
9
+ export default ruffFormatRunner;
@@ -0,0 +1,19 @@
1
+ import { defineRunner, goTool } from "./helpers.js";
2
+
3
+ const SHFMT_MODULE = "mvdan.cc/sh/v3/cmd/shfmt@v3.10.0";
4
+
5
+ const shfmtRunner = defineRunner({
6
+ id: "shfmt",
7
+ launcher: goTool("shfmt", SHFMT_MODULE),
8
+ async buildArgs(ctx) {
9
+ const hasEditorConfig = await ctx.hasEditorConfigInCwd();
10
+
11
+ if (hasEditorConfig) {
12
+ return ["-w", ctx.filePath];
13
+ }
14
+
15
+ return ["-i", "2", "-ci", "-bn", "-w", ctx.filePath];
16
+ },
17
+ });
18
+
19
+ export default shfmtRunner;
@@ -0,0 +1,57 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
+ import { delimiter, join } from "node:path";
4
+
5
+ const commandAvailability = new Map<string, boolean>();
6
+
7
+ function getExecutableCandidates(command: string): string[] {
8
+ if (process.platform !== "win32") {
9
+ return [command];
10
+ }
11
+
12
+ const pathExt = process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM";
13
+ const extensions = pathExt
14
+ .split(";")
15
+ .map((ext) => ext.trim())
16
+ .filter((ext) => ext.length > 0);
17
+
18
+ const hasExtension = extensions.some((ext) =>
19
+ command.toLowerCase().endsWith(ext.toLowerCase()),
20
+ );
21
+
22
+ if (hasExtension) {
23
+ return [command];
24
+ }
25
+
26
+ return [command, ...extensions.map((ext) => `${command}${ext}`)];
27
+ }
28
+
29
+ export async function hasCommand(command: string): Promise<boolean> {
30
+ const cached = commandAvailability.get(command);
31
+ if (cached !== undefined) {
32
+ return cached;
33
+ }
34
+
35
+ const pathValue = process.env.PATH ?? "";
36
+ const pathDirs = pathValue
37
+ .split(delimiter)
38
+ .map((entry) => entry.trim())
39
+ .filter((entry) => entry.length > 0);
40
+
41
+ const candidates = getExecutableCandidates(command);
42
+
43
+ for (const directory of pathDirs) {
44
+ for (const candidate of candidates) {
45
+ try {
46
+ await access(join(directory, candidate), constants.X_OK);
47
+ commandAvailability.set(command, true);
48
+ return true;
49
+ } catch {
50
+ // Try next candidate.
51
+ }
52
+ }
53
+ }
54
+
55
+ commandAvailability.set(command, false);
56
+ return false;
57
+ }