Skip to content

Commit 94ee274

Browse files
authored
feat(core): add V2 formatter runtime (#39564)
1 parent 3c259fc commit 94ee274

15 files changed

Lines changed: 916 additions & 59 deletions

File tree

packages/core/src/file-mutation.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
55
import { dirname } from "path"
66
import { KeyedMutex } from "./effect/keyed-mutex"
77
import { FSUtil } from "@opencode-ai/util/fs-util"
8+
import { Bom } from "@opencode-ai/util/bom"
89

910
export interface Target {
1011
readonly canonical: string
@@ -108,13 +109,13 @@ const layer = Layer.effect(
108109
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
109110
withTargetLock(input.target)(
110111
Effect.gen(function* () {
111-
const next = splitBom(input.content)
112+
const next = Bom.split(input.content)
112113
const current = yield* fs
113114
.readFile(input.target.canonical)
114115
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
115116
yield* fs.writeWithDirs(
116117
input.target.canonical,
117-
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
118+
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
118119
)
119120
return writeResult(input.target, current !== undefined)
120121
}),
@@ -172,20 +173,6 @@ const layer = Layer.effect(
172173
}),
173174
)
174175

175-
function splitBom(text: string) {
176-
const stripped = text.replace(/^\uFEFF+/, "")
177-
return { bom: stripped.length !== text.length, text: stripped }
178-
}
179-
180-
function joinBom(text: string, bom: boolean) {
181-
const stripped = splitBom(text).text
182-
return bom ? `\uFEFF${stripped}` : stripped
183-
}
184-
185-
function hasUtf8Bom(content: Uint8Array) {
186-
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
187-
}
188-
189176
function sameBytes(left: Uint8Array, right: Uint8Array) {
190177
if (left.length !== right.length) return false
191178
return left.every((byte, index) => byte === right[index])

packages/core/src/formatter.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
export * as Formatter from "./formatter"
2+
3+
import { Context, Effect, Layer, Schema } from "effect"
4+
import { ChildProcess } from "effect/unstable/process"
5+
import path from "path"
6+
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
7+
import { FSUtil } from "@opencode-ai/util/fs-util"
8+
import { Npm } from "@opencode-ai/util/npm"
9+
import { AppProcess } from "@opencode-ai/util/process"
10+
import { Config } from "./config"
11+
import { Location } from "./location"
12+
import { make, type Info } from "./formatter/builtins"
13+
14+
export const Status = Schema.Struct({
15+
name: Schema.String,
16+
extensions: Schema.Array(Schema.String),
17+
enabled: Schema.Boolean,
18+
}).annotate({ identifier: "FormatterStatus" })
19+
export type Status = typeof Status.Type
20+
21+
export interface Interface {
22+
readonly init: () => Effect.Effect<void>
23+
readonly status: () => Effect.Effect<Status[]>
24+
readonly file: (filepath: string) => Effect.Effect<boolean>
25+
}
26+
27+
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
28+
29+
const layer = Layer.effect(
30+
Service,
31+
Effect.gen(function* () {
32+
const config = yield* Config.Service
33+
const fs = yield* FSUtil.Service
34+
const location = yield* Location.Service
35+
const npm = yield* Npm.Service
36+
const processes = yield* AppProcess.Service
37+
const commands = new Map<string, string[] | false>()
38+
let formatters: Info[] = []
39+
40+
const load = yield* Effect.cached(
41+
Effect.gen(function* () {
42+
const configured = Config.latest(yield* config.entries(), "formatter")
43+
if (!configured) {
44+
yield* Effect.logInfo("all formatters are disabled")
45+
return
46+
}
47+
48+
const builtIns = make({
49+
directory: location.directory,
50+
worktree: location.project.directory,
51+
fs,
52+
npm,
53+
processes,
54+
})
55+
formatters = builtIns
56+
if (configured === true) return
57+
if (configured.ruff?.disabled || configured.uv?.disabled) {
58+
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
59+
}
60+
61+
for (const [name, entry] of Object.entries(configured)) {
62+
const index = formatters.findIndex((formatter) => formatter.name === name)
63+
if (entry.disabled) {
64+
if (index !== -1) formatters.splice(index, 1)
65+
continue
66+
}
67+
68+
const builtIn = builtIns.find((formatter) => formatter.name === name)
69+
const formatter: Info = {
70+
name,
71+
extensions: entry.extensions ?? builtIn?.extensions ?? [],
72+
environment: { ...builtIn?.environment, ...entry.environment },
73+
enabled:
74+
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
75+
}
76+
if (index === -1) formatters.push(formatter)
77+
else formatters[index] = formatter
78+
}
79+
}).pipe(Effect.withSpan("Formatter.load")),
80+
)
81+
82+
const command = Effect.fnUntraced(function* (formatter: Info) {
83+
const cached = commands.get(formatter.name)
84+
if (cached !== undefined) return cached
85+
const result = yield* formatter.enabled
86+
if (result !== false) commands.set(formatter.name, result)
87+
return result
88+
})
89+
90+
const init = Effect.fn("Formatter.init")(function* () {
91+
yield* load
92+
})
93+
94+
const status = Effect.fn("Formatter.status")(function* () {
95+
yield* load
96+
return yield* Effect.forEach(formatters, (formatter) =>
97+
command(formatter).pipe(
98+
Effect.map((enabled) => ({
99+
name: formatter.name,
100+
extensions: [...formatter.extensions],
101+
enabled: enabled !== false,
102+
})),
103+
),
104+
)
105+
})
106+
107+
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
108+
yield* load
109+
const matching = formatters.filter((formatter) =>
110+
formatter.extensions.includes(path.extname(filepath)),
111+
)
112+
113+
for (const formatter of matching) {
114+
const enabled = yield* command(formatter)
115+
if (enabled === false) continue
116+
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
117+
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
118+
const result = yield* processes
119+
.run(
120+
ChildProcess.make(cmd[0], cmd.slice(1), {
121+
cwd: location.directory,
122+
env: formatter.environment,
123+
extendEnv: true,
124+
stdin: "ignore",
125+
stdout: "ignore",
126+
stderr: "ignore",
127+
}),
128+
)
129+
.pipe(
130+
Effect.catch((error) =>
131+
Effect.logError("failed to format file", {
132+
file: filepath,
133+
command: cmd,
134+
error: error.message,
135+
}).pipe(Effect.as(undefined)),
136+
),
137+
)
138+
if (!result) continue
139+
if (result.exitCode === 0) return true
140+
yield* Effect.logError("formatter exited unsuccessfully", {
141+
file: filepath,
142+
command: cmd,
143+
exitCode: result.exitCode,
144+
})
145+
}
146+
return false
147+
})
148+
149+
return Service.of({ init, status, file })
150+
}),
151+
)
152+
153+
export const node = makeLocationNode({
154+
service: Service,
155+
layer,
156+
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
157+
})

0 commit comments

Comments
 (0)