import { type StructuredPatchHunk, structuredPatch } from 'diff'import { logError } from 'src/utils/log.js'import { expandPath } from 'src/utils/path.js'import { countCharInString } from 'src/utils/stringUtils.js'import {DIFF_TIMEOUT_MS,getPatchForDisplay,getPatchFromContents,} from '../../utils/diff.js'import { errorMessage, isENOENT } from '../../utils/errors.js'import {addLineNumbers,convertLeadingTabsToSpaces,readFileSyncCached,} from '../../utils/file.js'import type { EditInput, FileEdit } from './types.js'// Claude can't output curly quotes, so we define them as constants here for Claude to use// in the code. We do this because we normalize curly quotes to straight quotes// when applying edits.export const LEFT_SINGLE_CURLY_QUOTE = '‘'export const RIGHT_SINGLE_CURLY_QUOTE = '’'export const LEFT_DOUBLE_CURLY_QUOTE = '"'export const RIGHT_DOUBLE_CURLY_QUOTE = '"'/*** Normalizes quotes in a string by converting curly quotes to straight quotes* @param str The string to normalize* @returns The string with all curly quotes replaced by straight quotes*/export function normalizeQuotes(str: string): string {return str.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'").replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'").replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"').replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"')}/*** Strips trailing whitespace from each line in a string while preserving line endings* @param str The string to process* @returns The string with trailing whitespace removed from each line*/export function stripTrailingWhitespace(str: string): string {// Handle different line endings: CRLF, LF, CR// Use a regex that matches line endings and captures themconst lines = str.split(/(\r\n|\n|\r)/)let result = ''for (let i = 0; i < lines.length; i++) {const part = lines[i]if (part !== undefined) {if (i % 2 === 0) {// Even indices are line contentresult += part.replace(/\s+$/, '')} else {// Odd indices are line endingsresult += part}}}return result}/*** Finds the actual string in the file content that matches the search string,* accounting for quote normalization* @param fileContent The file content to search in* @param searchString The string to search for* @returns The actual string found in the file, or null if not found*/export function findActualString(fileContent: string,searchString: string,): string | null {// First try exact matchif (fileContent.includes(searchString)) {return searchString}// Try with normalized quotesconst normalizedSearch = normalizeQuotes(searchString)const normalizedFile = normalizeQuotes(fileContent)const searchIndex = normalizedFile.indexOf(normalizedSearch)if (searchIndex !== -1) {// Find the actual string in the file that matchesreturn fileContent.substring(searchIndex, searchIndex + searchString.length)}return null}/*** When old_string matched via quote normalization (curly quotes in file,* straight quotes from model), apply the same curly quote style to new_string* so the edit preserves the file's typography.** Uses a simple open/close heuristic: a quote character preceded by whitespace,* start of string, or opening punctuation is treated as an opening quote;* otherwise it's a closing quote.*/export function preserveQuoteStyle(oldString: string,actualOldString: string,newString: string,): string {// If they're the same, no normalization happenedif (oldString === actualOldString) {return newString}// Detect which curly quote types were in the fileconst hasDoubleQuotes =actualOldString.includes(LEFT_DOUBLE_CURLY_QUOTE) ||actualOldString.includes(RIGHT_DOUBLE_CURLY_QUOTE)const hasSingleQuotes =actualOldString.includes(LEFT_SINGLE_CURLY_QUOTE) ||actualOldString.includes(RIGHT_SINGLE_CURLY_QUOTE)if (!hasDoubleQuotes && !hasSingleQuotes) {return newString}let result = newStringif (hasDoubleQuotes) {result = applyCurlyDoubleQuotes(result)}if (hasSingleQuotes) {result = applyCurlySingleQuotes(result)}return result}function isOpeningContext(chars: string[], index: number): boolean {if (index === 0) {return true}const prev = chars[index - 1]return (prev === '' ||prev === '\t' ||prev === '\n' ||prev === '\r' ||prev === '(' ||prev === '[' ||prev === '{' ||prev === '\u2014' || // em dashprev === '\u2013' // en dash)}function applyCurlyDoubleQuotes(str: string): string {const chars = [...str]const result: string[] = []for (let i = 0; i < chars.length; i++) {if (chars[i] === '"') {result.push(isOpeningContext(chars, i)? LEFT_DOUBLE_CURLY_QUOTE: RIGHT_DOUBLE_CURLY_QUOTE,)} else {result.push(chars[i]!)}}return result.join('')}function applyCurlySingleQuotes(str: string): string {const chars = [...str]const result: string[] = []for (let i = 0; i < chars.length; i++) {if (chars[i] === "'") {// Don't convert apostrophes in contractions (e.g., "don't", "it's")// An apostrophe between two letters is a contraction, not a quoteconst prev = i > 0 ? chars[i - 1] : undefinedconst next = i < chars.length - 1 ? chars[i + 1] : undefinedconst prevIsLetter = prev !== undefined && /\p{L}/u.test(prev)const nextIsLetter = next !== undefined && /\p{L}/u.test(next)if (prevIsLetter && nextIsLetter) {// Apostrophe in a contraction — use right single curly quoteresult.push(RIGHT_SINGLE_CURLY_QUOTE)} else {result.push(isOpeningContext(chars, i)? LEFT_SINGLE_CURLY_QUOTE: RIGHT_SINGLE_CURLY_QUOTE,)}} else {result.push(chars[i]!)}}return result.join('')}/*** Transform edits to ensure replace_all always has a boolean value* @param edits Array of edits with optional replace_all* @returns Array of edits with replace_all guaranteed to be boolean*/export function applyEditToFile(originalContent: string,oldString: string,newString: string,replaceAll: boolean = false,): string {const f = replaceAll? (content: string, search: string, replace: string) =>content.replaceAll(search, () => replace): (content: string, search: string, replace: string) =>content.replace(search, () => replace)if (newString !== '') {return f(originalContent, oldString, newString)}const stripTrailingNewline =!oldString.endsWith('\n') && originalContent.includes(oldString + '\n')return stripTrailingNewline? f(originalContent, oldString + '\n', newString): f(originalContent, oldString, newString)}/*** Applies an edit to a file and returns the patch and updated file.* Does not write the file to disk.*/export function getPatchForEdit({filePath,fileContents,oldString,newString,replaceAll = false,}: {filePath: stringfileContents: stringoldString: stringnewString: stringreplaceAll?: boolean}): { patch: StructuredPatchHunk[]; updatedFile: string } {return getPatchForEdits({filePath,fileContents,edits: [{ old_string: oldString, new_string: newString, replace_all: replaceAll },],})}/*** Applies a list of edits to a file and returns the patch and updated file.* Does not write the file to disk.** NOTE: The returned patch is to be used for display purposes only - it has spaces instead of tabs*/export function getPatchForEdits({filePath,fileContents,edits,}: {filePath: stringfileContents: stringedits: FileEdit[]}): { patch: StructuredPatchHunk[]; updatedFile: string } {let updatedFile = fileContentsconst appliedNewStrings: string[] = []// Special case for empty files.if (!fileContents &&edits.length === 1 &&edits[0] &&edits[0].old_string === '' &&edits[0].new_string === '') {const patch = getPatchForDisplay({filePath,fileContents,edits: [{old_string: fileContents,new_string: updatedFile,replace_all: false,},],})return { patch, updatedFile: '' }}// Apply each edit and check if it actually changes the filefor (const edit of edits) {// Strip trailing newlines from old_string before checkingconst oldStringToCheck = edit.old_string.replace(/\n+$/, '')// Check if old_string is a substring of any previously applied new_stringfor (const previousNewString of appliedNewStrings) {if (oldStringToCheck !== '' &&previousNewString.includes(oldStringToCheck)) {throw new Error('Cannot edit file: old_string is a substring of a new_string from a previous edit.',)}}const previousContent = updatedFileupdatedFile =edit.old_string === ''? edit.new_string: applyEditToFile(updatedFile,edit.old_string,edit.new_string,edit.replace_all,)// If this edit didn't change anything, throw an errorif (updatedFile === previousContent) {throw new Error('String not found in file. Failed to apply edit.')}// Track the new string that was appliedappliedNewStrings.push(edit.new_string)}if (updatedFile === fileContents) {throw new Error('Original and edited file match exactly. Failed to apply edit.',)}// We already have before/after content, so call getPatchFromContents directly.// Previously this went through getPatchForDisplay with edits=[{old:fileContents,new:updatedFile}],// which transforms fileContents twice (once as preparedFileContents, again as escapedOldString// inside the reduce) and runs a no-op full-content .replace(). This saves ~20% on large files.const patch = getPatchFromContents({filePath,oldContent: convertLeadingTabsToSpaces(fileContents),newContent: convertLeadingTabsToSpaces(updatedFile),})return { patch, updatedFile }}// Cap on edited_text_file attachment snippets. Format-on-save of a large file// previously injected the entire file per turn (observed max 16.1KB, ~14K// tokens/session). 8KB preserves meaningful context while bounding worst case.const DIFF_SNIPPET_MAX_BYTES = 8192/*** Used for attachments, to show snippets when files change.** TODO: Unify this with the other snippet logic.*/export function getSnippetForTwoFileDiff(fileAContents: string,fileBContents: string,): string {const patch = structuredPatch('file.txt','file.txt',fileAContents,fileBContents,undefined,undefined,{context: 8,timeout: DIFF_TIMEOUT_MS,},)if (!patch) {return ''}const full = patch.hunks.map(_ => ({startLine: _.oldStart,content: _.lines// Filter out deleted lines AND diff metadata lines.filter(_ => !_.startsWith('-') && !_.startsWith('\\')).map(_ => _.slice(1)).join('\n'),})).map(addLineNumbers).join('\n...\n')if (full.length <= DIFF_SNIPPET_MAX_BYTES) {return full}// Truncate at the last line boundary that fits within the cap.// Marker format matches BashTool/utils.ts.const cutoff = full.lastIndexOf('\n', DIFF_SNIPPET_MAX_BYTES)const kept =cutoff > 0 ? full.slice(0, cutoff) : full.slice(0, DIFF_SNIPPET_MAX_BYTES)const remaining = countCharInString(full, '\n', kept.length) + 1return `${kept}\n\n... [${remaining} lines truncated] ...`}const CONTEXT_LINES = 4/*** Gets a snippet from a file showing the context around a patch with line numbers.* @param originalFile The original file content before applying the patch* @param patch The diff hunks to use for determining snippet location* @param newFile The file content after applying the patch* @returns The snippet text with line numbers and the starting line number*/export function getSnippetForPatch(patch: StructuredPatchHunk[],newFile: string,): { formattedSnippet: string; startLine: number } {if (patch.length === 0) {// No changes, return empty snippetreturn { formattedSnippet: '', startLine: 1 }}// Find the first and last changed lines across all hunkslet minLine = Infinitylet maxLine = -Infinityfor (const hunk of patch) {if (hunk.oldStart < minLine) {minLine = hunk.oldStart}// For the end line, we need to consider the new lines count since we're showing the new fileconst hunkEnd = hunk.oldStart + (hunk.newLines || 0) - 1if (hunkEnd > maxLine) {maxLine = hunkEnd}}// Calculate the range with contextconst startLine = Math.max(1, minLine - CONTEXT_LINES)const endLine = maxLine + CONTEXT_LINES// Split the new file into lines and get the snippetconst fileLines = newFile.split(/\r?\n/)const snippetLines = fileLines.slice(startLine - 1, endLine)const snippet = snippetLines.join('\n')// Add line numbersconst formattedSnippet = addLineNumbers({content: snippet,startLine,})return { formattedSnippet, startLine }}/*** Gets a snippet from a file showing the context around a single edit.* This is a convenience function that uses the original algorithm.* @param originalFile The original file content* @param oldString The text to replace* @param newString The text to replace it with* @param contextLines The number of lines to show before and after the change* @returns The snippet and the starting line number*/export function getSnippet(originalFile: string,oldString: string,newString: string,contextLines: number = 4,): { snippet: string; startLine: number } {// Use the original algorithm from FileEditTool.tsxconst before = originalFile.split(oldString)[0] ?? ''const replacementLine = before.split(/\r?\n/).length - 1const newFileLines = applyEditToFile(originalFile,oldString,newString,).split(/\r?\n/)// Calculate the start and end line numbers for the snippetconst startLine = Math.max(0, replacementLine - contextLines)const endLine =replacementLine + contextLines + newString.split(/\r?\n/).length// Get snippetconst snippetLines = newFileLines.slice(startLine, endLine)const snippet = snippetLines.join('\n')return { snippet, startLine: startLine + 1 }}export function getEditsForPatch(patch: StructuredPatchHunk[]): FileEdit[] {return patch.map(hunk => {// Extract the changes from this hunkconst contextLines: string[] = []const oldLines: string[] = []const newLines: string[] = []// Parse each line and categorize itfor (const line of hunk.lines) {if (line.startsWith('')) {// Context line - appears in both versionscontextLines.push(line.slice(1))oldLines.push(line.slice(1))newLines.push(line.slice(1))} else if (line.startsWith('-')) {// Deleted line - only in old versionoldLines.push(line.slice(1))} else if (line.startsWith('+')) {// Added line - only in new versionnewLines.push(line.slice(1))}}return {old_string: oldLines.join('\n'),new_string: newLines.join('\n'),replace_all: false,}})}/*** Contains replacements to de-sanitize strings from Claude* Since Claude can't see any of these strings (sanitized in the API)* It'll output the sanitized versions in the edit response*/const DESANITIZATIONS: Record<string, string> = {'<fnr>': '<function_results>','<n>': '<name>','</n>': '</name>','<o>': '<output>','</o>': '</output>','<e>': '<error>','</e>': '</error>','<s>': '<system>','</s>': '</system>','<r>': '<result>','</r>': '</result>','< META_START >': '<META_START>','< META_END >': '<META_END>','< EOT >': '<EOT>','< META >': '<META>','< SOS >': '<SOS>','\n\nH:': '\n\nHuman:','\n\nA:': '\n\nAssistant:',}/*** Normalizes a match string by applying specific replacements* This helps handle when exact matches fail due to formatting differences* @returns The normalized string and which replacements were applied*/function desanitizeMatchString(matchString: string): {result: stringappliedReplacements: Array<{ from: string; to: string }>} {let result = matchStringconst appliedReplacements: Array<{ from: string; to: string }> = []for (const [from, to] of Object.entries(DESANITIZATIONS)) {const beforeReplace = resultresult = result.replaceAll(from, to)if (beforeReplace !== result) {appliedReplacements.push({ from, to })}}return { result, appliedReplacements }}/*** Normalize the input for the FileEditTool* If the string to replace is not found in the file, try with a normalized version* Returns the normalized input if successful, or the original input if not*/export function normalizeFileEditInput({file_path,edits,}: {file_path: stringedits: EditInput[]}): {file_path: stringedits: EditInput[]} {if (edits.length === 0) {return { file_path, edits }}// Markdown uses two trailing spaces as a hard line break — stripping would// silently change semantics. Skip stripTrailingWhitespace for .md/.mdx.const isMarkdown = /\.(md|mdx)$/i.test(file_path)try {const fullPath = expandPath(file_path)// Use cached file read to avoid redundant I/O operations.// If the file doesn't exist, readFileSyncCached throws ENOENT which the// catch below handles by returning the original input (no TOCTOU pre-check).const fileContent = readFileSyncCached(fullPath)return {file_path,edits: edits.map(({ old_string, new_string, replace_all }) => {const normalizedNewString = isMarkdown? new_string: stripTrailingWhitespace(new_string)// If exact string match works, keep it as isif (fileContent.includes(old_string)) {return {old_string,new_string: normalizedNewString,replace_all,}}// Try de-sanitize string if exact match failsconst { result: desanitizedOldString, appliedReplacements } =desanitizeMatchString(old_string)if (fileContent.includes(desanitizedOldString)) {// Apply the same exact replacements to new_stringlet desanitizedNewString = normalizedNewStringfor (const { from, to } of appliedReplacements) {desanitizedNewString = desanitizedNewString.replaceAll(from, to)}return {old_string: desanitizedOldString,new_string: desanitizedNewString,replace_all,}}return {old_string,new_string: normalizedNewString,replace_all,}}),}} catch (error) {// If there's any error reading the file, just return original input.// ENOENT is expected when the file doesn't exist yet (e.g., new file).if (!isENOENT(error)) {logError(error)}}return { file_path, edits }}/*** Compare two sets of edits to determine if they are equivalent* by applying both sets to the original content and comparing results.* This handles cases where edits might be different but produce the same outcome.*/export function areFileEditsEquivalent(edits1: FileEdit[],edits2: FileEdit[],originalContent: string,): boolean {// Fast path: check if edits are literally identicalif (edits1.length === edits2.length &&edits1.every((edit1, index) => {const edit2 = edits2[index]return (edit2 !== undefined &&edit1.old_string === edit2.old_string &&edit1.new_string === edit2.new_string &&edit1.replace_all === edit2.replace_all)})) {return true}// Try applying both sets of editslet result1: { patch: StructuredPatchHunk[]; updatedFile: string } | null =nulllet error1: string | null = nulllet result2: { patch: StructuredPatchHunk[]; updatedFile: string } | null =nulllet error2: string | null = nulltry {result1 = getPatchForEdits({filePath: 'temp',fileContents: originalContent,edits: edits1,})} catch (e) {error1 = errorMessage(e)}try {result2 = getPatchForEdits({filePath: 'temp',fileContents: originalContent,edits: edits2,})} catch (e) {error2 = errorMessage(e)}// If both threw errors, they're equal only if the errors are the sameif (error1 !== null && error2 !== null) {// Normalize error messages for comparisonreturn error1 === error2}// If one threw an error and the other didn't, they're not equalif (error1 !== null || error2 !== null) {return false}// Both succeeded - compare the resultsreturn result1!.updatedFile === result2!.updatedFile}/*** Unified function to check if two file edit inputs are equivalent.* Handles file edits (FileEditTool).*/export function areFileEditsInputsEquivalent(input1: {file_path: stringedits: FileEdit[]},input2: {file_path: stringedits: FileEdit[]},): boolean {// Fast path: different filesif (input1.file_path !== input2.file_path) {return false}// Fast path: literal equalityif (input1.edits.length === input2.edits.length &&input1.edits.every((edit1, index) => {const edit2 = input2.edits[index]return (edit2 !== undefined &&edit1.old_string === edit2.old_string &&edit1.new_string === edit2.new_string &&edit1.replace_all === edit2.replace_all)})) {return true}// Semantic comparison (requires file read). If the file doesn't exist,// compare against empty content (no TOCTOU pre-check).let fileContent = ''try {fileContent = readFileSyncCached(input1.file_path)} catch (error) {if (!isENOENT(error)) {throw error}}return areFileEditsEquivalent(input1.edits, input2.edits, fileContent)}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。