first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt::{self, Display};
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use itertools::{EitherOrBoth, Itertools};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{self, Display},
|
||||
ops::Range,
|
||||
path::PathBuf,
|
||||
sync::LazyLock,
|
||||
};
|
||||
use strsim::jaro_winkler;
|
||||
lazy_static! {
|
||||
/// Regex to parse a line number from a string in the format "{number}|{line}"
|
||||
@@ -283,6 +282,35 @@ fn unmatched_line_suffix<'a>(search_line: &str, file_line: &'a str) -> Option<&'
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve the unmatched suffix of the final matched file line when the LLM emitted only a
|
||||
/// partial final line.
|
||||
///
|
||||
/// When search and replace have the same line count, preserve the suffix to maintain the legacy
|
||||
/// behavior for simple partial-line replacements. When line counts differ, only preserve it if the
|
||||
/// replacement's final line still carries the same partial context as the search's final line.
|
||||
fn append_unmatched_line_suffix(search: &str, file_line: &str, insertion: &mut String) {
|
||||
let Some(search_last_line) = lines(search).last() else {
|
||||
return;
|
||||
};
|
||||
let Some(suffix) = unmatched_line_suffix(search_last_line, file_line) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let search_line_count = lines(search).count();
|
||||
let insertion_line_count = lines(insertion).count();
|
||||
let Some(insertion_last_line) = lines(insertion).last() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if search_line_count != insertion_line_count
|
||||
&& search_last_line.trim_start() != insertion_last_line.trim_start()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let insertion_point = insertion.trim_end_matches('\n').len();
|
||||
insertion.insert_str(insertion_point, suffix);
|
||||
}
|
||||
/// We told the model not to include line numbers for the replacement content. However, it can
|
||||
/// still happen. Try to remove them here.
|
||||
/// https://github.com/warpdotdev/warp-server/blob/d9c1b6d1443290f2355979ae552d41af01a63bde/logic/ai/prompt/tools/suggest_diff.yaml#L34-L34
|
||||
@@ -402,6 +430,14 @@ pub fn fuzzy_match_v4a_diffs(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start line and remove overlapping deltas. When the LLM produces
|
||||
// multiple hunks targeting the same region (e.g. a large deletion whose
|
||||
// matched range subsumes a nearby single-line edit), the overlapping delta
|
||||
// must be dropped — applying both would produce an invalid edit range in
|
||||
// the editor buffer (see WARP-CLIENT-DEV-NYY).
|
||||
deltas.sort_by_key(|d| d.replacement_line_range.start);
|
||||
deltas = deduplicate_overlapping_deltas(deltas);
|
||||
|
||||
let update_deltas_empty = deltas.is_empty();
|
||||
let failures = if failures.fuzzy_match_failures > 0
|
||||
|| failures.missing_line_numbers > 0
|
||||
@@ -420,6 +456,33 @@ pub fn fuzzy_match_v4a_diffs(
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a list of `DiffDelta`s sorted by `replacement_line_range.start`,
|
||||
/// drop any delta whose range overlaps with the preceding accepted delta.
|
||||
///
|
||||
/// "Overlaps" means `B.start < A.end` (strictly inside or partial overlap).
|
||||
/// Adjacent ranges (`A.end == B.start`) are kept.
|
||||
fn deduplicate_overlapping_deltas(sorted_deltas: Vec<DiffDelta>) -> Vec<DiffDelta> {
|
||||
let mut result: Vec<DiffDelta> = Vec::with_capacity(sorted_deltas.len());
|
||||
|
||||
for delta in sorted_deltas {
|
||||
let dominated = result.last().is_some_and(|prev| {
|
||||
delta.replacement_line_range.start < prev.replacement_line_range.end
|
||||
});
|
||||
if dominated {
|
||||
log::warn!(
|
||||
"Dropping V4A delta with overlapping range {:?} \
|
||||
(subsumed by preceding delta with range {:?})",
|
||||
delta.replacement_line_range,
|
||||
result.last().unwrap().replacement_line_range,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result.push(delta);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn fuzzy_match_file_diffs(
|
||||
diffs: &[SearchAndReplace],
|
||||
file_content: &str,
|
||||
@@ -547,13 +610,12 @@ fn fuzzy_match_file_diffs(
|
||||
// the delta would replace the entire line and drop the unmatched
|
||||
// suffix. Detect this and preserve the suffix in the insertion.
|
||||
let mut insertion = diff.replace.clone();
|
||||
if range.end >= 2 && lines(&search).count() == lines(&insertion).count() {
|
||||
if let Some(suffix) = lines(&search)
|
||||
.last()
|
||||
.and_then(|last| unmatched_line_suffix(last, target_lines[range.end - 2]))
|
||||
{
|
||||
insertion.push_str(suffix);
|
||||
}
|
||||
if range.end >= 2 {
|
||||
append_unmatched_line_suffix(
|
||||
&search,
|
||||
target_lines[range.end - 2],
|
||||
&mut insertion,
|
||||
);
|
||||
}
|
||||
deltas.push(DiffDelta {
|
||||
replacement_line_range: range.start..range.end,
|
||||
@@ -1208,5 +1270,5 @@ fn find_change_context_start(change_context: &[String], file_lines: &[&str]) ->
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+145
@@ -608,6 +608,66 @@ fn test_partial_last_line_in_search_preserves_suffix() {
|
||||
assert_eq!(result, "func foo() {\nlet y = 1;\nlet x = 2;\n}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_last_line_in_multiline_replacement_preserves_suffix() {
|
||||
// This mirrors a model edit that deletes middle lines while leaving the final line as partial
|
||||
// trailing context. The final line should remain a no-op after suffix preservation.
|
||||
let file_content = "\
|
||||
mod proxy;
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// Logging is now handled by init_common (log_destination: File).
|
||||
|
||||
// socket_path: ~/.warp[-channel]/remote-server/server.sock
|
||||
// The Unix domain socket the daemon binds on.
|
||||
}
|
||||
";
|
||||
|
||||
let diffs = [SearchAndReplace {
|
||||
search: "\
|
||||
2|pub fn run_daemon() -> anyhow::Result<()> {
|
||||
3| // Logging is now handled by init_common (log_destination: File).
|
||||
4|
|
||||
5| // socket_path:"
|
||||
.to_string(),
|
||||
replace: "\
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// socket_path:"
|
||||
.to_string(),
|
||||
}];
|
||||
|
||||
let (deltas, _failures) = fuzzy_match_file_diffs(&diffs, file_content);
|
||||
|
||||
assert_eq!(deltas.len(), 1, "Expected one matched delta");
|
||||
assert_eq!(deltas[0].replacement_line_range, 2..6);
|
||||
assert_eq!(
|
||||
deltas[0].insertion,
|
||||
"pub fn run_daemon() -> anyhow::Result<()> {\n // socket_path: ~/.warp[-channel]/remote-server/server.sock"
|
||||
);
|
||||
|
||||
let file_lines: Vec<&str> = file_content.lines().collect();
|
||||
let range = &deltas[0].replacement_line_range;
|
||||
let mut result = String::new();
|
||||
for line in &file_lines[..range.start - 1] {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(&deltas[0].insertion);
|
||||
result.push('\n');
|
||||
for line in &file_lines[range.end - 1..] {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
assert_eq!(
|
||||
result,
|
||||
"\
|
||||
mod proxy;
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// socket_path: ~/.warp[-channel]/remote-server/server.sock
|
||||
// The Unix domain socket the daemon binds on.
|
||||
}
|
||||
"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_search_and_replace_accommodates_none() {
|
||||
let parsed_diff = ParsedDiff::StrReplaceEdit {
|
||||
@@ -679,3 +739,88 @@ fn test_custom_lines() {
|
||||
assert_eq!(lines("foo\nbar").collect_vec(), vec!["foo", "bar"]);
|
||||
assert_eq!(lines("foo\nbar\n").collect_vec(), vec!["foo", "bar"]);
|
||||
}
|
||||
|
||||
/// Regression test for WARP-CLIENT-DEV-NYY: panic "Invalid edit range 4042..3982".
|
||||
///
|
||||
/// Reproduces the crash from MAA conversation d71bf84b (request b621adb3).
|
||||
/// Two V4A hunks target the same region: a large deletion whose matched range
|
||||
/// subsumes a nearby single-line edit. Without `deduplicate_overlapping_deltas`,
|
||||
/// both deltas survive and `Buffer::edit` panics on the overlapping ranges.
|
||||
#[test]
|
||||
fn test_v4a_maa_crash_d71bf84b_no_overlapping_deltas() {
|
||||
// File content where hunk A (deletion) and hunk B (delegate tweak) both
|
||||
// match, and hunk A's matched range fully contains hunk B's.
|
||||
// The `ActiveMicButtonTheme.background` line that hunk B targets sits
|
||||
// inside `DefaultWeightAgentInputButtonTheme`'s impl, so hunk A's
|
||||
// deletion (which covers the whole impl) subsumes hunk B.
|
||||
let file_content = "\
|
||||
}\n\
|
||||
}\n\
|
||||
}\n\
|
||||
\n\
|
||||
struct DefaultWeightAgentInputButtonTheme;\n\
|
||||
\n\
|
||||
impl ActionButtonTheme for DefaultWeightAgentInputButtonTheme {\n\
|
||||
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {\n\
|
||||
AgentInputButtonTheme.background(hovered, appearance)\n\
|
||||
}\n\
|
||||
\n\
|
||||
fn text_color(\n\
|
||||
&self,\n\
|
||||
hovered: bool,\n\
|
||||
background: Option<Fill>,\n\
|
||||
appearance: &Appearance,\n\
|
||||
) -> ColorU {\n\
|
||||
AgentInputButtonTheme.text_color(hovered, background, appearance)\n\
|
||||
}\n\
|
||||
\n\
|
||||
fn border(&self, appearance: &Appearance) -> Option<ColorU> {\n\
|
||||
AgentInputButtonTheme.border(appearance)\n\
|
||||
}\n\
|
||||
\n\
|
||||
fn should_opt_out_of_contrast_adjustment(&self) -> bool {\n\
|
||||
true\n\
|
||||
}\n\
|
||||
}";
|
||||
|
||||
let hunks = vec![
|
||||
// Hunk A: delete the entire DefaultWeightAgentInputButtonTheme block.
|
||||
V4AHunk {
|
||||
change_context: vec![],
|
||||
pre_context: " }\n }\n}".to_string(),
|
||||
old: "\nstruct DefaultWeightAgentInputButtonTheme;\n\nimpl ActionButtonTheme for DefaultWeightAgentInputButtonTheme {\n fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {\n AgentInputButtonTheme.background(hovered, appearance)\n }\n\n fn text_color(\n &self,\n hovered: bool,\n background: Option<Fill>,\n appearance: &Appearance,\n ) -> ColorU {\n AgentInputButtonTheme.text_color(hovered, background, appearance)\n }\n\n fn border(&self, appearance: &Appearance) -> Option<ColorU> {\n AgentInputButtonTheme.border(appearance)\n }\n\n fn should_opt_out_of_contrast_adjustment(&self) -> bool {\n true\n }\n}".to_string(),
|
||||
new: String::new(),
|
||||
post_context: String::new(),
|
||||
},
|
||||
// Hunk B: tweak a delegate call inside the same region hunk A deletes.
|
||||
// Its preContext + old match a line inside hunk A's range, so it
|
||||
// produces a delta whose range overlaps with hunk A's.
|
||||
V4AHunk {
|
||||
change_context: vec![],
|
||||
pre_context: "impl ActionButtonTheme for DefaultWeightAgentInputButtonTheme {\n fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {".to_string(),
|
||||
old: " AgentInputButtonTheme.background(hovered, appearance)".to_string(),
|
||||
new: " AgentInputButtonTheme::default().background(hovered, appearance)".to_string(),
|
||||
post_context: " }".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let diff = fuzzy_match_v4a_diffs("mod.rs", &hunks, None, file_content);
|
||||
let deltas = deltas(&diff);
|
||||
|
||||
// Hunk B's matched range is inside hunk A's, so deduplication must drop it.
|
||||
// Only hunk A's delta (the deletion) should survive.
|
||||
assert_eq!(
|
||||
deltas.len(),
|
||||
1,
|
||||
"Expected 1 delta (subsumed hunk should be dropped), got {}: {:?}",
|
||||
deltas.len(),
|
||||
deltas
|
||||
.iter()
|
||||
.map(|d| &d.replacement_line_range)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(
|
||||
deltas[0].insertion.is_empty(),
|
||||
"The surviving delta should be the deletion"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user