Skip to content

constructNewFileContent:SEARCH/REPLACE 塊應用器

源码版本v4.0.10

職責

constructNewFileContent 是 Cline 把 replace_in_file 工具的 diff 內容套用到原檔案、生成新檔案內容的核心演算法,寫在 apps/vscode/src/core/assistant-message/diff.ts。它的輸入是三段:模型串流吐出的 diff 字串 (用 ------- SEARCH / ======= / +++++++ REPLACE 三個標記分段)、原檔案內容、以及 isFinal 標誌;輸出是 { newContent, matchIndices },newContent 是套用後的完整檔案內容,matchIndices 是每個 SEARCH 塊在原檔案裡的起始字元位置 (用於 UI 上高亮行號)。

它在 agent loop 裡的位置是:parseAssistantMessageV2 切出一個 replace_in_file 的 ToolUse block 後,presentAssistantMessage 把它交給 WriteToFileToolHandler.executeTool,handler 在 constructNewFileContent call:502 呼叫它。注意它會被串流呼叫很多次:每次模型多吐一段 diff,handler 就用 block.partial=true 呼叫一次,演算法要在不完整輸入下也能給出合理的 partial 結果,讓 diff view 能即時預覽。等 block.partial=false 時呼叫最後一次 isFinal=true 出最終結果。

設計動機

  • 三段標記協議:------- SEARCH 開,======= 分隔,+++++++ REPLACE 閉 (block regex:22)。比 unified diff 更適合 LLM:每個塊自包含,模型不需要算行號,只需要複製要改的原文 + 寫新內容。Cline 還相容舊版 <<< >>> 標記。
  • 串流 + 最終雙模式:isFinal=false 時盡量給 partial 結果讓 diff view 即時更新 (partial mode:434),isFinal=true 時才真正按 replacement 列表重建完整檔案 (isFinal mode:441)。partial 模式下沒看到的 REPLACE 標記會被當成還沒結束,只輸出已知的 search/replace 切片。
  • 多級 fallback 匹配:精確 indexOf 失敗 → 逐行 trim 匹配 → 首尾行 anchor 匹配 → 全文從 0 重新 indexOf (fallback chain:349)。模型經常多輸出空格、tab、縮排不一致,逐行 trim 能救大部分。最後兜底的全文搜尋處理「模型把後面的 SEARCH 塊寫在前面」這種亂序情況。
  • out-of-order 支援:如果 SEARCH 塊在 lastProcessedIndex 之前找到,標記 pendingOutOfOrderReplacement = true (out of order:384),不再立刻輸出,而是攢到 replacements 陣列裡,等 isFinal 時按 start 位置排序統一套用 (sort and apply:469)。
  • partial marker 截尾保護:最後一行如果以 -/</=/+/> 開頭但又不是已知 marker,說明是流到一半的 marker,直接 pop 掉 (pop partial marker:290),防止半個 ----- SEA 把後續行誤識別成 search 內容。
  • v1 / v2 雙實作:constructNewFileContentVersionMappingv1v2 都註冊 (version mapping:258),v2 用 NewFileContentConstructor 類做更精細的非標內容處理 (pendingNonStandardLines、tryFixSearchReplaceBlock 等),v1 是直接的函式式實作。呼叫方預設傳 v1。

關鍵檔案

資料流

v1 演算法逐行掃 diff,關鍵路徑是「遇到 SEARCH 開始 → 累積 search 內容 → 遇到 ======= 切換到 replace 累積 → 遇到 +++++++ REPLACE 把這次替換存進 replacements」。下面這段是 search 塊結束 (=======) 時,跑四級 fallback 找匹配位置的邏輯:

