diff --git a/packages/core/src/config/plugin/formatter.ts b/packages/core/src/config/plugin/formatter.ts new file mode 100644 index 0000000000..85f8e8707e --- /dev/null +++ b/packages/core/src/config/plugin/formatter.ts @@ -0,0 +1,70 @@ +export * as ConfigFormatterPlugin from "./formatter.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import { FSUtil } from "@opencode-ai/util/fs-util" +import { Global } from "@opencode-ai/util/global" +import { Npm } from "@opencode-ai/util/npm" +import { AppProcess } from "@opencode-ai/util/process" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { Formatter } from "../../formatter.js" +import { make, type Info } from "../../formatter/builtins.js" +import { Location } from "../../location.js" + +export const Plugin = define({ + id: "opencode.config.formatter", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const formatter = yield* Formatter.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const processes = yield* AppProcess.Service + const loaded = { entries: yield* config.entries() } + const reload = config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(formatter.reload()), + ) + + yield* ctx.event.subscribe().pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => reload), + Effect.forkScoped({ startImmediately: true }), + ) + // Refetch after subscribing so a config update between the first read and + // the live subscription cannot leave the transform on a stale snapshot. + loaded.entries = yield* config.entries() + + yield* formatter.transform((draft) => { + const configured = Config.latest(loaded.entries, "formatter") + if (!configured) return + const builtIns = make({ + directory: location.directory, + worktree: location.project.directory, + fs, + npm, + processes, + bin: global.bin, + }) + builtIns.forEach(draft.set) + if (configured === true) return + + for (const [name, entry] of Object.entries(configured)) { + if (entry.disabled) { + draft.remove(name) + continue + } + const builtIn = builtIns.find((formatter) => formatter.name === name) + const current: Info = { + name, + extensions: entry.extensions ?? builtIn?.extensions ?? [], + environment: { ...builtIn?.environment, ...entry.environment }, + enabled: + builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false), + } + draft.set(current) + } + }) + }), +}) diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts index 371bf11e23..4e6bbe3c55 100644 --- a/packages/core/src/formatter.ts +++ b/packages/core/src/formatter.ts @@ -4,15 +4,21 @@ import { Context, Effect, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import path from "path" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Npm } from "@opencode-ai/util/npm" import { AppProcess } from "@opencode-ai/util/process" -import { Global } from "@opencode-ai/util/global" -import { Config } from "./config.js" import { Location } from "./location.js" -import { make, type Info } from "./formatter/builtins.js" +import type { Info } from "./formatter/builtins.js" +import { State } from "./state.js" -export interface Interface { +type Data = { + formatters: Info[] +} + +export type Draft = { + set: (formatter: Info) => void + remove: (name: string) => void +} + +export interface Interface extends State.Transformable { readonly file: (filepath: string) => Effect.Effect } @@ -21,66 +27,36 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { - const config = yield* Config.Service - const fs = yield* FSUtil.Service const location = yield* Location.Service - const npm = yield* Npm.Service const processes = yield* AppProcess.Service - const global = yield* Global.Service - const commands = new Map() - let formatters: Info[] = [] - - const load = yield* Effect.cached( - Effect.gen(function* () { - const configured = Config.latest(yield* config.entries(), "formatter") - if (!configured) { - yield* Effect.logInfo("all formatters are disabled") - return - } - - const builtIns = make({ - directory: location.directory, - worktree: location.project.directory, - fs, - npm, - processes, - bin: global.bin, - }) - formatters = builtIns - if (configured === true) return - - for (const [name, entry] of Object.entries(configured)) { - const index = formatters.findIndex((formatter) => formatter.name === name) - if (entry.disabled) { - if (index !== -1) formatters.splice(index, 1) - continue - } - - const builtIn = builtIns.find((formatter) => formatter.name === name) - const formatter: Info = { - name, - extensions: entry.extensions ?? builtIn?.extensions ?? [], - environment: { ...builtIn?.environment, ...entry.environment }, - enabled: - builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false), - } - if (index === -1) formatters.push(formatter) - else formatters[index] = formatter - } - }).pipe(Effect.withSpan("Formatter.load")), - ) + const commands = new WeakMap() + const state = State.create({ + name: "formatter", + initial: () => ({ formatters: [] }), + draft: (draft) => ({ + set: (formatter) => { + const index = draft.formatters.findIndex((item) => item.name === formatter.name) + if (index === -1) draft.formatters.push(formatter) + else draft.formatters[index] = formatter + }, + remove: (name) => { + draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name) + }, + }), + }) const command = Effect.fnUntraced(function* (formatter: Info) { - const cached = commands.get(formatter.name) + const cached = commands.get(formatter) if (cached !== undefined) return cached const result = yield* formatter.enabled - if (result !== false) commands.set(formatter.name, result) + if (result !== false) commands.set(formatter, result) return result }) const file = Effect.fn("Formatter.file")(function* (filepath: string) { - yield* load - const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath))) + const matching = state + .get() + .formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath))) for (const formatter of matching) { const enabled = yield* command(formatter) @@ -118,12 +94,12 @@ const layer = Layer.effect( return false }) - return Service.of({ file }) + return Service.of({ transform: state.transform, reload: state.reload, file }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node], + deps: [Location.node, AppProcess.node], }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 17607027a9..0736d1f862 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -3,6 +3,7 @@ export * as PluginInternal from "./internal.js" import type { Plugin } from "@opencode-ai/plugin/effect/plugin" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" +import { AppProcess } from "@opencode-ai/util/process" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { Agent } from "../agent.js" @@ -12,6 +13,7 @@ import { Config } from "../config.js" import { Credential } from "../credential.js" import { ConfigAgentPlugin } from "../config/plugin/agent.js" import { ConfigCommandPlugin } from "../config/plugin/command.js" +import { ConfigFormatterPlugin } from "../config/plugin/formatter.js" import { ConfigInstructionPlugin } from "../config/plugin/instruction.js" import { ConfigMCPPlugin } from "../config/plugin/mcp.js" import { ConfigProviderPlugin } from "../config/plugin/provider.js" @@ -77,6 +79,7 @@ import { WellKnownPlugin } from "../wellknown/plugin.js" const services = Effect.fn("PluginInternal.services")(function* () { const agent = yield* Agent.Service + const processes = yield* AppProcess.Service const catalog = yield* Catalog.Service const command = yield* Command.Service const config = yield* Config.Service @@ -115,6 +118,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const wellknown = yield* WellKnown.Service return Context.mergeAll( Context.make(Agent.Service, agent), + Context.make(AppProcess.Service, processes), Context.make(Catalog.Service, catalog), Context.make(Command.Service, command), Context.make(Config.Service, config), @@ -160,6 +164,7 @@ export type Requirements = ContextServices Effect.succeed(undefined) })], - ]) -} - function withTemp(body: (directory: string) => Effect.Effect) { return Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -44,122 +33,208 @@ function withTemp(body: (directory: string) => Effect.Effect) ) } -describe("Formatter", () => { - it.live("does not run formatters marked as disabled in config", () => - withTemp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "test.disabled") - expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false) - }).pipe( - Effect.provide( - formatterLayer(directory, { - disabled: { - disabled: true, - command: [process.execPath, "-e", "process.exit(0)", "$FILE"], - extensions: [".disabled"], - }, - }), +function withFormatter( + configured: ConfigInput["formatter"], + body: (formatter: Formatter.Interface, directory: string) => Effect.Effect, +) { + return withTemp((directory) => + Effect.promise(() => + fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ formatter: configured })), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + const plugins = yield* PluginSupervisor.Service + yield* plugins.flush + return yield* body(yield* Formatter.Service, directory) + }).pipe( + Effect.scoped, + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })), + ), ), ), ), ) +} + +describe("Formatter", () => { + it.live("does not run formatters marked as disabled in config", () => + withFormatter( + { + disabled: { + disabled: true, + command: [process.execPath, "-e", "process.exit(0)", "$FILE"], + extensions: [".disabled"], + }, + }, + (formatter, directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.disabled") + expect(yield* formatter.file(file)).toBe(false) + }), + ), + ) it.live("file() returns false when no formatter runs", () => - withTemp((directory) => + withFormatter(false, (formatter, directory) => Effect.gen(function* () { const file = path.join(directory, "test.txt") yield* Effect.promise(() => fs.writeFile(file, "x")) - expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false) - }).pipe(Effect.provide(formatterLayer(directory, false))), + expect(yield* formatter.file(file)).toBe(false) + }), ), ) it.live("loads formatter state per directory", () => - withTemp((off) => - withTemp((on) => - Effect.gen(function* () { - const offFile = path.join(off, "test.isolated") - const onFile = path.join(on, "test.isolated") - const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe( - Effect.provide(formatterLayer(off, false)), - ) - const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe( - Effect.provide( - formatterLayer(on, { - isolated: { - command: [process.execPath, "-e", "process.exit(0)", "$FILE"], - extensions: [".isolated"], - }, - }), - ), - ) - expect(disabled).toBe(false) - expect(enabled).toBe(true) - }), + withFormatter(false, (disabledFormatter, off) => + withFormatter( + { + isolated: { + command: [process.execPath, "-e", "process.exit(0)", "$FILE"], + extensions: [".isolated"], + }, + }, + (enabledFormatter, on) => + Effect.gen(function* () { + const offFile = path.join(off, "test.isolated") + const onFile = path.join(on, "test.isolated") + const disabled = yield* disabledFormatter.file(offFile) + const enabled = yield* enabledFormatter.file(onFile) + expect(disabled).toBe(false) + expect(enabled).toBe(true) + }), ), ), ) it.live("stops after the first matching formatter succeeds", () => - withTemp((directory) => - Effect.gen(function* () { - const file = path.join(directory, "test.seq") - yield* Effect.promise(() => fs.writeFile(file, "x")) - expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) - expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA") - }).pipe( - Effect.provide( - formatterLayer(directory, { - first: { - command: [ - process.execPath, - "-e", - "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')", - "$FILE", - ], - extensions: [".seq"], - }, - second: { - command: [ - process.execPath, - "-e", - "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", - "$FILE", - ], - extensions: [".seq"], - }, - }), - ), - ), + withFormatter( + { + first: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')", + "$FILE", + ], + extensions: [".seq"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".seq"], + }, + }, + (formatter, directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.seq") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA") + }), ), ) it.live("tries the next matching formatter when the first fails", () => - withTemp((directory) => + withFormatter( + { + first: { + command: [process.execPath, "-e", "process.exit(1)", "$FILE"], + extensions: [".fallback"], + }, + second: { + command: [ + process.execPath, + "-e", + "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", + "$FILE", + ], + extensions: [".fallback"], + }, + }, + (formatter, directory) => + Effect.gen(function* () { + const file = path.join(directory, "test.fallback") + yield* Effect.promise(() => fs.writeFile(file, "x")) + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB") + }), + ), + ) + + it.live("rebuilds formatter state and clears resolved commands", () => + withFormatter(false, (formatter, directory) => Effect.gen(function* () { - const file = path.join(directory, "test.fallback") + const command = { suffix: "A" } + yield* formatter.transform((draft) => { + const suffix = command.suffix + draft.set({ + name: "reload", + extensions: [".reload"], + enabled: Effect.succeed([ + process.execPath, + "-e", + `const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`, + "$FILE", + ]), + }) + }) + const file = path.join(directory, "test.reload") yield* Effect.promise(() => fs.writeFile(file, "x")) - expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true) - expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB") - }).pipe( - Effect.provide( - formatterLayer(directory, { - first: { - command: [process.execPath, "-e", "process.exit(1)", "$FILE"], - extensions: [".fallback"], - }, - second: { - command: [ - process.execPath, - "-e", - "const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')", - "$FILE", - ], - extensions: [".fallback"], - }, - }), - ), - ), + expect(yield* formatter.file(file)).toBe(true) + + command.suffix = "B" + yield* formatter.reload() + + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB") + }), + ), + ) + + it.live("does not cache a command resolved before reload", () => + withFormatter(false, (formatter, directory) => + Effect.gen(function* () { + const resolving = yield* Deferred.make() + const release = yield* Deferred.make() + const command = { suffix: "A" } + yield* formatter.transform((draft) => { + const suffix = command.suffix + const resolved = [ + process.execPath, + "-e", + `const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, '${suffix}')`, + "$FILE", + ] + draft.set({ + name: "reload-race", + extensions: [".race"], + enabled: + suffix === "A" + ? Deferred.succeed(resolving, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(resolved), + ) + : Effect.succeed(resolved), + }) + }) + const file = path.join(directory, "test.race") + yield* Effect.promise(() => fs.writeFile(file, "x")) + const first = yield* formatter.file(file).pipe(Effect.forkChild({ startImmediately: true })) + yield* Deferred.await(resolving) + + command.suffix = "B" + yield* formatter.reload() + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(first)).toBe(true) + + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xAB") + }), ), ) })