constructNewFileContent:SEARCH/REPLACE ブロック適用器
役割
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 スライスだけを出力する。 - 多段フォールバックマッチング: 精密な
indexOf失敗 → 行単位 trim マッチ → 先頭末尾行のアンカーマッチ → 全文を 0 からindexOfし直す (fallback chain:349)。モデルはよく余分なスペース、タブ、インデントの不一致を出すので、行単位 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 の二実装:
constructNewFileContentVersionMappingはv1とv2を両方登録する (version mapping:258)。v2 はNewFileContentConstructorクラスでより精細な非標準コンテンツ処理 (pendingNonStandardLines、tryFixSearchReplaceBlock など) を行い、v1 は関数型の直接的な実装。呼び出し側はデフォルトで v1 を渡す。
主要ファイル
constructNewFileContent:245— 関数入口。versionで v1 か v2 かを選ぶ。version mapping:258—constructNewFileContentVersionMapping。"v1" | "v2"を具体的な実装にマップする。constructNewFileContentV1:266— v1 のメインループ。行単位で走査し、SEARCH/REPLACE マーカーを識別し、search 内容をマッチさせ、replace を適用する。block chars:16—SEARCH_BLOCK_CHAR = "-"、REPLACE_BLOCK_CHAR = "+"、LEGACY_SEARCH_BLOCK_CHAR = "<"、LEGACY_REPLACE_BLOCK_CHAR = ">"。block regex:22—SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/など。3+ 個の dash + SEARCH という柔軟な形式にマッチする。isSearchBlockStart:31— 行が SEARCH 開始マーカーかを判定。新旧両方のプレフィックスに互換。lineTrimmedFallbackMatch:51— 行単位の trim 後マッチ。モデルが余分なスペース/tab を出したケースを救う。blockAnchorFallbackMatch:132— 先頭末尾行のアンカーマッチ。3+ 行の大きなブロックに使い、中間行の不一致を許容する。fallback chain:349— 精密マッチ → 行 trim → 先頭末尾アンカー → 全文を 0 から検索、の四層フォールバック。isFinal rebuild:472— isFinal=true の時に replacement リストをソートして完全ファイルを再構築する。result を空にしてから replacement を一つずつ適用。getLineNumberFromCharIndex:11— 文字位置を行番号に変換。UI のハイライト用。constructNewFileContentV2:823— v2 実装。NewFileContentConstructorクラスで非標準コンテンツやより複雑な修復ロジックを扱う。internalProcessLine:600— v2 のコア行処理。tryFixSearchReplaceBlock、pendingNonStandardLinesなどの仕組みを持つ。caller:502— 呼び出し点。handler はblock.partialの否定をisFinalパラメータとして渡す。
データフロー
v1 アルゴリズムは diff を行単位で走査する。キーパスは「SEARCH 開始に遭遇 → search 内容を蓄積 → ======= で replace 蓄積に切り替え → +++++++ REPLACE で今回の置換を replacements に格納」である。以下は search ブロック終了時 (=======) に四段階フォールバックでマッチ位置を探すロジック:
// 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 から始めて、各 replacement について [currentPos, replacement.start) の原文 + replacement.content を result に積み、最後に残りの [currentPos, end) を補う。「貯めてから使う」ことで out-of-order ブロックも正しく適用できる。失敗時、handler は consecutiveMistakeCount++:516 で consecutiveMistakeCount をインクリメントし、diff_error を say して UI でユーザーに知らせる。
境界と失敗
- search ブロックがファイルに見つからない: 四段階フォールバックが全部失敗した時に
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を true にして partial 出力を一時停止し (out of order:384)、isFinal=trueの時にソートして一括適用する。このため partial 段階では diff view に完全な結果が見えないことがあるが、最終結果は正しい。 - isFinal 時にまだ replace 状態: ストリームの終わりで
+++++++ REPLACEが閉じていない場合 (missing replace close:444)、replace 内容は文字列末尾までと仮定して今回の置換も replacements に格納し、最後の編集を落とさないようにする。 - deepseek モデルの未エスケープ HTML: モデルが diff 内で
<ではなく<を使う場合、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」を「ディスクに書き戻せる新ファイル内容」に変えるアルゴリズムのコアである。四段階フォールバックでモデルの不一致出力を救い、partial/isFinal の双モードで diff view のリアルタイムプレビューを実現し、out-of-order のソート再構築で最終結果の正確性を保証する。このアルゴリズムの呼び出し側がどうやって結果をディスクに書き、diff view を更新するかは /edit-tools/write-to-file へ、上流の ToolUse block がどうやって押し込まれるかは /agent-loop/present-assistant-message へ。