fix: 删除 edit tool 中的旧逻辑处理, 现在已经不需要这些处理了, 大模型够屌 (#1251)

* refactor: remove tab/quote normalization from FileEditTool

* fix: resolve pre-existing typecheck errors (zod v4 compat + RCS web exclude)
This commit is contained in:
claude-code-best
2026-05-28 21:52:31 +08:00
committed by GitHub
parent c982104476
commit a91653a0dd
14 changed files with 23 additions and 447 deletions

View File

@@ -15,27 +15,6 @@ import {
} from 'src/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
@@ -64,261 +43,22 @@ export function stripTrailingWhitespace(str: string): string {
}
/**
* Normalizes whitespace for fuzzy matching by converting tabs to spaces
* and collapsing leading whitespace on each line to a canonical form.
* This handles the case where Read tool output renders tabs as spaces,
* so users copy spaces from the output but the file actually has tabs.
*/
function normalizeWhitespace(str: string): string {
return str.replace(/\t/g, ' ')
}
/**
* Finds the actual string in the file content that matches the search string,
* accounting for quote normalization and tab/space differences.
*
* Matching cascade:
* 1. Exact match
* 2. Quote normalization (curly → straight quotes)
* 3. Tab/space normalization (tabs ↔ spaces in leading whitespace)
* 4. Quote + tab/space normalization combined
* Finds the exact string in the file content.
*
* @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
* @returns The search string if found, or null if not found
*/
export function findActualString(
fileContent: string,
searchString: string,
): string | null {
// First try exact match
if (fileContent.includes(searchString)) {
return searchString
}
// Try with normalized quotes
const normalizedSearch = normalizeQuotes(searchString)
const normalizedFile = normalizeQuotes(fileContent)
const searchIndex = normalizedFile.indexOf(normalizedSearch)
if (searchIndex !== -1) {
// Find the actual string in the file that matches
return fileContent.substring(searchIndex, searchIndex + searchString.length)
}
// Try with tab/space normalization — handles the case where Read output
// renders tabs as spaces and the user copies the rendered version
const wsNormalizedFile = normalizeWhitespace(fileContent)
const wsNormalizedSearch = normalizeWhitespace(searchString)
const wsSearchIndex = wsNormalizedFile.indexOf(wsNormalizedSearch)
if (wsSearchIndex !== -1) {
// Map the match position back to the original file content.
// We need to find the corresponding range in the original string.
return mapNormalizedMatchBackToFile(
fileContent,
wsNormalizedFile,
wsSearchIndex,
wsNormalizedSearch.length,
)
}
// Try combined: quote normalization + tab/space normalization
const combinedFile = normalizeWhitespace(normalizedFile)
const combinedSearch = normalizeWhitespace(normalizedSearch)
const combinedIndex = combinedFile.indexOf(combinedSearch)
if (combinedIndex !== -1) {
return mapNormalizedMatchBackToFile(
fileContent,
combinedFile,
combinedIndex,
combinedSearch.length,
)
}
return null
}
/**
* Given a match found in a normalized version of fileContent, map the match
* position back to the original fileContent and extract the corresponding
* substring.
*
* Strategy: walk through both strings character by character, building a
* mapping from normalized offset to original offset. When a tab is expanded
* to 4 spaces in the normalized version, the normalized offset advances by 4
* while the original offset advances by 1.
*/
function mapNormalizedMatchBackToFile(
fileContent: string,
normalizedFile: string,
normalizedStart: number,
normalizedLength: number,
): string {
// Build a sparse mapping from normalized position → original position.
// We only need to map the range [normalizedStart, normalizedStart + normalizedLength].
let normPos = 0
let origPos = 0
let origStart = -1
let origEnd = -1
while (
origPos < fileContent.length &&
normPos <= normalizedStart + normalizedLength
) {
if (normPos === normalizedStart) {
origStart = origPos
}
if (normPos === normalizedStart + normalizedLength) {
origEnd = origPos
break
}
const origChar = fileContent[origPos]!
if (origChar === '\t') {
// Tab expands to 4 spaces in normalized version
const nextNormPos = normPos + 4
// If normalizedStart falls within this expanded tab, snap to origPos
if (
normPos < normalizedStart &&
nextNormPos > normalizedStart &&
origStart === -1
) {
origStart = origPos
}
if (
normPos < normalizedStart + normalizedLength &&
nextNormPos > normalizedStart + normalizedLength &&
origEnd === -1
) {
origEnd = origPos + 1
}
normPos = nextNormPos
origPos++
} else {
normPos++
origPos++
}
}
// Fallback: if we couldn't map precisely, use character-count heuristic
if (origStart === -1) origStart = 0
if (origEnd === -1) {
// Approximate: use the ratio of original to normalized length
const ratio = fileContent.length / normalizedFile.length
origEnd = Math.round(origStart + normalizedLength * ratio)
}
return fileContent.substring(origStart, origEnd)
}
/**
* 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 happened
if (oldString === actualOldString) {
return newString
}
// Detect which curly quote types were in the file
const 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 = newString
if (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 dash
prev === '\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 quote
const prev = i > 0 ? chars[i - 1] : undefined
const next = i < chars.length - 1 ? chars[i + 1] : undefined
const 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 quote
result.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