Use line-based and word-based diff when reloading and formatting buffers (#25129)

Closes https://github.com/zed-industries/zed/issues/10122
Closes https://github.com/zed-industries/zed/issues/25034

When formatting buffers or reloading them after they change on disk, we
performed a diff between the buffer's current contents and the new
content. We need this diff in order preserve the positions of cursors
and other decorations when updating the buffer's text.

In order to handle changes within lines, we would previously compute a
*character-wise* diff. This was extremely expensive for large files.

This PR gets rid of the character-wise diff, and instead performs a
normal line-wise diff. Then, for certain replace hunks, we compute a
secondary word-based diff. Also, I've switched to the
[`imara-diff`](https://github.com/pascalkuthe/imara-diff) crate, instead
of `similar`.

Release Notes:

- Fixed a hang that could occur when large files were changed on disk or
formatted.
This commit is contained in:
Max Brunsfeld 2025-02-19 16:56:01 -08:00 committed by GitHub
parent 1087e05da4
commit 0fdad0c0d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 429 additions and 343 deletions

View file

@ -29,8 +29,7 @@ use gpui::{
use http_client::{HttpClient, Method};
use input_excerpt::excerpt_for_cursor_position;
use language::{
Anchor, Buffer, BufferSnapshot, CharClassifier, CharKind, EditPreview, OffsetRangeExt,
ToOffset, ToPoint,
text_diff, Anchor, Buffer, BufferSnapshot, EditPreview, OffsetRangeExt, ToOffset, ToPoint,
};
use language_models::LlmApiToken;
use postage::watch;
@ -919,77 +918,18 @@ and then another
offset: usize,
snapshot: &BufferSnapshot,
) -> Vec<(Range<Anchor>, String)> {
fn tokenize(text: &str) -> Vec<&str> {
let classifier = CharClassifier::new(None).for_completion(true);
let mut chars = text.chars().peekable();
let mut prev_ch = chars.peek().copied();
let mut tokens = Vec::new();
let mut start = 0;
let mut end = 0;
while let Some(ch) = chars.next() {
let prev_kind = prev_ch.map(|ch| classifier.kind(ch));
let kind = classifier.kind(ch);
if Some(kind) != prev_kind || (kind == CharKind::Punctuation && Some(ch) != prev_ch)
{
tokens.push(&text[start..end]);
start = end;
}
end += ch.len_utf8();
prev_ch = Some(ch);
}
tokens.push(&text[start..end]);
tokens
}
let old_tokens = tokenize(&old_text);
let new_tokens = tokenize(new_text);
let diff = similar::TextDiffConfig::default()
.algorithm(similar::Algorithm::Patience)
.diff_slices(&old_tokens, &new_tokens);
let mut edits: Vec<(Range<usize>, String)> = Vec::new();
let mut old_start = offset;
for change in diff.iter_all_changes() {
let value = change.value();
match change.tag() {
similar::ChangeTag::Equal => {
old_start += value.len();
}
similar::ChangeTag::Delete => {
let old_end = old_start + value.len();
if let Some((last_old_range, _)) = edits.last_mut() {
if last_old_range.end == old_start {
last_old_range.end = old_end;
} else {
edits.push((old_start..old_end, String::new()));
}
} else {
edits.push((old_start..old_end, String::new()));
}
old_start = old_end;
}
similar::ChangeTag::Insert => {
if let Some((last_old_range, last_new_text)) = edits.last_mut() {
if last_old_range.end == old_start {
last_new_text.push_str(value);
} else {
edits.push((old_start..old_start, value.into()));
}
} else {
edits.push((old_start..old_start, value.into()));
}
}
}
}
edits
text_diff(&old_text, &new_text)
.into_iter()
.map(|(mut old_range, new_text)| {
old_range.start += offset;
old_range.end += offset;
let prefix_len = common_prefix(
snapshot.chars_for_range(old_range.clone()),
new_text.chars(),
);
old_range.start += prefix_len;
let suffix_len = common_prefix(
snapshot.reversed_chars_for_range(old_range.clone()),
new_text[prefix_len..].chars().rev(),
@ -1248,10 +1188,7 @@ impl Event {
writeln!(prompt, "User renamed {:?} to {:?}\n", old_path, new_path).unwrap();
}
let diff =
similar::TextDiff::from_lines(&old_snapshot.text(), &new_snapshot.text())
.unified_diff()
.to_string();
let diff = language::unified_diff(&old_snapshot.text(), &new_snapshot.text());
if !diff.is_empty() {
write!(
prompt,