typescript
// apps/vscode/src/core/assistant-message/diff.ts
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
if (exactIndex !== -1) {
    searchMatchIndex = exactIndex
    searchEndIndex = exactIndex + currentSearchContent.length
} else {
    // Attempt fallback line-trimmed matching
    const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
    if (lineMatch) {
        ;[searchMatchIndex, searchEndIndex] = lineMatch
    } else {
        // Try block anchor fallback for larger blocks
        const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
        if (blockMatch) {
            ;[searchMatchIndex, searchEndIndex] = blockMatch
        } else {
            // Last resort: search the entire file from the beginning
            const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
            if (fullFileIndex !== -1) {
                searchMatchIndex = fullFileIndex
                searchEndIndex = fullFileIndex + currentSearchContent.length
                if (searchMatchIndex < lastProcessedIndex) {
                    pendingOutOfOrderReplacement = true
                }
            } else {
                throw new Error(
                    `The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
                )
            }
        }
    }
}

這段在 fallback chain:349 附近。匹配到位置後,如果是 in-order (searchMatchIndex >= lastProcessedIndex),立刻把 lastProcessedIndex 之前的原文 + match 位置之前的原文拼到 result 裡,這樣 partial 模式下 diff view 能看到「改之前」+「改之後」的漸進內容 (partial output:389)。等所有 SEARCH/REPLACE 塊處理完且 isFinal=true,進入 isFinal rebuild:441 這段:對 replacements 按 start 排序,從原文 currentPos=0 開始,逐個把 [currentPos, replacement.start) 的原文 + replacement.content 拼到 result,最後補上 [currentPos, end) 的剩餘原文。這種「先攢後用」讓 out-of-order 塊也能正確套用。失敗時 handler 在 consecutiveMistakeCount++:516consecutiveMistakeCount 自增,並 say 一條 diff_error 讓 UI 提示使用者。

邊界與失敗

  • search 塊在檔案裡找不到:四級 fallback 全失敗時拋 The SEARCH block ... does not match anything in the file. (not found error:374)。handler 在 partial 模式下吞掉錯誤不彈 UI (skip partial error:512),只讓 final 模式的失敗計入 mistake。
  • 空 SEARCH 塊:模型發了 ------- SEARCH 緊接 =======,空 search 內容。如果原檔案也空,當成「新建檔案」從位置 0 插入;否則拋 Empty SEARCH block detected with non-empty file (empty search error:332),提示模型修正格式。
  • partial marker 殘留:最後一行像 marker 但不完整 (比如流到一半的 ------ SEA),pop 掉防止下一輪重解析時被當成 search 起點 (pop partial marker:290)。
  • out-of-order 塊:模型把後面的 SEARCH 塊寫在前面時,pendingOutOfOrderReplacement 置真,partial 輸出暫停 (out of order:384),等 isFinal=true 時統一排序套用。這樣 partial 階段 diff view 可能看不到完整結果,但最終結果是正確的。
  • isFinal 時還在 replace 狀態:流結束時如果還沒看到 +++++++ REPLACE 閉合 (missing replace close:444),假設 replace 內容到字串末尾就結束,把這次替換也存進 replacements,避免丟最後一次編輯。
  • deepseek 模型 unescaped HTML:模型在 diff 裡用 &lt; 而不是 <,handler 在呼叫 constructNewFileContent 前先 applyModelContentFixes (applyModelContentFixes:493) 把常見 HTML 實體轉回來,再交給 diff 演算法。
  • 檔案沒在 diff view 打開:模型給的內容正確但 Cline 報錯,因為 diffViewProvider.originalContent 是空。handler 先檢查 isEditing,沒開就先 diffViewProvider.open (ensure open:497) 再呼叫 diff。

小結

constructNewFileContent 是 Cline 把「LLM 串流吐出的 SEARCH/REPLACE diff」變成「可寫回磁碟的新檔案內容」的演算法核心。它用四級 fallback 匹配救模型不一致的輸出,partial/isFinal 雙模式讓 diff view 能即時預覽,out-of-order 排序重建保證最終結果正確。要看這個演算法的呼叫方怎麼把它的結果寫到磁碟、怎麼更新 diff view,轉 /edit-tools/write-to-file;要看上游 ToolUse block 怎麼被推進來,轉 /agent-loop/present-assistant-message

對照官方資料:Cline 文件 · README