complition

This commit is contained in:
2026-10-30 20:50:04 +03:00
parent 56115302df
commit 55c3955897
15 changed files with 4103 additions and 32 deletions
+598
View File
@@ -0,0 +1,598 @@
//! Элементы интерфейса автодополнения.
//!
//! Здесь живёт всплывающий список, который привязывается к карете редактора
//! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора
//! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только
//! отображением и небольшими преобразованиями элементов `CompletionItem`.
use std::ops::Range;
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry};
use gpui_kit::{
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px,
};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Documentation,
InsertTextFormat,
};
use crate::lsp::{offset_to_position, position_to_offset};
/// Функция, которая строит содержимое поповера в его `prepaint`.
///
/// Второй аргумент — признак того, что поповер будет нарисован над строкой
/// с каретой: в этом случае содержимое выравнивается по нижнему краю.
type PopupBuilder = Box<dyn Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement>;
/// Отступ от краёв окна, в который поповер не заходит.
pub(crate) const POPUP_MARGIN: f32 = 8.;
/// Зазор между каретой и краем поповера.
const POPUP_GAP: f32 = 4.;
/// Всплывающий список, привязанный к карете редактора.
///
/// Как и встроенные поповеры gpui-component, элемент разрешает позицию в
/// `prepaint`, то есть по геометрии текущего кадра, а не предыдущего.
///
/// Если снизу от кареты нет места, поповер раскрывается вверх; если он не
/// помещается справа, он сдвигается влево, оставаясь внутри окна.
pub struct CaretPopup {
editor: WeakEntity<EditorState>,
build: PopupBuilder,
content: Option<AnyElement>,
}
impl CaretPopup {
pub fn new(
editor: WeakEntity<EditorState>,
build: impl Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
editor,
build: Box::new(build),
content: None,
}
}
}
impl Element for CaretPopup {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let layout = window.request_layout(Style::default(), [], cx);
(layout, ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
let Some(editor) = self.editor.upgrade() else {
return;
};
let Some(geometry) = editor.read(cx).prepaint_caret_geometry() else {
return;
};
let anchor = caret_anchor(geometry);
let viewport = window.viewport_size();
let available = Size {
width: AvailableSpace::Definite(viewport.width),
height: AvailableSpace::Definite(viewport.height),
};
// Сначала меряем содержимое, чтобы решить, помещается ли оно снизу.
let mut content = (self.build)(anchor, false, window, cx);
let size = content.layout_as_root(available, window, cx);
let placement = popup_origin(anchor, caret_top(geometry), size, viewport);
// Над строкой поповеры выравниваются по нижнему краю. Это свойство
// их общего контейнера, поэтому для верхнего варианта собираем его
// заново с другим выравниванием.
if placement.above {
content = (self.build)(anchor, true, window, cx);
content.layout_as_root(available, window, cx);
}
content.prepaint_at(placement.origin, window, cx);
self.content = Some(content);
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(content) = self.content.as_mut() {
content.paint(window, cx);
}
}
}
impl IntoElement for CaretPopup {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
fn caret_anchor(geometry: PrepaintCaretGeometry) -> Point<Pixels> {
Point::new(
geometry.cursor_bounds().origin.x - px(POPUP_GAP),
caret_top(geometry) + geometry.line_height() + px(POPUP_GAP),
)
}
/// Верхняя граница строки с каретой в координатах окна.
fn caret_top(geometry: PrepaintCaretGeometry) -> Pixels {
geometry.cursor_bounds().origin.y + geometry.scroll_offset().y
}
/// Положение поповера и то, нарисован ли он над строкой с каретой.
#[derive(Clone, Copy, Debug, PartialEq)]
struct PopupPlacement {
origin: Point<Pixels>,
above: bool,
}
/// Выбирает положение поповера так, чтобы он целиком помещался в окне.
///
/// По умолчанию поповер стоит под каретой. Если снизу места мало, он
/// переносится над строкой; если не помещается справа, сдвигается влево.
fn popup_origin(
anchor: Point<Pixels>,
caret_top: Pixels,
size: Size<Pixels>,
viewport: Size<Pixels>,
) -> PopupPlacement {
let margin = px(POPUP_MARGIN);
let mut origin = anchor;
let mut above = false;
if origin.y + size.height > viewport.height - margin {
above = true;
let above_y = caret_top - size.height - px(POPUP_GAP);
origin.y = if above_y >= margin {
above_y
} else {
(viewport.height - margin - size.height).max(margin)
};
}
origin.x = origin.x.max(margin);
if origin.x + size.width > viewport.width - margin {
origin.x = (viewport.width - margin - size.width).max(margin);
}
PopupPlacement { origin, above }
}
/// Начало слова под каретой (для замены префикса при вставке).
pub fn word_start(text: &str, cursor: usize) -> usize {
let cursor = clamp_offset(text, cursor);
let mut start = cursor;
for (index, ch) in text[..cursor].char_indices().rev() {
if ch.is_alphanumeric() || ch == '_' {
start = index;
} else {
break;
}
}
start
}
/// Разбирает ответ `textDocument/completion`.
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
if value.is_null() {
return Vec::new();
}
serde_json::from_value::<CompletionResponse>(value)
.map(|response| match response {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
})
.unwrap_or_default()
}
/// Текст документации элемента в виде Markdown.
pub fn documentation_text(documentation: &Documentation) -> String {
match documentation {
Documentation::String(text) => text.clone(),
Documentation::MarkupContent(content) => content.value.clone(),
}
}
/// Запасной текст, если сервер не прислал документацию.
pub fn fallback_documentation(item: &CompletionItem) -> String {
let kind = completion_kind_name(item.kind);
match item.detail.as_ref() {
Some(detail) if !detail.is_empty() => format!("`{detail}`\n\n{kind}"),
_ => format!("**{}**\n\n{kind}", item.label),
}
}
/// Ключ кэша документации: разные элементы с одинаковым именем в разных
/// контекстах (например, `len` у разных типов) не должны перепутаться.
pub fn item_key(item: &CompletionItem) -> String {
format!(
"{}\u{1}{}\u{1}{}",
item.label,
item.detail.as_deref().unwrap_or_default(),
item.kind
.map(|kind| format!("{kind:?}"))
.unwrap_or_default(),
)
}
/// Человекочитаемый тип элемента.
pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
match kind {
Some(CompletionItemKind::METHOD) => "метод",
Some(CompletionItemKind::FUNCTION) => "функция",
Some(CompletionItemKind::CONSTRUCTOR) => "конструктор",
Some(CompletionItemKind::FIELD) => "поле",
Some(CompletionItemKind::VARIABLE) => "переменная",
Some(CompletionItemKind::CONSTANT) => "константа",
Some(CompletionItemKind::STRUCT) => "структура",
Some(CompletionItemKind::CLASS) => "класс",
Some(CompletionItemKind::INTERFACE) => "интерфейс",
Some(CompletionItemKind::ENUM) => "перечисление",
Some(CompletionItemKind::ENUM_MEMBER) => "элемент перечисления",
Some(CompletionItemKind::MODULE) => "модуль",
Some(CompletionItemKind::PROPERTY) => "свойство",
Some(CompletionItemKind::KEYWORD) => "ключевое слово",
Some(CompletionItemKind::SNIPPET) => "шаблон",
Some(CompletionItemKind::TYPE_PARAMETER) => "параметр типа",
Some(CompletionItemKind::OPERATOR) => "оператор",
Some(CompletionItemKind::EVENT) => "событие",
Some(CompletionItemKind::FILE) => "файл",
Some(CompletionItemKind::FOLDER) => "каталог",
Some(CompletionItemKind::TEXT) => "текст",
Some(_) => "значение",
None => "элемент",
}
}
/// Иконка для типа элемента автодополнения.
///
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
/// компонента: в нём есть `square-function`, `variable`, `braces` и другие
/// привычные символы IDE.
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
use gpui_kit::assets::IconName as Icon;
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => Icon::Box,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => Icon::Variable,
Some(CompletionItemKind::KEYWORD) => Icon::Type,
Some(CompletionItemKind::MODULE) => Icon::Braces,
Some(CompletionItemKind::FILE) => Icon::FileText,
Some(CompletionItemKind::FOLDER) => Icon::Folder,
Some(CompletionItemKind::SNIPPET) => Icon::Component,
Some(CompletionItemKind::EVENT) => Icon::Activity,
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
Some(_) => Icon::Circle,
None => Icon::Sparkles,
}
}
/// Цвет иконки для типа элемента.
pub fn completion_icon_color(
kind: Option<CompletionItemKind>,
theme: &gpui_kit::component::Theme,
) -> Hsla {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM,
) => theme.yellow,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => theme.green,
Some(CompletionItemKind::KEYWORD) => theme.magenta,
Some(CompletionItemKind::MODULE) => theme.cyan,
_ => theme.muted_foreground,
}
}
/// Диапазон подсветки совпадения в подписи элемента.
pub fn highlight_range(query: &str, label: &str) -> Option<Range<usize>> {
if query.is_empty() {
return None;
}
let query = query.to_lowercase();
let lowercase = label.to_lowercase();
let start = lowercase.find(&query)?;
// Нижний регистр иногда меняет длину строки, поэтому диапазон
// проверяем по исходной метке и не отдаём разрезанный символ.
let end = (start + query.len()).min(label.len());
if start < end && label.is_char_boundary(start) && label.is_char_boundary(end) {
Some(start..end)
} else {
None
}
}
/// Подготавливает замену текста при выборе элемента.
///
/// Возвращает байтовый диапазон в документе и вставляемый текст. Если сервер
/// прислал `textEdit`, используются его границы; иначе заменяется слово под
/// каретой. Сниппеты раскрываются в плоский текст: табов-заглушек в нашем
/// редакторе пока нет.
pub fn completion_edit(
item: &CompletionItem,
text: &str,
start: usize,
cursor: usize,
) -> (Range<usize>, String) {
let mut range = clamp_offset(text, start)..clamp_offset(text, cursor);
let mut new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
if let Some(edit) = &item.text_edit {
match edit {
CompletionTextEdit::Edit(edit) => {
range = position_to_offset(text, edit.range.start)
..position_to_offset(text, edit.range.end);
new_text.clone_from(&edit.new_text);
}
CompletionTextEdit::InsertAndReplace(edit) => {
range = position_to_offset(text, edit.replace.start)
..position_to_offset(text, edit.replace.end);
new_text.clone_from(&edit.new_text);
}
}
}
if item.insert_text_format == Some(InsertTextFormat::SNIPPET) {
new_text = strip_snippet(&new_text);
}
let start = clamp_offset(text, range.start).min(text.len());
let end = clamp_offset(text, range.end).max(start).min(text.len());
(start..end, new_text)
}
/// Разворачивает LSP-сниппет в обычный текст.
pub fn strip_snippet(snippet: &str) -> String {
let chars: Vec<char> = snippet.chars().collect();
let mut output = String::new();
let mut index = 0;
while index < chars.len() {
if chars[index] == '$' {
if index + 1 < chars.len() && chars[index + 1] == '{' {
if let Some(end) = (index + 2..chars.len()).find(|&pos| chars[pos] == '}') {
let inner: String = chars[index + 2..end].iter().collect();
if let Some((_, default)) = inner.split_once(':') {
output.push_str(default);
}
index = end + 1;
continue;
}
} else if index + 1 < chars.len() && chars[index + 1].is_ascii_digit() {
index += 2;
continue;
} else if index + 1 < chars.len() && chars[index + 1] == '$' {
output.push('$');
index += 2;
continue;
}
}
output.push(chars[index]);
index += 1;
}
output
}
fn clamp_offset(text: &str, offset: usize) -> usize {
let mut offset = offset.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
/// Позиция LSP для диапазона замены (удобно для `apply_lsp_edits`).
pub fn range_for_lsp(text: &str, range: &Range<usize>) -> lsp_types::Range {
lsp_types::Range {
start: offset_to_position(text, range.start),
end: offset_to_position(text, range.end),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(label: &str) -> CompletionItem {
CompletionItem {
label: label.to_owned(),
..Default::default()
}
}
#[test]
fn word_start_stops_at_non_identifier_characters() {
assert_eq!(word_start("let value", 9), 4);
assert_eq!(word_start("obj.field", 9), 4);
assert_eq!(word_start("obj.", 4), 4);
assert_eq!(word_start("привет", 6), 0);
}
#[test]
fn snippets_lose_placeholders() {
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
assert_eq!(strip_snippet("foo($1, $0)"), "foo(, )");
assert_eq!(strip_snippet("cost $$5"), "cost $5");
}
#[test]
fn popup_drops_below_the_caret_when_there_is_room() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let anchor = Point::new(px(100.), px(200.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, px(180.), size, viewport);
assert_eq!(placement.origin, Point::new(px(100.), px(200.)));
assert!(!placement.above);
}
#[test]
fn popup_flips_above_when_bottom_space_is_short() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let caret_top = px(480.);
let anchor = Point::new(px(100.), px(500.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, caret_top, size, viewport);
assert_eq!(placement.origin.y, caret_top - px(200.) - px(POPUP_GAP));
assert_eq!(placement.origin.x, px(100.));
assert!(placement.above);
}
#[test]
fn popup_shifts_left_when_it_does_not_fit_on_the_right() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let anchor = Point::new(px(700.), px(100.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, px(80.), size, viewport);
assert_eq!(placement.origin.x, px(800.) - px(POPUP_MARGIN) - px(300.));
assert_eq!(placement.origin.y, px(100.));
assert!(!placement.above);
}
#[test]
fn completion_icons_exist_in_the_bundled_asset_catalog() {
use gpui_kit::AssetSource;
use gpui_kit::assets::AllAssets;
// `Assets` — компактный набор компонента, и иконок автодополнения в
// нём нет. Приложение подключает `AllAssets`; тест следит за тем,
// чтобы каждая иконка списка действительно там была.
let kinds = [
None,
Some(CompletionItemKind::FUNCTION),
Some(CompletionItemKind::METHOD),
Some(CompletionItemKind::STRUCT),
Some(CompletionItemKind::CLASS),
Some(CompletionItemKind::INTERFACE),
Some(CompletionItemKind::ENUM),
Some(CompletionItemKind::CONSTRUCTOR),
Some(CompletionItemKind::VARIABLE),
Some(CompletionItemKind::FIELD),
Some(CompletionItemKind::PROPERTY),
Some(CompletionItemKind::CONSTANT),
Some(CompletionItemKind::ENUM_MEMBER),
Some(CompletionItemKind::KEYWORD),
Some(CompletionItemKind::MODULE),
Some(CompletionItemKind::FILE),
Some(CompletionItemKind::FOLDER),
Some(CompletionItemKind::SNIPPET),
Some(CompletionItemKind::EVENT),
Some(CompletionItemKind::TYPE_PARAMETER),
Some(CompletionItemKind::OPERATOR),
Some(CompletionItemKind::TEXT),
];
for kind in kinds {
let path = completion_icon(kind).path();
assert!(
AllAssets
.load(&path)
.expect("ошибка чтения ассетов")
.is_some(),
"в бандле нет иконки {path}"
);
}
}
#[test]
fn completion_edit_uses_the_lsp_range() {
let text = "let value";
let mut item = item("value");
item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position::new(0, 4),
end: lsp_types::Position::new(0, 9),
},
new_text: "answer".to_owned(),
}));
let (range, replacement) = completion_edit(&item, text, 4, 9);
assert_eq!(range, 4..9);
assert_eq!(replacement, "answer");
}
}