inline completion: Add syntax highlighting for edit prediction (#23361)

Closes #ISSUE

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Agus <agus@zed.dev>
This commit is contained in:
Bennet Bo Fenner 2025-01-23 18:32:43 +01:00 committed by GitHub
parent 75ae4dada4
commit 3dee32c43d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 796 additions and 485 deletions

View file

@ -736,13 +736,13 @@ impl CompletionsMenu {
}
CompletionEntry::InlineCompletionHint(InlineCompletionMenuHint::Loaded { text }) => {
match text {
InlineCompletionText::Edit { text, highlights } => div()
InlineCompletionText::Edit(highlighted_edits) => div()
.mx_1()
.rounded_md()
.bg(cx.theme().colors().editor_background)
.child(
gpui::StyledText::new(text.clone())
.with_highlights(&style.text, highlights.clone()),
gpui::StyledText::new(highlighted_edits.text.clone())
.with_highlights(&style.text, highlighted_edits.highlights.clone()),
),
InlineCompletionText::Move(text) => div().child(text.clone()),
}

View file

@ -100,8 +100,8 @@ use itertools::Itertools;
use language::{
language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
CursorShape, Diagnostic, Documentation, IndentKind, IndentSize, Language, OffsetRangeExt,
Point, Selection, SelectionGoal, TransactionId,
CursorShape, Diagnostic, Documentation, EditPreview, HighlightedEdits, IndentKind, IndentSize,
Language, OffsetRangeExt, Point, Selection, SelectionGoal, TransactionId,
};
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
use linked_editing_ranges::refresh_linked_ranges;
@ -120,6 +120,7 @@ use lsp::{
LanguageServerId, LanguageServerName,
};
use language::BufferSnapshot;
use movement::TextLayoutDetails;
pub use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset,
@ -479,10 +480,7 @@ impl InlineCompletionMenuHint {
#[derive(Clone, Debug)]
enum InlineCompletionText {
Move(SharedString),
Edit {
text: SharedString,
highlights: Vec<(Range<usize>, HighlightStyle)>,
},
Edit(HighlightedEdits),
}
pub(crate) enum EditDisplayMode {
@ -494,7 +492,9 @@ pub(crate) enum EditDisplayMode {
enum InlineCompletion {
Edit {
edits: Vec<(Range<Anchor>, String)>,
edit_preview: Option<EditPreview>,
display_mode: EditDisplayMode,
snapshot: BufferSnapshot,
},
Move(Anchor),
}
@ -4695,10 +4695,7 @@ impl Editor {
selections.select_anchor_ranges([position..position]);
});
}
InlineCompletion::Edit {
edits,
display_mode: _,
} => {
InlineCompletion::Edit { edits, .. } => {
if let Some(provider) = self.inline_completion_provider() {
provider.accept(cx);
}
@ -4745,10 +4742,7 @@ impl Editor {
selections.select_anchor_ranges([position..position]);
});
}
InlineCompletion::Edit {
edits,
display_mode: _,
} => {
InlineCompletion::Edit { edits, .. } => {
// Find an insertion that starts at the cursor position.
let snapshot = self.buffer.read(cx).snapshot(cx);
let cursor_offset = self.selections.newest::<usize>(cx).head();
@ -4883,8 +4877,8 @@ impl Editor {
let (buffer, cursor_buffer_position) =
self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
let completion = provider.suggest(&buffer, cursor_buffer_position, cx)?;
let edits = completion
let inline_completion = provider.suggest(&buffer, cursor_buffer_position, cx)?;
let edits = inline_completion
.edits
.into_iter()
.flat_map(|(range, new_text)| {
@ -4909,13 +4903,12 @@ impl Editor {
let mut inlay_ids = Vec::new();
let invalidation_row_range;
let completion;
if cursor_row < edit_start_row {
let completion = if cursor_row < edit_start_row {
invalidation_row_range = cursor_row..edit_end_row;
completion = InlineCompletion::Move(first_edit_start);
InlineCompletion::Move(first_edit_start)
} else if cursor_row > edit_end_row {
invalidation_row_range = edit_start_row..cursor_row;
completion = InlineCompletion::Move(first_edit_start);
InlineCompletion::Move(first_edit_start)
} else {
if edits
.iter()
@ -4960,10 +4953,14 @@ impl Editor {
EditDisplayMode::DiffPopover
};
completion = InlineCompletion::Edit {
let snapshot = multibuffer.buffer_for_excerpt(excerpt_id).cloned()?;
InlineCompletion::Edit {
edits,
edit_preview: inline_completion.edit_preview,
display_mode,
};
snapshot,
}
};
let invalidation_range = multibuffer
@ -5006,19 +5003,26 @@ impl Editor {
let text = match &self.active_inline_completion.as_ref()?.completion {
InlineCompletion::Edit {
edits,
edit_preview,
display_mode: _,
} => inline_completion_edit_text(&editor_snapshot, edits, true, cx),
snapshot,
} => edit_preview
.as_ref()
.and_then(|edit_preview| {
inline_completion_edit_text(&snapshot, &edits, edit_preview, true, cx)
})
.map(InlineCompletionText::Edit),
InlineCompletion::Move(target) => {
let target_point =
target.to_point(&editor_snapshot.display_snapshot.buffer_snapshot);
let target_line = target_point.row + 1;
InlineCompletionText::Move(
Some(InlineCompletionText::Move(
format!("Jump to edit in line {}", target_line).into(),
)
))
}
};
Some(InlineCompletionMenuHint::Loaded { text })
Some(InlineCompletionMenuHint::Loaded { text: text? })
} else if provider.is_refreshing(cx) {
Some(InlineCompletionMenuHint::Loading)
} else if provider.needs_terms_acceptance(cx) {
@ -14970,74 +14974,23 @@ pub fn diagnostic_block_renderer(
}
fn inline_completion_edit_text(
editor_snapshot: &EditorSnapshot,
edits: &Vec<(Range<Anchor>, String)>,
current_snapshot: &BufferSnapshot,
edits: &[(Range<Anchor>, String)],
edit_preview: &EditPreview,
include_deletions: bool,
cx: &WindowContext,
) -> InlineCompletionText {
let edit_start = edits
.first()
.unwrap()
.0
.start
.to_display_point(editor_snapshot);
) -> Option<HighlightedEdits> {
let edits = edits
.iter()
.map(|(anchor, text)| {
(
anchor.start.text_anchor..anchor.end.text_anchor,
text.clone(),
)
})
.collect::<Vec<_>>();
let mut text = String::new();
let mut offset = DisplayPoint::new(edit_start.row(), 0).to_offset(editor_snapshot, Bias::Left);
let mut highlights = Vec::new();
for (old_range, new_text) in edits {
let old_offset_range = old_range.to_offset(&editor_snapshot.buffer_snapshot);
text.extend(
editor_snapshot
.buffer_snapshot
.chunks(offset..old_offset_range.start, false)
.map(|chunk| chunk.text),
);
offset = old_offset_range.end;
let start = text.len();
let color = if include_deletions && new_text.is_empty() {
text.extend(
editor_snapshot
.buffer_snapshot
.chunks(old_offset_range.start..offset, false)
.map(|chunk| chunk.text),
);
cx.theme().status().deleted_background
} else {
text.push_str(new_text);
cx.theme().status().created_background
};
let end = text.len();
highlights.push((
start..end,
HighlightStyle {
background_color: Some(color),
..Default::default()
},
));
}
let edit_end = edits
.last()
.unwrap()
.0
.end
.to_display_point(editor_snapshot);
let end_of_line = DisplayPoint::new(edit_end.row(), editor_snapshot.line_len(edit_end.row()))
.to_offset(editor_snapshot, Bias::Right);
text.extend(
editor_snapshot
.buffer_snapshot
.chunks(offset..end_of_line, false)
.map(|chunk| chunk.text),
);
InlineCompletionText::Edit {
text: text.into(),
highlights,
}
Some(edit_preview.highlight_edits(current_snapshot, &edits, include_deletions, cx))
}
pub fn highlight_diagnostic_message(

View file

@ -14733,241 +14733,205 @@ async fn test_multi_buffer_with_single_excerpt_folding(cx: &mut gpui::TestAppCon
}
#[gpui::test]
fn test_inline_completion_text(cx: &mut TestAppContext) {
async fn test_inline_completion_text(cx: &mut TestAppContext) {
init_test(cx, |_| {});
// Simple insertion
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_range = snapshot.buffer_snapshot.anchor_after(Point::new(0, 6))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 6));
let edits = vec![(edit_range, " beautiful".to_string())];
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, false, cx)
else {
panic!("Failed to generate inline completion text");
};
assert_eq!(text, "Hello, beautiful world!");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 6..16);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
assert_highlighted_edits(
"Hello, world!",
vec![(Point::new(0, 6)..Point::new(0, 6), " beautiful".into())],
true,
cx,
|highlighted_edits, cx| {
assert_eq!(highlighted_edits.text, "Hello, beautiful world!");
assert_eq!(highlighted_edits.highlights.len(), 1);
assert_eq!(highlighted_edits.highlights[0].0, 6..16);
assert_eq!(
highlighted_edits.highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
},
)
.await;
// Replacement
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("This is a test.", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edits = vec![(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 4)),
"That".to_string(),
)];
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, false, cx)
else {
panic!("Failed to generate inline completion text");
};
assert_eq!(text, "That is a test.");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 0..4);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
assert_highlighted_edits(
"This is a test.",
vec![(Point::new(0, 0)..Point::new(0, 4), "That".into())],
false,
cx,
|highlighted_edits, cx| {
assert_eq!(highlighted_edits.text, "That is a test.");
assert_eq!(highlighted_edits.highlights.len(), 1);
assert_eq!(highlighted_edits.highlights[0].0, 0..4);
assert_eq!(
highlighted_edits.highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
},
)
.await;
// Multiple edits
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edits = vec![
(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 5)),
"Greetings".into(),
),
(
snapshot.buffer_snapshot.anchor_after(Point::new(0, 12))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 12)),
" and universe".into(),
),
];
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, false, cx)
else {
panic!("Failed to generate inline completion text");
};
assert_eq!(text, "Greetings, world and universe!");
assert_eq!(highlights.len(), 2);
assert_eq!(highlights[0].0, 0..9);
assert_eq!(highlights[1].0, 16..29);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
assert_eq!(
highlights[1].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
assert_highlighted_edits(
"Hello, world!",
vec![
(Point::new(0, 0)..Point::new(0, 5), "Greetings".into()),
(Point::new(0, 12)..Point::new(0, 12), " and universe".into()),
],
false,
cx,
|highlighted_edits, cx| {
assert_eq!(highlighted_edits.text, "Greetings, world and universe!");
assert_eq!(highlighted_edits.highlights.len(), 2);
assert_eq!(highlighted_edits.highlights[0].0, 0..9);
assert_eq!(highlighted_edits.highlights[1].0, 16..29);
assert_eq!(
highlighted_edits.highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
assert_eq!(
highlighted_edits.highlights[1].1.background_color,
Some(cx.theme().status().created_background)
);
},
)
.await;
// Multiple lines with edits
{
let window = cx.add_window(|cx| {
let buffer =
MultiBuffer::build_simple("First line\nSecond line\nThird line\nFourth line", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edits = vec![
(
snapshot.buffer_snapshot.anchor_before(Point::new(1, 7))
..snapshot.buffer_snapshot.anchor_before(Point::new(1, 11)),
"modified".to_string(),
),
(
snapshot.buffer_snapshot.anchor_before(Point::new(2, 0))
..snapshot.buffer_snapshot.anchor_before(Point::new(2, 10)),
"New third line".to_string(),
),
(
snapshot.buffer_snapshot.anchor_before(Point::new(3, 6))
..snapshot.buffer_snapshot.anchor_before(Point::new(3, 6)),
" updated".to_string(),
),
];
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, false, cx)
else {
panic!("Failed to generate inline completion text");
};
assert_eq!(text, "Second modified\nNew third line\nFourth updated line");
assert_eq!(highlights.len(), 3);
assert_eq!(highlights[0].0, 7..15); // "modified"
assert_eq!(highlights[1].0, 16..30); // "New third line"
assert_eq!(highlights[2].0, 37..45); // " updated"
for highlight in &highlights {
assert_eq!(
highlight.1.background_color,
Some(cx.theme().status().created_background)
);
}
})
.unwrap();
}
assert_highlighted_edits(
"First line\nSecond line\nThird line\nFourth line",
vec![
(Point::new(1, 7)..Point::new(1, 11), "modified".to_string()),
(
Point::new(2, 0)..Point::new(2, 10),
"New third line".to_string(),
),
(Point::new(3, 6)..Point::new(3, 6), " updated".to_string()),
],
false,
cx,
|highlighted_edits, cx| {
assert_eq!(
highlighted_edits.text,
"Second modified\nNew third line\nFourth updated line"
);
assert_eq!(highlighted_edits.highlights.len(), 3);
assert_eq!(highlighted_edits.highlights[0].0, 7..15); // "modified"
assert_eq!(highlighted_edits.highlights[1].0, 16..30); // "New third line"
assert_eq!(highlighted_edits.highlights[2].0, 37..45); // " updated"
for highlight in &highlighted_edits.highlights {
assert_eq!(
highlight.1.background_color,
Some(cx.theme().status().created_background)
);
}
},
)
.await;
}
#[gpui::test]
fn test_inline_completion_text_with_deletions(cx: &mut TestAppContext) {
async fn test_inline_completion_text_with_deletions(cx: &mut TestAppContext) {
init_test(cx, |_| {});
// Deletion
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_range = snapshot.buffer_snapshot.anchor_after(Point::new(0, 5))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 11));
let edits = vec![(edit_range, "".to_string())];
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, true, cx)
else {
panic!("Failed to generate inline completion text");
};
assert_eq!(text, "Hello, world!");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 5..11);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().deleted_background)
);
})
.unwrap();
}
assert_highlighted_edits(
"Hello, world!",
vec![(Point::new(0, 5)..Point::new(0, 11), "".to_string())],
true,
cx,
|highlighted_edits, cx| {
assert_eq!(highlighted_edits.text, "Hello, world!");
assert_eq!(highlighted_edits.highlights.len(), 1);
assert_eq!(highlighted_edits.highlights[0].0, 5..11);
assert_eq!(
highlighted_edits.highlights[0].1.background_color,
Some(cx.theme().status().deleted_background)
);
},
)
.await;
// Insertion
{
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple("Hello, world!", cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
assert_highlighted_edits(
"Hello, world!",
vec![(Point::new(0, 6)..Point::new(0, 6), " digital".to_string())],
true,
cx,
|highlighted_edits, cx| {
assert_eq!(highlighted_edits.highlights.len(), 1);
assert_eq!(highlighted_edits.highlights[0].0, 6..14);
assert_eq!(
highlighted_edits.highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
},
)
.await;
}
window
.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
let edit_range = snapshot.buffer_snapshot.anchor_after(Point::new(0, 6))
..snapshot.buffer_snapshot.anchor_before(Point::new(0, 6));
let edits = vec![(edit_range, " digital".to_string())];
async fn assert_highlighted_edits(
text: &str,
edits: Vec<(Range<Point>, String)>,
include_deletions: bool,
cx: &mut TestAppContext,
assertion_fn: impl Fn(HighlightedEdits, &AppContext),
) {
let window = cx.add_window(|cx| {
let buffer = MultiBuffer::build_simple(text, cx);
Editor::new(EditorMode::Full, buffer, None, true, cx)
});
let cx = &mut VisualTestContext::from_window(*window, cx);
let InlineCompletionText::Edit { text, highlights } =
inline_completion_edit_text(&snapshot, &edits, true, cx)
else {
panic!("Failed to generate inline completion text");
};
let (buffer, snapshot) = window
.update(cx, |editor, cx| {
(
editor.buffer().clone(),
editor.buffer().read(cx).snapshot(cx),
)
})
.unwrap();
assert_eq!(text, "Hello, digital world!");
assert_eq!(highlights.len(), 1);
assert_eq!(highlights[0].0, 6..14);
assert_eq!(
highlights[0].1.background_color,
Some(cx.theme().status().created_background)
);
})
.unwrap();
}
let edits = edits
.into_iter()
.map(|(range, edit)| {
(
snapshot.anchor_after(range.start)..snapshot.anchor_before(range.end),
edit,
)
})
.collect::<Vec<_>>();
let text_anchor_edits = edits
.clone()
.into_iter()
.map(|(range, edit)| (range.start.text_anchor..range.end.text_anchor, edit))
.collect::<Vec<_>>();
let edit_preview = window
.update(cx, |_, cx| {
buffer
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.preview_edits(text_anchor_edits.into(), cx)
})
.unwrap()
.await;
cx.update(|cx| {
let highlighted_edits = inline_completion_edit_text(
&snapshot.as_singleton().unwrap().2,
&edits,
&edit_preview,
include_deletions,
cx,
)
.expect("Missing highlighted edits");
assertion_fn(highlighted_edits, cx)
});
}
#[gpui::test]

