pub(crate) const REPLACE_ALL_ONLY_FOR_SEARCH_REPLACE: &str =
"replace_all is only valid for SEARCH/REPLACE documents";
pub(crate) const SEARCH_REPLACE_EMPTY_PATH: &str =
"SEARCH/REPLACE path must not be empty; put dest on its own line after SEARCH, then -------";
#[must_use]
pub fn has_search_replace_marker(input: &str) -> bool {
input.lines().any(|l| l.trim() == "<<<<<<< SEARCH")
}
pub(crate) fn map_unified_parse_error(input: &str, err: &str) -> String {
if err == "no files found in patch" && has_search_replace_marker(input) {
format!(
"{err}; SEARCH/REPLACE documents must start with `<<<<<<< SEARCH` (or a wrapping fence)"
)
} else {
err.to_string()
}
}
pub(crate) fn unified_parse_anyhow(input: &str, err: &str) -> anyhow::Error {
anyhow::Error::new(crate::exit::ParseErrorError {
msg: format!("patch parse error: {}", map_unified_parse_error(input, err)),
})
}
#[must_use]
pub fn looks_like_search_replace(input: &str) -> bool {
if !has_search_replace_marker(input) {
return false;
}
match first_nonempty_line(input) {
Some("<<<<<<< SEARCH") => true,
Some(l) if l.starts_with("```") => true,
_ => false,
}
}
fn first_nonempty_line(input: &str) -> Option<&str> {
input.lines().map(str::trim).find(|l| !l.is_empty())
}
fn first_nonempty_is_fence(input: &str) -> bool {
first_nonempty_line(input).is_some_and(|l| l.starts_with("```"))
}
#[must_use]
pub fn has_mixed_search_replace_grammar(input: &str) -> bool {
if !looks_like_search_replace(input) {
return false;
}
crate::ops::begin_patch::looks_like_begin_patch(input)
|| crate::ops::begin_patch::has_col0_begin_patch_start(input)
|| has_unified_diff_headers(input)
}
fn has_unified_diff_headers(input: &str) -> bool {
input
.lines()
.any(crate::ops::patch::line_looks_like_unified_file_header)
}
pub fn parse_search_replace_document(
input: &str,
) -> Result<Vec<SearchReplaceBlock>, SearchReplaceParseError> {
if has_mixed_search_replace_grammar(input) {
return Err(SearchReplaceParseError::malformed(
"mixed SEARCH/REPLACE and unified-diff or Begin Patch grammar is not supported",
));
}
if first_nonempty_is_fence(input) {
parse_diff_fenced(input)
} else {
parse_search_replace(input)
}
}
pub fn search_replace_declared_paths(input: &str) -> Result<Vec<String>, SearchReplaceParseError> {
let mut paths = Vec::new();
for block in parse_search_replace_document(input)? {
if block.path.is_empty() {
continue;
}
if !paths.iter().any(|p| p == &block.path) {
paths.push(block.path);
}
}
Ok(paths)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchReplaceBlock {
pub path: String,
pub old: String,
pub new: String,
}
#[derive(Debug)]
pub struct SearchReplaceParseError {
pub message: String,
pub complete: Vec<SearchReplaceBlock>,
pub truncated: bool,
}
impl std::fmt::Display for SearchReplaceParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for SearchReplaceParseError {}
impl SearchReplaceParseError {
fn malformed(msg: impl Into<String>) -> Self {
Self {
message: msg.into(),
complete: Vec::new(),
truncated: false,
}
}
fn truncated(complete: Vec<SearchReplaceBlock>) -> Self {
let n = complete.len();
Self {
message: format!(
"truncated SEARCH/REPLACE: {n} complete block(s) before incomplete last block"
),
complete,
truncated: true,
}
}
}
pub fn parse_search_replace(
input: &str,
) -> Result<Vec<SearchReplaceBlock>, SearchReplaceParseError> {
parse_search_replace_inner(input)
}
pub fn parse_diff_fenced(input: &str) -> Result<Vec<SearchReplaceBlock>, SearchReplaceParseError> {
let unwrapped = strip_fences_for_search_replace(input);
parse_search_replace_inner(&unwrapped)
}
fn parse_search_replace_inner(
response: &str,
) -> Result<Vec<SearchReplaceBlock>, SearchReplaceParseError> {
let cleaned = strip_eos_tokens(response);
let mut actions = Vec::new();
let mut remaining: &str = &cleaned;
while let Some(start) = remaining.find("<<<<<<< SEARCH") {
let block = &remaining[start..];
let (end, end_marker_len) =
if let Some(pos) = find_whole_line_marker(block, ">>>>>>> REPLACE") {
(pos, ">>>>>>> REPLACE".len())
} else if let Some(pos) = find_whole_line_marker(block, ">>>>>>>") {
let after = &block[pos + ">>>>>>>".len()..];
let trimmed = after.trim_start();
if trimmed.is_empty()
|| trimmed.starts_with('\n')
|| trimmed.starts_with("<<<<<<< SEARCH")
{
(pos, ">>>>>>>".len())
} else if actions.is_empty() {
return Err(SearchReplaceParseError::malformed(
"missing >>>>>>> REPLACE marker",
));
} else {
return Err(SearchReplaceParseError::truncated(actions));
}
} else if actions.is_empty() {
return Err(SearchReplaceParseError::malformed(
"missing >>>>>>> REPLACE marker",
));
} else {
return Err(SearchReplaceParseError::truncated(actions));
};
let block = &block[..end + end_marker_len];
let separator = find_whole_line_marker(block, "=======")
.ok_or_else(|| SearchReplaceParseError::malformed("missing ======= separator"))?;
let search_section = skip_one_eol(&block["<<<<<<< SEARCH".len()..separator]);
let (file, old_content) =
if let Some(dash_pos) = find_whole_line_marker(search_section, "-------") {
let f = lf_normalize(search_section[..dash_pos].trim());
let c = after_whole_line(search_section, dash_pos);
(f, lf_normalize(c).trim_end_matches('\n').to_string())
} else {
(
String::new(),
lf_normalize(search_section)
.trim_end_matches('\n')
.to_string(),
)
};
let before_close = &block[..end];
let replace_body = after_whole_line(before_close, separator);
let new_raw = replace_body.strip_suffix('\n').unwrap_or(replace_body);
let new_content = lf_normalize(new_raw);
actions.push(SearchReplaceBlock {
path: file,
old: old_content,
new: new_content,
});
remaining = &remaining[start + end + end_marker_len..];
}
Ok(actions)
}
fn find_whole_line_marker(block: &str, marker: &str) -> Option<usize> {
let mut offset = 0;
for line in block.split_inclusive('\n') {
let without_nl = line.strip_suffix('\n').unwrap_or(line);
let without_eol = without_nl.strip_suffix('\r').unwrap_or(without_nl);
if without_eol.trim_end() == marker {
return Some(offset);
}
offset += line.len();
}
None
}
fn after_whole_line(block: &str, line_start: usize) -> &str {
let rest = &block[line_start..];
match rest.find('\n') {
Some(n) => &rest[n + 1..],
None => "",
}
}
fn skip_one_eol(s: &str) -> &str {
s.strip_prefix("\r\n")
.or_else(|| s.strip_prefix('\n'))
.unwrap_or(s)
}
fn lf_normalize(s: &str) -> String {
s.replace("\r\n", "\n").replace('\r', "")
}
fn strip_eos_tokens(response: &str) -> String {
const EOS_PATTERNS: &[&str] = &[
"<|eos|>",
"<|eot_id|>",
"<|end|>",
"<|im_end|>",
"<|endoftext|>",
];
let mut cleaned = response.to_string();
for pat in EOS_PATTERNS {
cleaned = cleaned.replace(pat, "");
}
cleaned
}
fn strip_fences_for_search_replace(input: &str) -> String {
let mut unwrapped = String::with_capacity(input.len());
let mut in_fence = false;
for line in input.lines() {
let trimmed = line.trim();
if !in_fence && (trimmed == "```" || trimmed.starts_with("```")) {
in_fence = true;
continue;
}
if in_fence && trimmed == "```" {
in_fence = false;
continue;
}
unwrapped.push_str(line);
unwrapped.push('\n');
}
unwrapped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_search_replace_valid() {
let input = "\
<<<<<<< SEARCH
src/foo.rs
-------
old line
=======
new line
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("parse");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "src/foo.rs");
assert_eq!(blocks[0].old, "old line");
assert_eq!(blocks[0].new, "new line");
}
#[test]
fn parse_search_replace_eos_token_stripped() {
let input = "\
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>><|eos|>
";
let blocks = parse_search_replace(input).expect("eos");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].new, "new");
}
#[test]
fn parse_search_replace_eot_id_stripped() {
let input = "\
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>><|eot_id|>
";
let blocks = parse_search_replace(input).expect("eot");
assert_eq!(blocks[0].path, "a.rs");
}
#[test]
fn parse_search_replace_bare_close() {
let input = "\
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>>
";
let blocks = parse_search_replace(input).expect("bare");
assert_eq!(blocks[0].old, "old");
assert_eq!(blocks[0].new, "new");
}
#[test]
fn parse_search_replace_truncated_after_first_block() {
let input = "\
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>> REPLACE
<<<<<<< SEARCH
b.rs
-------
incomplete
";
let err = parse_search_replace(input).expect_err("truncated");
assert!(err.truncated);
assert_eq!(err.complete.len(), 1);
assert_eq!(err.complete[0].path, "a.rs");
}
#[test]
fn parse_diff_fenced_unwraps_fence() {
let input = "\
```
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>> REPLACE
```
";
let blocks = parse_diff_fenced(input).expect("fenced");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "a.rs");
}
#[test]
fn parse_search_replace_document_unwraps_leading_fence() {
let input = "\
```
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>> REPLACE
```
";
let blocks = parse_search_replace_document(input).expect("leading fence");
assert_eq!(blocks[0].path, "a.rs");
assert_eq!(blocks[0].old, "old");
assert_eq!(blocks[0].new, "new");
}
#[test]
fn parse_search_replace_document_keeps_inner_markdown_fences() {
let input = "\
<<<<<<< SEARCH
README.md
-------
before
```rust
fn x() {}
```
after
=======
before
```rust
fn y() {}
```
after
>>>>>>> REPLACE
";
assert!(
looks_like_search_replace(input),
"first line is SEARCH, not a wrapping fence"
);
let blocks = parse_search_replace_document(input).expect("inner fences stay");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "README.md");
assert_eq!(
blocks[0].old, "before\n```rust\nfn x() {}\n```\nafter",
"SEARCH body fences must not be stripped, got {:?}",
blocks[0].old
);
assert_eq!(
blocks[0].new, "before\n```rust\nfn y() {}\n```\nafter",
"REPLACE body fences must not be stripped, got {:?}",
blocks[0].new
);
}
#[test]
fn parse_search_replace_inline_close_stays_in_new() {
let input = "\
<<<<<<< SEARCH
close.rs
-------
alpha
=======
see >>>>>>> REPLACE in docs
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("inline close");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "close.rs");
assert_eq!(blocks[0].old, "alpha");
assert_eq!(
blocks[0].new, "see >>>>>>> REPLACE in docs",
"inline close text must stay in new, got {:?}",
blocks[0].new
);
}
#[test]
fn looks_like_search_replace_first_line_or_fence() {
assert!(looks_like_search_replace(
"<<<<<<< SEARCH\nfile.rs\n-------\nold\n=======\nnew\n>>>>>>> REPLACE\n"
));
assert!(looks_like_search_replace(
"```\n<<<<<<< SEARCH\nfile.rs\n-------\nold\n=======\nnew\n>>>>>>> REPLACE\n```\n"
));
assert!(!looks_like_search_replace(
"--- a/file.rs\n+++ b/file.rs\n@@ -1 +1 @@\n-old\n+new\n"
));
assert!(
!looks_like_search_replace(
"--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,3 @@\n context\n <<<<<<< SEARCH\n+keep\n"
),
"unified diff that mentions SEARCH later is not SEARCH/REPLACE"
);
let prose_first = "here is a patch\n<<<<<<< SEARCH\ncode.rs\n-------\nfn old() {}\n=======\nfn new() {}\n>>>>>>> REPLACE\n";
assert!(
!looks_like_search_replace(prose_first),
"prose before SEARCH must stay unified (first-line detect unchanged)"
);
assert!(has_search_replace_marker(prose_first));
assert!(has_search_replace_marker("--- a/x\n<<<<<<< SEARCH\nkeep\n"));
assert!(!has_search_replace_marker("--- a/x\n+++ b/x\n"));
let mapped = map_unified_parse_error(prose_first, "no files found in patch");
assert!(
mapped.contains("no files found in patch")
&& mapped.contains("<<<<<<< SEARCH")
&& mapped.contains("wrapping fence"),
"apply surfaces must hint SEARCH/REPLACE start, got {mapped}"
);
assert_eq!(
map_unified_parse_error("just some text\n", "no files found in patch"),
"no files found in patch"
);
assert_eq!(
map_unified_parse_error(prose_first, "no hunks found for file x.rs"),
"no hunks found for file x.rs"
);
}
#[test]
fn mixed_search_replace_and_unified_headers_refused() {
let input = "\
<<<<<<< SEARCH
file.rs
-------
old
=======
new
>>>>>>> REPLACE
--- a/file.rs
+++ b/file.rs
";
assert!(has_mixed_search_replace_grammar(input));
let err = parse_search_replace_document(input).expect_err("mixed");
assert!(!err.truncated, "mixed grammar is malformed, not truncated");
assert!(
err.message.contains("mixed SEARCH/REPLACE"),
"expected mixed-grammar refuse, got {}",
err.message
);
}
#[test]
fn mixed_search_replace_and_backslash_unified_headers_refused() {
let input = "\
<<<<<<< SEARCH
file.rs
-------
old
=======
new
>>>>>>> REPLACE
--- a\\file.rs
+++ b\\file.rs
";
assert!(has_mixed_search_replace_grammar(input));
let err = parse_search_replace_document(input).expect_err("mixed backslash");
assert!(!err.truncated, "mixed grammar is malformed, not truncated");
assert!(
err.message.contains("mixed SEARCH/REPLACE"),
"expected mixed-grammar refuse, got {}",
err.message
);
}
#[test]
fn has_mixed_search_replace_then_begin_patch_refused() {
let input = "\
<<<<<<< SEARCH
code.rs
-------
fn old() {}
=======
fn new() {}
>>>>>>> REPLACE
*** Begin Patch
*** Update File: code.rs
@@
-fn new() {}
+fn other() {}
*** End Patch
";
assert!(looks_like_search_replace(input));
assert!(
!crate::ops::begin_patch::looks_like_begin_patch(input),
"first non-blank is SEARCH, not Begin Patch"
);
assert!(has_mixed_search_replace_grammar(input));
let err = parse_search_replace_document(input).expect_err("mixed SEARCH then Begin Patch");
assert!(!err.truncated, "mixed grammar is malformed, not truncated");
assert!(
err.message.contains("mixed SEARCH/REPLACE"),
"expected mixed-grammar refuse, got {}",
err.message
);
}
#[test]
fn has_mixed_search_replace_then_update_file_refused() {
let input = "\
<<<<<<< SEARCH
code.rs
-------
fn old() {}
=======
fn new() {}
>>>>>>> REPLACE
*** Update File: code.rs
";
assert!(looks_like_search_replace(input));
assert!(has_mixed_search_replace_grammar(input));
let err = parse_search_replace_document(input).expect_err("mixed SEARCH then Update File");
assert!(
err.message.contains("mixed SEARCH/REPLACE"),
"expected mixed-grammar refuse, got {}",
err.message
);
}
#[test]
fn has_mixed_search_replace_begin_patch_first_stays_begin_patch() {
let input = "\
*** Begin Patch
<<<<<<< SEARCH
code.rs
-------
fn old() {}
=======
fn new() {}
>>>>>>> REPLACE
*** End Patch
";
assert!(
!looks_like_search_replace(input),
"Begin-Patch-first is not a SEARCH/REPLACE payload"
);
assert!(!has_mixed_search_replace_grammar(input));
assert!(crate::ops::begin_patch::looks_like_begin_patch(input));
assert!(has_search_replace_marker(input));
}
#[test]
fn parse_search_replace_destless_keeps_first_line_as_search() {
let input = "\
<<<<<<< SEARCH
only.rs
the old text
=======
the new text
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("dest-less");
assert_eq!(
blocks[0].path, "",
"no ------- dest: first SEARCH line is body, not dest"
);
assert_eq!(blocks[0].old, "only.rs\nthe old text");
assert_eq!(blocks[0].new, "the new text");
}
#[test]
fn parse_search_replace_destless_code_line_is_not_dest() {
let input = "\
<<<<<<< SEARCH
pub fn set(&mut self, deg: u32) -> Result<u64, Error> {
if deg == 0 {
=======
pub fn set(&mut self, deg: u32) -> Result<u64, Error> {
if deg == 0 {
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("dest-less code");
assert!(
blocks[0].path.is_empty(),
"code line must not become dest, got {:?}",
blocks[0].path
);
assert!(
blocks[0]
.old
.contains("pub fn set(&mut self, deg: u32) -> Result<u64, Error> {"),
"first SEARCH line stays in old, got {:?}",
blocks[0].old
);
}
#[test]
fn search_replace_declared_paths_skips_destless_blocks() {
let destless = "\
<<<<<<< SEARCH
only.rs
the old text
=======
the new text
>>>>>>> REPLACE
";
assert_eq!(
search_replace_declared_paths(destless).expect("dest-less"),
Vec::<String>::new(),
"dest-less must not declare the first SEARCH line as dest"
);
let mixed = "\
<<<<<<< SEARCH
keep.rs
-------
old
=======
new
>>>>>>> REPLACE
<<<<<<< SEARCH
skip.rs
body
=======
other
>>>>>>> REPLACE
";
assert_eq!(
search_replace_declared_paths(mixed).expect("mixed"),
vec!["keep.rs".to_string()],
"only ------- dests are declared"
);
}
#[test]
fn parse_search_replace_banner_equals_is_not_separator() {
let input = "\
<<<<<<< SEARCH
f.py
-------
# ==========
title
=======
# ==========
renamed
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("banner equals");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "f.py");
assert_eq!(blocks[0].old, "# ==========\ntitle");
assert_eq!(blocks[0].new, "# ==========\nrenamed");
}
#[test]
fn parse_search_replace_setext_underline_is_not_separator() {
let input = "\
<<<<<<< SEARCH
doc.md
-------
Heading
==========
=======
Heading
==========
updated
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("setext underline");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].path, "doc.md");
assert_eq!(blocks[0].old, "Heading\n==========");
assert_eq!(blocks[0].new, "Heading\n==========\nupdated");
}
#[test]
fn parse_search_replace_separator_allows_trailing_whitespace() {
let input = "\
<<<<<<< SEARCH
a.rs
-------
old
=======
new
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("trailing ws on separator");
assert_eq!(blocks[0].old, "old");
assert_eq!(blocks[0].new, "new");
}
#[test]
fn parse_search_replace_crlf_dest_present_matches_lf() {
let lf = "\
<<<<<<< SEARCH
code.rs
-------
fn old() {}
=======
fn new() {}
>>>>>>> REPLACE
";
let crlf = lf.replace('\n', "\r\n");
let lf_blocks = parse_search_replace(lf).expect("lf");
let crlf_blocks = parse_search_replace(&crlf).expect("crlf");
assert_eq!(crlf_blocks, lf_blocks);
assert_eq!(crlf_blocks[0].path, "code.rs");
assert_eq!(crlf_blocks[0].old, "fn old() {}");
assert_eq!(crlf_blocks[0].new, "fn new() {}");
assert!(
!crlf_blocks[0].old.contains('\r'),
"old must not keep CR: {:?}",
crlf_blocks[0].old
);
assert!(
!crlf_blocks[0].new.contains('\r'),
"new must not keep CR: {:?}",
crlf_blocks[0].new
);
}
#[test]
fn parse_search_replace_crlf_destless_has_empty_path_without_cr() {
let lf = "\
<<<<<<< SEARCH
only.rs
the old text
=======
the new text
>>>>>>> REPLACE
";
let crlf = lf.replace('\n', "\r\n");
let blocks = parse_search_replace(&crlf).expect("dest-less crlf");
assert_eq!(blocks[0].path, "");
assert_eq!(blocks[0].old, "only.rs\nthe old text");
assert_eq!(blocks[0].new, "the new text");
assert!(
!blocks[0].old.contains('\r'),
"old must not keep CR: {:?}",
blocks[0].old
);
assert!(
!blocks[0].new.contains('\r'),
"new must not keep CR: {:?}",
blocks[0].new
);
}
#[test]
fn parse_search_replace_inline_dashes_in_destless_search_are_not_dest() {
let input = "\
<<<<<<< SEARCH
x = \"-------\"
=======
x = \"eq\"
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("inline dashes");
assert_eq!(
blocks[0].path, "",
"inline ------- in SEARCH is not dest, got {:?}",
blocks[0].path
);
assert_eq!(blocks[0].old, "x = \"-------\"");
assert_eq!(blocks[0].new, "x = \"eq\"");
}
#[test]
fn parse_search_replace_dest_present_old_may_contain_dashes() {
let input = "\
<<<<<<< SEARCH
code.rs
-------
x = \"-------\"
=======
x = \"eq\"
>>>>>>> REPLACE
";
let blocks = parse_search_replace(input).expect("dest-present dashes in old");
assert_eq!(blocks[0].path, "code.rs");
assert_eq!(blocks[0].old, "x = \"-------\"");
assert_eq!(blocks[0].new, "x = \"eq\"");
}
}