From 335356280ce4bbb67b1b5e47265087e85a364988 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 16 Mar 2026 15:58:36 -0400 Subject: [PATCH] refactor(format): effectify FormatService as scoped service (#17675) --- packages/opencode/src/effect/instances.ts | 3 + packages/opencode/src/format/index.ts | 243 ++++++++++--------- packages/opencode/src/project/bootstrap.ts | 2 +- packages/opencode/test/file/time.test.ts | 2 +- packages/opencode/test/format/format.test.ts | 64 +++++ 5 files changed, 201 insertions(+), 113 deletions(-) create mode 100644 packages/opencode/test/format/format.test.ts diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 78b340e77..f5d9ac94a 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -7,6 +7,7 @@ import { PermissionService } from "@/permission/service" import { FileWatcherService } from "@/file/watcher" import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" +import { FormatService } from "@/format" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -18,6 +19,7 @@ export type InstanceServices = | FileWatcherService | VcsService | FileTimeService + | FormatService function lookup(directory: string) { const project = Instance.project @@ -29,6 +31,7 @@ function lookup(directory: string) { Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(VcsService.layer), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), + Layer.fresh(FormatService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index b849f778e..cb71fc363 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -9,10 +9,13 @@ import { Config } from "../config/config" import { mergeDeep } from "remeda" import { Instance } from "../project/instance" import { Process } from "../util/process" +import { InstanceContext } from "@/effect/instance-context" +import { Effect, Layer, ServiceMap } from "effect" +import { runPromiseInstance } from "@/effect/runtime" + +const log = Log.create({ service: "format" }) export namespace Format { - const log = Log.create({ service: "format" }) - export const Status = z .object({ name: z.string(), @@ -24,117 +27,135 @@ export namespace Format { }) export type Status = z.infer - const state = Instance.state(async () => { - const enabled: Record = {} - const cfg = await Config.get() - - const formatters: Record = {} - if (cfg.formatter === false) { - log.info("all formatters are disabled") - return { - enabled, - formatters, - } - } - - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue - } - const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (result.command.length === 0) continue - - result.enabled = async () => true - result.name = name - formatters[name] = result - } - - return { - enabled, - formatters, - } - }) - - async function isEnabled(item: Formatter.Info) { - const s = await state() - let status = s.enabled[item.name] - if (status === undefined) { - status = await item.enabled() - s.enabled[item.name] = status - } - return status - } - - async function getFormatter(ext: string) { - const formatters = await state().then((x) => x.formatters) - const result = [] - for (const item of Object.values(formatters)) { - log.info("checking", { name: item.name, ext }) - if (!item.extensions.includes(ext)) continue - if (!(await isEnabled(item))) continue - log.info("enabled", { name: item.name, ext }) - result.push(item) - } - return result + export async function init() { + return runPromiseInstance(FormatService.use((s) => s.init())) } export async function status() { - const s = await state() - const result: Status[] = [] - 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 - log.info("formatting", { file }) - const ext = path.extname(file) - - for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) - try { - const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), - { - cwd: Instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }, - ) - const exit = await proc.exited - if (exit !== 0) - log.error("failed", { - command: item.command, - ...item.environment, - }) - } catch (error) { - log.error("failed to format file", { - error, - command: item.command, - ...item.environment, - file, - }) - } - } - }) + return runPromiseInstance(FormatService.use((s) => s.status())) } } + +export namespace FormatService { + export interface Service { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + } +} + +export class FormatService extends ServiceMap.Service()("@opencode/Format") { + static readonly layer = Layer.effect( + FormatService, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const enabled: Record = {} + const formatters: Record = {} + + const cfg = yield* Effect.promise(() => Config.get()) + + if (cfg.formatter !== false) { + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const result = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) as Formatter.Info + + if (result.command.length === 0) continue + + result.enabled = async () => true + result.name = name + formatters[name] = result + } + } else { + log.info("all formatters are disabled") + } + + async function isEnabled(item: Formatter.Info) { + let status = enabled[item.name] + if (status === undefined) { + status = await item.enabled() + enabled[item.name] = status + } + return status + } + + async function getFormatter(ext: string) { + const result = [] + for (const item of Object.values(formatters)) { + log.info("checking", { name: item.name, ext }) + if (!item.extensions.includes(ext)) continue + if (!(await isEnabled(item))) continue + log.info("enabled", { name: item.name, ext }) + result.push(item) + } + return result + } + + const unsubscribe = Bus.subscribe( + File.Event.Edited, + Instance.bind(async (payload) => { + const file = payload.properties.file + log.info("formatting", { file }) + const ext = path.extname(file) + + for (const item of await getFormatter(ext)) { + log.info("running", { command: item.command }) + try { + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) + const exit = await proc.exited + if (exit !== 0) + log.error("failed", { + command: item.command, + ...item.environment, + }) + } catch (error) { + log.error("failed to format file", { + error, + command: item.command, + ...item.environment, + file, + }) + } + } + }), + ) + + 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 }) + }), + ) +} diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index da4a67dba..00ced358d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -18,7 +18,7 @@ export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) await Plugin.init() ShareNext.init() - Format.init() + await Format.init() await LSP.init() await runPromiseInstance(FileWatcherService.use((service) => service.init())) File.init() diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts index 9eedffd76..2a3c56b2c 100644 --- a/packages/opencode/test/file/time.test.ts +++ b/packages/opencode/test/file/time.test.ts @@ -132,7 +132,7 @@ describe("file/time", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - FileTime.read(sessionID, filepath) + await FileTime.read(sessionID, filepath) await Bun.sleep(100) await fs.writeFile(filepath, "modified", "utf-8") diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts new file mode 100644 index 000000000..610850d47 --- /dev/null +++ b/packages/opencode/test/format/format.test.ts @@ -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())) + }) + }) +})