refactor(format): effectify FormatService as scoped service (#17675)

This commit is contained in:
Kit Langton
2026-03-16 15:58:36 -04:00
committed by GitHub
parent 03d84f49c2
commit 335356280c
5 changed files with 201 additions and 113 deletions
@@ -7,6 +7,7 @@ import { PermissionService } from "@/permission/service"
import { FileWatcherService } from "@/file/watcher" import { FileWatcherService } from "@/file/watcher"
import { VcsService } from "@/project/vcs" import { VcsService } from "@/project/vcs"
import { FileTimeService } from "@/file/time" import { FileTimeService } from "@/file/time"
import { FormatService } from "@/format"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
export { InstanceContext } from "./instance-context" export { InstanceContext } from "./instance-context"
@@ -18,6 +19,7 @@ export type InstanceServices =
| FileWatcherService | FileWatcherService
| VcsService | VcsService
| FileTimeService | FileTimeService
| FormatService
function lookup(directory: string) { function lookup(directory: string) {
const project = Instance.project const project = Instance.project
@@ -29,6 +31,7 @@ function lookup(directory: string) {
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer), Layer.fresh(VcsService.layer),
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie),
Layer.fresh(FormatService.layer),
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }
+61 -40
View File
@@ -9,10 +9,13 @@ import { Config } from "../config/config"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Process } from "../util/process" import { Process } from "../util/process"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime"
export namespace Format {
const log = Log.create({ service: "format" }) const log = Log.create({ service: "format" })
export namespace Format {
export const Status = z export const Status = z
.object({ .object({
name: z.string(), name: z.string(),
@@ -24,19 +27,34 @@ export namespace Format {
}) })
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
const state = Instance.state(async () => { export async function init() {
return runPromiseInstance(FormatService.use((s) => s.init()))
}
export async function status() {
return runPromiseInstance(FormatService.use((s) => s.status()))
}
}
export namespace FormatService {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () {
const instance = yield* InstanceContext
const enabled: Record<string, boolean> = {} const enabled: Record<string, boolean> = {}
const cfg = await Config.get()
const formatters: Record<string, Formatter.Info> = {} const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
const cfg = yield* Effect.promise(() => Config.get())
if (cfg.formatter !== false) {
for (const item of Object.values(Formatter)) { for (const item of Object.values(Formatter)) {
formatters[item.name] = item formatters[item.name] = item
} }
@@ -45,11 +63,11 @@ export namespace Format {
delete formatters[name] delete formatters[name]
continue continue
} }
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { const result = mergeDeep(formatters[name] ?? {}, {
command: [], command: [],
extensions: [], extensions: [],
...item, ...item,
}) }) as Formatter.Info
if (result.command.length === 0) continue if (result.command.length === 0) continue
@@ -57,25 +75,20 @@ export namespace Format {
result.name = name result.name = name
formatters[name] = result formatters[name] = result
} }
} else {
return { log.info("all formatters are disabled")
enabled,
formatters,
} }
})
async function isEnabled(item: Formatter.Info) { async function isEnabled(item: Formatter.Info) {
const s = await state() let status = enabled[item.name]
let status = s.enabled[item.name]
if (status === undefined) { if (status === undefined) {
status = await item.enabled() status = await item.enabled()
s.enabled[item.name] = status enabled[item.name] = status
} }
return status return status
} }
async function getFormatter(ext: string) { async function getFormatter(ext: string) {
const formatters = await state().then((x) => x.formatters)
const result = [] const result = []
for (const item of Object.values(formatters)) { for (const item of Object.values(formatters)) {
log.info("checking", { name: item.name, ext }) log.info("checking", { name: item.name, ext })
@@ -87,23 +100,9 @@ export namespace Format {
return result return result
} }
export async function status() { const unsubscribe = Bus.subscribe(
const s = await state() File.Event.Edited,
const result: Status[] = [] Instance.bind(async (payload) => {
for (const formatter of Object.values(s.formatters)) {
const enabled = await isEnabled(formatter)
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled,
})
}
return result
}
export function init() {
log.info("init")
Bus.subscribe(File.Event.Edited, async (payload) => {
const file = payload.properties.file const file = payload.properties.file
log.info("formatting", { file }) log.info("formatting", { file })
const ext = path.extname(file) const ext = path.extname(file)
@@ -114,7 +113,7 @@ export namespace Format {
const proc = Process.spawn( const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)), item.command.map((x) => x.replace("$FILE", file)),
{ {
cwd: Instance.directory, cwd: instance.directory,
env: { ...process.env, ...item.environment }, env: { ...process.env, ...item.environment },
stdout: "ignore", stdout: "ignore",
stderr: "ignore", stderr: "ignore",
@@ -135,6 +134,28 @@ export namespace Format {
}) })
} }
} }
}),
)
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init")
const init = Effect.fn("FormatService.init")(function* () {})
const status = Effect.fn("FormatService.status")(function* () {
const result: Format.Status[] = []
for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({
name: formatter.name,
extensions: formatter.extensions,
enabled: isOn,
}) })
} }
return result
})
return FormatService.of({ init, status })
}),
)
} }
+1 -1
View File
@@ -18,7 +18,7 @@ export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
Format.init() await Format.init()
await LSP.init() await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init())) await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init() File.init()
+1 -1
View File
@@ -132,7 +132,7 @@ describe("file/time", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
FileTime.read(sessionID, filepath) await FileTime.read(sessionID, filepath)
await Bun.sleep(100) await Bun.sleep(100)
await fs.writeFile(filepath, "modified", "utf-8") await fs.writeFile(filepath, "modified", "utf-8")
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
import { FormatService } from "../../src/format"
import { Instance } from "../../src/project/instance"
describe("FormatService", () => {
afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0)
for (const s of statuses) {
expect(typeof s.name).toBe("string")
expect(Array.isArray(s.extensions)).toBe(true)
expect(typeof s.enabled).toBe("boolean")
}
const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt!.extensions).toContain(".go")
})
})
test("status() returns empty list when formatter is disabled", async () => {
await using tmp = await tmpdir({
config: { formatter: false },
})
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
expect(statuses).toEqual([])
})
})
test("status() excludes formatters marked as disabled in config", async () => {
await using tmp = await tmpdir({
config: {
formatter: {
gofmt: { disabled: true },
},
},
})
await withServices(tmp.path, FormatService.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined()
})
})
test("init() completes without error", async () => {
await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => {
await rt.runPromise(FormatService.use((s) => s.init()))
})
})
})