Files
qode/src/completion.rs
T
2026-11-01 01:13:30 +03:00

672 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Элементы интерфейса автодополнения.
//!
//! Здесь живёт всплывающий список, который привязывается к карете редактора
//! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора
//! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только
//! отображением и небольшими преобразованиями элементов `CompletionItem`.
use std::ops::Range;
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry, Rope};
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 }
}
/// Начало слова под каретой (для замены префикса при вставке).
/// Находит начало слова под курсором в обычной строке.
///
/// Нужен только для тестов: редактор работает с [`word_start_rope`].
#[cfg(test)]
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
}
/// То же для rope: обходит только символы слова, а не весь документ.
pub fn word_start_rope(text: &Rope, cursor: usize) -> usize {
let cursor = cursor.min(text.len());
let mut start = cursor;
let mut char_index = text.byte_to_char_idx(cursor);
while char_index > 0 {
char_index -= 1;
let byte = text.char_to_byte_idx(char_index);
let ch = text.char(byte);
if ch.is_alphanumeric() || ch == '_' {
start = byte;
} else {
break;
}
}
start
}
/// Символ перед байтовым смещением `cursor`.
pub fn char_before(text: &Rope, cursor: usize) -> Option<char> {
let cursor = cursor.min(text.len());
let mut char_index = text.byte_to_char_idx(cursor);
if char_index == 0 {
return None;
}
char_index -= 1;
let byte = text.char_to_byte_idx(char_index);
text.get_char(byte).ok()
}
/// Разбирает ответ `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 => "элемент",
}
}
/// Группа элемента автодополнения: по ней выбираются иконка и цвет.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CompletionKindGroup {
Function,
Type,
Variable,
Keyword,
Module,
File,
Folder,
Snippet,
Event,
TypeParameter,
Operator,
Other,
Unknown,
}
fn kind_group(kind: Option<CompletionItemKind>) -> CompletionKindGroup {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => {
CompletionKindGroup::Function
}
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => CompletionKindGroup::Type,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => CompletionKindGroup::Variable,
Some(CompletionItemKind::KEYWORD) => CompletionKindGroup::Keyword,
Some(CompletionItemKind::MODULE) => CompletionKindGroup::Module,
Some(CompletionItemKind::FILE) => CompletionKindGroup::File,
Some(CompletionItemKind::FOLDER) => CompletionKindGroup::Folder,
Some(CompletionItemKind::SNIPPET) => CompletionKindGroup::Snippet,
Some(CompletionItemKind::EVENT) => CompletionKindGroup::Event,
Some(CompletionItemKind::TYPE_PARAMETER) => CompletionKindGroup::TypeParameter,
Some(CompletionItemKind::OPERATOR) => CompletionKindGroup::Operator,
Some(_) => CompletionKindGroup::Other,
None => CompletionKindGroup::Unknown,
}
}
/// Иконка для типа элемента автодополнения.
///
/// Используется полный каталог иконок 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_group(kind) {
CompletionKindGroup::Function => Icon::SquareFunction,
CompletionKindGroup::Type => Icon::Box,
CompletionKindGroup::Variable => Icon::Variable,
CompletionKindGroup::Keyword => Icon::Type,
CompletionKindGroup::Module => Icon::Braces,
CompletionKindGroup::File => Icon::FileText,
CompletionKindGroup::Folder => Icon::Folder,
CompletionKindGroup::Snippet => Icon::Component,
CompletionKindGroup::Event => Icon::Activity,
CompletionKindGroup::TypeParameter => Icon::TypeOutline,
CompletionKindGroup::Operator => Icon::CaseSensitive,
CompletionKindGroup::Other => Icon::Circle,
CompletionKindGroup::Unknown => Icon::Sparkles,
}
}
/// Цвет иконки для типа элемента.
pub fn completion_icon_color(
kind: Option<CompletionItemKind>,
theme: &gpui_kit::component::Theme,
) -> Hsla {
match kind_group(kind) {
CompletionKindGroup::Function => theme.blue,
CompletionKindGroup::Type => theme.yellow,
CompletionKindGroup::Variable => theme.green,
CompletionKindGroup::Keyword => theme.magenta,
CompletionKindGroup::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 rope_word_start_matches_string_version() {
for text in ["let value", "obj.field", "obj.", "привет"] {
let rope = Rope::from(text);
for cursor in 0..=text.len() {
if text.is_char_boundary(cursor) {
assert_eq!(word_start_rope(&rope, cursor), word_start(text, cursor));
}
}
}
}
#[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");
}
}