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
+277 -3
View File
@@ -10,11 +10,16 @@
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use serde_json::Number;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
use crate::panel::{PanelHandle, PanelState};
thread_local! {
@@ -86,6 +91,8 @@ pub struct AppApi {
right_panel: PanelHandle,
#[lua(skip)]
bottom_panel: PanelHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
}
/// API одной панели окна.
@@ -145,6 +152,7 @@ impl AppApi {
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))),
lsp: Arc::new(LspManager::new()),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
@@ -181,6 +189,12 @@ impl AppApi {
self.bottom_panel.clone()
}
/// Менеджер LSP-серверов (для вида и тестов).
#[lua(skip)]
pub fn lsp_handle(&self) -> Arc<LspManager> {
self.lsp.clone()
}
/// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
@@ -236,10 +250,16 @@ impl AppApi {
#[lua(infallible)]
pub fn close_tab(&self, index: usize) -> bool {
let mut workspace = self.workspace.borrow_mut();
if index >= workspace.documents().len() {
let Some(document) = workspace.document(index) else {
return false;
}
};
let uri = document_uri(&document.name);
workspace.close(index);
drop(workspace);
if let Some(uri) = uri {
self.lsp.close_document(&uri);
}
true
}
@@ -254,7 +274,10 @@ impl AppApi {
let doc = workspace.active();
(doc.name.clone(), doc.content.clone())
};
std::fs::write(&path, content)?;
std::fs::write(&path, &content)?;
if let Some(uri) = document_uri(&path) {
self.lsp.did_save(&uri, &content);
}
self.workspace.borrow_mut().mark_active_saved();
Ok(())
}
@@ -311,6 +334,34 @@ impl AppApi {
workspace.active_index()
}
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
let path = path.as_ref();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err),
};
let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut();
Ok(workspace.open_file(name, content, language))
}
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String) -> mlua::Result<usize> {
self.open(&path)
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет.
@@ -423,4 +474,227 @@ impl AppApi {
pub fn is_maximized(&self) -> bool {
WINDOW_MAXIMIZED.with(Cell::get)
}
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
#[lua(skip)]
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
self.lsp.start(config)
}
/// Запускает LSP-сервер.
///
/// Пример из Lua:
///
/// ```lua
/// app:run_lsp({
/// name = "gopls",
/// cmd = "/usr/local/bin/gopls",
/// args = { "serve" },
/// env = { GOFLAGS = "-mod=mod" },
/// languages = { "go" },
/// watcher = "**/*.go",
/// initialization_options = { usePlaceholders = true },
/// })
/// ```
///
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
/// `initialize` превращается в ошибку Lua.
#[lua(name = "run_lsp")]
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
let config = parse_lsp_config(&config)?;
self.run_lsp(config)
.map(|()| true)
.map_err(mlua::Error::RuntimeError)
}
/// Имена всех запущенных LSP-серверов.
#[lua(infallible)]
pub fn lsp_servers(&self) -> Vec<String> {
self.lsp.server_names()
}
/// Останавливает LSP-сервер по имени.
///
/// Возвращает `false`, если сервера с таким именем не было.
#[lua(infallible)]
pub fn stop_lsp(&self, name: String) -> bool {
self.lsp.stop(&name)
}
/// Перезапускает LSP-сервер с прежними настройками.
///
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
#[lua(infallible)]
pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name)
}
}
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
let name: String = table
.get("name")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
let command = ["cmd", "command", "path", "binary"]
.iter()
.find_map(|key| table.get::<String>(*key).ok())
.ok_or_else(|| {
mlua::Error::RuntimeError(
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
)
})?;
let args = match table.get::<mlua::Table>("args") {
Ok(args) => args
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?,
Err(_) => Vec::new(),
};
let env = match table.get::<mlua::Table>("env") {
Ok(env) => env
.pairs::<String, String>()
.collect::<mlua::Result<Vec<_>>>()?,
Err(_) => Vec::new(),
};
let root = table
.get::<String>("root")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let root = if root.is_absolute() {
root
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(root)
};
let root = std::fs::canonicalize(&root).unwrap_or(root);
let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(),
};
let initialization_options = ["initialization_options", "init_options"]
.iter()
.find_map(|key| match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)),
})
.transpose()?;
Ok(LspConfig {
name,
command,
args,
env,
root,
languages,
watcher: parse_watcher(table)?,
initialization_options,
})
}
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(value),
}
});
let Some(value) = value else {
return Ok(None);
};
match value {
mlua::Value::String(pattern) => Ok(Some(WatcherConfig {
patterns: vec![pattern.to_string_lossy()],
ignore: Vec::new(),
})),
mlua::Value::Boolean(true) => Ok(Some(WatcherConfig {
patterns: vec!["**/*".to_owned()],
ignore: Vec::new(),
})),
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
mlua::Value::Table(config) => {
let patterns = ["pattern", "patterns"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let ignore = ["ignore", "ignored"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
if patterns.is_empty() {
Ok(None)
} else {
Ok(Some(WatcherConfig { patterns, ignore }))
}
}
_ => Err(mlua::Error::RuntimeError(
"watcher должен быть строкой, таблицей или boolean".to_owned(),
)),
}
}
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
match value {
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
mlua::Value::Table(table) => table
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>(),
mlua::Value::Nil => Ok(Vec::new()),
_ => Err(mlua::Error::RuntimeError(
"ожидалась строка или массив строк".to_owned(),
)),
}
}
fn lua_to_json(value: mlua::Value) -> mlua::Result<serde_json::Value> {
match value {
mlua::Value::Nil => Ok(serde_json::Value::Null),
mlua::Value::Boolean(value) => Ok(serde_json::Value::Bool(value)),
mlua::Value::Integer(value) => Ok(serde_json::Value::Number(value.into())),
mlua::Value::Number(value) => Ok(Number::from_f64(value)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)),
mlua::Value::String(value) => Ok(serde_json::Value::String(value.to_string_lossy())),
mlua::Value::Table(table) => {
let length = table.raw_len();
if length > 0 {
let mut items = Vec::with_capacity(length);
for value in table.sequence_values::<mlua::Value>() {
items.push(lua_to_json(value?)?);
}
Ok(serde_json::Value::Array(items))
} else {
let mut map = serde_json::Map::new();
for pair in table.pairs::<mlua::Value, mlua::Value>() {
let (key, value) = pair?;
let key = match key {
mlua::Value::String(key) => key.to_string_lossy(),
mlua::Value::Integer(key) => key.to_string(),
mlua::Value::Number(key) => key.to_string(),
_ => continue,
};
map.insert(key, lua_to_json(value)?);
}
Ok(serde_json::Value::Object(map))
}
}
_ => Ok(serde_json::Value::Null),
}
}
+923 -10
View File
File diff suppressed because it is too large Load Diff
+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");
}
}
+179 -3
View File
@@ -1,5 +1,9 @@
//! Константы оформления окна и сценариев.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Ширина окна при запуске.
pub const WINDOW_WIDTH: f32 = 900.0;
@@ -33,11 +37,183 @@ pub const MIN_MAIN_AREA_WIDTH: f32 = 240.0;
/// Высота нижней панели при открытии.
pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
/// Каталог, в котором приложение ищет Lua-сценарии.
/// Каталог, в котором приложение ищет Lua-сценарии по умолчанию.
///
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение
/// ожидает `init.lua`.
/// Это текущий рабочий каталог процесса — именно там приложение ожидает
/// `init.lua`, если не передан аргумент `--config`. Конкретный каталог всегда
/// возвращает [`script_dir`].
pub const SCRIPT_DIR: &str = ".";
/// Каталог сценариев, заданный через `--config`.
static SCRIPT_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Файл, который нужно открыть при старте (первый позиционный аргумент).
static STARTUP_FILE: OnceLock<PathBuf> = OnceLock::new();
/// Разобранные аргументы командной строки.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommandLine {
/// Каталог, из которого брать `init.lua`.
pub config_dir: Option<PathBuf>,
/// Файл, открываемый при старте как `vim file.go`.
pub file: Option<PathBuf>,
}
/// Каталог, из которого загружаются `init.lua`, `include` и `require`.
///
/// Возвращает значение `--config`, если оно было задано, иначе текущий
/// рабочий каталог.
pub fn script_dir() -> &'static Path {
SCRIPT_DIR_OVERRIDE
.get()
.map_or_else(|| Path::new(SCRIPT_DIR), PathBuf::as_path)
}
/// Файл, который надо открыть при старте редактора.
pub fn startup_file() -> Option<&'static Path> {
STARTUP_FILE.get().map(PathBuf::as_path)
}
/// Запоминает каталог сценариев из аргумента `--config`.
///
/// Путь канонизируется, поэтому относительный `--config ./scripts` работает
/// относительно каталога запуска, а `include`/`require` видят абсолютные пути.
pub fn set_script_dir(path: PathBuf) -> Result<(), String> {
let canonical = std::fs::canonicalize(&path).map_err(|err| {
format!(
"не удалось открыть каталог конфигурации {}: {err}",
path.display()
)
})?;
if !canonical.is_dir() {
return Err(format!(
"--config: {} не является каталогом",
canonical.display()
));
}
SCRIPT_DIR_OVERRIDE
.set(canonical)
.map_err(|_| "каталог конфигурации уже задан".to_owned())
}
/// Запоминает файл, открываемый при старте.
pub fn set_startup_file(path: PathBuf) -> Result<(), String> {
STARTUP_FILE
.set(path)
.map_err(|_| "файл для открытия уже задан".to_owned())
}
/// Разбирает аргументы командной строки.
///
/// Поддерживаются:
/// * `файл` — первый позиционный аргумент, как в `vim file.go`;
/// * `--config <каталог>` и `--config=<каталог>`;
/// * `--` — всё после него считается позиционным (для имён, начинающихся с `-`).
///
/// Неизвестные флаги игнорируются, чтобы запуск не ломался от лишних
/// аргументов окружения/обёрток.
pub fn parse_command_line<I>(args: I) -> Result<CommandLine, String>
where
I: IntoIterator<Item = OsString>,
{
let mut command_line = CommandLine::default();
let mut args = args.into_iter();
let mut positional_only = false;
while let Some(arg) = args.next() {
let text = arg.to_string_lossy();
if positional_only {
if command_line.file.is_none() {
command_line.file = Some(PathBuf::from(arg));
}
continue;
}
if text == "--" {
positional_only = true;
} else if text == "--config" {
let value = args
.next()
.ok_or_else(|| "--config требует путь к каталогу".to_owned())?;
command_line.config_dir = Some(PathBuf::from(value));
} else if let Some(value) = text.strip_prefix("--config=") {
if value.is_empty() {
return Err("--config требует путь к каталогу".to_owned());
}
command_line.config_dir = Some(PathBuf::from(value));
} else if !text.starts_with('-') && command_line.file.is_none() {
command_line.file = Some(PathBuf::from(arg));
}
}
Ok(command_line)
}
/// Имя стартового сценария, который выполняется при запуске приложения.
pub const STARTUP_SCRIPT: &str = "init.lua";
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn config_dir_is_read_from_both_argument_forms() {
assert_eq!(
parse_command_line(args(&["--config", "/tmp/project"]))
.unwrap()
.config_dir,
Some(PathBuf::from("/tmp/project"))
);
assert_eq!(
parse_command_line(args(&["--config=/tmp/project"]))
.unwrap()
.config_dir,
Some(PathBuf::from("/tmp/project"))
);
assert!(parse_command_line(args(&[])).unwrap().config_dir.is_none());
}
#[test]
fn config_dir_requires_a_value() {
assert!(parse_command_line(args(&["--config"])).is_err());
assert!(parse_command_line(args(&["--config="])).is_err());
}
#[test]
fn unknown_arguments_do_not_hide_the_config_dir() {
let parsed = parse_command_line(args(&["--verbose", "--config", "cfg"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
}
#[test]
fn first_positional_argument_is_the_file_to_open() {
let parsed = parse_command_line(args(&["main.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
let parsed = parse_command_line(args(&["--config", "cfg", "main.go"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
let parsed = parse_command_line(args(&["main.go", "--config=cfg"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
}
#[test]
fn double_dash_allows_file_names_starting_with_dash() {
let parsed = parse_command_line(args(&["--", "-weird.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("-weird.go")));
}
#[test]
fn only_the_first_positional_argument_is_opened() {
let parsed = parse_command_line(args(&["first.go", "second.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("first.go")));
}
}
+108
View File
@@ -75,6 +75,67 @@ impl Language {
Self::Python => "python",
}
}
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
///
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
/// `javascript`, а не `js`.
pub fn lsp_name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Go => "go",
Self::JavaScript => "javascript",
Self::Json => "json",
Self::Python => "python",
}
}
/// Определяет язык подсветки по имени файла.
///
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
/// подсветка включалась автоматически там, где для неё есть грамматика.
pub fn from_path(path: &str) -> Option<Self> {
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
match extension.as_str() {
"rs" => Some(Self::Rust),
"go" => Some(Self::Go),
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"py" => Some(Self::Python),
_ => None,
}
}
}
/// Язык LSP по расширению имени файла.
///
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
/// выбирается и получает `languageId` независимо от tree-sitter.
pub fn language_id_from_path(name: &str) -> &'static str {
let extension = name
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase());
match extension.as_deref() {
Some("rs") => "rust",
Some("go") => "go",
Some("js" | "mjs" | "cjs") => "javascript",
Some("jsx") => "javascriptreact",
Some("ts") => "typescript",
Some("tsx") => "typescriptreact",
Some("json") => "json",
Some("py") => "python",
Some("md" | "markdown") => "markdown",
Some("toml") => "toml",
Some("yaml" | "yml") => "yaml",
Some("lua") => "lua",
Some("c" | "h") => "c",
Some("cc" | "cpp" | "cxx" | "hpp" | "hh") => "cpp",
Some("sh" | "bash") => "shellscript",
Some("html" | "htm") => "html",
Some("css") => "css",
_ => "plaintext",
}
}
/// Открытый в редакторе документ.
@@ -115,6 +176,17 @@ impl Document {
self.content != self.saved_content
}
/// Идентификатор языка для LSP.
///
/// Если подсветка уже включена, берётся её язык. Иначе язык угадывается
/// по расширению имени вкладки — так LSP работает и без `set_language`.
pub fn lsp_language_id(&self) -> String {
if let Some(language) = self.language {
return language.lsp_name().to_owned();
}
language_id_from_path(&self.name).to_owned()
}
/// Помечает текущее содержимое сохранённым.
pub fn mark_saved(&mut self) {
self.saved_content = self.content.clone();
@@ -207,6 +279,42 @@ impl Workspace {
self.bump();
}
/// Открывает файл с диска как вкладку и делает её активной.
///
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
/// изменений, её содержимое обновляется из файла — повторный `app:open`
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
pub fn open_file(
&mut self,
name: impl Into<String>,
content: impl Into<String>,
language: Option<Language>,
) -> usize {
let name = name.into();
let content = content.into();
if let Some(index) = self
.documents
.iter()
.position(|document| document.name == name && !document.is_dirty())
{
let document = &mut self.documents[index];
document.content = content;
document.mark_saved();
document.language = language;
self.active = index;
self.bump();
return index;
}
let mut document = Document::new(name, content);
document.language = language;
self.documents.push(document);
self.active = self.documents.len() - 1;
self.bump();
self.active
}
/// Закрывает вкладку и выбирает соседнюю.
///
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
+1227
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -7,9 +7,11 @@
mod api;
mod app;
mod completion;
mod constants;
mod editor;
mod keymap;
mod lsp;
mod panel;
mod script;
@@ -23,7 +25,15 @@ use app::MyApp;
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
fn main() {
application().with_assets(assets::Assets).run(|cx| {
if let Err(err) = apply_command_line() {
eprintln!("{err}");
std::process::exit(2);
}
// `AllAssets` содержит полный каталог Lucide: автодополнение использует
// иконки (`square-function`, `variable`, `braces` и т. п.), которых нет
// в компактном наборе `Assets`.
application().with_assets(assets::AllAssets).run(|cx| {
// Инициализация темы, шрифтов и компонентов обязательна до создания окон.
init(cx);
@@ -34,6 +44,22 @@ fn main() {
});
}
/// Применяет аргументы запуска до создания окна.
///
/// `--config` задаёт каталог `init.lua`, а первый позиционный аргумент — файл,
/// который [`MyApp`] откроет через `app:open(path)` после выполнения сценария.
/// Рабочий каталог процесса и пути сохранения файлов не меняются.
fn apply_command_line() -> Result<(), String> {
let command_line = constants::parse_command_line(std::env::args_os().skip(1))?;
if let Some(directory) = command_line.config_dir {
constants::set_script_dir(directory)?;
}
if let Some(file) = command_line.file {
constants::set_startup_file(file)?;
}
Ok(())
}
/// Параметры окна в стиле исходного приложения: размер 900×600, минимум
/// 420×280 и собственный заголовок вместо системного.
fn window_options() -> WindowOptions {
+35 -13
View File
@@ -1,20 +1,24 @@
//! Выполнение Lua-сценариев приложения.
//!
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`SCRIPT_DIR`]
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//!
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`);
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`SCRIPT_DIR`]
//! * функция `include(path)` — выполняет другой Lua-файл относительно [`script_dir`]
//! (или по абсолютному пути) в общем глобальном окружении;
//! * стандартный `require`, который также ищет модули в [`SCRIPT_DIR`].
//! * стандартный `require`, который также ищет модули в [`script_dir`].
//!
//! Каталог по умолчанию — текущий рабочий каталог. Его можно переопределить
//! аргументом `--config <каталог>` при запуске приложения (см.
//! [`parse_command_line`](crate::constants::parse_command_line)).
use std::path::{Path, PathBuf};
use mlua::{Lua, Table};
use crate::api::{AppApi, TabInfo};
use crate::constants::{SCRIPT_DIR, STARTUP_SCRIPT};
use crate::constants::{STARTUP_SCRIPT, script_dir};
/// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
impl mlua::IntoLua for TabInfo {
@@ -29,24 +33,40 @@ impl mlua::IntoLua for TabInfo {
/// Движок Lua и состояние его глобального окружения.
pub struct ScriptEngine {
lua: Lua,
/// Каталог, относительно которого ищутся `init.lua`, `include` и `require`.
script_dir: PathBuf,
}
impl ScriptEngine {
/// Создаёт движок и публикует в нём API приложения.
///
/// Каталог сценариев берётся из [`script_dir`] (то есть из `--config`
/// или текущего рабочего каталога).
pub fn new(api: AppApi) -> mlua::Result<Self> {
let engine = Self { lua: Lua::new() };
Self::new_in(api, script_dir())
}
/// Создаёт движок с явно заданным каталогом сценариев.
///
/// Полезно тестам и будущим режимам, где каталог конфигурации приходит
/// не из глобального `--config`.
pub fn new_in(api: AppApi, directory: impl AsRef<Path>) -> mlua::Result<Self> {
let engine = Self {
lua: Lua::new(),
script_dir: directory.as_ref().to_path_buf(),
};
engine.expose_app_api(api)?;
engine.configure_require()?;
Ok(engine)
}
/// Выполняет стартовый сценарий из [`SCRIPT_DIR`].
/// Выполняет стартовый сценарий из каталога движка.
///
/// # Errors
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
/// есть синтаксическая/исполняемая ошибка.
pub fn run_startup(&self) -> mlua::Result<()> {
self.run_file(&script_path(STARTUP_SCRIPT))
self.run_file(&script_path(&self.script_dir, STARTUP_SCRIPT))
}
/// Выполняет Lua-файл в глобальном окружении движка.
@@ -73,7 +93,7 @@ impl ScriptEngine {
if let Ok(package) = globals.get::<Table>("package") {
let current: String = package.get("path").unwrap_or_default();
let dir = SCRIPT_DIR;
let dir = self.script_dir.display().to_string().replace('\\', "/");
let extra = format!("{dir}/?.lua;{dir}/?/init.lua");
let path = if current.is_empty() {
extra
@@ -85,10 +105,12 @@ impl ScriptEngine {
// include выполняет файл сразу, как dofile, но путь по умолчанию
// отсчитывается от каталога сценариев, а не от cwd.
let directory = self.script_dir.clone();
globals.set(
"include",
self.lua
.create_function(|lua, path: String| run_file(lua, &script_path(&path)))?,
self.lua.create_function(move |lua, path: String| {
run_file(lua, &script_path(&directory, &path))
})?,
)?;
Ok(())
@@ -96,13 +118,13 @@ impl ScriptEngine {
}
/// Путь к файлу сценария: абсолютные пути используются как есть, остальные
/// отсчитываются от [`SCRIPT_DIR`].
fn script_path(path: &str) -> PathBuf {
/// отсчитываются от каталога сценариев.
fn script_path(directory: &Path, path: &str) -> PathBuf {
let path = Path::new(path);
if path.is_absolute() {
path.to_path_buf()
} else {
Path::new(SCRIPT_DIR).join(path)
directory.join(path)
}
}
+294 -1
View File
@@ -7,7 +7,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use gpui_kit::{Keystroke, Modifiers, TestAppContext};
use gpui_kit::{Keystroke, Modifiers, TestAppContext, px};
use crate::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp;
@@ -21,6 +21,35 @@ fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>
(api, keymap)
}
#[test]
fn lua_lsp_api_lists_no_servers_and_validates_config() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let dir = temp_script_dir("lsp-api");
let script = dir.join("lsp.lua");
std::fs::write(
&script,
r#"
assert(#app:lsp_servers() == 0)
assert(app:stop_lsp("missing") == false)
assert(app:restart_lsp("missing") == false)
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end)
assert(not ok)
assert(tostring(err):find("cmd", 1, true), tostring(err))
"#,
)
.unwrap();
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("скрипт LSP API");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default();
@@ -327,6 +356,111 @@ fn lua_script_can_include_files_and_control_the_app() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_opens_file_from_disk_and_detects_language() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("open-file");
let path = dir.join("main.go");
std::fs::write(&path, "package main\n").unwrap();
let index = api.open(&path).expect("файл открыт");
{
let state = workspace.borrow();
assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy());
assert_eq!(state.active().content, "package main\n");
assert_eq!(state.active().language, Some(Language::Go));
assert!(!state.active().is_dirty());
}
// Повторное открытие чистого файла переиспользует вкладку.
let tabs = workspace.borrow().documents().len();
assert_eq!(api.open(&path).unwrap(), index);
assert_eq!(workspace.borrow().documents().len(), tabs);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn api_open_missing_file_creates_an_empty_buffer() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("open-missing");
let path = dir.join("new.go");
let index = api.open(&path).expect("новый буфер");
let state = workspace.borrow();
assert_eq!(index, state.active_index());
assert_eq!(state.active().name, path.to_string_lossy());
assert!(state.active().content.is_empty());
assert_eq!(state.active().language, Some(Language::Go));
assert!(!state.active().is_dirty());
drop(state);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_app_open_opens_a_file_and_returns_its_tab() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("lua-open");
let path = dir.join("main.go");
std::fs::write(&path, "package main\n").unwrap();
let script = dir.join("open.lua");
std::fs::write(
&script,
format!(
"local index = app:open({:?})\nassert(index == app:active_tab())\n",
path.to_string_lossy()
),
)
.unwrap();
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_file(&script).expect("app:open из Lua");
let state = workspace.borrow();
assert_eq!(state.active().name, path.to_string_lossy());
assert_eq!(state.active().content, "package main\n");
assert_eq!(state.active().language, Some(Language::Go));
drop(state);
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_engine_loads_init_from_a_custom_config_dir() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace.clone());
let dir = temp_script_dir("config-dir");
std::fs::write(
dir.join("helper.lua"),
"function config_value() app:set_active_text(\"custom config\") end\n",
)
.expect("helper.lua");
std::fs::write(
dir.join("init.lua"),
"include(\"helper.lua\")\nconfig_value()\n",
)
.expect("init.lua");
// --config меняет именно каталог поиска init.lua/include, а не cwd.
let engine = ScriptEngine::new_in(api, &dir).expect("движок Lua");
engine.run_startup().expect("init.lua из своего каталога");
assert_eq!(workspace.borrow().active().content, "custom config");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lua_key_bindings_match_press_release_and_modifiers() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
@@ -597,3 +731,162 @@ fn typing_into_editor_updates_active_document(cx: &mut TestAppContext) {
);
assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
}
#[gpui_kit::test]
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})
});
visual.run_until_parked();
visual.simulate_keystrokes("down");
visual.run_until_parked();
let (cursor, selected, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_selected(),
this.completion_is_open(),
)
})
});
assert_eq!(selected, 1, "стрелка вниз должна выбрать второй элемент");
assert_eq!(cursor, before, "пока список открыт, курсор не двигается");
assert!(open, "список должен остаться открытым");
// На последнем элементе выделение стоит на месте, но клавиша всё равно
// принадлежит списку и не должна двигать курсор.
visual.simulate_keystrokes("down");
visual.run_until_parked();
visual.simulate_keystrokes("up");
visual.run_until_parked();
let (cursor, selected, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_selected(),
this.completion_is_open(),
)
})
});
assert_eq!(selected, 0, "стрелка вверх должна вернуть первый элемент");
assert_eq!(cursor, before);
assert!(open);
visual.simulate_keystrokes("escape");
visual.run_until_parked();
let (cursor, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_is_open(),
)
})
});
assert!(!open, "Esc должен закрыть список");
assert_eq!(cursor, before);
}
#[gpui_kit::test]
fn long_completion_list_scrolls_instead_of_shrinking_rows(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion_with_count(60, cx);
});
});
visual.run_until_parked();
let scroll =
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_scroll_handle()));
assert!(
scroll.max_offset().y > px(0.),
"длинный список должен прокручиваться, а не сжимать строки"
);
}
#[gpui_kit::test]
fn completion_popup_above_the_caret_renders(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
visual.update(|window, cx| {
app.update(cx, |this, cx| {
let text = (0..200)
.map(|index| format!("let value_{index} = {index};\n"))
.collect::<String>();
this.editor_handle().update(cx, |state, cx| {
state.set_value(text.clone(), window, cx);
let end = text.len();
state.set_selected_range(end..end, cx);
state.focus(window, cx);
});
this.open_test_completion(cx);
});
});
// Первый кадр обновляет прокрутку редактора, второй — положение поповера,
// который должен оказаться над каретой.
visual.run_until_parked();
visual.run_until_parked();
assert!(
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_is_open())),
"поповер над каретой должен отрисоваться"
);
}
#[gpui_kit::test]
fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})
});
visual.run_until_parked();
visual.simulate_keystrokes("right");
visual.run_until_parked();
let (after, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_is_open(),
)
})
});
assert!(
after > before,
"горизонтальная стрелка двигает курсор как обычно"
);
assert!(!open, "горизонтальная стрелка закрывает список");
}