opencode/packages/core/src/tool/plugin/edit.ts

236 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
}),
}