f993541e0b
This release has a bunch of minor breaking changes if you are using opencode plugins or sdk 1. storage events have been removed (we might bring this back but had some issues) 2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project 3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo 4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object) 5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
621 lines
19 KiB
TypeScript
621 lines
19 KiB
TypeScript
// the approaches in this edit tool are sourced from
|
|
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
|
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
|
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
|
|
|
import { z } from "zod"
|
|
import * as path from "path"
|
|
import { Tool } from "./tool"
|
|
import { LSP } from "../lsp"
|
|
import { createTwoFilesPatch } from "diff"
|
|
import { Permission } from "../permission"
|
|
import DESCRIPTION from "./edit.txt"
|
|
import { File } from "../file"
|
|
import { Bus } from "../bus"
|
|
import { FileTime } from "../file/time"
|
|
import { Filesystem } from "../util/filesystem"
|
|
import { Instance } from "../project/instance"
|
|
import { Agent } from "../agent/agent"
|
|
|
|
export const EditTool = Tool.define("edit", {
|
|
description: DESCRIPTION,
|
|
parameters: z.object({
|
|
filePath: z.string().describe("The absolute path to the file to modify"),
|
|
oldString: z.string().describe("The text to replace"),
|
|
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
|
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
|
}),
|
|
async execute(params, ctx) {
|
|
if (!params.filePath) {
|
|
throw new Error("filePath is required")
|
|
}
|
|
|
|
if (params.oldString === params.newString) {
|
|
throw new Error("oldString and newString must be different")
|
|
}
|
|
|
|
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
|
if (!Filesystem.contains(Instance.directory, filePath)) {
|
|
throw new Error(`File ${filePath} is not in the current working directory`)
|
|
}
|
|
|
|
const agent = await Agent.get(ctx.agent)
|
|
let diff = ""
|
|
let contentOld = ""
|
|
let contentNew = ""
|
|
await (async () => {
|
|
if (params.oldString === "") {
|
|
contentNew = params.newString
|
|
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
if (agent.permission.edit === "ask") {
|
|
await Permission.ask({
|
|
type: "edit",
|
|
sessionID: ctx.sessionID,
|
|
messageID: ctx.messageID,
|
|
callID: ctx.callID,
|
|
title: "Edit this file: " + filePath,
|
|
metadata: {
|
|
filePath,
|
|
diff,
|
|
},
|
|
})
|
|
}
|
|
await Bun.write(filePath, params.newString)
|
|
await Bus.publish(File.Event.Edited, {
|
|
file: filePath,
|
|
})
|
|
return
|
|
}
|
|
|
|
const file = Bun.file(filePath)
|
|
const stats = await file.stat().catch(() => {})
|
|
if (!stats) throw new Error(`File ${filePath} not found`)
|
|
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
|
await FileTime.assert(ctx.sessionID, filePath)
|
|
contentOld = await file.text()
|
|
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
|
|
|
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
if (agent.permission.edit === "ask") {
|
|
await Permission.ask({
|
|
type: "edit",
|
|
sessionID: ctx.sessionID,
|
|
messageID: ctx.messageID,
|
|
callID: ctx.callID,
|
|
pattern: filePath,
|
|
title: "Edit this file: " + filePath,
|
|
metadata: {
|
|
filePath,
|
|
diff,
|
|
},
|
|
})
|
|
}
|
|
|
|
await file.write(contentNew)
|
|
await Bus.publish(File.Event.Edited, {
|
|
file: filePath,
|
|
})
|
|
contentNew = await file.text()
|
|
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
})()
|
|
|
|
FileTime.read(ctx.sessionID, filePath)
|
|
|
|
let output = ""
|
|
await LSP.touchFile(filePath, true)
|
|
const diagnostics = await LSP.diagnostics()
|
|
for (const [file, issues] of Object.entries(diagnostics)) {
|
|
if (issues.length === 0) continue
|
|
if (file === filePath) {
|
|
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
|
|
continue
|
|
}
|
|
output += `\n<project_diagnostics>\n${file}\n${issues
|
|
// TODO: may want to make more leniant for eslint
|
|
.filter((item) => item.severity === 1)
|
|
.map(LSP.Diagnostic.pretty)
|
|
.join("\n")}\n</project_diagnostics>\n`
|
|
}
|
|
|
|
return {
|
|
metadata: {
|
|
diagnostics,
|
|
diff,
|
|
},
|
|
title: `${path.relative(Instance.worktree, filePath)}`,
|
|
output,
|
|
}
|
|
},
|
|
})
|
|
|
|
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
|
|
|
// Similarity thresholds for block anchor fallback matching
|
|
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
|
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
|
|
|
|
/**
|
|
* Levenshtein distance algorithm implementation
|
|
*/
|
|
function levenshtein(a: string, b: string): number {
|
|
// Handle empty strings
|
|
if (a === "" || b === "") {
|
|
return Math.max(a.length, b.length)
|
|
}
|
|
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
)
|
|
|
|
for (let i = 1; i <= a.length; i++) {
|
|
for (let j = 1; j <= b.length; j++) {
|
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
|
}
|
|
}
|
|
return matrix[a.length][b.length]
|
|
}
|
|
|
|
export const SimpleReplacer: Replacer = function* (_content, find) {
|
|
yield find
|
|
}
|
|
|
|
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
const originalLines = content.split("\n")
|
|
const searchLines = find.split("\n")
|
|
|
|
if (searchLines[searchLines.length - 1] === "") {
|
|
searchLines.pop()
|
|
}
|
|
|
|
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
let matches = true
|
|
|
|
for (let j = 0; j < searchLines.length; j++) {
|
|
const originalTrimmed = originalLines[i + j].trim()
|
|
const searchTrimmed = searchLines[j].trim()
|
|
|
|
if (originalTrimmed !== searchTrimmed) {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
|
|
if (matches) {
|
|
let matchStartIndex = 0
|
|
for (let k = 0; k < i; k++) {
|
|
matchStartIndex += originalLines[k].length + 1
|
|
}
|
|
|
|
let matchEndIndex = matchStartIndex
|
|
for (let k = 0; k < searchLines.length; k++) {
|
|
matchEndIndex += originalLines[i + k].length
|
|
if (k < searchLines.length - 1) {
|
|
matchEndIndex += 1 // Add newline character except for the last line
|
|
}
|
|
}
|
|
|
|
yield content.substring(matchStartIndex, matchEndIndex)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
const originalLines = content.split("\n")
|
|
const searchLines = find.split("\n")
|
|
|
|
if (searchLines.length < 3) {
|
|
return
|
|
}
|
|
|
|
if (searchLines[searchLines.length - 1] === "") {
|
|
searchLines.pop()
|
|
}
|
|
|
|
const firstLineSearch = searchLines[0].trim()
|
|
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
|
const searchBlockSize = searchLines.length
|
|
|
|
// Collect all candidate positions where both anchors match
|
|
const candidates: Array<{ startLine: number; endLine: number }> = []
|
|
for (let i = 0; i < originalLines.length; i++) {
|
|
if (originalLines[i].trim() !== firstLineSearch) {
|
|
continue
|
|
}
|
|
|
|
// Look for the matching last line after this first line
|
|
for (let j = i + 2; j < originalLines.length; j++) {
|
|
if (originalLines[j].trim() === lastLineSearch) {
|
|
candidates.push({ startLine: i, endLine: j })
|
|
break // Only match the first occurrence of the last line
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return immediately if no candidates
|
|
if (candidates.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Handle single candidate scenario (using relaxed threshold)
|
|
if (candidates.length === 1) {
|
|
const { startLine, endLine } = candidates[0]
|
|
const actualBlockSize = endLine - startLine + 1
|
|
|
|
let similarity = 0
|
|
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
|
|
|
if (linesToCheck > 0) {
|
|
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
const originalLine = originalLines[startLine + j].trim()
|
|
const searchLine = searchLines[j].trim()
|
|
const maxLen = Math.max(originalLine.length, searchLine.length)
|
|
if (maxLen === 0) {
|
|
continue
|
|
}
|
|
const distance = levenshtein(originalLine, searchLine)
|
|
similarity += (1 - distance / maxLen) / linesToCheck
|
|
|
|
// Exit early when threshold is reached
|
|
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
// No middle lines to compare, just accept based on anchors
|
|
similarity = 1.0
|
|
}
|
|
|
|
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
let matchStartIndex = 0
|
|
for (let k = 0; k < startLine; k++) {
|
|
matchStartIndex += originalLines[k].length + 1
|
|
}
|
|
let matchEndIndex = matchStartIndex
|
|
for (let k = startLine; k <= endLine; k++) {
|
|
matchEndIndex += originalLines[k].length
|
|
if (k < endLine) {
|
|
matchEndIndex += 1 // Add newline character except for the last line
|
|
}
|
|
}
|
|
yield content.substring(matchStartIndex, matchEndIndex)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Calculate similarity for multiple candidates
|
|
let bestMatch: { startLine: number; endLine: number } | null = null
|
|
let maxSimilarity = -1
|
|
|
|
for (const candidate of candidates) {
|
|
const { startLine, endLine } = candidate
|
|
const actualBlockSize = endLine - startLine + 1
|
|
|
|
let similarity = 0
|
|
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
|
|
|
if (linesToCheck > 0) {
|
|
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
const originalLine = originalLines[startLine + j].trim()
|
|
const searchLine = searchLines[j].trim()
|
|
const maxLen = Math.max(originalLine.length, searchLine.length)
|
|
if (maxLen === 0) {
|
|
continue
|
|
}
|
|
const distance = levenshtein(originalLine, searchLine)
|
|
similarity += 1 - distance / maxLen
|
|
}
|
|
similarity /= linesToCheck // Average similarity
|
|
} else {
|
|
// No middle lines to compare, just accept based on anchors
|
|
similarity = 1.0
|
|
}
|
|
|
|
if (similarity > maxSimilarity) {
|
|
maxSimilarity = similarity
|
|
bestMatch = candidate
|
|
}
|
|
}
|
|
|
|
// Threshold judgment
|
|
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
|
const { startLine, endLine } = bestMatch
|
|
let matchStartIndex = 0
|
|
for (let k = 0; k < startLine; k++) {
|
|
matchStartIndex += originalLines[k].length + 1
|
|
}
|
|
let matchEndIndex = matchStartIndex
|
|
for (let k = startLine; k <= endLine; k++) {
|
|
matchEndIndex += originalLines[k].length
|
|
if (k < endLine) {
|
|
matchEndIndex += 1
|
|
}
|
|
}
|
|
yield content.substring(matchStartIndex, matchEndIndex)
|
|
}
|
|
}
|
|
|
|
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
|
const normalizedFind = normalizeWhitespace(find)
|
|
|
|
// Handle single line matches
|
|
const lines = content.split("\n")
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
if (normalizeWhitespace(line) === normalizedFind) {
|
|
yield line
|
|
} else {
|
|
// Only check for substring matches if the full line doesn't match
|
|
const normalizedLine = normalizeWhitespace(line)
|
|
if (normalizedLine.includes(normalizedFind)) {
|
|
// Find the actual substring in the original line that matches
|
|
const words = find.trim().split(/\s+/)
|
|
if (words.length > 0) {
|
|
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
|
try {
|
|
const regex = new RegExp(pattern)
|
|
const match = line.match(regex)
|
|
if (match) {
|
|
yield match[0]
|
|
}
|
|
} catch (e) {
|
|
// Invalid regex pattern, skip
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle multi-line matches
|
|
const findLines = find.split("\n")
|
|
if (findLines.length > 1) {
|
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
const block = lines.slice(i, i + findLines.length)
|
|
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
|
yield block.join("\n")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
const removeIndentation = (text: string) => {
|
|
const lines = text.split("\n")
|
|
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
|
if (nonEmptyLines.length === 0) return text
|
|
|
|
const minIndent = Math.min(
|
|
...nonEmptyLines.map((line) => {
|
|
const match = line.match(/^(\s*)/)
|
|
return match ? match[1].length : 0
|
|
}),
|
|
)
|
|
|
|
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
|
}
|
|
|
|
const normalizedFind = removeIndentation(find)
|
|
const contentLines = content.split("\n")
|
|
const findLines = find.split("\n")
|
|
|
|
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
|
if (removeIndentation(block) === normalizedFind) {
|
|
yield block
|
|
}
|
|
}
|
|
}
|
|
|
|
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
const unescapeString = (str: string): string => {
|
|
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
|
switch (capturedChar) {
|
|
case "n":
|
|
return "\n"
|
|
case "t":
|
|
return "\t"
|
|
case "r":
|
|
return "\r"
|
|
case "'":
|
|
return "'"
|
|
case '"':
|
|
return '"'
|
|
case "`":
|
|
return "`"
|
|
case "\\":
|
|
return "\\"
|
|
case "\n":
|
|
return "\n"
|
|
case "$":
|
|
return "$"
|
|
default:
|
|
return match
|
|
}
|
|
})
|
|
}
|
|
|
|
const unescapedFind = unescapeString(find)
|
|
|
|
// Try direct match with unescaped find string
|
|
if (content.includes(unescapedFind)) {
|
|
yield unescapedFind
|
|
}
|
|
|
|
// Also try finding escaped versions in content that match unescaped find
|
|
const lines = content.split("\n")
|
|
const findLines = unescapedFind.split("\n")
|
|
|
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
const block = lines.slice(i, i + findLines.length).join("\n")
|
|
const unescapedBlock = unescapeString(block)
|
|
|
|
if (unescapedBlock === unescapedFind) {
|
|
yield block
|
|
}
|
|
}
|
|
}
|
|
|
|
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
// This replacer yields all exact matches, allowing the replace function
|
|
// to handle multiple occurrences based on replaceAll parameter
|
|
let startIndex = 0
|
|
|
|
while (true) {
|
|
const index = content.indexOf(find, startIndex)
|
|
if (index === -1) break
|
|
|
|
yield find
|
|
startIndex = index + find.length
|
|
}
|
|
}
|
|
|
|
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
const trimmedFind = find.trim()
|
|
|
|
if (trimmedFind === find) {
|
|
// Already trimmed, no point in trying
|
|
return
|
|
}
|
|
|
|
// Try to find the trimmed version
|
|
if (content.includes(trimmedFind)) {
|
|
yield trimmedFind
|
|
}
|
|
|
|
// Also try finding blocks where trimmed content matches
|
|
const lines = content.split("\n")
|
|
const findLines = find.split("\n")
|
|
|
|
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
const block = lines.slice(i, i + findLines.length).join("\n")
|
|
|
|
if (block.trim() === trimmedFind) {
|
|
yield block
|
|
}
|
|
}
|
|
}
|
|
|
|
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
const findLines = find.split("\n")
|
|
if (findLines.length < 3) {
|
|
// Need at least 3 lines to have meaningful context
|
|
return
|
|
}
|
|
|
|
// Remove trailing empty line if present
|
|
if (findLines[findLines.length - 1] === "") {
|
|
findLines.pop()
|
|
}
|
|
|
|
const contentLines = content.split("\n")
|
|
|
|
// Extract first and last lines as context anchors
|
|
const firstLine = findLines[0].trim()
|
|
const lastLine = findLines[findLines.length - 1].trim()
|
|
|
|
// Find blocks that start and end with the context anchors
|
|
for (let i = 0; i < contentLines.length; i++) {
|
|
if (contentLines[i].trim() !== firstLine) continue
|
|
|
|
// Look for the matching last line
|
|
for (let j = i + 2; j < contentLines.length; j++) {
|
|
if (contentLines[j].trim() === lastLine) {
|
|
// Found a potential context block
|
|
const blockLines = contentLines.slice(i, j + 1)
|
|
const block = blockLines.join("\n")
|
|
|
|
// Check if the middle content has reasonable similarity
|
|
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
|
|
if (blockLines.length === findLines.length) {
|
|
let matchingLines = 0
|
|
let totalNonEmptyLines = 0
|
|
|
|
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
const blockLine = blockLines[k].trim()
|
|
const findLine = findLines[k].trim()
|
|
|
|
if (blockLine.length > 0 || findLine.length > 0) {
|
|
totalNonEmptyLines++
|
|
if (blockLine === findLine) {
|
|
matchingLines++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
|
yield block
|
|
break // Only match the first occurrence
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function trimDiff(diff: string): string {
|
|
const lines = diff.split("\n")
|
|
const contentLines = lines.filter(
|
|
(line) =>
|
|
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
!line.startsWith("---") &&
|
|
!line.startsWith("+++"),
|
|
)
|
|
|
|
if (contentLines.length === 0) return diff
|
|
|
|
let min = Infinity
|
|
for (const line of contentLines) {
|
|
const content = line.slice(1)
|
|
if (content.trim().length > 0) {
|
|
const match = content.match(/^(\s*)/)
|
|
if (match) min = Math.min(min, match[1].length)
|
|
}
|
|
}
|
|
if (min === Infinity || min === 0) return diff
|
|
const trimmedLines = lines.map((line) => {
|
|
if (
|
|
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
!line.startsWith("---") &&
|
|
!line.startsWith("+++")
|
|
) {
|
|
const prefix = line[0]
|
|
const content = line.slice(1)
|
|
return prefix + content.slice(min)
|
|
}
|
|
return line
|
|
})
|
|
|
|
return trimmedLines.join("\n")
|
|
}
|
|
|
|
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
|
if (oldString === newString) {
|
|
throw new Error("oldString and newString must be different")
|
|
}
|
|
|
|
for (const replacer of [
|
|
SimpleReplacer,
|
|
LineTrimmedReplacer,
|
|
BlockAnchorReplacer,
|
|
WhitespaceNormalizedReplacer,
|
|
IndentationFlexibleReplacer,
|
|
EscapeNormalizedReplacer,
|
|
// TrimmedBoundaryReplacer,
|
|
// ContextAwareReplacer,
|
|
// MultiOccurrenceReplacer,
|
|
]) {
|
|
for (const search of replacer(content, oldString)) {
|
|
const index = content.indexOf(search)
|
|
if (index === -1) continue
|
|
if (replaceAll) {
|
|
return content.replaceAll(search, newString)
|
|
}
|
|
const lastIndex = content.lastIndexOf(search)
|
|
if (index !== lastIndex) continue
|
|
return content.substring(0, index) + newString + content.substring(index + search.length)
|
|
}
|
|
}
|
|
throw new Error("oldString not found in content or was found multiple times")
|
|
}
|