feat(core): add V2 formatter runtime (#39564)
This commit is contained in:
parent
3c259fc552
commit
94ee274aeb
15 changed files with 916 additions and 59 deletions
|
|
@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { dirname } from "path"
|
import { dirname } from "path"
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
|
||||||
export interface Target {
|
export interface Target {
|
||||||
readonly canonical: string
|
readonly canonical: string
|
||||||
|
|
@ -108,13 +109,13 @@ const layer = Layer.effect(
|
||||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = splitBom(input.content)
|
const next = Bom.split(input.content)
|
||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.canonical)
|
.readFile(input.target.canonical)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
yield* fs.writeWithDirs(
|
||||||
input.target.canonical,
|
input.target.canonical,
|
||||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||||
)
|
)
|
||||||
return writeResult(input.target, current !== undefined)
|
return writeResult(input.target, current !== undefined)
|
||||||
}),
|
}),
|
||||||
|
|
@ -172,20 +173,6 @@ const layer = Layer.effect(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function splitBom(text: string) {
|
|
||||||
const stripped = text.replace(/^\uFEFF+/, "")
|
|
||||||
return { bom: stripped.length !== text.length, text: stripped }
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinBom(text: string, bom: boolean) {
|
|
||||||
const stripped = splitBom(text).text
|
|
||||||
return bom ? `\uFEFF${stripped}` : stripped
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUtf8Bom(content: Uint8Array) {
|
|
||||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||||
if (left.length !== right.length) return false
|
if (left.length !== right.length) return false
|
||||||
return left.every((byte, index) => byte === right[index])
|
return left.every((byte, index) => byte === right[index])
|
||||||
|
|
|
||||||
157
packages/core/src/formatter.ts
Normal file
157
packages/core/src/formatter.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
export * as Formatter from "./formatter"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer, Schema } 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 { Config } from "./config"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { make, type Info } from "./formatter/builtins"
|
||||||
|
|
||||||
|
export const Status = Schema.Struct({
|
||||||
|
name: Schema.String,
|
||||||
|
extensions: Schema.Array(Schema.String),
|
||||||
|
enabled: Schema.Boolean,
|
||||||
|
}).annotate({ identifier: "FormatterStatus" })
|
||||||
|
export type Status = typeof Status.Type
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly init: () => Effect.Effect<void>
|
||||||
|
readonly status: () => Effect.Effect<Status[]>
|
||||||
|
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
|
||||||
|
|
||||||
|
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 commands = new Map<string, string[] | false>()
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
formatters = builtIns
|
||||||
|
if (configured === true) return
|
||||||
|
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
||||||
|
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 command = Effect.fnUntraced(function* (formatter: Info) {
|
||||||
|
const cached = commands.get(formatter.name)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const result = yield* formatter.enabled
|
||||||
|
if (result !== false) commands.set(formatter.name, result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = Effect.fn("Formatter.init")(function* () {
|
||||||
|
yield* load
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = Effect.fn("Formatter.status")(function* () {
|
||||||
|
yield* load
|
||||||
|
return yield* Effect.forEach(formatters, (formatter) =>
|
||||||
|
command(formatter).pipe(
|
||||||
|
Effect.map((enabled) => ({
|
||||||
|
name: formatter.name,
|
||||||
|
extensions: [...formatter.extensions],
|
||||||
|
enabled: enabled !== false,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||||
|
yield* load
|
||||||
|
const matching = formatters.filter((formatter) =>
|
||||||
|
formatter.extensions.includes(path.extname(filepath)),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const formatter of matching) {
|
||||||
|
const enabled = yield* command(formatter)
|
||||||
|
if (enabled === false) continue
|
||||||
|
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
|
||||||
|
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
|
||||||
|
const result = yield* processes
|
||||||
|
.run(
|
||||||
|
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||||
|
cwd: location.directory,
|
||||||
|
env: formatter.environment,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.logError("failed to format file", {
|
||||||
|
file: filepath,
|
||||||
|
command: cmd,
|
||||||
|
error: error.message,
|
||||||
|
}).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!result) continue
|
||||||
|
if (result.exitCode === 0) return true
|
||||||
|
yield* Effect.logError("formatter exited unsuccessfully", {
|
||||||
|
file: filepath,
|
||||||
|
command: cmd,
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ init, status, file })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
service: Service,
|
||||||
|
layer,
|
||||||
|
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||||
|
})
|
||||||
315
packages/core/src/formatter/builtins.ts
Normal file
315
packages/core/src/formatter/builtins.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { AppProcess } from "@opencode-ai/util/process"
|
||||||
|
import { which } from "../util/which"
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
readonly name: string
|
||||||
|
readonly environment?: Record<string, string>
|
||||||
|
readonly extensions: readonly string[]
|
||||||
|
readonly enabled: Effect.Effect<string[] | false>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make(input: {
|
||||||
|
readonly directory: string
|
||||||
|
readonly worktree: string
|
||||||
|
readonly fs: FSUtil.Interface
|
||||||
|
readonly npm: Npm.Interface
|
||||||
|
readonly processes: AppProcess.Interface
|
||||||
|
readonly experimentalOxfmt?: boolean
|
||||||
|
}) {
|
||||||
|
const disabled = false as const
|
||||||
|
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||||
|
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||||
|
const commandOutput = (command: string[]) =>
|
||||||
|
input.processes
|
||||||
|
.run(
|
||||||
|
ChildProcess.make(command[0], command.slice(1), {
|
||||||
|
cwd: input.directory,
|
||||||
|
extendEnv: true,
|
||||||
|
stdin: "ignore",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.pipe(Effect.option)
|
||||||
|
|
||||||
|
const gofmt: Info = {
|
||||||
|
name: "gofmt",
|
||||||
|
extensions: [".go"],
|
||||||
|
enabled: Effect.sync(() => {
|
||||||
|
const match = which("gofmt")
|
||||||
|
return match ? [match, "-w", "$FILE"] : disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const mix: Info = {
|
||||||
|
name: "mix",
|
||||||
|
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||||
|
enabled: Effect.sync(() => {
|
||||||
|
const match = which("mix")
|
||||||
|
return match ? [match, "format", "$FILE"] : disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettier: Info = {
|
||||||
|
name: "prettier",
|
||||||
|
environment: { BUN_BE_BUN: "1" },
|
||||||
|
extensions: [
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".mts",
|
||||||
|
".cts",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".sass",
|
||||||
|
".less",
|
||||||
|
".vue",
|
||||||
|
".svelte",
|
||||||
|
".json",
|
||||||
|
".jsonc",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".xml",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".graphql",
|
||||||
|
".gql",
|
||||||
|
],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
for (const file of yield* findUp("package.json")) {
|
||||||
|
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
|
||||||
|
const bin = yield* input.npm.which("prettier")
|
||||||
|
if (bin) return [bin, "--write", "$FILE"]
|
||||||
|
}
|
||||||
|
return disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const oxfmt: Info = {
|
||||||
|
name: "oxfmt",
|
||||||
|
environment: { BUN_BE_BUN: "1" },
|
||||||
|
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
for (const file of yield* findUp("package.json")) {
|
||||||
|
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
|
||||||
|
const bin = yield* input.npm.which("oxfmt")
|
||||||
|
if (bin) return [bin, "$FILE"]
|
||||||
|
}
|
||||||
|
return disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const biome: Info = {
|
||||||
|
name: "biome",
|
||||||
|
environment: { BUN_BE_BUN: "1" },
|
||||||
|
extensions: [
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".mts",
|
||||||
|
".cts",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".sass",
|
||||||
|
".less",
|
||||||
|
".vue",
|
||||||
|
".svelte",
|
||||||
|
".json",
|
||||||
|
".jsonc",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".xml",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".graphql",
|
||||||
|
".gql",
|
||||||
|
],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
|
||||||
|
if (!found.some((items) => items.length > 0)) return disabled
|
||||||
|
const bin = yield* input.npm.which("@biomejs/biome")
|
||||||
|
return bin ? [bin, "format", "--write", "$FILE"] : disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const zig: Info = {
|
||||||
|
name: "zig",
|
||||||
|
extensions: [".zig", ".zon"],
|
||||||
|
enabled: Effect.sync(() => {
|
||||||
|
const match = which("zig")
|
||||||
|
return match ? [match, "fmt", "$FILE"] : disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const clang: Info = {
|
||||||
|
name: "clang-format",
|
||||||
|
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||||
|
const match = which("clang-format")
|
||||||
|
return match ? [match, "-i", "$FILE"] : disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ktlint: Info = {
|
||||||
|
name: "ktlint",
|
||||||
|
extensions: [".kt", ".kts"],
|
||||||
|
enabled: Effect.sync(() => {
|
||||||
|
const match = which("ktlint")
|
||||||
|
return match ? [match, "-F", "$FILE"] : disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruff: Info = {
|
||||||
|
name: "ruff",
|
||||||
|
extensions: [".py", ".pyi"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
if (!which("ruff")) return disabled
|
||||||
|
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||||
|
const found = yield* findUp(config)
|
||||||
|
if (!found.length) continue
|
||||||
|
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||||
|
return ["ruff", "format", "$FILE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||||
|
const found = yield* findUp(dependency)
|
||||||
|
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||||
|
}
|
||||||
|
return disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const air: Info = {
|
||||||
|
name: "air",
|
||||||
|
extensions: [".R"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
const bin = which("air")
|
||||||
|
if (!bin) return disabled
|
||||||
|
const output = yield* commandOutput([bin, "--help"])
|
||||||
|
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||||
|
const first = output.value.stdout.toString("utf8").split("\n")[0]
|
||||||
|
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const uv: Info = {
|
||||||
|
name: "uv",
|
||||||
|
extensions: [".py", ".pyi"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
const bin = which("uv")
|
||||||
|
if (!bin) return disabled
|
||||||
|
const output = yield* commandOutput([bin, "format", "--help"])
|
||||||
|
return output._tag === "Some" && output.value.exitCode === 0
|
||||||
|
? [bin, "format", "--", "$FILE"]
|
||||||
|
: disabled
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||||
|
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||||
|
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||||
|
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||||
|
|
||||||
|
const ocamlformat: Info = {
|
||||||
|
name: "ocamlformat",
|
||||||
|
extensions: [".ml", ".mli"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||||
|
const match = which("ocamlformat")
|
||||||
|
return match ? [match, "-i", "$FILE"] : disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||||
|
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||||
|
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||||
|
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||||
|
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||||
|
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||||
|
|
||||||
|
const pint: Info = {
|
||||||
|
name: "pint",
|
||||||
|
extensions: [".php"],
|
||||||
|
enabled: Effect.gen(function* () {
|
||||||
|
for (const file of yield* findUp("composer.json")) {
|
||||||
|
const json = yield* input.fs.readJson(file)
|
||||||
|
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
|
||||||
|
return ["./vendor/bin/pint", "$FILE"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return disabled
|
||||||
|
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||||
|
}
|
||||||
|
|
||||||
|
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||||
|
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||||
|
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||||
|
|
||||||
|
return [
|
||||||
|
gofmt,
|
||||||
|
mix,
|
||||||
|
oxfmt,
|
||||||
|
prettier,
|
||||||
|
biome,
|
||||||
|
zig,
|
||||||
|
clang,
|
||||||
|
ktlint,
|
||||||
|
ruff,
|
||||||
|
air,
|
||||||
|
uv,
|
||||||
|
rubocop,
|
||||||
|
standardrb,
|
||||||
|
htmlbeautifier,
|
||||||
|
dart,
|
||||||
|
ocamlformat,
|
||||||
|
terraform,
|
||||||
|
latexindent,
|
||||||
|
gleam,
|
||||||
|
shfmt,
|
||||||
|
nixfmt,
|
||||||
|
rustfmt,
|
||||||
|
pint,
|
||||||
|
ormolu,
|
||||||
|
cljfmt,
|
||||||
|
dfmt,
|
||||||
|
] satisfies Info[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
extensions,
|
||||||
|
enabled: Effect.sync(() => {
|
||||||
|
const match = which(name)
|
||||||
|
return match ? [match, ...args] : false
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDependency(input: unknown, dependency: string) {
|
||||||
|
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecordKey(input: unknown, field: string, key: string) {
|
||||||
|
if (!isRecord(input)) return false
|
||||||
|
return isRecord(input[field]) && key in input[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||||
|
return Boolean(input && typeof input === "object" && !Array.isArray(input))
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Bus } from "./bus"
|
import { Bus } from "./bus"
|
||||||
import { FileMutation } from "./file-mutation"
|
import { FileMutation } from "./file-mutation"
|
||||||
|
import { Formatter } from "./formatter"
|
||||||
import { FileSystem } from "./filesystem"
|
import { FileSystem } from "./filesystem"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Generate } from "./generate"
|
import { Generate } from "./generate"
|
||||||
|
|
@ -73,6 +74,7 @@ const locationServiceNodes = [
|
||||||
InstructionDiscovery.node,
|
InstructionDiscovery.node,
|
||||||
LocationMutation.node,
|
LocationMutation.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
|
Formatter.node,
|
||||||
MCP.node,
|
MCP.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
|
import { Formatter } from "../formatter"
|
||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
|
@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const mutation = yield* FileMutation.Service
|
const mutation = yield* FileMutation.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
const filesystem = yield* FileSystem.Service
|
const filesystem = yield* FileSystem.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
|
|
@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||||
Context.make(Config.Service, config),
|
Context.make(Config.Service, config),
|
||||||
Context.make(Bus.Service, bus),
|
Context.make(Bus.Service, bus),
|
||||||
Context.make(FileMutation.Service, mutation),
|
Context.make(FileMutation.Service, mutation),
|
||||||
|
Context.make(Formatter.Service, formatter),
|
||||||
Context.make(FileSystem.Service, filesystem),
|
Context.make(FileSystem.Service, filesystem),
|
||||||
Context.make(FSUtil.Service, fs),
|
Context.make(FSUtil.Service, fs),
|
||||||
Context.make(Global.Service, global),
|
Context.make(Global.Service, global),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
|
import { Formatter } from "../formatter"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { Watcher } from "../filesystem/watcher"
|
import { Watcher } from "../filesystem/watcher"
|
||||||
import { Form } from "../form"
|
import { Form } from "../form"
|
||||||
|
|
@ -318,6 +319,7 @@ export const node = makeLocationNode({
|
||||||
Config.node,
|
Config.node,
|
||||||
Bus.node,
|
Bus.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
|
Formatter.node,
|
||||||
FileSystem.node,
|
FileSystem.node,
|
||||||
FSUtil.node,
|
FSUtil.node,
|
||||||
Global.node,
|
Global.node,
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ export * as EditTool from "./edit"
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||||
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { createTwoFilesPatch, diffLines } from "diff"
|
import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
|
import { Formatter } from "../../formatter"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
|
|
@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
|
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||||
// TODO: Add formatter integration after formatter runtime exists.
|
|
||||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||||
// TODO: Add snapshots / undo after design exists.
|
// TODO: Add snapshots / undo after design exists.
|
||||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||||
|
|
@ -109,6 +110,7 @@ export const Plugin = {
|
||||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
|
|
@ -167,9 +169,8 @@ export const Plugin = {
|
||||||
if (info.type === "Directory") {
|
if (info.type === "Directory") {
|
||||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||||
}
|
}
|
||||||
const bytes = yield* fs.readFile(target.canonical)
|
const original = yield* Bom.readFile(fs, target.canonical)
|
||||||
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
const source = original.text
|
||||||
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
|
|
||||||
const ending = source.includes(crlf) ? crlf : "\n"
|
const ending = source.includes(crlf) ? crlf : "\n"
|
||||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||||
|
|
@ -201,23 +202,27 @@ export const Plugin = {
|
||||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
const counts = diffLines(source, replaced).reduce(
|
const replacementBom = replaced.startsWith("\uFEFF")
|
||||||
|
const result = yield* files.write({
|
||||||
|
target,
|
||||||
|
content: Bom.join(replaced, original.bom || replacementBom),
|
||||||
|
})
|
||||||
|
const bom = original.bom || replacementBom
|
||||||
|
const formatted = (yield* formatter.file(target.canonical))
|
||||||
|
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||||
|
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||||
|
const counts = diffLines(source, formatted).reduce(
|
||||||
(result, item) => ({
|
(result, item) => ({
|
||||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||||
}),
|
}),
|
||||||
{ additions: 0, deletions: 0 },
|
{ additions: 0, deletions: 0 },
|
||||||
)
|
)
|
||||||
const replacementBom = replaced.startsWith("\uFEFF")
|
|
||||||
const result = yield* files.write({
|
|
||||||
target,
|
|
||||||
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
|
|
||||||
})
|
|
||||||
return {
|
return {
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
file: result.resource,
|
file: result.resource,
|
||||||
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
|
patch: createTwoFilesPatch(result.resource, result.resource, source, formatted),
|
||||||
status: "modified" as const,
|
status: "modified" as const,
|
||||||
...counts,
|
...counts,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { PlatformError } from "effect/PlatformError"
|
import { PlatformError } from "effect/PlatformError"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Formatter } from "../../formatter"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
import { Patch } from "@opencode-ai/util/patch"
|
import { Patch } from "@opencode-ai/util/patch"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
|
|
@ -68,6 +70,7 @@ export const Plugin = {
|
||||||
id: "opencode.tool.patch",
|
id: "opencode.tool.patch",
|
||||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
|
|
@ -129,15 +132,16 @@ export const Plugin = {
|
||||||
...hunk,
|
...hunk,
|
||||||
target,
|
target,
|
||||||
before: "",
|
before: "",
|
||||||
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
|
after: Bom.split(
|
||||||
? hunk.contents
|
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||||
: `${hunk.contents}\n`
|
? hunk.contents
|
||||||
).replace(/^\uFEFF/, ""),
|
: `${hunk.contents}\n`,
|
||||||
|
).text,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hunk.type === "delete") {
|
if (hunk.type === "delete") {
|
||||||
const content = yield* fs.readFile(target.canonical).pipe(
|
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||||
Effect.mapError(
|
Effect.mapError(
|
||||||
(error) =>
|
(error) =>
|
||||||
new ToolFailure({
|
new ToolFailure({
|
||||||
|
|
@ -145,8 +149,7 @@ export const Plugin = {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||||
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const previous = updates.get(target.canonical)
|
const previous = updates.get(target.canonical)
|
||||||
|
|
@ -166,18 +169,17 @@ export const Plugin = {
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||||
yield* fs.readFile(target.canonical).pipe(
|
Effect.mapError(
|
||||||
Effect.mapError(
|
(error) =>
|
||||||
(error) =>
|
new ToolFailure({
|
||||||
new ToolFailure({
|
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
return Bom.join(content.text, content.bom)
|
||||||
}))
|
}))
|
||||||
const before = original.replace(/^\uFEFF/, "")
|
const before = Bom.split(original).text
|
||||||
const update = yield* Effect.try({
|
const update = yield* Effect.try({
|
||||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
|
|
@ -217,7 +219,7 @@ export const Plugin = {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const patchFiles = prepared.map(patchFile)
|
const patchFiles = prepared.map((change) => patchFile(change))
|
||||||
yield* permission.assert({
|
yield* permission.assert({
|
||||||
action: "edit",
|
action: "edit",
|
||||||
resources: [...new Set(targets.map((target) => target.resource))],
|
resources: [...new Set(targets.map((target) => target.resource))],
|
||||||
|
|
@ -295,7 +297,31 @@ export const Plugin = {
|
||||||
}),
|
}),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
return { applied, files: patchFiles }
|
const formatted = new Map<string, string>()
|
||||||
|
yield* Effect.forEach(
|
||||||
|
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||||
|
(target) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const current = yield* Bom.readFile(fs, target).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||||
|
)
|
||||||
|
formatted.set(
|
||||||
|
target,
|
||||||
|
(yield* formatter.file(target))
|
||||||
|
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||||
|
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||||
|
)
|
||||||
|
: current.text,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
const files = yield* Effect.forEach(prepared, (change) => {
|
||||||
|
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||||
|
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||||
|
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||||
|
})
|
||||||
|
return { applied, files }
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({
|
Effect.map((output) => ({
|
||||||
output,
|
output,
|
||||||
|
|
@ -337,15 +363,15 @@ function errorMessage(error: unknown) {
|
||||||
return error instanceof Error ? error.message : String(error)
|
return error instanceof Error ? error.message : String(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||||
const patch = trimDiff(
|
const patch = trimDiff(
|
||||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
|
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||||
)
|
)
|
||||||
const counts =
|
const counts =
|
||||||
change.type === "delete"
|
change.type === "delete"
|
||||||
? { additions: 0, deletions: change.before.split("\n").length }
|
? { additions: 0, deletions: change.before.split("\n").length }
|
||||||
: diffLines(change.before, change.after).reduce(
|
: diffLines(change.before, after).reduce(
|
||||||
(result, item) => ({
|
(result, item) => ({
|
||||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ export * as WriteTool from "./write"
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
import { ToolFailure } from "@opencode-ai/ai"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { FileMutation } from "../../file-mutation"
|
import { FileMutation } from "../../file-mutation"
|
||||||
|
import { Formatter } from "../../formatter"
|
||||||
import { LocationMutation } from "../../location-mutation"
|
import { LocationMutation } from "../../location-mutation"
|
||||||
import { Permission } from "../../permission"
|
import { Permission } from "../../permission"
|
||||||
|
|
||||||
|
|
@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) =>
|
||||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||||
|
|
||||||
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
||||||
// TODO: Add formatter integration after formatter runtime exists.
|
|
||||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||||
// TODO: Add snapshots / undo after design exists.
|
// TODO: Add snapshots / undo after design exists.
|
||||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||||
|
|
@ -46,6 +48,8 @@ export const Plugin = {
|
||||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
|
const formatter = yield* Formatter.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
const permission = yield* Permission.Service
|
const permission = yield* Permission.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* ctx.tool
|
||||||
|
|
@ -82,7 +86,10 @@ export const Plugin = {
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||||
|
const bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||||
|
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||||
|
return result
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
|
|
|
||||||
199
packages/core/test/formatter.test.ts
Normal file
199
packages/core/test/formatter.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer, Schema, Stream } from "effect"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
|
import { Config } from "../src/config"
|
||||||
|
import { Formatter } from "../src/formatter"
|
||||||
|
import { Location } from "../src/location"
|
||||||
|
import { location } from "./fixture/location"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.empty)
|
||||||
|
type ConfigInput = typeof Config.Info.Encoded
|
||||||
|
|
||||||
|
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||||
|
const entries =
|
||||||
|
configured === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
return AppNodeBuilder.build(Formatter.node, [
|
||||||
|
[
|
||||||
|
Config.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Config.Service,
|
||||||
|
Config.Service.of({
|
||||||
|
entries: () => Effect.succeed(entries),
|
||||||
|
changes: () => Stream.empty,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||||
|
return Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => body(tmp.path),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Formatter", () => {
|
||||||
|
it.live("status() returns empty list when no formatters are configured", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Formatter.Service.use((formatter) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const statuses = yield* formatter.status()
|
||||||
|
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||||
|
expect(gofmt).toBeDefined()
|
||||||
|
expect(gofmt?.extensions).toContain(".go")
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(formatterLayer(directory, true))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Formatter.Service.use((formatter) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const statuses = yield* formatter.status()
|
||||||
|
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
||||||
|
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Formatter.Service.use((formatter) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const statuses = yield* formatter.status()
|
||||||
|
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
||||||
|
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("service initializes without error", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("file() returns false when no formatter runs", () =>
|
||||||
|
withTemp((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))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("status() initializes formatter state per directory", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||||
|
([off, on]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||||
|
Effect.provide(formatterLayer(off.path, false)),
|
||||||
|
)
|
||||||
|
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||||
|
Effect.provide(formatterLayer(on.path, true)),
|
||||||
|
)
|
||||||
|
expect(disabled).toEqual([])
|
||||||
|
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
||||||
|
}),
|
||||||
|
(directories) =>
|
||||||
|
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("tries the next matching formatter when the first fails", () =>
|
||||||
|
withTemp((directory) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(directory, "test.fallback")
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
|
@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||||
const editToolNode = makeLocationNode({
|
const editToolNode = makeLocationNode({
|
||||||
name: "test/edit-tool-plugin",
|
name: "test/edit-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||||
|
|
@ -31,6 +32,7 @@ const writes: string[] = []
|
||||||
let reads = 0
|
let reads = 0
|
||||||
let denyAction: string | undefined
|
let denyAction: string | undefined
|
||||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||||
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
Permission.Service,
|
Permission.Service,
|
||||||
|
|
@ -57,12 +59,17 @@ const permission = Layer.succeed(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const formatter = Layer.mock(Formatter.Service, {
|
||||||
|
file: (target) => formatFile(target),
|
||||||
|
})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
writes.length = 0
|
writes.length = 0
|
||||||
reads = 0
|
reads = 0
|
||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
afterRead = () => Effect.void
|
afterRead = () => Effect.void
|
||||||
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesystem = Layer.effect(
|
const filesystem = Layer.effect(
|
||||||
|
|
@ -109,6 +116,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||||
[
|
[
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -181,6 +189,39 @@ describe("EditTool", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("returns the diff for final formatted content", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
const target = path.join(tmp.path, "formatted.txt")
|
||||||
|
formatFile = (file) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
withTool(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("completed")
|
||||||
|
if (settled.status !== "completed") return
|
||||||
|
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
|
||||||
|
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("accepts an absolute file path inside the active Location", () =>
|
it.live("accepts an absolute file path inside the active Location", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
@ -574,6 +615,11 @@ describe("EditTool", () => {
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
reset()
|
reset()
|
||||||
const target = path.join(tmp.path, "windows.txt")
|
const target = path.join(tmp.path, "windows.txt")
|
||||||
|
formatFile = (file) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||||
|
return true
|
||||||
|
})
|
||||||
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
||||||
Effect.andThen(
|
Effect.andThen(
|
||||||
withTool(tmp.path, (registry) =>
|
withTool(tmp.path, (registry) =>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
|
@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||||
const patchToolNode = makeLocationNode({
|
const patchToolNode = makeLocationNode({
|
||||||
name: "test/patch-tool-plugin",
|
name: "test/patch-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||||
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
|
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||||
|
|
@ -33,6 +34,7 @@ let failWriteTarget: string | undefined
|
||||||
let readsBeforeEditApproval = 0
|
let readsBeforeEditApproval = 0
|
||||||
let editApproved = false
|
let editApproved = false
|
||||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||||
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
Permission.Service,
|
Permission.Service,
|
||||||
|
|
@ -63,6 +65,10 @@ const permission = Layer.succeed(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const formatter = Layer.mock(Formatter.Service, {
|
||||||
|
file: (target) => formatFile(target),
|
||||||
|
})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
|
|
@ -72,6 +78,7 @@ const reset = () => {
|
||||||
readsBeforeEditApproval = 0
|
readsBeforeEditApproval = 0
|
||||||
editApproved = false
|
editApproved = false
|
||||||
afterEditApproval = () => Effect.void
|
afterEditApproval = () => Effect.void
|
||||||
|
formatFile = () => Effect.succeed(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesystem = Layer.effect(
|
const filesystem = Layer.effect(
|
||||||
|
|
@ -135,6 +142,7 @@ const withTool = <A, E, R>(
|
||||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|
@ -254,6 +262,28 @@ describe("PatchTool", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("returns file diffs for final formatted content", () =>
|
||||||
|
withTempTool((directory, registry) => {
|
||||||
|
const target = path.join(directory, "formatted.txt")
|
||||||
|
formatFile = (file) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const settled = yield* executeTool(
|
||||||
|
registry,
|
||||||
|
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
|
||||||
|
)
|
||||||
|
expect(settled.status).toBe("completed")
|
||||||
|
if (settled.status !== "completed") return
|
||||||
|
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
|
||||||
|
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("moves and updates a file", () =>
|
it.live("moves and updates a file", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
@ -552,6 +582,11 @@ describe("PatchTool", () => {
|
||||||
const bom = "\uFEFF"
|
const bom = "\uFEFF"
|
||||||
const target = path.join(directory, "example.cs")
|
const target = path.join(directory, "example.cs")
|
||||||
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
||||||
|
formatFile = (file) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||||
|
return true
|
||||||
|
})
|
||||||
const settled = yield* executeTool(
|
const settled = yield* executeTool(
|
||||||
registry,
|
registry,
|
||||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||||
|
import { Formatter } from "@opencode-ai/core/formatter"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
|
|
@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||||
const writeToolNode = makeLocationNode({
|
const writeToolNode = makeLocationNode({
|
||||||
name: "test/write-tool-plugin",
|
name: "test/write-tool-plugin",
|
||||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||||
const assertions: Permission.AssertInput[] = []
|
const assertions: Permission.AssertInput[] = []
|
||||||
const writes: string[] = []
|
const writes: string[] = []
|
||||||
|
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||||
let denyAction: string | undefined
|
let denyAction: string | undefined
|
||||||
|
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
|
|
@ -55,9 +57,14 @@ const permission = Layer.succeed(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const formatter = Layer.mock(Formatter.Service, {
|
||||||
|
file: (target) => formatFile(target),
|
||||||
|
})
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
assertions.length = 0
|
assertions.length = 0
|
||||||
writes.length = 0
|
writes.length = 0
|
||||||
|
formatFile = () => Effect.succeed(false)
|
||||||
denyAction = undefined
|
denyAction = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +100,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||||
[
|
[
|
||||||
[FSUtil.node, filesystem],
|
[FSUtil.node, filesystem],
|
||||||
[Location.node, activeLocation],
|
[Location.node, activeLocation],
|
||||||
|
[Formatter.node, formatter],
|
||||||
[Permission.node, permission],
|
[Permission.node, permission],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -140,6 +148,30 @@ describe("WriteTool", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("formats the committed file", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
const target = path.join(tmp.path, "formatted.txt")
|
||||||
|
formatFile = (file) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return withTool(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||||
|
status: "completed",
|
||||||
|
})
|
||||||
|
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
|
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|
@ -174,6 +206,11 @@ describe("WriteTool", () => {
|
||||||
reset()
|
reset()
|
||||||
const preserved = path.join(tmp.path, "preserved.txt")
|
const preserved = path.join(tmp.path, "preserved.txt")
|
||||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||||
|
formatFile = (target) =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||||
|
return true
|
||||||
|
})
|
||||||
return Effect.promise(() =>
|
return Effect.promise(() =>
|
||||||
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
|
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
|
|
||||||
38
packages/util/src/bom.ts
Normal file
38
packages/util/src/bom.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export * as Bom from "./bom.js"
|
||||||
|
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { FSUtil } from "./fs-util.js"
|
||||||
|
|
||||||
|
const code = 0xfeff
|
||||||
|
const value = String.fromCharCode(code)
|
||||||
|
|
||||||
|
export function split(text: string) {
|
||||||
|
const stripped = text.replace(/^\uFEFF+/, "")
|
||||||
|
return { bom: stripped.length !== text.length, text: stripped }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function join(text: string, bom: boolean) {
|
||||||
|
const stripped = split(text).text
|
||||||
|
return bom ? value + stripped : stripped
|
||||||
|
}
|
||||||
|
|
||||||
|
export function has(content: Uint8Array) {
|
||||||
|
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
||||||
|
return split(decode(yield* fs.readFile(filepath)))
|
||||||
|
})
|
||||||
|
|
||||||
|
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
|
||||||
|
const decoded = decode(yield* fs.readFile(filepath))
|
||||||
|
const current = split(decoded)
|
||||||
|
const canonical = join(current.text, bom)
|
||||||
|
if (decoded === canonical) return current.text
|
||||||
|
yield* fs.writeWithDirs(filepath, canonical)
|
||||||
|
return current.text
|
||||||
|
})
|
||||||
|
|
||||||
|
function decode(content: Uint8Array) {
|
||||||
|
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * as Patch from "./patch.js"
|
export * as Patch from "./patch.js"
|
||||||
|
|
||||||
import { Result, Schema } from "effect"
|
import { Result, Schema } from "effect"
|
||||||
|
import { Bom } from "./bom.js"
|
||||||
|
|
||||||
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
||||||
boundary: Schema.Literals(["first", "last"]),
|
boundary: Schema.Literals(["first", "last"]),
|
||||||
|
|
@ -125,20 +126,19 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||||
}
|
}
|
||||||
|
|
||||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||||
const source = splitBom(original)
|
const source = Bom.split(original)
|
||||||
const lines = source.text.split("\n")
|
const lines = source.text.split("\n")
|
||||||
if (lines.at(-1) === "") lines.pop()
|
if (lines.at(-1) === "") lines.pop()
|
||||||
const replacements = computeReplacements(lines, path, chunks)
|
const replacements = computeReplacements(lines, path, chunks)
|
||||||
const updated = [...lines]
|
const updated = [...lines]
|
||||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||||
if (updated.at(-1) !== "") updated.push("")
|
if (updated.at(-1) !== "") updated.push("")
|
||||||
const next = splitBom(updated.join("\n"))
|
const next = Bom.split(updated.join("\n"))
|
||||||
return { content: next.text, bom: source.bom || next.bom }
|
return { content: next.text, bom: source.bom || next.bom }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinBom(text: string, bom: boolean) {
|
export function joinBom(text: string, bom: boolean) {
|
||||||
const stripped = splitBom(text).text
|
return Bom.join(text, bom)
|
||||||
return bom ? `\uFEFF${stripped}` : stripped
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAdd(
|
function parseAdd(
|
||||||
|
|
@ -379,6 +379,4 @@ const normalize = (value: string) =>
|
||||||
.replace(/[“”„‟]/g, '"')
|
.replace(/[“”„‟]/g, '"')
|
||||||
.replace(/[‐‑‒–—―−]/g, "-")
|
.replace(/[‐‑‒–—―−]/g, "-")
|
||||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||||
const splitBom = (text: string) =>
|
|
||||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
|
||||||
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input
|
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue