Explorar el Código

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

Kit Langton hace 5 meses
padre
commit
335356280c

+ 3 - 0
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))
 }
 

+ 127 - 106
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 })
+    }),
+  )
 }

+ 1 - 1
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()

+ 1 - 1
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")
 

+ 64 - 0
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()))
+    })
+  })
+})