mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:26:12 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd7c55cd6 | ||
|
|
f641186d56 | ||
|
|
edbe308787 |
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
@@ -109,13 +108,13 @@ const layer = Layer.effect(
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const next = splitBom(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.canonical)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.canonical,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
@@ -173,6 +172,20 @@ 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) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
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],
|
||||
})
|
||||
@@ -1,315 +0,0 @@
|
||||
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,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
@@ -74,7 +73,6 @@ const locationServiceNodes = [
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { Form } from "../form"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -69,7 +68,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
@@ -100,7 +98,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
@@ -319,7 +318,6 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Bus.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
Use the `patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
|
||||
Use the `patch` tool to edit files. Provide one patch in this format:
|
||||
|
||||
*** Begin Patch
|
||||
[ one or more file sections ]
|
||||
[one or more file operations]
|
||||
*** End Patch
|
||||
|
||||
Within that envelope, you get a sequence of file operations.
|
||||
You MUST include a header to specify the action you are taking.
|
||||
Each operation starts with one of three headers:
|
||||
Each operation starts with `*** Add File: <path>`, `*** Delete File: <path>`, or `*** Update File: <path>`. Add file contents use `+` lines; delete operations have no body.
|
||||
|
||||
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
|
||||
*** Delete File: <path> - remove an existing file. Nothing follows.
|
||||
*** Update File: <path> - patch an existing file in place (optionally with a rename).
|
||||
|
||||
Example patch:
|
||||
|
||||
```
|
||||
*** Begin Patch
|
||||
*** Add File: hello.txt
|
||||
+Hello world
|
||||
*** Update File: src/app.py
|
||||
*** Move to: src/main.py
|
||||
@@ def greet():
|
||||
-print("Hi")
|
||||
+print("Hello, world!")
|
||||
*** Delete File: obsolete.txt
|
||||
*** End Patch
|
||||
```
|
||||
|
||||
It is important to remember:
|
||||
|
||||
- You must include a header with your intended action (Add/Delete/Update)
|
||||
- You must prefix new lines with `+` even when creating a new file
|
||||
An update may start with `*** Move to: <path>` and contains change lines, optionally separated by `@@` or `@@ <context>` markers. Change lines start with a space for unchanged context, `-` for removed content, or `+` for added content. An optional `*** End of File` marker anchors the final change to the end of the file.
|
||||
|
||||
@@ -9,11 +9,9 @@ export * as EditTool from "./edit"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -101,6 +99,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
}
|
||||
|
||||
/** 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: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -110,7 +109,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -169,8 +167,9 @@ export const Plugin = {
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const bytes = yield* fs.readFile(target.canonical)
|
||||
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
@@ -202,27 +201,23 @@ export const Plugin = {
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
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(
|
||||
const counts = diffLines(source, replaced).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 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 {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, formatted),
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
|
||||
@@ -7,9 +7,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -19,7 +17,7 @@ export const name = "patch"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
patchText: Schema.String.annotate({
|
||||
description: "The full patch text describing add, update, and delete operations",
|
||||
description: "The complete patch text",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -70,7 +68,6 @@ export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -132,16 +129,15 @@ export const Plugin = {
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`,
|
||||
).text,
|
||||
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`
|
||||
).replace(/^\uFEFF/, ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
const content = yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -149,7 +145,8 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
)
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.canonical)
|
||||
@@ -169,17 +166,18 @@ export const Plugin = {
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
||||
yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = Bom.split(original).text
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
@@ -219,7 +217,7 @@ export const Plugin = {
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
@@ -297,31 +295,7 @@ export const Plugin = {
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
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 }
|
||||
return { applied, files: patchFiles }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
@@ -363,15 +337,15 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
|
||||
)
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
: diffLines(change.before, after).reduce(
|
||||
: diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
|
||||
@@ -13,19 +13,15 @@ export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
[
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
||||
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
||||
].join("\n")
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -35,8 +31,7 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
export const description = [
|
||||
"Spawns an agent in a child session to work on the specified task.",
|
||||
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
|
||||
@@ -9,10 +9,7 @@ export * as WriteTool from "./write"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
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 { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
@@ -39,6 +36,7 @@ export const toModelOutput = (output: Output) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
/** 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: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -48,8 +46,6 @@ export const Plugin = {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -86,10 +82,7 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
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
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
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,7 +5,6 @@ import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -32,7 +31,6 @@ const writes: string[] = []
|
||||
let reads = 0
|
||||
let denyAction: string | undefined
|
||||
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(
|
||||
Permission.Service,
|
||||
@@ -59,17 +57,12 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
reads = 0
|
||||
denyAction = undefined
|
||||
afterRead = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -116,7 +109,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -189,39 +181,6 @@ 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", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -615,11 +574,6 @@ describe("EditTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
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(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -22,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -34,7 +33,6 @@ let failWriteTarget: string | undefined
|
||||
let readsBeforeEditApproval = 0
|
||||
let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
@@ -65,10 +63,6 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
@@ -78,7 +72,6 @@ const reset = () => {
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
afterEditApproval = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -142,7 +135,6 @@ const withTool = <A, E, R>(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
]),
|
||||
),
|
||||
@@ -262,28 +254,6 @@ 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", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -582,11 +552,6 @@ describe("PatchTool", () => {
|
||||
const bom = "\uFEFF"
|
||||
const target = path.join(directory, "example.cs")
|
||||
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(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
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 { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -23,13 +22,12 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const writes: string[] = []
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
let denyAction: string | undefined
|
||||
|
||||
const permission = Layer.succeed(
|
||||
@@ -57,14 +55,9 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
formatFile = () => Effect.succeed(false)
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
@@ -100,7 +93,6 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -148,30 +140,6 @@ 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", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -206,11 +174,6 @@ describe("WriteTool", () => {
|
||||
reset()
|
||||
const preserved = path.join(tmp.path, "preserved.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(() =>
|
||||
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
|
||||
).pipe(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
export * as Patch from "./patch.js"
|
||||
|
||||
import { Result, Schema } from "effect"
|
||||
import { Bom } from "./bom.js"
|
||||
|
||||
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
||||
boundary: Schema.Literals(["first", "last"]),
|
||||
@@ -126,19 +125,20 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
const source = Bom.split(original)
|
||||
const source = splitBom(original)
|
||||
const lines = source.text.split("\n")
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
const replacements = computeReplacements(lines, path, chunks)
|
||||
const updated = [...lines]
|
||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||
if (updated.at(-1) !== "") updated.push("")
|
||||
const next = Bom.split(updated.join("\n"))
|
||||
const next = splitBom(updated.join("\n"))
|
||||
return { content: next.text, bom: source.bom || next.bom }
|
||||
}
|
||||
|
||||
export function joinBom(text: string, bom: boolean) {
|
||||
return Bom.join(text, bom)
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function parseAdd(
|
||||
@@ -379,4 +379,6 @@ const normalize = (value: string) =>
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―−]/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
|
||||
|
||||
Reference in New Issue
Block a user