summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-16 15:58:36 -0400
committerGitHub <[email protected]>2026-03-16 15:58:36 -0400
commit335356280ce4bbb67b1b5e47265087e85a364988 (patch)
tree80b6b5392419027de83fbb72f24c723223e28bed
parent03d84f49c2b947d94ac8bcb5f8ba7c2fc4907e8d (diff)
downloadopencode-335356280ce4bbb67b1b5e47265087e85a364988.tar.gz
opencode-335356280ce4bbb67b1b5e47265087e85a364988.zip
refactor(format): effectify FormatService as scoped service (#17675)
-rw-r--r--packages/opencode/src/effect/instances.ts3
-rw-r--r--packages/opencode/src/format/index.ts233
-rw-r--r--packages/opencode/src/project/bootstrap.ts2
-rw-r--r--packages/opencode/test/file/time.test.ts2
-rw-r--r--packages/opencode/test/format/format.test.ts64
5 files changed, 196 insertions, 108 deletions
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"
-export namespace Format {
- const log = Log.create({ service: "format" })
+const log = Log.create({ service: "format" })
+export namespace Format {
export const Status = z
.object({
name: z.string(),
@@ -24,117 +27,135 @@ export namespace Format {
})
export type Status = z.infer<typeof Status>
- const state = Instance.state(async () => {
- const enabled: Record<string, boolean> = {}
- const cfg = await Config.get()
+ 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 formatters: Record<string, Formatter.Info> = {}
+
+ 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")
+ }
- const formatters: Record<string, Formatter.Info> = {}
- if (cfg.formatter === false) {
- log.info("all formatters are disabled")
- return {
- enabled,
- formatters,
+ async function isEnabled(item: Formatter.Info) {
+ let status = enabled[item.name]
+ if (status === undefined) {
+ status = await item.enabled()
+ enabled[item.name] = status
+ }
+ return status
}
- }
-
- 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
+
+ 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 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
- }
+ 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)
- 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
- }
+ 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,
+ })
+ }
+ }
+ }),
+ )
- 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
- }
+ yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
+ log.info("init")
- 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,
+ 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()))
+ })
+ })
+})