From 55c3955897e3a9fc7015f967e8c4380c99fd1215 Mon Sep 17 00:00:00 2001 From: "dedal.qq" Date: Fri, 30 Oct 2026 20:50:04 +0300 Subject: [PATCH] complition --- .gitignore | 1 + AGENTS.md | 128 +++++ Cargo.lock | 6 + Cargo.toml | 8 + DOCS.md | 221 ++++++++ init.lua | 72 ++- src/api.rs | 280 ++++++++++- src/app.rs | 933 +++++++++++++++++++++++++++++++++- src/completion.rs | 598 ++++++++++++++++++++++ src/constants.rs | 182 ++++++- src/editor.rs | 108 ++++ src/lsp.rs | 1227 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 28 +- src/script.rs | 48 +- src/tests.rs | 295 ++++++++++- 15 files changed, 4103 insertions(+), 32 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 DOCS.md create mode 100644 src/completion.rs create mode 100644 src/lsp.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..afb6e6c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md + +Instructions for AI coding agents working on this repository. Read this file +before changing code. The rules below are architectural requirements, not +suggestions. + +## Project shape + +| Path | Responsibility | +| --- | --- | +| `src/api.rs` | `AppApi` / `PanelApi` — the single control surface of the application | +| `src/script.rs` | Lua engine; publishes `app`, `include`, and `require` | +| `src/keymap.rs` | Key binding parsing (`KeyBinding`) and dispatch (`KeyMap`) | +| `src/app.rs` | GPUI view, rendering, event wiring | +| `src/editor.rs` | `Workspace`, documents, tabs | +| `src/panel.rs` | `PanelState` for left/right/bottom panels | +| `src/lsp.rs` | LSP server manager | +| `src/constants.rs` | CLI arguments and script directory | +| `init.lua` | Example startup script (also a usage reference) | +| `DOCS.md` | User-facing Lua API documentation | + +## 1. `AppApi` is the center of the application + +- `AppApi` (in `src/api.rs`) is the one object through which the application is + controlled. All operations on documents, tabs, panels, the window, syntax + highlighting, key bindings, and LSP go through it. +- Shared state is held as `Rc>` handles (workspace, keymap, panels, + LSP manager). The UI and `AppApi` must observe the *same* instances; never + create a second copy of the state. +- Rust code that needs to act on the application should call + `AppApi::global()` (or an `AppApi` instance passed in) instead of mutating + `Workspace`, `PanelState`, `KeyMap`, or `LspManager` directly. +- Methods that are implementation details of `AppApi` stay private or are + marked `#[lua(skip)]`. Do not add a second, parallel control path. + +## 2. Every `AppApi` capability is available from Lua + +- Everything in the `impl AppApi` block annotated with + `#[mlua::userdata_impl]` is automatically exposed as `app:method(...)`. +- When adding functionality, add it to `AppApi` and expose it to Lua at the same + time. An operation that a user can trigger in the UI must also be callable + from a Lua script. +- Use `#[lua(infallible)]` where the method cannot fail, and return + `mlua::Result` / convert errors to `mlua::Error::RuntimeError` otherwise. +- Generic Rust methods are not exported. Add a thin non-generic Lua wrapper, + following the existing pattern: `save` / `lua_save`, `open` / `lua_open`, + `set_active_text` / `lua_set_active_text`, `set_language` / + `lua_set_language`. +- Use `#[lua(name = "...")]` when the Lua name should differ from the Rust + name (for example `active_tab_index` → `active_tab`). +- Lua API naming: lower `snake_case`, tabs and indices are zero-based, panels + are accessed as `app.left_panel`, `app.right_panel`, `app.bottom_panel`. + +## 3. All UI actions and keyboard shortcuts go through the API + +### UI actions + +- UI event handlers and rendered controls must invoke `AppApi` methods, not + manipulate state directly. Examples: + - window close button → `app:close()` (i.e. `AppApi::close`) + - minimize button → `app:minimize()` + - maximize / restore → `app:toggle_maximize()` or + `app:set_maximized(bool)`; read the current state with `app:is_maximized()` + - tab close button → `app:close_tab(index)` + - "new tab" button → `app:open_tab()` + - panel toggles → `app.right_panel:toggle()` and friends +- Rendering code may *read* state (for example `workspace.borrow()` to draw the + active document), but must not mutate it. Mutations belong to `AppApi`. +- Window operations are queued as `WindowCommand`s and applied by + `apply_window_commands`; keep this mechanism as the only way the UI and Lua + request window changes. +- If an external component (for example `gpui_kit::component::TitleBar`) + performs an action internally, do not leave it as a separate control path: + intercept the action or replace the component so that it calls `AppApi`. + +### Keyboard shortcuts + +- Every key binding must be registered through `AppApi::map_key` / + `KeyMap`. There must be no hard-coded shortcuts that scripts cannot inspect, + override, or remove. +- Existing hard-coded checks such as `is_save_shortcut` and + `is_completion_shortcut` in `src/app.rs` are technical debt: when touching + this area, move those defaults into the `KeyMap` mechanism so they can be + rebound from Lua. +- Default bindings should be installed through the same mechanism that scripts + use (or a documented equivalent), so that `app:map_key(...)` can replace them. + Rebinding the same chord must replace the previous handler. +- Dispatch order matters: the key interceptor runs before key bindings, and + `dispatch_keymap` is used for both key down and key up (`<...>` vs `!key`). + Preserve this behavior. +- Any `KeyMap` change must keep the documented chord syntax working: + `a`, `1`, `!a`, ``, ``, ``, named keys (case-insensitive). +- Editor text input itself is not a shortcut, but all app-level commands + (save, close, panels, completion popup navigation, etc.) must be remappable. + +## 4. Keep `DOCS.md` in sync + +`DOCS.md` is the source of truth for the Lua API. In the **same change** that +modifies the public surface, update the docs: + +- added/removed/renamed `AppApi` or `PanelApi` methods; +- changed arguments, return values, or error behavior; +- new supported languages, LSP configuration fields, CLI flags, or + `include`/`require` behavior; +- changed default shortcuts or their syntax. + +Also update the usage comments in `init.lua` when they describe behavior that +changed. Documentation must describe the code as it actually behaves — no +aspirational APIs. + +## Conventions + +- Keep changes focused and consistent with the existing style; comments and + user-facing documentation are written in Russian. +- Rust API docs use `///`; write them for every new public method. +- Validate with `cargo fmt` and `cargo test`, then `cargo build` if behavior + changed. Do not commit unless explicitly asked. + +## Checklist + +Before finishing a change, verify: + +- [ ] The new behavior is implemented in `AppApi`, not in a UI handler. +- [ ] The operation is callable from Lua and has a documented signature. +- [ ] No new hard-coded keyboard shortcut bypasses `KeyMap`. +- [ ] UI controls call the corresponding `AppApi` method. +- [ ] `DOCS.md` (and `init.lua` comments if needed) are updated. +- [ ] `cargo fmt` and `cargo test` pass (or explain why they could not run). diff --git a/Cargo.lock b/Cargo.lock index 91c4896..d1af83f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2181,8 +2181,14 @@ dependencies = [ name = "gpui-editor-test" version = "0.1.0" dependencies = [ + "anyhow", + "globset", "gpui-kit", + "lsp-types", "mlua", + "notify", + "parking_lot", + "serde_json", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 17ff95b..6fa3329 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,14 @@ gpui-kit = { version = "0.8.1", features = [ ] } mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] } +# LSP-клиент: JSON-RPC поверх stdio, отслеживание файлов и типы протокола. +anyhow = "1" +lsp-types = "0.97" +notify = "8.2" +globset = "0.4" +parking_lot = "0.12" +serde_json = "1" + [dev-dependencies] gpui-kit = { version = "0.8.1", features = ["test-support"] } diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..a031f0b --- /dev/null +++ b/DOCS.md @@ -0,0 +1,221 @@ +# Lua API + +Приложение выполняет `init.lua` при запуске и предоставляет сценариям объект `app` +для управления окном, вкладками, подсветкой и LSP-серверами. + +## Запуск и каталог сценариев + +Каталог поиска `init.lua` задаётся константой `SCRIPT_DIR` в `src/constants.rs` +(по умолчанию — текущий каталог). Его можно переопределить аргументом командной строки: + +```sh +gpui-editor-test --config /path/to/project main.go +cargo run -- --config ./config +cargo run -- main.go +``` + +- В каталоге `--config` должны лежать `init.lua` и подключаемые им модули. +- Первый позиционный аргумент открывается после выполнения `init.lua` + через `app:open(path)` — как `vim main.go` или `nano main.go`. + +## Объект `app` + +Номера вкладок во всех методах нумеруются с нуля. + +### Вкладки + +| Метод | Описание | +| --- | --- | +| `app:tabs()` | Список вкладок: `{{ index = 0, title = "main.rs" }, ...}` | +| `app:active_tab()` | Индекс активной вкладки | +| `app:set_active_tab(index)` | Сделать вкладку активной; `true`, если вкладка существует | +| `app:next_tab()` | Переключить на следующую вкладку, вернуть индекс | +| `app:previous_tab()` | Переключить на предыдущую вкладку, вернуть индекс | +| `app:open_tab()` | Открыть пустую вкладку, вернуть индекс | +| `app:open(path)` | Открыть файл с диска, вернуть индекс вкладки. Если файла нет — открывается пустой буфер с таким именем | +| `app:close_tab(index)` | Закрыть вкладку; `true`, если вкладка закрыта | + +### Текст + +| Метод | Описание | +| --- | --- | +| `app:active_text()` | Текст активной вкладки | +| `app:set_active_text(text)` | Заменить текст активной вкладки | + +### Подсветка синтаксиса + +Подсветка по умолчанию выключена. Поддерживаемые языки: `rust`, `go`, `js`, `json`, +`python`. + +| Метод | Описание | +| --- | --- | +| `app:set_language(index, "rust")` | Включить подсветку для вкладки; `false`, если вкладки нет или язык не поддерживается | +| `app:language(index)` | Язык подсветки вкладки или `nil` | +| `app:clear_language(index)` | Выключить подсветку для вкладки | + +```lua +app:set_language(0, "rust") +``` + +### Сохранение + +| Метод | Описание | +| --- | --- | +| `app:save()` | Сохранить активную вкладку в её файл | + +### Привязка клавиш + +`app:map_key(keys, fn)` назначает обработчик на нажатие или отпускание клавиши +(синтаксис в духе nvim): + +| Запись | Значение | +| --- | --- | +| `"a"` | нажатие A | +| `"1"` | нажатие 1 | +| `"!a"` | отпускание A | +| `""` | C при зажатом Shift | +| `""` | Z при зажатом Ctrl | +| `""` | отпускание R при зажатом Alt | + +Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux +и Windows буквы берутся по аппаратному keycode, поэтому `` работает +и на русской раскладке. + +```lua +app:map_key("", function() app:save() end) +app:map_key("", function() app:close() end) +app:map_key("", function() app:close() end) +app:map_key("", function() app.right_panel:toggle() end) +app:map_key("", function() app.bottom_panel:toggle() end) +``` + +### Окно + +| Метод | Описание | +| --- | --- | +| `app:close()` | Закрыть окно | +| `app:toggle_maximize()` | Развернуть окно / вернуть прежний размер | +| `app:set_maximized(true/false)` | Развернуть (`true`) или восстановить (`false`) окно | +| `app:minimize()` | Свернуть окно | +| `app:is_maximized()` | Развёрнуто ли окно прямо сейчас | + +## LSP-серверы + +Запуск серверов целиком настраивается Lua-сценарием. + +```lua +app:run_lsp({ + name = "gopls", -- условное имя для stop/restart + cmd = "gopls", -- путь к бинарнику или имя в PATH + args = { "serve" }, -- аргументы процесса + env = { GOFLAGS = "-mod=mod" }, -- переменные окружения + languages = { "go" }, -- какие languageId обслуживает + watcher = "**/*.go", -- шаблон файлового вотчера + root = ".", -- корень workspace (по умолчанию cwd) + initialization_options = {}, -- initializationOptions сервера +}) +``` + +| Метод | Описание | +| --- | --- | +| `app:run_lsp(config)` | Запустить сервер по описанию | +| `app:lsp_servers()` | Имена запущенных серверов: `{ "gopls", ... }` | +| `app:stop_lsp("gopls")` | Остановить сервер; `true`, если сервер был запущен | +| `app:restart_lsp("gopls")` | Перезапустить сервер с прежними настройками | + +Поле `watcher` принимает строку или таблицу: + +```lua +watcher = "**/*.go" +watcher = { pattern = "**/*.go", ignore = { "vendor/**" } } +``` + +Автодополнение включается само при вводе идентификатора; `Ctrl+Space` открывает +список вручную. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` перемещают +выделение (курсор при этом стоит на месте), `Enter` или `Tab` вставляют выбранный +элемент, `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` закрывают +список и работают с курсором как обычно. Документация выбранного элемента +показывается второй всплывающей панелью справа от списка. + +Примеры запуска: + +```lua +app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, + languages = { "go" }, watcher = "**/*.go" }) + +app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", + languages = { "rust" }, watcher = "**/*.rs" }) +``` + +Пример из `init.lua` с защитой от ошибки запуска: + +```lua +local gopls_ok, gopls_err = pcall(function() + app:run_lsp({ + name = "gopls", + cmd = "gopls", + args = { "serve" }, + root = ".", + languages = { "go" }, + watcher = { pattern = "**/*.go", ignore = { "vendor/**" } }, + initialization_options = { + usePlaceholders = true, + completeUnimported = true, + }, + }) +end) +if not gopls_ok then + print("[lsp] gopls не запущен: " .. tostring(gopls_err)) +end +``` + +> `--config` меняет только каталог `init.lua`, поэтому `root = "."` указывает +> на текущий каталог запуска — проект должен быть текущим рабочим каталогом. + +## Панели окна + +По умолчанию панели закрыты, размер меняется перетаскиванием. +Для `app.left_panel`, `app.right_panel` и `app.bottom_panel` доступны одинаковые методы: + +| Метод | Описание | +| --- | --- | +| `app.left_panel:open()` | Открыть панель | +| `app.left_panel:close()` | Закрыть панель | +| `app.left_panel:toggle()` | Переключить панель; вернуть `true`, если панель открыта | +| `app.left_panel:is_open()` | Открыта ли панель | + +```lua +app.left_panel:open() +app.right_panel:toggle() +app.bottom_panel:close() +``` + +## Подключение других Lua-файлов + +Файлы подключаются из каталога сценариев: + +| Способ | Описание | +| --- | --- | +| `include("tools.lua")` | Выполнить файл сразу | +| `require("tools")` | Выполнить один раз (как обычный Lua-модуль) | + +## Добавление новых методов + +Методы добавляются в `src/api.rs`: всё, что есть в `impl AppApi`, помеченном +`#[mlua::userdata_impl]`, автоматически доступно из Lua. +Чтобы не экспортировать метод, его помечают `#[lua(skip)]`. + +## Полный пример `init.lua` + +```lua +print("[lua] init.lua выполнен") + +app:set_language(0, "rust") +app:next_tab() + +app:map_key("", function() app:close() end) +app:map_key("", function() app.right_panel:toggle() end) +app:map_key("", function() app.bottom_panel:toggle() end) + +app:open_tab() +``` diff --git a/init.lua b/init.lua index c1aab30..c1c91ee 100644 --- a/init.lua +++ b/init.lua @@ -1,7 +1,16 @@ -- Стартовый сценарий приложения. -- -- Приложение выполняет этот файл при запуске. Каталог поиска задаётся --- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог). +-- константой SCRIPT_DIR в src/constants.rs (по умолчанию — текущий каталог), +-- но его можно переопределить аргументом командной строки: +-- +-- gpui-editor-test --config /path/to/project main.go +-- cargo run -- --config ./config +-- cargo run -- main.go +-- +-- В каталоге --config должны лежать init.lua и подключаемые им модули. +-- Первый позиционный аргумент открывается после выполнения этого файла +-- через app:open(path) — как `vim main.go` или `nano main.go`. print("[lua] init.lua выполнен") @@ -12,6 +21,8 @@ print("[lua] init.lua выполнен") -- app:next_tab() -- переключить на следующую, вернуть индекс -- app:previous_tab() -- переключить на предыдущую, вернуть индекс -- app:open_tab() -- открыть пустую вкладку, вернуть индекс +-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки; +-- -- если файла нет — пустой буфер с таким именем -- app:close_tab(index) -- true, если вкладка закрыта -- app:active_text() -- текст активной вкладки -- app:set_active_text(text) -- заменить текст активной вкладки @@ -37,6 +48,36 @@ print("[lua] init.lua выполнен") -- app:minimize() -- app:is_maximized() -- +-- LSP-серверы (запуск целиком настраивается этим сценарием): +-- app:run_lsp({ +-- name = "gopls", -- условное имя для stop/restart +-- cmd = "gopls", -- путь к бинарнику или имя в PATH +-- args = { "serve" }, -- аргументы процесса +-- env = { GOFLAGS = "-mod=mod" }, -- переменные окружения +-- languages = { "go" }, -- какие languageId обслуживает +-- watcher = "**/*.go", -- шаблон файлового вотчера +-- root = ".", -- корень workspace (по умолчанию cwd) +-- initialization_options = {}, -- initializationOptions сервера +-- }) +-- app:lsp_servers() -- { "gopls", ... } +-- app:stop_lsp("gopls") -- true, если сервер был запущен +-- app:restart_lsp("gopls") -- перезапуск с прежними настройками +-- +-- Поле watcher принимает строку или таблицу: +-- watcher = "**/*.go" +-- watcher = { pattern = "**/*.go", ignore = { "vendor/**" } } +-- +-- Автодополнение включается само при вводе идентификатора; Ctrl+Space +-- открывает список вручную. Пока список открыт, ↑/↓ и PageUp/PageDown +-- перемещают выделение, Enter или Tab вставляют элемент, Esc закрывает +-- список; горизонтальные стрелки закрывают его и двигают курсор. +-- Документация выбранного элемента показывается второй всплывающей панелью +-- справа от списка. Примеры запуска: +-- app:run_lsp({ name = "gopls", cmd = "gopls", args = { "serve" }, +-- languages = { "go" }, watcher = "**/*.go" }) +-- app:run_lsp({ name = "rust-analyzer", cmd = "rust-analyzer", +-- languages = { "rust" }, watcher = "**/*.rs" }) +-- -- Панели окна (по умолчанию закрыты, размер меняется перетаскиванием): -- app.left_panel:open() -- app.left_panel:close() @@ -64,6 +105,33 @@ print("[lua] init.lua выполнен") app:set_language(0, "rust") app:next_tab() +-- gopls запускается автоматически при старте редактора. +-- Сервер обслуживает только документы с languageId "go" и получает изменения +-- всех .go-файлов проекта (кроме vendor/) через файловый вотчер. +local gopls_ok, gopls_err = pcall(function() + app:run_lsp({ + name = "gopls", + cmd = "gopls", + args = { "serve" }, + -- Рабочий каталог gopls. --config меняет только каталог init.lua, + -- поэтому проект должен быть текущим каталогом запуска. + root = ".", + languages = { "go" }, + watcher = { + pattern = "**/*.go", + ignore = { "vendor/**" }, + }, + -- gopls читает эти параметры из initializationOptions. + initialization_options = { + usePlaceholders = true, + completeUnimported = true, + }, + }) +end) +if not gopls_ok then + print("[lsp] gopls не запущен: " .. tostring(gopls_err)) +end + -- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно. -- app:map_key("", function() app:save() end) -- app:map_key("", function() app:close() end) @@ -72,3 +140,5 @@ app:map_key("", function() app:close() end) app:map_key("", function() app.right_panel:toggle() end) app:map_key("", function() app.bottom_panel:toggle() end) + +app:open_tab() diff --git a/src/api.rs b/src/api.rs index 59be0a5..b8ccdbf 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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, } /// 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 { + 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) -> std::io::Result { + 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 { + 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 { + 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 { + 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 { + 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::(*key).ok()) + .ok_or_else(|| { + mlua::Error::RuntimeError( + "в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(), + ) + })?; + + let args = match table.get::("args") { + Ok(args) => args + .sequence_values::() + .collect::>>()?, + Err(_) => Vec::new(), + }; + + let env = match table.get::("env") { + Ok(env) => env + .pairs::() + .collect::>>()?, + Err(_) => Vec::new(), + }; + + let root = table + .get::("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::("languages") { + Ok(value) => lua_strings(value)?, + Err(_) => Vec::new(), + }; + + let initialization_options = ["initialization_options", "init_options"] + .iter() + .find_map(|key| match table.get::(*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> { + let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| { + match table.get::(*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::(*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::(*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> { + match value { + mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]), + mlua::Value::Table(table) => table + .sequence_values::() + .collect::>>(), + mlua::Value::Nil => Ok(Vec::new()), + _ => Err(mlua::Error::RuntimeError( + "ожидалась строка или массив строк".to_owned(), + )), + } +} + +fn lua_to_json(value: mlua::Value) -> mlua::Result { + 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::() { + 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::() { + 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), + } } diff --git a/src/app.rs b/src/app.rs index 5e5c84f..dc83657 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,22 +6,34 @@ //! `render`, когда меняется [`Workspace::revision`]. use std::cell::RefCell; +use std::collections::HashMap; +use std::ops::Range; use std::rc::Rc; +use std::time::Duration; +use gpui_kit::assets::IconName as AssetIcon; use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::input::{Editor, EditorState, InputEvent, TabSize}; +use gpui_kit::component::input::{Editor, EditorState, HoverProvider, InputEvent, Rope, TabSize}; use gpui_kit::component::resizable::HandleEdge; use gpui_kit::component::tab::{Tab, TabBar}; +use gpui_kit::component::text::TextView; use gpui_kit::component::{ - ActiveTheme, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable, resizable_panel, - resize_handle, v_resizable, + ActiveTheme, Icon, IconName, Sizable, StyledExt, TitleBar, h_flex, h_resizable, + resizable_panel, resize_handle, v_resizable, }; use gpui_kit::*; +use lsp_types::{CompletionItem, Hover, TextEdit}; use crate::api::{self, AppApi, WindowCommand}; +use crate::completion::{ + CaretPopup, POPUP_MARGIN, completion_edit, completion_icon, completion_icon_color, + completion_kind_name, documentation_text, fallback_documentation, highlight_range, item_key, + parse_completion_response, range_for_lsp, word_start, +}; use crate::constants::{MIN_LEFT_PANEL_WIDTH, MIN_MAIN_AREA_WIDTH, TAB_SIZE, WINDOW_TITLE}; use crate::editor::Workspace; use crate::keymap::KeyMap; +use crate::lsp::{LspManager, document_uri, position_to_offset}; use crate::panel::PanelHandle; use crate::script::ScriptEngine; @@ -41,6 +53,75 @@ impl Render for PanelDragPreview { } } +/// Сколько строк списка рисуем за один кадр. +/// +/// Ответы некоторых серверов содержат тысячи элементов; рисуем только +/// начало списка, а выделение не выходит за эти строки. +const MAX_COMPLETION_ROWS: usize = 300; + +/// Сколько строк пролистывают PageUp и PageDown. +const COMPLETION_PAGE_ROWS: isize = 10; + +/// Всплывающий список автодополнения и выбранный в нём элемент. +#[derive(Default)] +struct CompletionUi { + open: bool, + items: Vec, + selected: usize, + start_offset: usize, + query: String, + /// Номер запроса: ответы на устаревшие запросы отбрасываются. + generation: u64, + /// Прокрутка списка: выделение всегда прокручивается в видимую часть. + scroll: ScrollHandle, +} + +/// Документация выбранного элемента для второй всплывающей панели. +#[derive(Default)] +struct DocsUi { + /// Кэш-ключ элемента, к которому относится документация. + key: String, + label: String, + detail: String, + kind: String, + text: String, +} + +/// Провайдер `textDocument/hover` для встроенного попапа редактора. +/// +/// Сам список автодополнения рисуется приложением (см. +/// [`MyApp::render_completion_popup`]), а наведение мыши оставляем +/// встроенному механизму gpui-base: он уже умеет показывать Markdown +/// у символа под курсором. +struct LspHoverProvider { + manager: std::sync::Arc, +} + +impl HoverProvider for LspHoverProvider { + fn hover( + &self, + _text: &Rope, + offset: usize, + _window: &mut Window, + cx: &mut App, + ) -> Task>> { + let manager = self.manager.clone(); + let receiver = match manager.hover(offset) { + Ok(receiver) => receiver, + Err(_) => return Task::ready(Ok(None)), + }; + + cx.background_executor().spawn(async move { + match crate::lsp::wait_response(receiver, Duration::from_secs(5)) { + Ok(value) => { + serde_json::from_value::>(value).map_err(anyhow::Error::from) + } + Err(_) => Ok(None), + } + }) + } +} + /// Приложение текстового редактора. pub struct MyApp { /// Открытые документы, вкладки и активная вкладка. @@ -72,11 +153,29 @@ pub struct MyApp { focused_once: bool, /// Подписка на изменения редактора; держим её, пока жив вид. _editor_subscription: Subscription, + /// Глобальный перехватчик клавиш: он срабатывает раньше действий + /// редактора и закрывает автодополнение на стрелках, не мешая курсору. + _key_interceptor: Subscription, /// Движок Lua, созданный при старте. /// /// Хранится, чтобы глобальное окружение и загруженные модули жили, пока /// работает приложение. scripts: Option, + /// Запущенные LSP-серверы. + lsp: std::sync::Arc, + /// Состояние всплывающего списка автодополнения. + completion: CompletionUi, + /// Документация выбранного элемента для всплывающей панели рядом + /// со списком автодополнения. + docs: DocsUi, + /// Документация, полученная через `completionItem/resolve`. + docs_cache: HashMap, + /// Номер запроса документации: старые ответы не должны перебивать новые. + docs_generation: u64, + /// Пока это поле заполнено, изменения текста от вставки автодополнения + /// (их может быть несколько из-за `additionalTextEdits`) не должны + /// переоткрывать список. Очищается после финального текста или в `defer`. + suppress_completion_for_text: Option, } impl MyApp { @@ -87,6 +186,8 @@ impl MyApp { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let workspace = Rc::new(RefCell::new(Workspace::default())); let keymap = Rc::new(RefCell::new(KeyMap::default())); + let api = AppApi::install(workspace.clone(), keymap.clone()); + let lsp = api.lsp_handle(); let initial_text = workspace.borrow().active().content.clone(); let editor = cx.new(|cx| { @@ -101,22 +202,45 @@ impl MyApp { .default_value(initial_text) }); + // Наведение мыши показывает Markdown-документацию символа. Провайдер + // живёт вместе с видом и сам решает, есть ли сейчас LSP-сервер. + let hover_provider: Rc = Rc::new(LspHoverProvider { + manager: lsp.clone(), + }); + editor.update(cx, |state, cx| { + state.lsp_mut().hover_provider = Some(hover_provider); + state.refresh(cx); + }); + // Любая правка в виджете сразу попадает в модель. Ревизию при этом не // трогаем: редактор уже показывает этот текст, и обратная // синхронизация сбросила бы курсор после каждого символа. let _editor_subscription = cx.subscribe_in( &editor, window, - |this, _editor, event: &InputEvent, _window, cx| { + |this, _editor, event: &InputEvent, window, cx| { if matches!(event, InputEvent::Change) { let text = this.editor.read(cx).value().to_string(); + let cursor = this.editor.read(cx).cursor(); let mut workspace = this.workspace.borrow_mut(); let was_dirty = workspace.active().is_dirty(); - workspace.replace_active_content(text); + workspace.replace_active_content(text.clone()); // Индикатор на вкладке меняется только при переходе // «чисто → грязно» (или обратно после отмены правки). - if was_dirty != workspace.active().is_dirty() { - drop(workspace); + let dirty_changed = was_dirty != workspace.active().is_dirty(); + drop(workspace); + + if let Some(expected) = this.suppress_completion_for_text.as_deref() { + if expected == text { + this.suppress_completion_for_text = None; + } + return; + } + + this.sync_lsp_document(); + this.on_editor_changed(&text, cursor, window, cx); + + if dirty_changed { cx.notify(); } } @@ -124,11 +248,25 @@ impl MyApp { ); let displayed_id = workspace.borrow().active().id; - let api = AppApi::install(workspace.clone(), keymap.clone()); let left_panel = api.left_panel_handle(); let right_panel = api.right_panel_handle(); let bottom_panel = api.bottom_panel_handle(); + // `capture_key_down` на корне вызывается уже после обработки action, + // поэтому для стрелок он не успевал. Interceptor срабатывает до + // key bindings: можно закрыть список и не мешать движению курсора. + let view = cx.weak_entity(); + let _key_interceptor = cx.intercept_keystrokes(move |event, window, cx| { + let handled = view + .update(cx, |this, cx| { + this.handle_completion_key(&event.keystroke, window, cx) + }) + .unwrap_or(false); + if handled { + cx.stop_propagation(); + } + }); + Self { workspace, keymap, @@ -140,7 +278,14 @@ impl MyApp { displayed_id, focused_once: false, _editor_subscription, + _key_interceptor, scripts: None, + lsp, + completion: CompletionUi::default(), + docs: DocsUi::default(), + docs_cache: HashMap::new(), + docs_generation: 0, + suppress_completion_for_text: None, } } @@ -158,6 +303,15 @@ impl MyApp { } Err(err) => eprintln!("Lua: не удалось создать движок: {err}"), } + + // Первый позиционный аргумент запуска ведёт себя как `vim file.go`: + // открываем его тем же API-методом, который доступен из Lua. + if let Some(path) = crate::constants::startup_file() + && let Err(err) = AppApi::global().open(path) + { + eprintln!("Не удалось открыть {}: {err}", path.display()); + } + self } @@ -173,6 +327,736 @@ impl MyApp { self.editor.clone() } + /// Открывает тестовый список автодополнения без LSP (доступ для тестов). + #[cfg(test)] + pub fn open_test_completion(&mut self, cx: &mut Context) { + self.open_test_completion_with_count(2, cx); + } + + /// Открывает тестовый список из `count` элементов (доступ для тестов). + #[cfg(test)] + pub fn open_test_completion_with_count(&mut self, count: usize, cx: &mut Context) { + let cursor = self.editor.read(cx).cursor(); + self.completion.open = true; + self.completion.items = (0..count) + .map(|index| CompletionItem { + label: format!("test_{index}"), + detail: Some("test".to_owned()), + ..Default::default() + }) + .collect(); + self.completion.selected = 0; + self.completion.start_offset = cursor; + self.completion.query.clear(); + self.completion.scroll.scroll_to_item(0); + self.update_docs_for_selection(cx); + cx.notify(); + } + + /// Прокрутка списка автодополнения (доступ для тестов). + #[cfg(test)] + pub fn completion_scroll_handle(&self) -> ScrollHandle { + self.completion.scroll.clone() + } + + /// Открыт ли сейчас список автодополнения (доступ для тестов). + #[cfg(test)] + pub fn completion_is_open(&self) -> bool { + self.completion.open + } + + /// Индекс выделенного элемента списка (доступ для тестов). + #[cfg(test)] + pub fn completion_selected(&self) -> usize { + self.completion.selected + } + + /// Отдаёт активный документ LSP-серверам, которые его обслуживают. + fn sync_lsp_document(&self) { + let workspace = self.workspace.borrow(); + let document = workspace.active(); + if let Some(uri) = document_uri(&document.name) { + self.lsp + .sync_document(uri, document.lsp_language_id(), &document.content); + } + } + + /// Решает, нужно ли после правки запросить автодополнение. + fn on_editor_changed( + &mut self, + text: &str, + cursor: usize, + window: &mut Window, + cx: &mut Context, + ) { + let cursor = cursor.min(text.len()); + let last = text[..cursor].chars().next_back(); + let triggers = last.is_some_and(|ch| { + ch.is_alphanumeric() || matches!(ch, '_' | '.' | ':' | '/' | '#' | '@') + }); + + if triggers || self.completion.open { + self.request_completions(window, cx); + } else { + self.close_completion(cx); + } + } + + /// Запрашивает `textDocument/completion` и показывает результат. + /// + /// Запрос уходит с небольшой задержкой, ответ ждётся в фоновом потоке. + /// Номер поколения отбрасывает ответы, которые уже не соответствуют + /// текущей позиции кареты. + fn request_completions(&mut self, window: &mut Window, cx: &mut Context) { + // Без запущенных серверов незачем будить таймеры и фоновые задачи. + if self.lsp.is_empty() { + self.close_completion(cx); + return; + } + + let (text, cursor) = { + let editor = self.editor.read(cx); + (editor.value().to_string(), editor.cursor()) + }; + let cursor = cursor.min(text.len()); + let start = word_start(&text, cursor); + let query = text[start..cursor].to_string(); + + let generation = { + self.completion.generation = self.completion.generation.wrapping_add(1); + self.completion.generation + }; + let manager = self.lsp.clone(); + + cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(70)) + .await; + + let receiver = match manager.completion(cursor) { + Ok(receiver) => receiver, + Err(_) => { + let _ = this.update_in(cx, |this, _window, cx| { + if this.completion.generation == generation { + this.close_completion(cx); + } + }); + return; + } + }; + + let result = cx + .background_executor() + .spawn(async move { receiver.recv_timeout(Duration::from_secs(5)) }) + .await; + + let _ = this.update_in(cx, |this, _window, cx| { + if this.completion.generation != generation { + return; + } + match result { + Ok(Ok(value)) => this.apply_completion_response(value, start, query, cx), + _ => this.close_completion(cx), + } + }); + }) + .detach(); + } + + fn apply_completion_response( + &mut self, + value: serde_json::Value, + start: usize, + query: String, + cx: &mut Context, + ) { + let items = parse_completion_response(value); + if items.is_empty() { + self.close_completion(cx); + return; + } + + // Выделение обязано указывать на реально нарисованную строку. + let max_selected = items.len().min(MAX_COMPLETION_ROWS).saturating_sub(1); + let selected = items + .iter() + .position(|item| item.preselect == Some(true)) + .unwrap_or(0) + .min(max_selected); + + self.completion.open = true; + self.completion.items = items; + self.completion.selected = selected; + self.completion.start_offset = start; + self.completion.query = query; + self.completion.scroll.scroll_to_item(selected); + + self.update_docs_for_selection(cx); + cx.notify(); + } + + fn close_completion(&mut self, cx: &mut Context) { + self.completion.generation = self.completion.generation.wrapping_add(1); + self.docs_generation = self.docs_generation.wrapping_add(1); + + let changed = self.completion.open || !self.completion.items.is_empty(); + self.completion.open = false; + self.completion.items.clear(); + self.completion.selected = 0; + self.completion.query.clear(); + + if changed { + cx.notify(); + } + } + + fn select_completion(&mut self, selected: usize, cx: &mut Context) { + let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS); + if selected >= visible || selected == self.completion.selected { + return; + } + self.completion.selected = selected; + self.completion.scroll.scroll_to_item(selected); + self.update_docs_for_selection(cx); + cx.notify(); + } + + /// Сдвигает выделение на `delta` строк, не выходя за пределы списка. + fn move_completion_selection(&mut self, delta: isize, cx: &mut Context) { + let visible = self.completion.items.len().min(MAX_COMPLETION_ROWS); + if visible == 0 { + return; + } + let selected = self.completion.selected.min(visible - 1) as isize; + let next = (selected + delta).clamp(0, visible as isize - 1) as usize; + self.select_completion(next, cx); + } + + /// Обновляет документацию для выделенного элемента списка. + fn update_docs_for_selection(&mut self, cx: &mut Context) { + let Some(item) = self.completion.items.get(self.completion.selected).cloned() else { + return; + }; + + let key = item_key(&item); + self.docs_generation = self.docs_generation.wrapping_add(1); + self.docs.key = key.clone(); + self.docs.label = item.label.clone(); + self.docs.detail = item.detail.clone().unwrap_or_default(); + self.docs.kind = completion_kind_name(item.kind).to_owned(); + + if let Some(documentation) = &item.documentation { + self.docs.text = documentation_text(documentation); + return; + } + if let Some(cached) = self.docs_cache.get(&key) { + self.docs.text = cached.clone(); + return; + } + + self.docs.text = fallback_documentation(&item); + let generation = self.docs_generation; + self.spawn_docs_resolve(item, key, generation, cx); + } + + /// Дозапрашивает документацию через `completionItem/resolve`. + fn spawn_docs_resolve( + &mut self, + item: CompletionItem, + key: String, + generation: u64, + cx: &mut Context, + ) { + let manager = self.lsp.clone(); + let receiver = match manager.resolve_completion(&item) { + Ok(receiver) => receiver, + Err(_) => return, + }; + + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { receiver.recv_timeout(Duration::from_secs(5)) }) + .await; + + let _ = this.update(cx, |this, cx| { + if this.docs_generation != generation || this.docs.key != key { + return; + } + let Ok(Ok(value)) = result else { + return; + }; + let Ok(item) = serde_json::from_value::(value) else { + return; + }; + let Some(documentation) = item.documentation.as_ref() else { + return; + }; + let text = documentation_text(documentation); + if text.is_empty() { + return; + } + this.docs_cache.insert(key.clone(), text.clone()); + this.docs.text = text; + cx.notify(); + }); + }) + .detach(); + } + + /// Обрабатывает клавиши, пока открыт список автодополнения. + /// + /// Вызывается из перехватчика клавиш, который срабатывает раньше + /// действий редактора. Пока список открыт, `↑`/`↓` и `PageUp`/`PageDown` + /// двигают выделение, а не курсор; `Enter`/`Tab` вставляют элемент, + /// `Esc` закрывает список. Горизонтальные стрелки, `Home` и `End` + /// закрывают список и работают с курсором как обычно. + fn handle_completion_key( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if is_completion_shortcut(keystroke) { + self.request_completions(window, cx); + return true; + } + if !self.completion.open { + return false; + } + + // Сочетания с модификаторами — это не навигация по списку: закрываем + // его и отдаём клавишу редактору (Shift+↑ выделяет текст, Shift+Enter + // переносит строку и т. п.). + let plain = !(keystroke.modifiers.control + || keystroke.modifiers.platform + || keystroke.modifiers.alt + || keystroke.modifiers.shift); + + match keystroke.key.as_str() { + _ if !plain => { + self.close_completion(cx); + false + } + "enter" | "tab" => { + self.accept_completion(window, cx); + true + } + "escape" => { + self.close_completion(cx); + true + } + "up" => { + self.move_completion_selection(-1, cx); + true + } + "down" => { + self.move_completion_selection(1, cx); + true + } + "pageup" => { + self.move_completion_selection(-COMPLETION_PAGE_ROWS, cx); + true + } + "pagedown" => { + self.move_completion_selection(COMPLETION_PAGE_ROWS, cx); + true + } + "left" | "right" | "home" | "end" => { + self.close_completion(cx); + false + } + _ => false, + } + } + + /// Вставляет выбранный элемент в редактор. + fn accept_completion(&mut self, window: &mut Window, cx: &mut Context) { + let Some(item) = self.completion.items.get(self.completion.selected).cloned() else { + self.close_completion(cx); + return; + }; + + let (text, cursor) = { + let editor = self.editor.read(cx); + (editor.value().to_string(), editor.cursor()) + }; + let (completion_range, replacement) = + completion_edit(&item, &text, self.completion.start_offset, cursor); + let completion_start = completion_range.start; + let inserted_len = replacement.len(); + + // `additionalTextEdits` — это, например, автоматические import'ы. + // Применяем их вместе с вставкой, от конца файла к началу, чтобы + // позиции более ранних правок не сдвигались. + let mut replacements: Vec<(Range, String)> = Vec::new(); + if let Some(additional) = &item.additional_text_edits { + for edit in additional { + replacements.push(( + position_to_offset(&text, edit.range.start) + ..position_to_offset(&text, edit.range.end), + edit.new_text.clone(), + )); + } + } + + let shift = replacements + .iter() + .filter(|(range, _)| range.end <= completion_start) + .map(|(range, new_text)| new_text.len() as isize - (range.end - range.start) as isize) + .sum::(); + + replacements.push((completion_range.clone(), replacement.clone())); + replacements.sort_by_key(|(range, _)| std::cmp::Reverse(range.start)); + + let mut expected = text.clone(); + for (range, new_text) in &replacements { + if range.start <= range.end + && range.end <= expected.len() + && expected.is_char_boundary(range.start) + && expected.is_char_boundary(range.end) + { + expected.replace_range(range.clone(), new_text); + } + } + + let edits = replacements + .iter() + .map(|(range, new_text)| TextEdit { + range: range_for_lsp(&text, range), + new_text: new_text.clone(), + }) + .collect::>(); + + self.close_completion(cx); + self.suppress_completion_for_text = Some(expected); + + self.editor.update(cx, |state, cx| { + state.apply_lsp_edits(&edits, window, cx); + let end = (completion_start as isize + inserted_len as isize + shift).max(0) as usize; + let end = end.min(state.value().len()); + state.set_selected_range(end..end, cx); + state.focus(window, cx); + }); + + // `InputEvent::Change` доставляются эффектами после текущего update. + // `defer` стоит в очереди после них и снимает подавление в любом + // случае, даже если финальный текст почему-то не совпал. + cx.defer_in(window, |this, _window, _cx| { + this.suppress_completion_for_text = None; + }); + + let new_text = self.editor.read(cx).value().to_string(); + self.workspace.borrow_mut().replace_active_content(new_text); + self.sync_lsp_document(); + cx.notify(); + } + + /// Всплывающий список автодополнения с иконками, типами и подсветкой. + fn render_completion_popup(&self, cx: &mut Context) -> AnyElement { + if !self.completion.open || self.completion.items.is_empty() { + return div().into_any_element(); + } + + let view = cx.entity().downgrade(); + let editor = self.editor.downgrade(); + let scroll = self.completion.scroll.clone(); + + deferred(CaretPopup::new(editor, move |anchor, above, window, cx| { + let Some(this) = view.upgrade() else { + return div().into_any_element(); + }; + let app = this.read(cx); + let items = &app.completion.items; + let total = items.len(); + let selected = app.completion.selected.min(total.saturating_sub(1)); + let show_docs = !app.docs.text.is_empty(); + + // Ширины считаем от места справа от кареты. Если его совсем мало, + // берём минимальные ширины, а `CaretPopup` сдвинет окно влево. + let available = + (window.viewport_size().width - anchor.x - px(POPUP_MARGIN)).max(px(200.)); + let docs_width = px((available.as_f32() * 0.42).clamp(180., 380.)); + let list_width = if show_docs { + px((available.as_f32() - docs_width.as_f32() - 8.).clamp(200., 460.)) + } else { + px(available.as_f32().clamp(240., 460.)) + }; + + let mut rows = Vec::with_capacity(total.min(MAX_COMPLETION_ROWS)); + for (index, item) in items.iter().take(MAX_COMPLETION_ROWS).enumerate() { + let is_selected = index == selected; + let label = item.label.clone(); + let detail = item.detail.clone().unwrap_or_default(); + let kind = item.kind; + let icon = completion_icon(kind); + let color = completion_icon_color(kind, cx.theme()); + let highlights = highlight_range(&app.completion.query, &label) + .into_iter() + .map(|range| { + ( + range, + HighlightStyle { + color: Some(cx.theme().blue), + ..Default::default() + }, + ) + }) + .collect::>(); + let click_view = view.clone(); + let hover_view = view.clone(); + + let mut row = div() + .id(format!("completion-row-{index}")) + .flex() + .items_center() + .gap_1p5() + .px_1p5() + .py_0p5() + .flex_shrink_0() + .overflow_hidden() + .whitespace_nowrap() + .rounded(cx.theme().radius) + .on_click(move |_, window, cx| { + let _ = click_view.update(cx, |this, cx| { + this.select_completion(index, cx); + this.accept_completion(window, cx); + }); + }) + .on_hover(move |hovered, _window, cx| { + if *hovered { + let _ = + hover_view.update(cx, |this, cx| this.select_completion(index, cx)); + } + }) + .child(Icon::new(icon).xsmall().flex_shrink_0().text_color(color)) + // У варианта автодополнения приоритет: он занимает свою + // естественную ширину, а сигнатура получает остаток. + .child( + div() + .min_w_0() + .overflow_hidden() + .text_xs() + .whitespace_nowrap() + .text_ellipsis() + .child(StyledText::new(label).with_highlights(highlights)), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_xs() + .text_right() + .whitespace_nowrap() + .text_ellipsis() + .text_color(cx.theme().muted_foreground) + .child(if detail.is_empty() { + completion_kind_name(kind).to_owned() + } else { + detail + }), + ); + + if is_selected { + row = row.bg(cx.theme().selection); + } + rows.push(row.into_any_element()); + } + + let mut list = div() + .id("lsp-completion-menu") + .flex() + .flex_col() + .w(list_width) + .max_h(px(340.)) + .occlude() + // Список набран тем же моноширинным шрифтом, что и редактор. + .font_family(cx.theme().mono_font_family.clone()) + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .p_0p5() + .child( + h_flex() + .justify_between() + .px_1p5() + .pb_0p5() + .whitespace_nowrap() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{} / {total}", selected + 1)) + .child("↑↓ · Enter · Esc"), + ) + .child( + div() + .id("lsp-completion-scroll") + .flex() + .flex_col() + .overflow_hidden() + .max_h(px(290.)) + .overflow_y_scroll() + .track_scroll(&scroll) + .children(rows), + ); + + if total > MAX_COMPLETION_ROWS { + list = list.child( + div() + .px_3() + .py_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("Показаны первые {MAX_COMPLETION_ROWS} из {total}")), + ); + } + + let detail = if app.docs.detail.is_empty() { + app.docs.kind.clone() + } else { + format!("{} · {}", app.docs.kind, app.docs.detail) + }; + + let docs = div() + .id("lsp-completion-docs") + .flex() + .flex_col() + .w(docs_width) + .max_h(px(340.)) + .occlude() + .rounded(cx.theme().radius) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .p_2() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .child( + Icon::new(AssetIcon::BookOpen) + .xsmall() + .text_color(cx.theme().blue), + ) + .child( + div() + .text_sm() + .font_medium() + .overflow_hidden() + .child(app.docs.label.clone()), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(detail), + ) + .child( + div() + .id("lsp-completion-docs-scroll") + .max_h(px(270.)) + .overflow_y_scroll() + .child(TextView::markdown( + "lsp-completion-docs-text", + app.docs.text.clone(), + )), + ); + + let row = div() + .id("lsp-completion-popups") + .flex() + .flex_row() + .gap_2() + .flex_none() + .w(if show_docs { + list_width + docs_width + px(8.) + } else { + list_width + }); + // Снизу окна выравниваются по верхнему краю, сверху — по + // нижнему, чтобы оба окна заканчивались на линии кареты. + let mut popups = if above { + row.items_end() + } else { + row.items_start() + } + .child(list); + if show_docs { + popups = popups.child(docs); + } + + popups + .on_mouse_down_out({ + let view = view.clone(); + move |_, _, cx| { + let _ = view.update(cx, |this, cx| this.close_completion(cx)); + } + }) + .into_any_element() + })) + .into_any_element() + } + + /// Правая панель: состояние запущенных LSP-серверов. + /// + /// Документация автодополнения живёт не здесь, а во втором всплывающем + /// окне рядом со списком. + fn render_right_panel(&self, cx: &mut Context) -> AnyElement { + let servers = self.lsp.server_names(); + let mut content = div().flex().flex_col().gap_2(); + + if servers.is_empty() { + content = content.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child( + "Серверы LSP не запущены. Запустите их из init.lua через app:run_lsp{...}.", + ), + ); + } else { + for name in servers { + content = content.child( + h_flex() + .items_center() + .gap_2() + .child(div().size(px(6.)).rounded_full().bg(cx.theme().green)) + .child(div().text_sm().child(name)), + ); + } + } + + let log = self.lsp.log_lines(); + if !log.is_empty() { + let tail = log.into_iter().rev().take(4).collect::>(); + content = content.child( + div() + .mt_2() + .flex() + .flex_col() + .gap_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .children(tail.into_iter().rev().map(|line| div().child(line))), + ); + } + + div() + .size_full() + .flex() + .flex_col() + .p_3() + .gap_2() + .child(div().text_sm().font_medium().child("LSP")) + .child(content) + .into_any_element() + } + /// Переносит изменения модели в виджет редактора. /// /// `set_value` сбрасывает выделение и прокрутку, поэтому вызывается только @@ -221,12 +1105,17 @@ impl MyApp { } state.focus(window, cx); }); + + if switching { + self.close_completion(cx); + self.sync_lsp_document(); + } } /// Редактор с левой, правой и нижней панелями. /// /// Все панели меняют размер перетаскиванием разделителя, а закрытые - /// панели не занимают места. Сами панели пока пустые. + /// панели не занимают места. Правая панель показывает статус LSP. fn render_workspace(&self, window: &Window, cx: &mut Context) -> impl IntoElement { let (left_open, left_size) = { let panel = self.left_panel.borrow(); @@ -263,7 +1152,12 @@ impl MyApp { resizable_panel() .size(px(right_size)) .visible(right_open) - .child(div().size_full().bg(cx.theme().sidebar)), + .child( + div() + .size_full() + .bg(cx.theme().sidebar) + .child(self.render_right_panel(cx)), + ), ); let mut row = div().size_full().flex().flex_row(); @@ -336,7 +1230,15 @@ impl MyApp { // соседнюю по уже устаревшему индексу. cx.stop_propagation(); entity.update(cx, |this, cx| { + let uri = this + .workspace + .borrow() + .document(index) + .and_then(|doc| document_uri(&doc.name)); this.workspace.borrow_mut().close(index); + if let Some(uri) = uri { + this.lsp.close_document(&uri); + } cx.notify(); }); }) @@ -382,6 +1284,9 @@ impl Render for MyApp { apply_window_commands(window); self.sync_editor(window, cx); + // Документ мог открыться до запуска сервера (init.lua выполняется + // сразу после создания окна) — отдаём его LSP при первом кадре. + self.sync_lsp_document(); if !self.focused_once { self.focused_once = true; self.editor.update(cx, |state, cx| state.focus(window, cx)); @@ -453,6 +1358,7 @@ impl Render for MyApp { .min_h_0() .child(self.render_workspace(window, cx)), ) + .child(self.render_completion_popup(cx)) } } @@ -482,6 +1388,13 @@ fn is_save_shortcut(keystroke: &Keystroke) -> bool { && !keystroke.modifiers.alt } +/// Ctrl+Space открывает автодополнение вручную. +fn is_completion_shortcut(keystroke: &Keystroke) -> bool { + keystroke.key.eq_ignore_ascii_case("space") + && (keystroke.modifiers.control || keystroke.modifiers.platform) + && !keystroke.modifiers.alt +} + /// Сохраняет активную вкладку, печатая ошибку в stderr. fn save_active_tab() { if let Err(err) = AppApi::global().save() { diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 0000000..edf054f --- /dev/null +++ b/src/completion.rs @@ -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, 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, + build: PopupBuilder, + content: Option, +} + +impl CaretPopup { + pub fn new( + editor: WeakEntity, + build: impl Fn(Point, 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 { + 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, + _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, + _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 { + 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, + above: bool, +} + +/// Выбирает положение поповера так, чтобы он целиком помещался в окне. +/// +/// По умолчанию поповер стоит под каретой. Если снизу места мало, он +/// переносится над строкой; если не помещается справа, сдвигается влево. +fn popup_origin( + anchor: Point, + caret_top: Pixels, + size: Size, + viewport: Size, +) -> 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 { + if value.is_null() { + return Vec::new(); + } + serde_json::from_value::(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) -> &'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) -> 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, + 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> { + 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, 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 = 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) -> 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"); + } +} diff --git a/src/constants.rs b/src/constants.rs index e946a71..ba2d6f6 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -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 = OnceLock::new(); + +/// Файл, который нужно открыть при старте (первый позиционный аргумент). +static STARTUP_FILE: OnceLock = OnceLock::new(); + +/// Разобранные аргументы командной строки. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommandLine { + /// Каталог, из которого брать `init.lua`. + pub config_dir: Option, + /// Файл, открываемый при старте как `vim file.go`. + pub file: Option, +} + +/// Каталог, из которого загружаются `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(args: I) -> Result +where + I: IntoIterator, +{ + 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 { + 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"))); + } +} diff --git a/src/editor.rs b/src/editor.rs index 40eb3cf..d37e8ba 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { + 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, + content: impl Into, + language: Option, + ) -> 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 + } + /// Закрывает вкладку и выбирает соседнюю. /// /// Если закрывается последняя вкладка, вместо неё открывается пустой документ. diff --git a/src/lsp.rs b/src/lsp.rs new file mode 100644 index 0000000..9d7bca5 --- /dev/null +++ b/src/lsp.rs @@ -0,0 +1,1227 @@ +//! Минимальный LSP-клиент на stdio. +//! +//! Модуль отвечает только за «жизненный цикл» языка: +//! +//! * запуск и остановку процесса LSP-сервера; +//! * JSON-RPC 2.0 с кадрами `Content-Length`; +//! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`); +//! * запросы автодополнения, подсказок и `completionItem/resolve`; +//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`. +//! +//! Всё остальное — какие серверы запускать, с какими аргументами и когда — +//! решает Lua-сценарий через [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). +//! +//! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и +//! складывает их в каналы конкретных запросов, а UI ждёт ответ в +//! `background_executor` GPUI и не блокирует кадр. + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Weak}; +use std::time::Duration; + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use lsp_types::{CompletionItem, Position, Uri}; +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use parking_lot::Mutex; +use serde_json::{Value, json}; + +/// Настройки файлового вотчера для одного LSP-сервера. +#[derive(Clone, Debug, Default)] +pub struct WatcherConfig { + /// Шаблоны вроде `**/*.go`. Пустой список означает «не следить». + pub patterns: Vec, + /// Дополнительные шаблоны игнорирования. + pub ignore: Vec, +} + +/// Описание запускаемого LSP-сервера. +/// +/// Создаётся из Lua-таблицы в [`AppApi::run_lsp`](crate::api::AppApi::run_lsp). +#[derive(Clone, Debug)] +pub struct LspConfig { + /// Условное имя, по которому сервер останавливают и перезапускают. + pub name: String, + /// Путь к исполняемому файлу или имя в `PATH`. + pub command: String, + pub args: Vec, + pub env: Vec<(String, String)>, + /// Корень рабочей области (по умолчанию текущий каталог процесса). + pub root: PathBuf, + /// Языки (`languageId`), которые обслуживает этот сервер. + /// + /// Пустой список — сервер считается универсальным и получает активный + /// документ любого языка. + pub languages: Vec, + pub watcher: Option, + /// Необязательные `initializationOptions` для сервера. + pub initialization_options: Option, +} + +/// Открытый документ, о котором знает менеджер. +#[derive(Clone, Debug)] +struct ActiveDocument { + uri: Uri, + language_id: String, + version: i32, + text: String, +} + +type PendingMap = Mutex>>>; +type LogBuffer = Arc>>; + +/// Менеджер всех запущенных LSP-серверов. +/// +/// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые +/// задачи автодополнения. +pub struct LspManager { + servers: Mutex>>, + active: Mutex>, + log: LogBuffer, +} + +impl Default for LspManager { + fn default() -> Self { + Self::new() + } +} + +impl LspManager { + pub fn new() -> Self { + Self { + servers: Mutex::new(BTreeMap::new()), + active: Mutex::new(None), + log: Arc::new(Mutex::new(VecDeque::new())), + } + } + + /// Запускает сервер и выполняет `initialize`/`initialized`. + /// + /// Операция синхронная: Lua-сценарий получает ошибку, если процесс не + /// стартовал или не ответил на `initialize` за 30 секунд. + pub fn start(&self, config: LspConfig) -> Result<(), String> { + if self.servers.lock().contains_key(&config.name) { + return Err(format!("LSP-сервер «{}» уже запущен", config.name)); + } + + // Вотчер сопоставляет абсолютные пути от notify с корнем, поэтому + // корень должен быть абсолютным и нормализованным. + let mut config = config; + if !config.root.is_absolute() { + config.root = std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(&config.root); + } + config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root); + + let server = LspServer::spawn(config.clone())?; + + // `initialize` — единственный запрос, который ждём прямо здесь: + // без ответа сервер нельзя считать рабочим. + let initialize = server.request("initialize", initialize_params(&config))?; + if let Err(err) = wait_response(initialize, Duration::from_secs(30)) { + server.shutdown(); + return Err(format!("initialize «{}»: {err}", config.name)); + } + + if let Err(err) = server.notify("initialized", json!({})) { + server.shutdown(); + return Err(format!("initialized «{}»: {err}", config.name)); + } + + // Если документ уже открыт в редакторе, сразу отдаём его серверу. + if let Some(document) = self.active.lock().clone() + && server.serves(&document.language_id) + { + server.did_open( + &document.uri, + &document.language_id, + document.version, + &document.text, + ); + } + + if let Err(err) = server.start_watcher() { + server.log(format!("файловый вотчер не запущен: {err}")); + } + + let mut servers = self.servers.lock(); + if servers.contains_key(&config.name) { + drop(servers); + server.shutdown(); + return Err(format!("LSP-сервер «{}» уже запущен", config.name)); + } + servers.insert(config.name.clone(), server); + drop(servers); + + self.log(format!("запущен LSP-сервер «{}»", config.name)); + Ok(()) + } + + /// Останавливает сервер по имени. Возвращает `false`, если его не было. + pub fn stop(&self, name: &str) -> bool { + let server = self.servers.lock().remove(name); + let Some(server) = server else { + return false; + }; + server.shutdown(); + self.log(format!("остановлен LSP-сервер «{name}»")); + true + } + + /// Перезапускает сервер с прежними настройками. + pub fn restart(&self, name: &str) -> bool { + let config = self + .servers + .lock() + .get(name) + .map(|server| server.config.clone()); + let Some(config) = config else { + return false; + }; + self.stop(name); + match self.start(config) { + Ok(()) => true, + Err(err) => { + self.log(format!("не удалось перезапустить «{name}»: {err}")); + false + } + } + } + + /// Имена запущенных серверов по алфавиту. + pub fn server_names(&self) -> Vec { + self.servers.lock().keys().cloned().collect() + } + + /// Есть ли хотя бы один запущенный сервер. + pub fn is_empty(&self) -> bool { + self.servers.lock().is_empty() + } + + /// Синхронизирует активный документ со всеми подходящими серверами. + /// + /// Первый вызов для документа отправляет `didOpen`, последующие с + /// изменившимся текстом — `didChange` с новой версией. + pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) { + let version; + { + let mut active = self.active.lock(); + if let Some(document) = active.as_mut() + && document.uri == uri + && document.text == text + { + if document.language_id != language_id { + document.language_id = language_id; + } + return; + } + + let same_uri = active.as_ref().is_some_and(|document| document.uri == uri); + version = if same_uri { + active.as_ref().map_or(1, |document| document.version + 1) + } else { + 1 + }; + + *active = Some(ActiveDocument { + uri: uri.clone(), + language_id: language_id.clone(), + version, + text: text.to_owned(), + }); + } + + for server in self.servers.lock().values() { + if !server.serves(&language_id) { + continue; + } + if server.is_opened(&uri) { + server.did_change(&uri, version, text); + } else { + server.did_open(&uri, &language_id, version, text); + } + } + } + + /// Сообщает серверам, что документ сохранён на диск. + pub fn did_save(&self, uri: &Uri, text: &str) { + for server in self.servers.lock().values() { + if server.is_opened(uri) { + server.did_save(uri, text); + } + } + } + + /// Закрывает документ на всех серверах, которые его открыли. + pub fn close_document(&self, uri: &Uri) { + for server in self.servers.lock().values() { + server.did_close(uri); + } + } + + /// Отправляет `textDocument/completion` в подходящий сервер. + /// + /// Возвращает канал, по которому придёт ответ. Ждать его нужно в фоновом + /// исполнителе, чтобы не блокировать UI. + pub fn completion(&self, offset: usize) -> Result>, String> { + let document = self + .active + .lock() + .clone() + .ok_or_else(|| "нет открытого документа".to_owned())?; + let server = self + .server_for(&document.language_id) + .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; + + server.request( + "textDocument/completion", + json!({ + "textDocument": { "uri": document.uri.as_str() }, + "position": offset_to_position(&document.text, offset), + "context": { "triggerKind": 1 }, + }), + ) + } + + /// Отправляет `textDocument/hover` в подходящий сервер. + pub fn hover(&self, offset: usize) -> Result>, String> { + let document = self + .active + .lock() + .clone() + .ok_or_else(|| "нет открытого документа".to_owned())?; + let server = self + .server_for(&document.language_id) + .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; + + server.request( + "textDocument/hover", + json!({ + "textDocument": { "uri": document.uri.as_str() }, + "position": offset_to_position(&document.text, offset), + }), + ) + } + + /// Дозапрашивает документацию элемента автодополнения. + pub fn resolve_completion( + &self, + item: &CompletionItem, + ) -> Result>, String> { + let language_id = self + .active + .lock() + .as_ref() + .map(|document| document.language_id.clone()) + .ok_or_else(|| "нет открытого документа".to_owned())?; + let server = self + .server_for(&language_id) + .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; + let params = serde_json::to_value(item).map_err(|err| err.to_string())?; + server.request("completionItem/resolve", params) + } + + /// Последние строки журнала серверов. + pub fn log_lines(&self) -> Vec { + self.log.lock().iter().cloned().collect() + } + + fn log(&self, message: impl Into) { + push_log(&self.log, "lsp", &message.into()); + } + + fn server_for(&self, language_id: &str) -> Option> { + let servers = self.servers.lock(); + let mut fallback = None; + for server in servers.values() { + if server + .config + .languages + .iter() + .any(|language| language.eq_ignore_ascii_case(language_id)) + { + return Some(server.clone()); + } + if server.config.languages.is_empty() && fallback.is_none() { + fallback = Some(server.clone()); + } + } + fallback + } +} + +/// Один запущенный процесс LSP-сервера. +struct LspServer { + config: LspConfig, + name: String, + root_uri: String, + stdin: Arc>, + child: Mutex>, + next_id: AtomicU64, + pending: PendingMap, + opened: Mutex>, + watcher: Mutex>, + alive: AtomicBool, + log: LogBuffer, +} + +impl LspServer { + fn spawn(config: LspConfig) -> Result, String> { + let mut command = Command::new(&config.command); + command + .args(&config.args) + .current_dir(&config.root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if !config.env.is_empty() { + command.envs(config.env.iter().cloned()); + } + + let mut child = command + .spawn() + .map_err(|err| format!("не удалось запустить «{}»: {err}", config.command))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| "LSP-сервер не отдал stdin".to_owned())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "LSP-сервер не отдал stdout".to_owned())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "LSP-сервер не отдал stderr".to_owned())?; + + let log = Arc::new(Mutex::new(VecDeque::new())); + let name = config.name.clone(); + let root_uri = path_to_uri(&config.root).as_str().to_owned(); + + let server = Arc::new(Self { + config, + name, + root_uri, + stdin: Arc::new(Mutex::new(stdin)), + child: Mutex::new(Some(child)), + next_id: AtomicU64::new(1), + pending: Mutex::new(HashMap::new()), + opened: Mutex::new(HashSet::new()), + watcher: Mutex::new(None), + alive: AtomicBool::new(true), + log: log.clone(), + }); + + spawn_stdout_reader(Arc::downgrade(&server), stdout); + spawn_stderr_reader(log, server.name.clone(), stderr); + Ok(server) + } + + fn serves(&self, language_id: &str) -> bool { + self.config.languages.is_empty() + || self + .config + .languages + .iter() + .any(|language| language.eq_ignore_ascii_case(language_id)) + } + + fn is_opened(&self, uri: &Uri) -> bool { + self.opened.lock().contains(uri.as_str()) + } + + fn did_open(&self, uri: &Uri, language_id: &str, version: i32, text: &str) { + let params = json!({ + "textDocument": { + "uri": uri.as_str(), + "languageId": language_id, + "version": version, + "text": text, + } + }); + if self.notify("textDocument/didOpen", params).is_ok() { + self.opened.lock().insert(uri.as_str().to_owned()); + } + } + + fn did_change(&self, uri: &Uri, version: i32, text: &str) { + let params = json!({ + "textDocument": { "uri": uri.as_str(), "version": version }, + "contentChanges": [{ "text": text }], + }); + let _ = self.notify("textDocument/didChange", params); + } + + fn did_save(&self, uri: &Uri, text: &str) { + let params = json!({ + "textDocument": { "uri": uri.as_str() }, + "text": text, + }); + let _ = self.notify("textDocument/didSave", params); + } + + fn did_close(&self, uri: &Uri) { + if self.opened.lock().remove(uri.as_str()) { + let params = json!({ "textDocument": { "uri": uri.as_str() } }); + let _ = self.notify("textDocument/didClose", params); + } + } + + fn request( + &self, + method: &str, + params: Value, + ) -> Result>, String> { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = mpsc::channel(); + self.pending.lock().insert(id, sender); + + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + if let Err(err) = self.send_value(&message) { + self.pending.lock().remove(&id); + return Err(err); + } + Ok(receiver) + } + + fn notify(&self, method: &str, params: Value) -> Result<(), String> { + self.send_value(&json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + })) + } + + fn respond(&self, id: Value, result: Value) { + let _ = self.send_value(&json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })); + } + + fn send_value(&self, message: &Value) -> Result<(), String> { + let mut stdin = self.stdin.lock(); + write_message(&mut stdin, message) + } + + fn handle_message(&self, message: Value) { + let method = message + .get("method") + .and_then(Value::as_str) + .map(str::to_owned); + let has_id = message.get("id").is_some(); + + match (method, has_id) { + (None, true) => self.handle_response(message), + (Some(method), true) => self.handle_server_request(&method, message), + (Some(method), false) => self.handle_notification(&method, &message), + _ => {} + } + } + + fn handle_response(&self, message: Value) { + let Some(id) = message.get("id").and_then(Value::as_u64) else { + return; + }; + let Some(sender) = self.pending.lock().remove(&id) else { + return; + }; + + let result = if let Some(error) = message.get("error") { + let text = error + .get("message") + .and_then(Value::as_str) + .unwrap_or("ошибка LSP-сервера") + .to_owned(); + Err(text) + } else { + Ok(message.get("result").cloned().unwrap_or(Value::Null)) + }; + let _ = sender.send(result); + } + + fn handle_server_request(&self, method: &str, message: Value) { + let id = message.get("id").cloned().unwrap_or(Value::Null); + let result = match method { + "workspace/configuration" => { + let count = message + .pointer("/params/items") + .and_then(Value::as_array) + .map_or(0, Vec::len); + Value::Array(vec![Value::Null; count]) + } + "workspace/workspaceFolders" => json!([{ + "uri": self.root_uri, + "name": self.name, + }]), + "workspace/applyEdit" => json!({ "applied": false }), + "client/registerCapability" => Value::Null, + "window/workDoneProgress/create" => Value::Null, + "window/showMessageRequest" => Value::Null, + _ => { + self.log(format!("запрос сервера «{method}»: ответ null")); + Value::Null + } + }; + self.respond(id, result); + } + + fn handle_notification(&self, method: &str, message: &Value) { + match method { + "textDocument/publishDiagnostics" => { + let uri = message + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or("?"); + let count = message + .pointer("/params/diagnostics") + .and_then(Value::as_array) + .map_or(0, Vec::len); + if count > 0 { + self.log(format!("диагностика {uri}: {count}")); + } + } + "window/logMessage" | "window/showMessage" => { + if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) { + self.log(text.to_owned()); + } + } + "window/logTrace" => { + if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) { + self.log(text.to_owned()); + } + } + _ => {} + } + } + + fn start_watcher(&self) -> Result<(), String> { + let Some(config) = self.config.watcher.clone() else { + return Ok(()); + }; + if config.patterns.is_empty() { + return Ok(()); + } + + let mut builder = GlobSetBuilder::new(); + for pattern in &config.patterns { + builder.add(Glob::new(pattern).map_err(|err| format!("шаблон «{pattern}»: {err}"))?); + // `**/*.go` удобно писать для вложенных каталогов, но GlobSet + // трактует `**/` как минимум один каталог. Добавляем вариант без + // префикса, чтобы корневые файлы тоже попадали в вотчер. + if let Some(rest) = pattern.strip_prefix("**/") + && !rest.is_empty() + { + builder.add( + Glob::new(rest) + .map_err(|err| format!("шаблон «{rest}» из «{pattern}»: {err}"))?, + ); + } + } + let matcher = Arc::new( + builder + .build() + .map_err(|err| format!("не удалось собрать файловый вотчер: {err}"))?, + ); + + let mut ignore_builder = GlobSetBuilder::new(); + for pattern in &config.ignore { + if let Ok(glob) = Glob::new(pattern) { + ignore_builder.add(glob); + } + } + let ignore = Arc::new( + ignore_builder + .build() + .map_err(|err| format!("не удалось собрать игнор-список: {err}"))?, + ); + + let root = self.config.root.clone(); + let stdin = self.stdin.clone(); + let log = self.log.clone(); + let name = self.name.clone(); + + let mut watcher = + notify::recommended_watcher(move |result: notify::Result| { + let Ok(event) = result else { + return; + }; + let change_type = match event.kind { + EventKind::Create(_) => 1, + EventKind::Modify(_) => 2, + EventKind::Remove(_) => 3, + _ => return, + }; + + let mut changes = Vec::new(); + for path in event.paths { + let Ok(relative) = path.strip_prefix(&root) else { + continue; + }; + if is_ignored(relative, &ignore) { + continue; + } + let relative = relative.to_string_lossy().replace('\\', "/"); + if !matcher.is_match(&relative) { + continue; + } + changes.push(json!({ + "uri": path_to_uri(&path).as_str(), + "type": change_type, + })); + } + + if changes.is_empty() { + return; + } + + let message = json!({ + "jsonrpc": "2.0", + "method": "workspace/didChangeWatchedFiles", + "params": { "changes": changes }, + }); + let mut stdin = stdin.lock(); + if let Err(err) = write_message(&mut stdin, &message) { + drop(stdin); + push_log( + &log, + &name, + &format!("не удалось отправить didChangeWatchedFiles: {err}"), + ); + } + }) + .map_err(|err| format!("файловый вотчер: {err}"))?; + + watcher + .watch(&self.config.root, RecursiveMode::Recursive) + .map_err(|err| { + format!( + "не удалось следить за {}: {err}", + self.config.root.display() + ) + })?; + + *self.watcher.lock() = Some(watcher); + self.log("файловый вотчер запущен".to_owned()); + Ok(()) + } + + fn shutdown(&self) { + if !self.alive.swap(false, Ordering::SeqCst) { + return; + } + + // Сначала отключаем вотчер, чтобы его события не писали в закрытый stdin. + *self.watcher.lock() = None; + self.opened.lock().clear(); + + if let Ok(receiver) = self.request("shutdown", Value::Null) { + let _ = wait_response(receiver, Duration::from_secs(2)); + } + let _ = self.notify("exit", Value::Null); + + if let Some(mut child) = self.child.lock().take() { + for _ in 0..20 { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = child.kill(); + let _ = child.wait(); + } + } + + fn log(&self, message: impl Into) { + push_log(&self.log, &self.name, &message.into()); + } +} + +impl Drop for LspServer { + fn drop(&mut self) { + *self.watcher.lock() = None; + if let Some(mut child) = self.child.lock().take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// Ждёт ответ на запрос в текущем потоке. +/// +/// Для UI используйте `background_executor`, чтобы не блокировать кадр. +pub fn wait_response( + receiver: Receiver>, + timeout: Duration, +) -> Result { + match receiver.recv_timeout(timeout) { + Ok(result) => result, + Err(RecvTimeoutError::Timeout) => Err("таймаут ответа LSP".to_owned()), + Err(RecvTimeoutError::Disconnected) => Err("LSP-сервер закрыл соединение".to_owned()), + } +} + +fn spawn_stdout_reader(server: Weak, stdout: ChildStdout) { + let _ = std::thread::Builder::new() + .name("lsp-stdout".to_owned()) + .spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let message = match read_message(&mut reader) { + Ok(Some(message)) => message, + Ok(None) | Err(_) => break, + }; + let Some(server) = server.upgrade() else { + break; + }; + server.handle_message(message); + } + }); +} + +fn spawn_stderr_reader(log: LogBuffer, name: String, stderr: std::process::ChildStderr) { + let _ = std::thread::Builder::new() + .name("lsp-stderr".to_owned()) + .spawn(move || { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + let text = line.trim_end(); + if !text.is_empty() { + push_log(&log, &name, text); + } + } + } + } + }); +} + +fn read_message(reader: &mut R) -> std::io::Result> { + let mut content_length = None; + let mut line = String::new(); + + loop { + line.clear(); + let read = reader.read_line(&mut line)?; + if read == 0 { + return Ok(None); + } + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = value.trim().parse::().ok(); + } + } + + let Some(length) = content_length else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LSP-сообщение без Content-Length", + )); + }; + + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + let value = serde_json::from_slice(&body) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Ok(Some(value)) +} + +fn write_message(stdin: &mut ChildStdin, message: &Value) -> Result<(), String> { + let body = serde_json::to_vec(message).map_err(|err| err.to_string())?; + write!(stdin, "Content-Length: {}\r\n\r\n", body.len()) + .map_err(|err| format!("запись LSP: {err}"))?; + stdin + .write_all(&body) + .map_err(|err| format!("запись LSP: {err}"))?; + stdin.flush().map_err(|err| format!("запись LSP: {err}")) +} + +fn push_log(log: &LogBuffer, name: &str, message: &str) { + let line = format!("[{name}] {message}"); + eprintln!("[lsp] {line}"); + let mut log = log.lock(); + if log.len() >= 200 { + log.pop_front(); + } + log.push_back(line); +} + +fn is_ignored(relative: &Path, ignore: &GlobSet) -> bool { + let has_ignored_component = relative.components().any(|component| { + matches!( + component, + Component::Normal(name) + if matches!( + name.to_str(), + Some(".git" | "target" | "node_modules" | ".venv" | "dist" | "build") + ) + ) + }); + if has_ignored_component { + return true; + } + + let relative = relative.to_string_lossy().replace('\\', "/"); + ignore.is_match(&relative) +} + +fn initialize_params(config: &LspConfig) -> Value { + let root_uri = path_to_uri(&config.root); + let mut params = serde_json::Map::new(); + params.insert("processId".into(), json!(std::process::id())); + params.insert( + "clientInfo".into(), + json!({ + "name": "gpui-editor", + "version": env!("CARGO_PKG_VERSION"), + }), + ); + params.insert("rootUri".into(), json!(root_uri.as_str())); + params.insert( + "rootPath".into(), + json!(config.root.to_string_lossy().to_string()), + ); + params.insert( + "workspaceFolders".into(), + json!([{ "uri": root_uri.as_str(), "name": config.name }]), + ); + params.insert( + "capabilities".into(), + json!({ + "workspace": { + "workspaceFolders": true, + "configuration": true, + "didChangeWatchedFiles": { "dynamicRegistration": false }, + "didChangeConfiguration": { "dynamicRegistration": false }, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": false, + "willSave": false, + "didSave": true, + }, + "completion": { + "dynamicRegistration": false, + "completionItem": { + "snippetSupport": false, + "documentationFormat": ["markdown", "plaintext"], + "insertReplaceSupport": true, + "resolveSupport": { + "properties": ["documentation", "detail"] + }, + "labelDetailsSupport": true, + }, + "completionItemKind": { + "valueSet": (1..=25).collect::>(), + }, + }, + "hover": { + "dynamicRegistration": false, + "contentFormat": ["markdown", "plaintext"], + }, + "publishDiagnostics": { + "relatedInformation": true, + "versionSupport": true, + }, + }, + }), + ); + + if let Some(options) = &config.initialization_options { + params.insert("initializationOptions".into(), options.clone()); + } + + Value::Object(params) +} + +/// Превращает путь в `file://`-URI с процентным кодированием. +pub fn path_to_uri(path: &Path) -> Uri { + let mut path = path.to_string_lossy().replace('\\', "/"); + if !path.starts_with('/') { + path.insert(0, '/'); + } + + let mut encoded = String::from("file://"); + for byte in path.bytes() { + let keep = + byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b':' | b'-' | b'_' | b'.' | b'~'); + if keep { + encoded.push(byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + + // Всё, что мы построили, является корректным URI: путь уже абсолютный, + // недопустимые для пути байты закодированы. + Uri::from_str(&encoded).expect("путь превращается в корректный file:// URI") +} + +/// URI документа по его имени во вкладке редактора. +/// +/// Относительные имена считаются от текущего рабочего каталога — именно +/// относительно него работают `save` и запуск LSP-сервера. +pub fn document_uri(name: &str) -> Option { + let path = Path::new(name); + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().ok()?.join(path) + }; + Some(path_to_uri(&path)) +} + +/// Переводит байтовое смещение в LSP-позицию (`line`/`character` в UTF-16). +pub fn offset_to_position(text: &str, offset: usize) -> Position { + let mut offset = offset.min(text.len()); + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + + let mut line = 0u32; + let mut line_start = 0usize; + for (index, ch) in text.char_indices() { + if index >= offset { + break; + } + if ch == '\n' { + line += 1; + line_start = index + 1; + } + } + + let character = text[line_start..offset].encode_utf16().count() as u32; + Position::new(line, character) +} + +/// Переводит LSP-позицию в байтовое смещение. +pub fn position_to_offset(text: &str, position: Position) -> usize { + let line = position.line as usize; + let mut line_start = 0usize; + let mut current_line = 0usize; + + for (index, ch) in text.char_indices() { + if current_line == line { + break; + } + if ch == '\n' { + current_line += 1; + line_start = index + 1; + } + } + + if current_line != line { + return text.len(); + } + + let line_end = text[line_start..] + .find('\n') + .map_or(text.len(), |index| line_start + index); + + let mut utf16 = 0usize; + let mut offset = line_start; + for ch in text[line_start..line_end].chars() { + if utf16 >= position.character as usize { + break; + } + utf16 += ch.len_utf16(); + offset += ch.len_utf8(); + } + offset +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn reads_content_length_framed_message() { + let input = b"Content-Length: 13\r\n\r\n{\"jsonrpc\":1}"; + let mut reader = Cursor::new(input); + let message = read_message(&mut reader).unwrap().unwrap(); + assert_eq!(message["jsonrpc"], 1); + + let mut empty = Cursor::new(Vec::new()); + assert!(read_message(&mut empty).unwrap().is_none()); + } + + #[test] + fn positions_use_utf16_columns() { + let text = "a\nβγ\n🙂x"; + let beta = text.find('β').unwrap(); + assert_eq!(offset_to_position(text, beta), Position::new(1, 0)); + + let gamma = text.find('γ').unwrap(); + assert_eq!(offset_to_position(text, gamma), Position::new(1, 1)); + assert_eq!(position_to_offset(text, Position::new(1, 1)), gamma); + + let emoji = text.find('🙂').unwrap(); + assert_eq!(offset_to_position(text, emoji + 4), Position::new(2, 2)); + assert_eq!(position_to_offset(text, Position::new(2, 2)), emoji + 4); + } + + #[test] + fn file_uri_encodes_spaces_and_cyrillic() { + let uri = path_to_uri(Path::new("/tmp/файл с пробелом.go")); + assert!(uri.as_str().starts_with("file:///tmp/")); + assert!(!uri.as_str().contains(' ')); + } + + /// Проверяет реальный цикл жизни с clangd. + /// + /// Тест помечен `ignore`, потому что требует установленного `clangd`. + /// Запуск: `cargo test --offline clangd_smoke -- --ignored --nocapture`. + #[test] + #[ignore = "требует установленный clangd"] + fn clangd_smoke() { + if Command::new("clangd").arg("--version").output().is_err() { + return; + } + + let directory = temp_smoke_dir("clangd"); + let manager = LspManager::new(); + manager + .start(LspConfig { + name: "clangd".to_owned(), + command: "clangd".to_owned(), + args: vec!["--background-index=false".to_owned()], + env: Vec::new(), + root: directory.clone(), + languages: vec!["c".to_owned()], + watcher: None, + initialization_options: None, + }) + .expect("clangd запускается"); + + let path = directory.join("main.c"); + let text = "int main(void) { return 0; }\n"; + std::fs::write(&path, text).unwrap(); + manager.sync_document(path_to_uri(&path), "c".to_owned(), text); + + let receiver = manager + .completion(text.find("return").unwrap()) + .expect("запрос автодополнения"); + let value = wait_response(receiver, Duration::from_secs(20)).expect("ответ clangd"); + assert!( + value.is_array() || value.get("items").is_some(), + "неожиданный ответ completion: {value}" + ); + + assert!(manager.stop("clangd")); + assert!(manager.server_names().is_empty()); + let _ = std::fs::remove_dir_all(&directory); + } + + /// Проверяет `gopls` с теми же настройками, что стоят в `init.lua`. + /// + /// Запуск: `cargo test --offline gopls_smoke -- --ignored --nocapture`. + #[test] + #[ignore = "требует установленный gopls"] + fn gopls_smoke() { + if Command::new("gopls").arg("version").output().is_err() { + return; + } + + let directory = temp_smoke_dir("gopls"); + std::fs::write(directory.join("go.mod"), "module smoke\n\ngo 1.21\n").unwrap(); + let cache = directory.join("cache"); + let gopath = directory.join("gopath"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::create_dir_all(&gopath).unwrap(); + + // В sandbox домашний каталог только для чтения, поэтому все кэши go/gopls + // направляем во временный каталог. На обычной машине это не нужно. + let env = vec![ + ("HOME".to_owned(), directory.to_string_lossy().into_owned()), + ( + "XDG_CACHE_HOME".to_owned(), + cache.to_string_lossy().into_owned(), + ), + ("GOPATH".to_owned(), gopath.to_string_lossy().into_owned()), + ( + "GOCACHE".to_owned(), + directory.join("gocache").to_string_lossy().into_owned(), + ), + ( + "GOMODCACHE".to_owned(), + directory.join("gomodcache").to_string_lossy().into_owned(), + ), + ("GOTOOLCHAIN".to_owned(), "local".to_owned()), + ]; + + let manager = LspManager::new(); + manager + .start(LspConfig { + name: "gopls".to_owned(), + command: "gopls".to_owned(), + args: vec!["serve".to_owned()], + env, + root: directory.clone(), + languages: vec!["go".to_owned()], + watcher: None, + initialization_options: Some(json!({ + "usePlaceholders": true, + "completeUnimported": true, + })), + }) + .expect("gopls запускается"); + + let text = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.\n}\n"; + let path = directory.join("main.go"); + std::fs::write(&path, text).unwrap(); + manager.sync_document(path_to_uri(&path), "go".to_owned(), text); + + let offset = text.find("fmt.").unwrap() + "fmt.".len(); + let receiver = manager.completion(offset).expect("запрос автодополнения"); + let value = wait_response(receiver, Duration::from_secs(60)).expect("ответ gopls"); + let count = if let Some(items) = value.as_array() { + items.len() + } else { + value + .get("items") + .and_then(Value::as_array) + .map_or(0, Vec::len) + }; + assert!(count > 0, "gopls не вернул автодополнение: {value}"); + + assert!(manager.stop("gopls")); + assert!(manager.server_names().is_empty()); + let _ = std::fs::remove_dir_all(&directory); + } + + fn temp_smoke_dir(prefix: &str) -> PathBuf { + let directory = std::env::temp_dir().join(format!( + "gpui-lsp-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + directory + } +} diff --git a/src/main.rs b/src/main.rs index e513c0b..18b37a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/script.rs b/src/script.rs index 1835f52..07b7302 100644 --- a/src/script.rs +++ b/src/script.rs @@ -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 { - let engine = Self { lua: Lua::new() }; + Self::new_in(api, script_dir()) + } + + /// Создаёт движок с явно заданным каталогом сценариев. + /// + /// Полезно тестам и будущим режимам, где каталог конфигурации приходит + /// не из глобального `--config`. + pub fn new_in(api: AppApi, directory: impl AsRef) -> mlua::Result { + 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::("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) } } diff --git a/src/tests.rs b/src/tests.rs index 645703e..800bc81 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -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>) -> (AppApi, Rc (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::(); + 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, "горизонтальная стрелка закрывает список"); +}