refactor(format): effectify FormatService as scoped service (#17675)
This commit is contained in:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "format" })
|
||||||
|
|
||||||
export namespace Format {
|
export namespace Format {
|
||||||
const log = Log.create({ service: "format" })
|
|
||||||
|
|
||||||
export const Status = z
|
export const Status = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -24,117 +27,135 @@ 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() {
|
||||||
const enabled: Record<string, boolean> = {}
|
return runPromiseInstance(FormatService.use((s) => s.init()))
|
||||||
const cfg = await Config.get()
|
|
||||||
|
|
||||||
const formatters: Record<string, Formatter.Info> = {}
|
|
||||||
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 status() {
|
export async function status() {
|
||||||
const s = await state()
|
return runPromiseInstance(FormatService.use((s) => s.status()))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user