View file

@ -3390,7 +3390,9 @@ impl EditorElement {
}
InlineCompletion::Edit {
edits,
edit_preview,
display_mode,
snapshot,
} => {
if self.editor.read(cx).has_active_completions_menu() {
return None;
@ -3442,13 +3444,11 @@ impl EditorElement {
EditDisplayMode::DiffPopover => {}
}
let crate::InlineCompletionText::Edit { text, highlights } =
crate::inline_completion_edit_text(editor_snapshot, edits, false, cx)
else {
return None;
};
let highlighted_edits = edit_preview.as_ref().and_then(|edit_preview| {
crate::inline_completion_edit_text(&snapshot, edits, edit_preview, false, cx)
})?;
let line_count = text.lines().count() + 1;
let line_count = highlighted_edits.text.lines().count() + 1;
let longest_row =
editor_snapshot.longest_row_in_range(edit_start.row()..edit_end.row() + 1);
@ -3466,15 +3466,14 @@ impl EditorElement {
.width
};
let styled_text =
gpui::StyledText::new(text.clone()).with_highlights(&style.text, highlights);
let styled_text = gpui::StyledText::new(highlighted_edits.text.clone())
.with_highlights(&style.text, highlighted_edits.highlights);
let mut element = div()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(cx.theme().colors().border)
.rounded_md()
.px_1()
.child(styled_text)
.into_any();

View file

@ -333,6 +333,7 @@ fn propose_edits<T: ToOffset>(
provider.update(cx, |provider, _| {
provider.set_inline_completion(Some(inline_completion::InlineCompletion {
edits: edits.collect(),
edit_preview: None,
}))
})
});