mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 16:53:27 +00:00
236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
/**
|
||
* Model-facing exact-edit leaf. Relative paths resolve within the active
|
||
* Location. Absolute paths inside that Location are accepted, while explicit
|
||
* absolute external paths retain mutation capability through a separate
|
||
* external_directory approval before edit approval.
|
||
*/
|
||
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 { 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"
|
||
import { fileDiff } from "./file-diff"
|
||
|
||
export const name = "edit"
|
||
|
||
export const Input = Schema.Struct({
|
||
path: Schema.String.annotate({
|
||
description: "File to edit",
|
||
}),
|
||
oldString: Schema.String.annotate({ description: "Exact text to find and replace" }),
|
||
newString: Schema.String.annotate({ description: "Text to replace oldString with (must differ from oldString)" }),
|
||
replaceAll: Schema.optionalKey(Schema.Boolean).annotate({
|
||
description:
|
||
"Whether to replace every occurrence of oldString. When false, oldString must match exactly once. Defaults to false.",
|
||
}),
|
||
})
|
||
|
||
export const Output = Schema.Struct({
|
||
files: Schema.Array(FileDiff.Info),
|
||
replacements: Schema.Number,
|
||
})
|
||
export type Output = typeof Output.Type
|
||
|
||
const crlf = "\r\n"
|
||
|
||
interface Match {
|
||
readonly start: number
|
||
readonly end: number
|
||
}
|
||
|
||
const normalizeForMatch = (value: string) =>
|
||
value
|
||
.replace(/[‘’‚‛]/g, "'")
|
||
.replace(/[“”„‟]/g, '"')
|
||
.replace(/[‐‑‒–—―−]/g, "-")
|
||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||
|
||
const findOccurrences = (content: string, search: string) => {
|
||
const result: Match[] = []
|
||
let offset = 0
|
||
while ((offset = content.indexOf(search, offset)) !== -1) {
|
||
result.push({ start: offset, end: offset + search.length })
|
||
offset += search.length
|
||
}
|
||
return result
|
||
}
|
||
|
||
const findLineOccurrences = (content: string, search: string) => {
|
||
const trailingNewline = search.endsWith("\n")
|
||
const expected = search.split("\n")
|
||
if (trailingNewline) expected.pop()
|
||
const lines = [...content.matchAll(/[^\n]*(?:\n|$)/g)]
|
||
.filter((match) => match[0] !== "")
|
||
.map((match) => {
|
||
const newline = match[0].endsWith("\n")
|
||
const text = newline ? match[0].slice(0, -1) : match[0]
|
||
return {
|
||
start: match.index,
|
||
end: match.index + match[0].length,
|
||
text,
|
||
contentEnd: match.index + text.length - (text.endsWith("\r") ? 1 : 0),
|
||
newline,
|
||
}
|
||
})
|
||
const candidates = lines.flatMap((line, index) => {
|
||
const actual = lines.slice(index, index + expected.length)
|
||
if (actual.length !== expected.length) return []
|
||
if (
|
||
!actual.every(
|
||
(item, lineIndex) =>
|
||
normalizeForMatch(item.text.trimEnd()) === normalizeForMatch(expected[lineIndex]!.trimEnd()),
|
||
)
|
||
)
|
||
return []
|
||
const last = actual.at(-1)!
|
||
if (trailingNewline && !last.newline) return []
|
||
return [{ start: line.start, end: trailingNewline ? last.end : last.contentEnd }]
|
||
})
|
||
return candidates.reduce<Match[]>((result, candidate) => {
|
||
if (result.some((match) => match.end > candidate.start && match.start < candidate.end)) return result
|
||
result.push(candidate)
|
||
return result
|
||
}, [])
|
||
}
|
||
|
||
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
|
||
// 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.
|
||
|
||
export const Plugin = {
|
||
id: "opencode.tool.edit",
|
||
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
|
||
|
||
yield* ctx.tool
|
||
.transform((draft) =>
|
||
draft.add({
|
||
name,
|
||
options: { codemode: false, permission: "edit" },
|
||
description:
|
||
"Edit the contents of a file by finding and replacing exact text. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix, such as `1: `. Never include the prefix in oldString or newString. The edit fails if oldString is not found. By default, oldString must identify a UNIQUE location. Multiple matches FAIL unless replaceAll is true. Add more surrounding context to disambiguate, or set replaceAll to true to replace every occurrence. Use replaceAll when the change should apply to every occurrence, such as renaming a variable.",
|
||
input: Input,
|
||
output: Output,
|
||
execute: (input, context) => {
|
||
return Effect.gen(function* () {
|
||
const permissionSource = {
|
||
type: "tool" as const,
|
||
messageID: context.messageID,
|
||
id: context.id,
|
||
}
|
||
if (input.oldString === input.newString) {
|
||
return yield* new ToolFailure({
|
||
message: "No changes to apply: oldString and newString are identical.",
|
||
})
|
||
}
|
||
if (input.oldString === "") {
|
||
return yield* new ToolFailure({
|
||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||
})
|
||
}
|
||
|
||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||
const external = target.externalDirectory
|
||
if (external) {
|
||
yield* permission.assert({
|
||
...LocationMutation.externalDirectoryPermission(external),
|
||
sessionID: context.sessionID,
|
||
agent: context.agent,
|
||
source: permissionSource,
|
||
})
|
||
}
|
||
|
||
const info = yield* fs
|
||
.stat(target.absolute)
|
||
.pipe(
|
||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||
),
|
||
)
|
||
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.absolute)
|
||
const source = original.text
|
||
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)
|
||
const exact = findOccurrences(source, oldString)
|
||
// These one-to-one mappings preserve offsets into the original source.
|
||
const unicode =
|
||
exact.length > 0 ? [] : findOccurrences(normalizeForMatch(source), normalizeForMatch(oldString))
|
||
const trailing = exact.length > 0 || unicode.length > 0 ? [] : findLineOccurrences(source, oldString)
|
||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||
const replacements = matches.length
|
||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||
.toReversed()
|
||
.reduce(
|
||
(content, match) => `${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||
source,
|
||
)
|
||
const preview =
|
||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||
? fileDiff(target.resource, source, replaced)
|
||
: undefined
|
||
yield* permission.assert({
|
||
action: "edit",
|
||
resources: [target.resource],
|
||
save: ["*"],
|
||
metadata: preview ? { files: [preview] } : undefined,
|
||
sessionID: context.sessionID,
|
||
agent: context.agent,
|
||
source: permissionSource,
|
||
})
|
||
if (replacements === 0) {
|
||
return yield* new ToolFailure({
|
||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||
})
|
||
}
|
||
if (replacements > 1 && input.replaceAll !== true) {
|
||
return yield* new ToolFailure({
|
||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||
})
|
||
}
|
||
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.absolute))
|
||
? yield* Bom.syncFile(fs, target.absolute, bom)
|
||
: (yield* Bom.readFile(fs, target.absolute)).text
|
||
return {
|
||
files: [fileDiff(result.resource, source, formatted)],
|
||
replacements,
|
||
} satisfies Output
|
||
}).pipe(
|
||
Effect.map((output) => ({
|
||
output,
|
||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||
metadata: { files: output.files },
|
||
})),
|
||
Effect.mapError((error) =>
|
||
error instanceof ToolFailure
|
||
? error
|
||
: new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
|
||
),
|
||
)
|
||
},
|
||
}),
|
||
)
|
||
.pipe(Effect.orDie)
|
||
}),
|
||
}
|