complition

This commit is contained in:
2026-10-30 20:50:04 +03:00
parent 56115302df
commit 55c3955897
15 changed files with 4103 additions and 32 deletions
+1
View File
@@ -0,0 +1 @@
target
+128
View File
@@ -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<RefCell<_>>` 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`, `<S-c>`, `<C-z>`, `<A-!r>`, 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).
Generated
+6
View File
@@ -2181,8 +2181,14 @@ dependencies = [
name = "gpui-editor-test" name = "gpui-editor-test"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"globset",
"gpui-kit", "gpui-kit",
"lsp-types",
"mlua", "mlua",
"notify",
"parking_lot",
"serde_json",
] ]
[[package]] [[package]]
+8
View File
@@ -13,6 +13,14 @@ gpui-kit = { version = "0.8.1", features = [
] } ] }
mlua = { version = "0.12", features = ["lua54", "vendored", "macros"] } 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] [dev-dependencies]
gpui-kit = { version = "0.8.1", features = ["test-support"] } gpui-kit = { version = "0.8.1", features = ["test-support"] }
+221
View File
@@ -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 |
| `"<S-c>"` | C при зажатом Shift |
| `"<C-z>"` | Z при зажатом Ctrl |
| `"<A-!r>"` | отпускание R при зажатом Alt |
Клавиша сравнивается с именем клавиши от GPUI. Caps Lock не важен. На Linux
и Windows буквы берутся по аппаратному keycode, поэтому `<C-q>` работает
и на русской раскладке.
```lua
app:map_key("<C-s>", function() app:save() end)
app:map_key("<!Esc>", function() app:close() end)
app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-t>", 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("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
app:open_tab()
```
+71 -1
View File
@@ -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 выполнен") print("[lua] init.lua выполнен")
@@ -12,6 +21,8 @@ print("[lua] init.lua выполнен")
-- app:next_tab() -- переключить на следующую, вернуть индекс -- app:next_tab() -- переключить на следующую, вернуть индекс
-- app:previous_tab() -- переключить на предыдущую, вернуть индекс -- app:previous_tab() -- переключить на предыдущую, вернуть индекс
-- app:open_tab() -- открыть пустую вкладку, вернуть индекс -- app:open_tab() -- открыть пустую вкладку, вернуть индекс
-- app:open(path) -- открыть файл с диска, вернуть индекс вкладки;
-- -- если файла нет — пустой буфер с таким именем
-- app:close_tab(index) -- true, если вкладка закрыта -- app:close_tab(index) -- true, если вкладка закрыта
-- app:active_text() -- текст активной вкладки -- app:active_text() -- текст активной вкладки
-- app:set_active_text(text) -- заменить текст активной вкладки -- app:set_active_text(text) -- заменить текст активной вкладки
@@ -37,6 +48,36 @@ print("[lua] init.lua выполнен")
-- app:minimize() -- app:minimize()
-- app:is_maximized() -- 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:open()
-- app.left_panel:close() -- app.left_panel:close()
@@ -64,6 +105,33 @@ print("[lua] init.lua выполнен")
app:set_language(0, "rust") app:set_language(0, "rust")
app:next_tab() 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 закрывает окно. -- Пример привязки: Ctrl+S сохраняет вкладку, отпускание Esc закрывает окно.
-- app:map_key("<C-s>", function() app:save() end) -- app:map_key("<C-s>", function() app:save() end)
-- app:map_key("<!Esc>", function() app:close() end) -- app:map_key("<!Esc>", function() app:close() end)
@@ -72,3 +140,5 @@ app:map_key("<C-q>", function() app:close() end)
app:map_key("<C-e>", function() app.right_panel:toggle() end) app:map_key("<C-e>", function() app.right_panel:toggle() end)
app:map_key("<C-t>", function() app.bottom_panel:toggle() end) app:map_key("<C-t>", function() app.bottom_panel:toggle() end)
app:open_tab()
+277 -3
View File
@@ -10,11 +10,16 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc; 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::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{Language, Workspace}; use crate::editor::{Language, Workspace};
use crate::keymap::{KeyBinding, KeyMap}; use crate::keymap::{KeyBinding, KeyMap};
use crate::lsp::{LspConfig, LspManager, WatcherConfig, document_uri};
use crate::panel::{PanelHandle, PanelState}; use crate::panel::{PanelHandle, PanelState};
thread_local! { thread_local! {
@@ -86,6 +91,8 @@ pub struct AppApi {
right_panel: PanelHandle, right_panel: PanelHandle,
#[lua(skip)] #[lua(skip)]
bottom_panel: PanelHandle, bottom_panel: PanelHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
} }
/// API одной панели окна. /// API одной панели окна.
@@ -145,6 +152,7 @@ impl AppApi {
left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))), left_panel: Rc::new(RefCell::new(PanelState::new(LEFT_PANEL_WIDTH))),
right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))), right_panel: Rc::new(RefCell::new(PanelState::new(RIGHT_PANEL_WIDTH))),
bottom_panel: Rc::new(RefCell::new(PanelState::new(BOTTOM_PANEL_HEIGHT))), 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())); INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api api
@@ -181,6 +189,12 @@ impl AppApi {
self.bottom_panel.clone() self.bottom_panel.clone()
} }
/// Менеджер LSP-серверов (для вида и тестов).
#[lua(skip)]
pub fn lsp_handle(&self) -> Arc<LspManager> {
self.lsp.clone()
}
/// Все открытые вкладки в порядке слева направо. /// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)] #[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами. // Часть публичного API приложения: вызывается внешним кодом и тестами.
@@ -236,10 +250,16 @@ impl AppApi {
#[lua(infallible)] #[lua(infallible)]
pub fn close_tab(&self, index: usize) -> bool { pub fn close_tab(&self, index: usize) -> bool {
let mut workspace = self.workspace.borrow_mut(); let mut workspace = self.workspace.borrow_mut();
if index >= workspace.documents().len() { let Some(document) = workspace.document(index) else {
return false; return false;
} };
let uri = document_uri(&document.name);
workspace.close(index); workspace.close(index);
drop(workspace);
if let Some(uri) = uri {
self.lsp.close_document(&uri);
}
true true
} }
@@ -254,7 +274,10 @@ impl AppApi {
let doc = workspace.active(); let doc = workspace.active();
(doc.name.clone(), doc.content.clone()) (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(); self.workspace.borrow_mut().mark_active_saved();
Ok(()) Ok(())
} }
@@ -311,6 +334,34 @@ impl AppApi {
workspace.active_index() workspace.active_index()
} }
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
let path = path.as_ref();
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(err),
};
let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut();
Ok(workspace.open_file(name, content, language))
}
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String) -> mlua::Result<usize> {
self.open(&path)
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`]. /// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
/// ///
/// Возвращает `false`, если вкладки с таким номером нет. /// Возвращает `false`, если вкладки с таким номером нет.
@@ -423,4 +474,227 @@ impl AppApi {
pub fn is_maximized(&self) -> bool { pub fn is_maximized(&self) -> bool {
WINDOW_MAXIMIZED.with(Cell::get) WINDOW_MAXIMIZED.with(Cell::get)
} }
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
#[lua(skip)]
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
self.lsp.start(config)
}
/// Запускает LSP-сервер.
///
/// Пример из Lua:
///
/// ```lua
/// app:run_lsp({
/// name = "gopls",
/// cmd = "/usr/local/bin/gopls",
/// args = { "serve" },
/// env = { GOFLAGS = "-mod=mod" },
/// languages = { "go" },
/// watcher = "**/*.go",
/// initialization_options = { usePlaceholders = true },
/// })
/// ```
///
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
/// `initialize` превращается в ошибку Lua.
#[lua(name = "run_lsp")]
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
let config = parse_lsp_config(&config)?;
self.run_lsp(config)
.map(|()| true)
.map_err(mlua::Error::RuntimeError)
}
/// Имена всех запущенных LSP-серверов.
#[lua(infallible)]
pub fn lsp_servers(&self) -> Vec<String> {
self.lsp.server_names()
}
/// Останавливает LSP-сервер по имени.
///
/// Возвращает `false`, если сервера с таким именем не было.
#[lua(infallible)]
pub fn stop_lsp(&self, name: String) -> bool {
self.lsp.stop(&name)
}
/// Перезапускает LSP-сервер с прежними настройками.
///
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
#[lua(infallible)]
pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name)
}
}
/// Разбирает Lua-таблицу, переданную в `app:run_lsp`.
fn parse_lsp_config(table: &mlua::Table) -> mlua::Result<LspConfig> {
let name: String = table
.get("name")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый name".to_owned()))?;
let command = ["cmd", "command", "path", "binary"]
.iter()
.find_map(|key| table.get::<String>(*key).ok())
.ok_or_else(|| {
mlua::Error::RuntimeError(
"в run_lsp нужен путь к серверу: cmd, command, path или binary".to_owned(),
)
})?;
let args = match table.get::<mlua::Table>("args") {
Ok(args) => args
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>()?,
Err(_) => Vec::new(),
};
let env = match table.get::<mlua::Table>("env") {
Ok(env) => env
.pairs::<String, String>()
.collect::<mlua::Result<Vec<_>>>()?,
Err(_) => Vec::new(),
};
let root = table
.get::<String>("root")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let root = if root.is_absolute() {
root
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(root)
};
let root = std::fs::canonicalize(&root).unwrap_or(root);
let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(),
};
let initialization_options = ["initialization_options", "init_options"]
.iter()
.find_map(|key| match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)),
})
.transpose()?;
Ok(LspConfig {
name,
command,
args,
env,
root,
languages,
watcher: parse_watcher(table)?,
initialization_options,
})
}
fn parse_watcher(table: &mlua::Table) -> mlua::Result<Option<WatcherConfig>> {
let value = ["watcher", "file_watcher", "watch"].iter().find_map(|key| {
match table.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(value),
}
});
let Some(value) = value else {
return Ok(None);
};
match value {
mlua::Value::String(pattern) => Ok(Some(WatcherConfig {
patterns: vec![pattern.to_string_lossy()],
ignore: Vec::new(),
})),
mlua::Value::Boolean(true) => Ok(Some(WatcherConfig {
patterns: vec!["**/*".to_owned()],
ignore: Vec::new(),
})),
mlua::Value::Boolean(false) | mlua::Value::Nil => Ok(None),
mlua::Value::Table(config) => {
let patterns = ["pattern", "patterns"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
let ignore = ["ignore", "ignored"]
.iter()
.find_map(|key| match config.get::<mlua::Value>(*key) {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_strings(value)),
})
.transpose()?
.unwrap_or_default();
if patterns.is_empty() {
Ok(None)
} else {
Ok(Some(WatcherConfig { patterns, ignore }))
}
}
_ => Err(mlua::Error::RuntimeError(
"watcher должен быть строкой, таблицей или boolean".to_owned(),
)),
}
}
fn lua_strings(value: mlua::Value) -> mlua::Result<Vec<String>> {
match value {
mlua::Value::String(value) => Ok(vec![value.to_string_lossy()]),
mlua::Value::Table(table) => table
.sequence_values::<String>()
.collect::<mlua::Result<Vec<_>>>(),
mlua::Value::Nil => Ok(Vec::new()),
_ => Err(mlua::Error::RuntimeError(
"ожидалась строка или массив строк".to_owned(),
)),
}
}
fn lua_to_json(value: mlua::Value) -> mlua::Result<serde_json::Value> {
match value {
mlua::Value::Nil => Ok(serde_json::Value::Null),
mlua::Value::Boolean(value) => Ok(serde_json::Value::Bool(value)),
mlua::Value::Integer(value) => Ok(serde_json::Value::Number(value.into())),
mlua::Value::Number(value) => Ok(Number::from_f64(value)
.map(serde_json::Value::Number)
.unwrap_or(serde_json::Value::Null)),
mlua::Value::String(value) => Ok(serde_json::Value::String(value.to_string_lossy())),
mlua::Value::Table(table) => {
let length = table.raw_len();
if length > 0 {
let mut items = Vec::with_capacity(length);
for value in table.sequence_values::<mlua::Value>() {
items.push(lua_to_json(value?)?);
}
Ok(serde_json::Value::Array(items))
} else {
let mut map = serde_json::Map::new();
for pair in table.pairs::<mlua::Value, mlua::Value>() {
let (key, value) = pair?;
let key = match key {
mlua::Value::String(key) => key.to_string_lossy(),
mlua::Value::Integer(key) => key.to_string(),
mlua::Value::Number(key) => key.to_string(),
_ => continue,
};
map.insert(key, lua_to_json(value)?);
}
Ok(serde_json::Value::Object(map))
}
}
_ => Ok(serde_json::Value::Null),
}
} }
+922 -9
View File
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
//! Элементы интерфейса автодополнения.
//!
//! Здесь живёт всплывающий список, который привязывается к карете редактора
//! через свежую геометрию prepaint. Логика запросов к LSP и состояние выбора
//! находятся в [`MyApp`](crate::app::MyApp): этот модуль занимается только
//! отображением и небольшими преобразованиями элементов `CompletionItem`.
use std::ops::Range;
use gpui_kit::component::input::{EditorState, PrepaintCaretGeometry};
use gpui_kit::{
AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, Hsla,
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Size, Style, WeakEntity, Window, px,
};
use lsp_types::{
CompletionItem, CompletionItemKind, CompletionResponse, CompletionTextEdit, Documentation,
InsertTextFormat,
};
use crate::lsp::{offset_to_position, position_to_offset};
/// Функция, которая строит содержимое поповера в его `prepaint`.
///
/// Второй аргумент — признак того, что поповер будет нарисован над строкой
/// с каретой: в этом случае содержимое выравнивается по нижнему краю.
type PopupBuilder = Box<dyn Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement>;
/// Отступ от краёв окна, в который поповер не заходит.
pub(crate) const POPUP_MARGIN: f32 = 8.;
/// Зазор между каретой и краем поповера.
const POPUP_GAP: f32 = 4.;
/// Всплывающий список, привязанный к карете редактора.
///
/// Как и встроенные поповеры gpui-component, элемент разрешает позицию в
/// `prepaint`, то есть по геометрии текущего кадра, а не предыдущего.
///
/// Если снизу от кареты нет места, поповер раскрывается вверх; если он не
/// помещается справа, он сдвигается влево, оставаясь внутри окна.
pub struct CaretPopup {
editor: WeakEntity<EditorState>,
build: PopupBuilder,
content: Option<AnyElement>,
}
impl CaretPopup {
pub fn new(
editor: WeakEntity<EditorState>,
build: impl Fn(Point<Pixels>, bool, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
editor,
build: Box::new(build),
content: None,
}
}
}
impl Element for CaretPopup {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let layout = window.request_layout(Style::default(), [], cx);
(layout, ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) {
let Some(editor) = self.editor.upgrade() else {
return;
};
let Some(geometry) = editor.read(cx).prepaint_caret_geometry() else {
return;
};
let anchor = caret_anchor(geometry);
let viewport = window.viewport_size();
let available = Size {
width: AvailableSpace::Definite(viewport.width),
height: AvailableSpace::Definite(viewport.height),
};
// Сначала меряем содержимое, чтобы решить, помещается ли оно снизу.
let mut content = (self.build)(anchor, false, window, cx);
let size = content.layout_as_root(available, window, cx);
let placement = popup_origin(anchor, caret_top(geometry), size, viewport);
// Над строкой поповеры выравниваются по нижнему краю. Это свойство
// их общего контейнера, поэтому для верхнего варианта собираем его
// заново с другим выравниванием.
if placement.above {
content = (self.build)(anchor, true, window, cx);
content.layout_as_root(available, window, cx);
}
content.prepaint_at(placement.origin, window, cx);
self.content = Some(content);
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(content) = self.content.as_mut() {
content.paint(window, cx);
}
}
}
impl IntoElement for CaretPopup {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
fn caret_anchor(geometry: PrepaintCaretGeometry) -> Point<Pixels> {
Point::new(
geometry.cursor_bounds().origin.x - px(POPUP_GAP),
caret_top(geometry) + geometry.line_height() + px(POPUP_GAP),
)
}
/// Верхняя граница строки с каретой в координатах окна.
fn caret_top(geometry: PrepaintCaretGeometry) -> Pixels {
geometry.cursor_bounds().origin.y + geometry.scroll_offset().y
}
/// Положение поповера и то, нарисован ли он над строкой с каретой.
#[derive(Clone, Copy, Debug, PartialEq)]
struct PopupPlacement {
origin: Point<Pixels>,
above: bool,
}
/// Выбирает положение поповера так, чтобы он целиком помещался в окне.
///
/// По умолчанию поповер стоит под каретой. Если снизу места мало, он
/// переносится над строкой; если не помещается справа, сдвигается влево.
fn popup_origin(
anchor: Point<Pixels>,
caret_top: Pixels,
size: Size<Pixels>,
viewport: Size<Pixels>,
) -> PopupPlacement {
let margin = px(POPUP_MARGIN);
let mut origin = anchor;
let mut above = false;
if origin.y + size.height > viewport.height - margin {
above = true;
let above_y = caret_top - size.height - px(POPUP_GAP);
origin.y = if above_y >= margin {
above_y
} else {
(viewport.height - margin - size.height).max(margin)
};
}
origin.x = origin.x.max(margin);
if origin.x + size.width > viewport.width - margin {
origin.x = (viewport.width - margin - size.width).max(margin);
}
PopupPlacement { origin, above }
}
/// Начало слова под каретой (для замены префикса при вставке).
pub fn word_start(text: &str, cursor: usize) -> usize {
let cursor = clamp_offset(text, cursor);
let mut start = cursor;
for (index, ch) in text[..cursor].char_indices().rev() {
if ch.is_alphanumeric() || ch == '_' {
start = index;
} else {
break;
}
}
start
}
/// Разбирает ответ `textDocument/completion`.
pub fn parse_completion_response(value: serde_json::Value) -> Vec<CompletionItem> {
if value.is_null() {
return Vec::new();
}
serde_json::from_value::<CompletionResponse>(value)
.map(|response| match response {
CompletionResponse::Array(items) => items,
CompletionResponse::List(list) => list.items,
})
.unwrap_or_default()
}
/// Текст документации элемента в виде Markdown.
pub fn documentation_text(documentation: &Documentation) -> String {
match documentation {
Documentation::String(text) => text.clone(),
Documentation::MarkupContent(content) => content.value.clone(),
}
}
/// Запасной текст, если сервер не прислал документацию.
pub fn fallback_documentation(item: &CompletionItem) -> String {
let kind = completion_kind_name(item.kind);
match item.detail.as_ref() {
Some(detail) if !detail.is_empty() => format!("`{detail}`\n\n{kind}"),
_ => format!("**{}**\n\n{kind}", item.label),
}
}
/// Ключ кэша документации: разные элементы с одинаковым именем в разных
/// контекстах (например, `len` у разных типов) не должны перепутаться.
pub fn item_key(item: &CompletionItem) -> String {
format!(
"{}\u{1}{}\u{1}{}",
item.label,
item.detail.as_deref().unwrap_or_default(),
item.kind
.map(|kind| format!("{kind:?}"))
.unwrap_or_default(),
)
}
/// Человекочитаемый тип элемента.
pub fn completion_kind_name(kind: Option<CompletionItemKind>) -> &'static str {
match kind {
Some(CompletionItemKind::METHOD) => "метод",
Some(CompletionItemKind::FUNCTION) => "функция",
Some(CompletionItemKind::CONSTRUCTOR) => "конструктор",
Some(CompletionItemKind::FIELD) => "поле",
Some(CompletionItemKind::VARIABLE) => "переменная",
Some(CompletionItemKind::CONSTANT) => "константа",
Some(CompletionItemKind::STRUCT) => "структура",
Some(CompletionItemKind::CLASS) => "класс",
Some(CompletionItemKind::INTERFACE) => "интерфейс",
Some(CompletionItemKind::ENUM) => "перечисление",
Some(CompletionItemKind::ENUM_MEMBER) => "элемент перечисления",
Some(CompletionItemKind::MODULE) => "модуль",
Some(CompletionItemKind::PROPERTY) => "свойство",
Some(CompletionItemKind::KEYWORD) => "ключевое слово",
Some(CompletionItemKind::SNIPPET) => "шаблон",
Some(CompletionItemKind::TYPE_PARAMETER) => "параметр типа",
Some(CompletionItemKind::OPERATOR) => "оператор",
Some(CompletionItemKind::EVENT) => "событие",
Some(CompletionItemKind::FILE) => "файл",
Some(CompletionItemKind::FOLDER) => "каталог",
Some(CompletionItemKind::TEXT) => "текст",
Some(_) => "значение",
None => "элемент",
}
}
/// Иконка для типа элемента автодополнения.
///
/// Используется полный каталог иконок gpui-kit-assets, а не компактный набор
/// компонента: в нём есть `square-function`, `variable`, `braces` и другие
/// привычные символы IDE.
pub fn completion_icon(kind: Option<CompletionItemKind>) -> gpui_kit::assets::IconName {
use gpui_kit::assets::IconName as Icon;
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => Icon::SquareFunction,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM
| CompletionItemKind::CONSTRUCTOR,
) => Icon::Box,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => Icon::Variable,
Some(CompletionItemKind::KEYWORD) => Icon::Type,
Some(CompletionItemKind::MODULE) => Icon::Braces,
Some(CompletionItemKind::FILE) => Icon::FileText,
Some(CompletionItemKind::FOLDER) => Icon::Folder,
Some(CompletionItemKind::SNIPPET) => Icon::Component,
Some(CompletionItemKind::EVENT) => Icon::Activity,
Some(CompletionItemKind::TYPE_PARAMETER) => Icon::TypeOutline,
Some(CompletionItemKind::OPERATOR) => Icon::CaseSensitive,
Some(_) => Icon::Circle,
None => Icon::Sparkles,
}
}
/// Цвет иконки для типа элемента.
pub fn completion_icon_color(
kind: Option<CompletionItemKind>,
theme: &gpui_kit::component::Theme,
) -> Hsla {
match kind {
Some(CompletionItemKind::FUNCTION | CompletionItemKind::METHOD) => theme.blue,
Some(
CompletionItemKind::STRUCT
| CompletionItemKind::CLASS
| CompletionItemKind::INTERFACE
| CompletionItemKind::ENUM,
) => theme.yellow,
Some(
CompletionItemKind::VARIABLE
| CompletionItemKind::FIELD
| CompletionItemKind::PROPERTY
| CompletionItemKind::CONSTANT
| CompletionItemKind::ENUM_MEMBER,
) => theme.green,
Some(CompletionItemKind::KEYWORD) => theme.magenta,
Some(CompletionItemKind::MODULE) => theme.cyan,
_ => theme.muted_foreground,
}
}
/// Диапазон подсветки совпадения в подписи элемента.
pub fn highlight_range(query: &str, label: &str) -> Option<Range<usize>> {
if query.is_empty() {
return None;
}
let query = query.to_lowercase();
let lowercase = label.to_lowercase();
let start = lowercase.find(&query)?;
// Нижний регистр иногда меняет длину строки, поэтому диапазон
// проверяем по исходной метке и не отдаём разрезанный символ.
let end = (start + query.len()).min(label.len());
if start < end && label.is_char_boundary(start) && label.is_char_boundary(end) {
Some(start..end)
} else {
None
}
}
/// Подготавливает замену текста при выборе элемента.
///
/// Возвращает байтовый диапазон в документе и вставляемый текст. Если сервер
/// прислал `textEdit`, используются его границы; иначе заменяется слово под
/// каретой. Сниппеты раскрываются в плоский текст: табов-заглушек в нашем
/// редакторе пока нет.
pub fn completion_edit(
item: &CompletionItem,
text: &str,
start: usize,
cursor: usize,
) -> (Range<usize>, String) {
let mut range = clamp_offset(text, start)..clamp_offset(text, cursor);
let mut new_text = item
.insert_text
.clone()
.unwrap_or_else(|| item.label.clone());
if let Some(edit) = &item.text_edit {
match edit {
CompletionTextEdit::Edit(edit) => {
range = position_to_offset(text, edit.range.start)
..position_to_offset(text, edit.range.end);
new_text.clone_from(&edit.new_text);
}
CompletionTextEdit::InsertAndReplace(edit) => {
range = position_to_offset(text, edit.replace.start)
..position_to_offset(text, edit.replace.end);
new_text.clone_from(&edit.new_text);
}
}
}
if item.insert_text_format == Some(InsertTextFormat::SNIPPET) {
new_text = strip_snippet(&new_text);
}
let start = clamp_offset(text, range.start).min(text.len());
let end = clamp_offset(text, range.end).max(start).min(text.len());
(start..end, new_text)
}
/// Разворачивает LSP-сниппет в обычный текст.
pub fn strip_snippet(snippet: &str) -> String {
let chars: Vec<char> = snippet.chars().collect();
let mut output = String::new();
let mut index = 0;
while index < chars.len() {
if chars[index] == '$' {
if index + 1 < chars.len() && chars[index + 1] == '{' {
if let Some(end) = (index + 2..chars.len()).find(|&pos| chars[pos] == '}') {
let inner: String = chars[index + 2..end].iter().collect();
if let Some((_, default)) = inner.split_once(':') {
output.push_str(default);
}
index = end + 1;
continue;
}
} else if index + 1 < chars.len() && chars[index + 1].is_ascii_digit() {
index += 2;
continue;
} else if index + 1 < chars.len() && chars[index + 1] == '$' {
output.push('$');
index += 2;
continue;
}
}
output.push(chars[index]);
index += 1;
}
output
}
fn clamp_offset(text: &str, offset: usize) -> usize {
let mut offset = offset.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
/// Позиция LSP для диапазона замены (удобно для `apply_lsp_edits`).
pub fn range_for_lsp(text: &str, range: &Range<usize>) -> lsp_types::Range {
lsp_types::Range {
start: offset_to_position(text, range.start),
end: offset_to_position(text, range.end),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(label: &str) -> CompletionItem {
CompletionItem {
label: label.to_owned(),
..Default::default()
}
}
#[test]
fn word_start_stops_at_non_identifier_characters() {
assert_eq!(word_start("let value", 9), 4);
assert_eq!(word_start("obj.field", 9), 4);
assert_eq!(word_start("obj.", 4), 4);
assert_eq!(word_start("привет", 6), 0);
}
#[test]
fn snippets_lose_placeholders() {
assert_eq!(strip_snippet("println!(${1:value})"), "println!(value)");
assert_eq!(strip_snippet("foo($1, $0)"), "foo(, )");
assert_eq!(strip_snippet("cost $$5"), "cost $5");
}
#[test]
fn popup_drops_below_the_caret_when_there_is_room() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let anchor = Point::new(px(100.), px(200.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, px(180.), size, viewport);
assert_eq!(placement.origin, Point::new(px(100.), px(200.)));
assert!(!placement.above);
}
#[test]
fn popup_flips_above_when_bottom_space_is_short() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let caret_top = px(480.);
let anchor = Point::new(px(100.), px(500.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, caret_top, size, viewport);
assert_eq!(placement.origin.y, caret_top - px(200.) - px(POPUP_GAP));
assert_eq!(placement.origin.x, px(100.));
assert!(placement.above);
}
#[test]
fn popup_shifts_left_when_it_does_not_fit_on_the_right() {
let viewport = Size {
width: px(800.),
height: px(600.),
};
let anchor = Point::new(px(700.), px(100.));
let size = Size {
width: px(300.),
height: px(200.),
};
let placement = popup_origin(anchor, px(80.), size, viewport);
assert_eq!(placement.origin.x, px(800.) - px(POPUP_MARGIN) - px(300.));
assert_eq!(placement.origin.y, px(100.));
assert!(!placement.above);
}
#[test]
fn completion_icons_exist_in_the_bundled_asset_catalog() {
use gpui_kit::AssetSource;
use gpui_kit::assets::AllAssets;
// `Assets` — компактный набор компонента, и иконок автодополнения в
// нём нет. Приложение подключает `AllAssets`; тест следит за тем,
// чтобы каждая иконка списка действительно там была.
let kinds = [
None,
Some(CompletionItemKind::FUNCTION),
Some(CompletionItemKind::METHOD),
Some(CompletionItemKind::STRUCT),
Some(CompletionItemKind::CLASS),
Some(CompletionItemKind::INTERFACE),
Some(CompletionItemKind::ENUM),
Some(CompletionItemKind::CONSTRUCTOR),
Some(CompletionItemKind::VARIABLE),
Some(CompletionItemKind::FIELD),
Some(CompletionItemKind::PROPERTY),
Some(CompletionItemKind::CONSTANT),
Some(CompletionItemKind::ENUM_MEMBER),
Some(CompletionItemKind::KEYWORD),
Some(CompletionItemKind::MODULE),
Some(CompletionItemKind::FILE),
Some(CompletionItemKind::FOLDER),
Some(CompletionItemKind::SNIPPET),
Some(CompletionItemKind::EVENT),
Some(CompletionItemKind::TYPE_PARAMETER),
Some(CompletionItemKind::OPERATOR),
Some(CompletionItemKind::TEXT),
];
for kind in kinds {
let path = completion_icon(kind).path();
assert!(
AllAssets
.load(&path)
.expect("ошибка чтения ассетов")
.is_some(),
"в бандле нет иконки {path}"
);
}
}
#[test]
fn completion_edit_uses_the_lsp_range() {
let text = "let value";
let mut item = item("value");
item.text_edit = Some(CompletionTextEdit::Edit(lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position::new(0, 4),
end: lsp_types::Position::new(0, 9),
},
new_text: "answer".to_owned(),
}));
let (range, replacement) = completion_edit(&item, text, 4, 9);
assert_eq!(range, 4..9);
assert_eq!(replacement, "answer");
}
}
+179 -3
View File
@@ -1,5 +1,9 @@
//! Константы оформления окна и сценариев. //! Константы оформления окна и сценариев.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Ширина окна при запуске. /// Ширина окна при запуске.
pub const WINDOW_WIDTH: f32 = 900.0; 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; pub const BOTTOM_PANEL_HEIGHT: f32 = 160.0;
/// Каталог, в котором приложение ищет Lua-сценарии. /// Каталог, в котором приложение ищет Lua-сценарии по умолчанию.
/// ///
/// По умолчанию это текущий рабочий каталог процесса — именно там приложение /// Это текущий рабочий каталог процесса — именно там приложение ожидает
/// ожидает `init.lua`. /// `init.lua`, если не передан аргумент `--config`. Конкретный каталог всегда
/// возвращает [`script_dir`].
pub const SCRIPT_DIR: &str = "."; pub const SCRIPT_DIR: &str = ".";
/// Каталог сценариев, заданный через `--config`.
static SCRIPT_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
/// Файл, который нужно открыть при старте (первый позиционный аргумент).
static STARTUP_FILE: OnceLock<PathBuf> = OnceLock::new();
/// Разобранные аргументы командной строки.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CommandLine {
/// Каталог, из которого брать `init.lua`.
pub config_dir: Option<PathBuf>,
/// Файл, открываемый при старте как `vim file.go`.
pub file: Option<PathBuf>,
}
/// Каталог, из которого загружаются `init.lua`, `include` и `require`.
///
/// Возвращает значение `--config`, если оно было задано, иначе текущий
/// рабочий каталог.
pub fn script_dir() -> &'static Path {
SCRIPT_DIR_OVERRIDE
.get()
.map_or_else(|| Path::new(SCRIPT_DIR), PathBuf::as_path)
}
/// Файл, который надо открыть при старте редактора.
pub fn startup_file() -> Option<&'static Path> {
STARTUP_FILE.get().map(PathBuf::as_path)
}
/// Запоминает каталог сценариев из аргумента `--config`.
///
/// Путь канонизируется, поэтому относительный `--config ./scripts` работает
/// относительно каталога запуска, а `include`/`require` видят абсолютные пути.
pub fn set_script_dir(path: PathBuf) -> Result<(), String> {
let canonical = std::fs::canonicalize(&path).map_err(|err| {
format!(
"не удалось открыть каталог конфигурации {}: {err}",
path.display()
)
})?;
if !canonical.is_dir() {
return Err(format!(
"--config: {} не является каталогом",
canonical.display()
));
}
SCRIPT_DIR_OVERRIDE
.set(canonical)
.map_err(|_| "каталог конфигурации уже задан".to_owned())
}
/// Запоминает файл, открываемый при старте.
pub fn set_startup_file(path: PathBuf) -> Result<(), String> {
STARTUP_FILE
.set(path)
.map_err(|_| "файл для открытия уже задан".to_owned())
}
/// Разбирает аргументы командной строки.
///
/// Поддерживаются:
/// * `файл` — первый позиционный аргумент, как в `vim file.go`;
/// * `--config <каталог>` и `--config=<каталог>`;
/// * `--` — всё после него считается позиционным (для имён, начинающихся с `-`).
///
/// Неизвестные флаги игнорируются, чтобы запуск не ломался от лишних
/// аргументов окружения/обёрток.
pub fn parse_command_line<I>(args: I) -> Result<CommandLine, String>
where
I: IntoIterator<Item = OsString>,
{
let mut command_line = CommandLine::default();
let mut args = args.into_iter();
let mut positional_only = false;
while let Some(arg) = args.next() {
let text = arg.to_string_lossy();
if positional_only {
if command_line.file.is_none() {
command_line.file = Some(PathBuf::from(arg));
}
continue;
}
if text == "--" {
positional_only = true;
} else if text == "--config" {
let value = args
.next()
.ok_or_else(|| "--config требует путь к каталогу".to_owned())?;
command_line.config_dir = Some(PathBuf::from(value));
} else if let Some(value) = text.strip_prefix("--config=") {
if value.is_empty() {
return Err("--config требует путь к каталогу".to_owned());
}
command_line.config_dir = Some(PathBuf::from(value));
} else if !text.starts_with('-') && command_line.file.is_none() {
command_line.file = Some(PathBuf::from(arg));
}
}
Ok(command_line)
}
/// Имя стартового сценария, который выполняется при запуске приложения. /// Имя стартового сценария, который выполняется при запуске приложения.
pub const STARTUP_SCRIPT: &str = "init.lua"; pub const STARTUP_SCRIPT: &str = "init.lua";
#[cfg(test)]
mod tests {
use super::*;
fn args(values: &[&str]) -> Vec<OsString> {
values.iter().map(OsString::from).collect()
}
#[test]
fn config_dir_is_read_from_both_argument_forms() {
assert_eq!(
parse_command_line(args(&["--config", "/tmp/project"]))
.unwrap()
.config_dir,
Some(PathBuf::from("/tmp/project"))
);
assert_eq!(
parse_command_line(args(&["--config=/tmp/project"]))
.unwrap()
.config_dir,
Some(PathBuf::from("/tmp/project"))
);
assert!(parse_command_line(args(&[])).unwrap().config_dir.is_none());
}
#[test]
fn config_dir_requires_a_value() {
assert!(parse_command_line(args(&["--config"])).is_err());
assert!(parse_command_line(args(&["--config="])).is_err());
}
#[test]
fn unknown_arguments_do_not_hide_the_config_dir() {
let parsed = parse_command_line(args(&["--verbose", "--config", "cfg"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
}
#[test]
fn first_positional_argument_is_the_file_to_open() {
let parsed = parse_command_line(args(&["main.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
let parsed = parse_command_line(args(&["--config", "cfg", "main.go"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
let parsed = parse_command_line(args(&["main.go", "--config=cfg"])).unwrap();
assert_eq!(parsed.config_dir, Some(PathBuf::from("cfg")));
assert_eq!(parsed.file, Some(PathBuf::from("main.go")));
}
#[test]
fn double_dash_allows_file_names_starting_with_dash() {
let parsed = parse_command_line(args(&["--", "-weird.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("-weird.go")));
}
#[test]
fn only_the_first_positional_argument_is_opened() {
let parsed = parse_command_line(args(&["first.go", "second.go"])).unwrap();
assert_eq!(parsed.file, Some(PathBuf::from("first.go")));
}
}
+108
View File
@@ -75,6 +75,67 @@ impl Language {
Self::Python => "python", Self::Python => "python",
} }
} }
/// Идентификатор языка для LSP (`languageId` в `textDocument/didOpen`).
///
/// Отличается от [`Self::name`] тем же, чем `highlight_name`: LSP ждёт
/// `javascript`, а не `js`.
pub fn lsp_name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Go => "go",
Self::JavaScript => "javascript",
Self::Json => "json",
Self::Python => "python",
}
}
/// Определяет язык подсветки по имени файла.
///
/// Используется при открытии файла с диска (`app:open(path)`), чтобы
/// подсветка включалась автоматически там, где для неё есть грамматика.
pub fn from_path(path: &str) -> Option<Self> {
let extension = path.rsplit_once('.')?.1.to_ascii_lowercase();
match extension.as_str() {
"rs" => Some(Self::Rust),
"go" => Some(Self::Go),
"js" | "mjs" | "cjs" | "jsx" => Some(Self::JavaScript),
"json" => Some(Self::Json),
"py" => Some(Self::Python),
_ => None,
}
}
}
/// Язык LSP по расширению имени файла.
///
/// Нужен для документов, у которых подсветка ещё не включена: LSP-сервер
/// выбирается и получает `languageId` независимо от tree-sitter.
pub fn language_id_from_path(name: &str) -> &'static str {
let extension = name
.rsplit_once('.')
.map(|(_, extension)| extension.to_ascii_lowercase());
match extension.as_deref() {
Some("rs") => "rust",
Some("go") => "go",
Some("js" | "mjs" | "cjs") => "javascript",
Some("jsx") => "javascriptreact",
Some("ts") => "typescript",
Some("tsx") => "typescriptreact",
Some("json") => "json",
Some("py") => "python",
Some("md" | "markdown") => "markdown",
Some("toml") => "toml",
Some("yaml" | "yml") => "yaml",
Some("lua") => "lua",
Some("c" | "h") => "c",
Some("cc" | "cpp" | "cxx" | "hpp" | "hh") => "cpp",
Some("sh" | "bash") => "shellscript",
Some("html" | "htm") => "html",
Some("css") => "css",
_ => "plaintext",
}
} }
/// Открытый в редакторе документ. /// Открытый в редакторе документ.
@@ -115,6 +176,17 @@ impl Document {
self.content != self.saved_content 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) { pub fn mark_saved(&mut self) {
self.saved_content = self.content.clone(); self.saved_content = self.content.clone();
@@ -207,6 +279,42 @@ impl Workspace {
self.bump(); self.bump();
} }
/// Открывает файл с диска как вкладку и делает её активной.
///
/// Если вкладка с таким именем уже открыта и не содержит несохранённых
/// изменений, её содержимое обновляется из файла — повторный `app:open`
/// не создаёт дубликат. Грязная вкладка не трогается: открывается новая.
pub fn open_file(
&mut self,
name: impl Into<String>,
content: impl Into<String>,
language: Option<Language>,
) -> usize {
let name = name.into();
let content = content.into();
if let Some(index) = self
.documents
.iter()
.position(|document| document.name == name && !document.is_dirty())
{
let document = &mut self.documents[index];
document.content = content;
document.mark_saved();
document.language = language;
self.active = index;
self.bump();
return index;
}
let mut document = Document::new(name, content);
document.language = language;
self.documents.push(document);
self.active = self.documents.len() - 1;
self.bump();
self.active
}
/// Закрывает вкладку и выбирает соседнюю. /// Закрывает вкладку и выбирает соседнюю.
/// ///
/// Если закрывается последняя вкладка, вместо неё открывается пустой документ. /// Если закрывается последняя вкладка, вместо неё открывается пустой документ.
+1227
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -7,9 +7,11 @@
mod api; mod api;
mod app; mod app;
mod completion;
mod constants; mod constants;
mod editor; mod editor;
mod keymap; mod keymap;
mod lsp;
mod panel; mod panel;
mod script; mod script;
@@ -23,7 +25,15 @@ use app::MyApp;
use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH}; use constants::{MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH};
fn main() { 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); 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, минимум /// Параметры окна в стиле исходного приложения: размер 900×600, минимум
/// 420×280 и собственный заголовок вместо системного. /// 420×280 и собственный заголовок вместо системного.
fn window_options() -> WindowOptions { fn window_options() -> WindowOptions {
+35 -13
View File
@@ -1,20 +1,24 @@
//! Выполнение Lua-сценариев приложения. //! Выполнение Lua-сценариев приложения.
//! //!
//! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`SCRIPT_DIR`] //! При запуске приложение выполняет [`STARTUP_SCRIPT`] из каталога [`script_dir`]
//! (см. [`ScriptEngine::run_startup`]). Скриптам доступны: //! (см. [`ScriptEngine::run_startup`]). Скриптам доступны:
//! //!
//! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются //! * объект `app` — управление окном и редактором (методы [`AppApi`], вызываются
//! через двоеточие: `app:next_tab()`); //! через двоеточие: `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 std::path::{Path, PathBuf};
use mlua::{Lua, Table}; use mlua::{Lua, Table};
use crate::api::{AppApi, TabInfo}; 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" }`. /// Вкладка превращается в Lua-таблицу `{ index = 0, title = "main.rs" }`.
impl mlua::IntoLua for TabInfo { impl mlua::IntoLua for TabInfo {
@@ -29,24 +33,40 @@ impl mlua::IntoLua for TabInfo {
/// Движок Lua и состояние его глобального окружения. /// Движок Lua и состояние его глобального окружения.
pub struct ScriptEngine { pub struct ScriptEngine {
lua: Lua, lua: Lua,
/// Каталог, относительно которого ищутся `init.lua`, `include` и `require`.
script_dir: PathBuf,
} }
impl ScriptEngine { impl ScriptEngine {
/// Создаёт движок и публикует в нём API приложения. /// Создаёт движок и публикует в нём API приложения.
///
/// Каталог сценариев берётся из [`script_dir`] (то есть из `--config`
/// или текущего рабочего каталога).
pub fn new(api: AppApi) -> mlua::Result<Self> { pub fn new(api: AppApi) -> mlua::Result<Self> {
let engine = Self { lua: Lua::new() }; Self::new_in(api, script_dir())
}
/// Создаёт движок с явно заданным каталогом сценариев.
///
/// Полезно тестам и будущим режимам, где каталог конфигурации приходит
/// не из глобального `--config`.
pub fn new_in(api: AppApi, directory: impl AsRef<Path>) -> mlua::Result<Self> {
let engine = Self {
lua: Lua::new(),
script_dir: directory.as_ref().to_path_buf(),
};
engine.expose_app_api(api)?; engine.expose_app_api(api)?;
engine.configure_require()?; engine.configure_require()?;
Ok(engine) Ok(engine)
} }
/// Выполняет стартовый сценарий из [`SCRIPT_DIR`]. /// Выполняет стартовый сценарий из каталога движка.
/// ///
/// # Errors /// # Errors
/// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём /// Возвращает ошибку, если файла нет, его не удалось прочитать или в нём
/// есть синтаксическая/исполняемая ошибка. /// есть синтаксическая/исполняемая ошибка.
pub fn run_startup(&self) -> mlua::Result<()> { 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-файл в глобальном окружении движка. /// Выполняет Lua-файл в глобальном окружении движка.
@@ -73,7 +93,7 @@ impl ScriptEngine {
if let Ok(package) = globals.get::<Table>("package") { if let Ok(package) = globals.get::<Table>("package") {
let current: String = package.get("path").unwrap_or_default(); 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 extra = format!("{dir}/?.lua;{dir}/?/init.lua");
let path = if current.is_empty() { let path = if current.is_empty() {
extra extra
@@ -85,10 +105,12 @@ impl ScriptEngine {
// include выполняет файл сразу, как dofile, но путь по умолчанию // include выполняет файл сразу, как dofile, но путь по умолчанию
// отсчитывается от каталога сценариев, а не от cwd. // отсчитывается от каталога сценариев, а не от cwd.
let directory = self.script_dir.clone();
globals.set( globals.set(
"include", "include",
self.lua self.lua.create_function(move |lua, path: String| {
.create_function(|lua, path: String| run_file(lua, &script_path(&path)))?, run_file(lua, &script_path(&directory, &path))
})?,
)?; )?;
Ok(()) 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); let path = Path::new(path);
if path.is_absolute() { if path.is_absolute() {
path.to_path_buf() path.to_path_buf()
} else { } else {
Path::new(SCRIPT_DIR).join(path) directory.join(path)
} }
} }
+294 -1
View File
@@ -7,7 +7,7 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; 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::api::{AppApi, WindowCommand, drain_window_commands};
use crate::app::MyApp; use crate::app::MyApp;
@@ -21,6 +21,35 @@ fn install_api(workspace: Rc<RefCell<Workspace>>) -> (AppApi, Rc<RefCell<KeyMap>
(api, keymap) (api, keymap)
} }
#[test]
fn lua_lsp_api_lists_no_servers_and_validates_config() {
let workspace = Rc::new(RefCell::new(Workspace::default()));
let (api, _keymap) = install_api(workspace);
let dir = temp_script_dir("lsp-api");
let script = dir.join("lsp.lua");
std::fs::write(
&script,
r#"
assert(#app:lsp_servers() == 0)
assert(app:stop_lsp("missing") == false)
assert(app:restart_lsp("missing") == false)
-- Без cmd сервер не запускается, а ошибка объясняет, чего не хватает.
local ok, err = pcall(function() app:run_lsp({ name = "broken" }) end)
assert(not ok)
assert(tostring(err):find("cmd", 1, true), tostring(err))
"#,
)
.unwrap();
let engine = ScriptEngine::new(api).expect("движок Lua");
engine.run_file(&script).expect("скрипт LSP API");
drop(engine);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn workspace_keeps_at_least_one_tab() { fn workspace_keeps_at_least_one_tab() {
let mut state = Workspace::default(); 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); 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] #[test]
fn lua_key_bindings_match_press_release_and_modifiers() { fn lua_key_bindings_match_press_release_and_modifiers() {
let workspace = Rc::new(RefCell::new(Workspace::default())); 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(), "правка должна помечать документ изменённым"); assert!(doc.is_dirty(), "правка должна помечать документ изменённым");
} }
#[gpui_kit::test]
fn arrows_move_completion_selection_without_moving_cursor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})
});
visual.run_until_parked();
visual.simulate_keystrokes("down");
visual.run_until_parked();
let (cursor, selected, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_selected(),
this.completion_is_open(),
)
})
});
assert_eq!(selected, 1, "стрелка вниз должна выбрать второй элемент");
assert_eq!(cursor, before, "пока список открыт, курсор не двигается");
assert!(open, "список должен остаться открытым");
// На последнем элементе выделение стоит на месте, но клавиша всё равно
// принадлежит списку и не должна двигать курсор.
visual.simulate_keystrokes("down");
visual.run_until_parked();
visual.simulate_keystrokes("up");
visual.run_until_parked();
let (cursor, selected, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_selected(),
this.completion_is_open(),
)
})
});
assert_eq!(selected, 0, "стрелка вверх должна вернуть первый элемент");
assert_eq!(cursor, before);
assert!(open);
visual.simulate_keystrokes("escape");
visual.run_until_parked();
let (cursor, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_is_open(),
)
})
});
assert!(!open, "Esc должен закрыть список");
assert_eq!(cursor, before);
}
#[gpui_kit::test]
fn long_completion_list_scrolls_instead_of_shrinking_rows(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion_with_count(60, cx);
});
});
visual.run_until_parked();
let scroll =
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_scroll_handle()));
assert!(
scroll.max_offset().y > px(0.),
"длинный список должен прокручиваться, а не сжимать строки"
);
}
#[gpui_kit::test]
fn completion_popup_above_the_caret_renders(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
visual.update(|window, cx| {
app.update(cx, |this, cx| {
let text = (0..200)
.map(|index| format!("let value_{index} = {index};\n"))
.collect::<String>();
this.editor_handle().update(cx, |state, cx| {
state.set_value(text.clone(), window, cx);
let end = text.len();
state.set_selected_range(end..end, cx);
state.focus(window, cx);
});
this.open_test_completion(cx);
});
});
// Первый кадр обновляет прокрутку редактора, второй — положение поповера,
// который должен оказаться над каретой.
visual.run_until_parked();
visual.run_until_parked();
assert!(
visual.update(|_window, cx| app.update(cx, |this, _cx| this.completion_is_open())),
"поповер над каретой должен отрисоваться"
);
}
#[gpui_kit::test]
fn horizontal_arrows_close_completion_and_move_cursor(cx: &mut TestAppContext) {
cx.update(gpui_kit::init);
let (app, visual) = cx.add_window_view(MyApp::new);
let before = visual.update(|window, cx| {
app.update(cx, |this, cx| {
this.editor_handle()
.update(cx, |state, cx| state.focus(window, cx));
this.open_test_completion(cx);
this.editor_handle().read(cx).cursor()
})
});
visual.run_until_parked();
visual.simulate_keystrokes("right");
visual.run_until_parked();
let (after, open) = visual.update(|_window, cx| {
app.update(cx, |this, cx| {
(
this.editor_handle().read(cx).cursor(),
this.completion_is_open(),
)
})
});
assert!(
after > before,
"горизонтальная стрелка двигает курсор как обычно"
);
assert!(!open, "горизонтальная стрелка закрывает список");
}