Files
qode/src/api.rs
T
2026-10-31 01:06:21 +03:00

1144 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Глобальный API приложения.
//!
//! Единственная точка входа для команд управления окном и редактором: UI-код
//! и Lua-сценарии не трогают `Window` и состояние вида напрямую, а вызывают
//! методы [`AppApi`].
//!
//! Команды окна не выполняются в момент вызова: Lua-обработчик выполняется вне
//! контекста `App`/`Window`, поэтому [`AppApi`] складывает их в очередь, а вид
//! вычитывает её в начале кадра.
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use mlua::IntoLua;
use serde_json::Number;
use crate::constants::{BOTTOM_PANEL_HEIGHT, LEFT_PANEL_WIDTH, RIGHT_PANEL_WIDTH};
use crate::editor::{FocusAfterSync, Language, Workspace};
use crate::icons;
use crate::keymap::{AppCommand, CompletionCommand, KeyBinding, KeyContext, KeyHandler, KeyMap};
use crate::lsp::{
DocumentDiagnostics, LspConfig, LspManager, WatcherConfig, document_uri, position_to_offset,
};
use crate::panel::{PanelHandle, PanelState};
use crate::tree::{TreeHandle, TreeNode};
thread_local! {
/// Текущий экземпляр API. `thread_local`, потому что UI GPUI привязан к
/// потоку с event loop; заодно это делает тесты независимыми друг от друга.
static INSTANCE: RefCell<Option<AppApi>> = const { RefCell::new(None) };
/// Команды окна, ожидающие ближайшего кадра.
static WINDOW_COMMANDS: RefCell<VecDeque<WindowCommand>> = const { RefCell::new(VecDeque::new()) };
/// Развёрнуто ли окно. Вид обновляет значение в каждом кадре, чтобы
/// `app:is_maximized()` из Lua видел актуальное состояние.
static WINDOW_MAXIMIZED: Cell<bool> = const { Cell::new(false) };
}
/// Действие над окном, отложенное до ближайшего кадра.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowCommand {
Close,
Minimize,
ToggleMaximize,
SetMaximized(bool),
}
/// Запоминает, развёрнуто ли окно сейчас.
pub fn set_window_maximized(maximized: bool) {
WINDOW_MAXIMIZED.with(|state| state.set(maximized));
}
/// Забирает накопленные команды окна.
///
/// Очередь очищается: выполнять команды нужно ровно один раз.
pub fn drain_window_commands() -> Vec<WindowCommand> {
WINDOW_COMMANDS.with(|queue| queue.borrow_mut().drain(..).collect())
}
/// Кладёт команду в очередь.
fn push_window_command(command: WindowCommand) {
WINDOW_COMMANDS.with(|queue| queue.borrow_mut().push_back(command));
}
/// Краткая информация об открытой вкладке.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabInfo {
/// Номер вкладки (с нуля), слева направо.
pub index: usize,
/// Название — то же, что показано в заголовке вкладки.
pub title: String,
/// Имя иконки вкладки; по умолчанию — `file`.
pub icon: String,
}
/// Элемент каталога, возвращаемый `app:list_dir(path)`.
///
/// `id` — путь относительно текущего каталога процесса: его можно сразу
/// положить в `id` узла дерева и передать в `app:open(id)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DirEntryInfo {
/// Путь элемента относительно текущего каталога.
pub id: String,
/// Имя файла или каталога без пути.
pub name: String,
/// Является ли элемент каталогом.
pub is_dir: bool,
}
/// Зарегистрированный Lua-обработчик диагностики.
///
/// `delay` — пауза тишины, после которой обработчик вызывается. Если сервер
/// присылает сообщения чаще, вызов откладывается до последнего изменения.
#[derive(Clone)]
struct DiagnosticsHandler {
delay: Duration,
callback: mlua::Function,
}
/// Глобальный объект с функциями управления приложением.
///
/// Использование: `AppApi::global().close()`, `AppApi::global().tabs()`.
/// Экземпляр устанавливается один раз при создании приложения (см.
/// [`AppApi::install`]) и указывает на общие с видом документы и карту клавиш.
///
/// Методы этого `impl`-блока автоматически экспортируются в Lua через
/// `#[mlua::userdata_impl]`: в сценариях они доступны как `app:method(...)`.
/// Чтобы не экспортировать метод, пометьте его `#[lua(skip)]`.
#[derive(Clone, mlua::UserData)]
pub struct AppApi {
#[lua(skip)]
workspace: Rc<RefCell<Workspace>>,
#[lua(skip)]
keymap: Rc<RefCell<KeyMap>>,
#[lua(skip)]
left_panel: PanelHandle,
#[lua(skip)]
right_panel: PanelHandle,
#[lua(skip)]
bottom_panel: PanelHandle,
#[lua(skip)]
lsp: Arc<LspManager>,
#[lua(skip)]
diagnostics_handler: Rc<RefCell<Option<DiagnosticsHandler>>>,
}
/// API одной панели окна.
///
/// Один и тот же тип используется для `app.left_panel`, `app.right_panel` и
/// `app.bottom_panel`: отличается только состояние, на которое ссылается
/// объект. Поэтому панели управляются одинаково: `app.left_panel:open()`,
/// `app.bottom_panel:toggle()` и так далее.
#[derive(mlua::UserData)]
pub struct PanelApi {
#[lua(skip)]
state: PanelHandle,
}
impl PanelApi {
fn new(state: PanelHandle) -> Self {
Self { state }
}
}
/// API одного блока дерева.
///
/// Объект возвращает `app.left_panel:add_tree()`. Редактор не знает, что
/// означают узлы: сценарий сам решает, будет это файловое дерево проекта или
/// что-то ещё, и передаёт готовый список узлов в [`TreeApi::append`].
#[derive(mlua::UserData)]
pub struct TreeApi {
#[lua(skip)]
state: TreeHandle,
}
impl TreeApi {
fn new(state: TreeHandle) -> Self {
Self { state }
}
}
#[mlua::userdata_impl]
impl TreeApi {
/// Добавляет узлы к родительскому узлу `parent`.
///
/// `nodes` — массив таблиц:
///
/// ```lua
/// {
/// id = "src/main.rs", -- строковый идентификатор (путь файла)
/// label = "main.rs", -- отображаемый текст
/// icon = "file-code", -- имя иконки Lucide (необязательно)
/// expandable = false, -- раскрываемая ли нода (папка)
/// expanded = false, -- раскрыта ли она сейчас
/// }
/// ```
///
/// Пустая строка в `parent` означает корень дерева. Существующие узлы не
/// удаляются: повторная передача того же `id` обновляет узел.
pub fn append(&self, nodes: mlua::Table, parent: Option<String>) -> mlua::Result<()> {
let nodes = parse_tree_nodes(&nodes)?;
let parent = parent.unwrap_or_default();
self.state
.borrow_mut()
.append(&parent, nodes)
.map_err(mlua::Error::RuntimeError)
}
/// Удаляет узел по `id` вместе со всеми дочерними узлами рекурсивно.
///
/// Возвращает `false`, если узла с таким идентификатором нет.
#[lua(infallible)]
pub fn remove(&self, id: String) -> bool {
self.state.borrow_mut().remove(&id)
}
/// Удаляет все узлы дерева и сбрасывает выбор.
///
/// Обработчики `on_toggle` и `on_click` продолжают действовать: дерево
/// можно наполнить заново. Удобно для списков, которые перерисовываются
/// целиком, например для диагностики.
#[lua(infallible)]
pub fn clear(&self) {
self.state.borrow_mut().clear();
}
/// Назначает обработчик раскрытия/сворачивания узла.
///
/// Обработчик получает `(expanded, id, loaded)`, где `expanded` — новая
/// нода раскрыта, `id` — идентификатор узла, `loaded` — были ли в узел уже
/// загружены дочерние элементы. После первого добавления детей всегда
/// приходит `true`.
#[lua(infallible)]
pub fn on_toggle(&self, callback: mlua::Function) {
self.state.borrow_mut().set_toggle_callback(Some(callback));
}
/// Назначает обработчик клика по конечной ноде.
///
/// Обработчик получает `(id)` — идентификатор узла.
#[lua(infallible)]
pub fn on_click(&self, callback: mlua::Function) {
self.state.borrow_mut().set_click_callback(Some(callback));
}
/// Идентификатор выбранной строки или `nil`.
#[lua(infallible)]
pub fn selected(&self) -> Option<String> {
self.state.borrow().selected().map(str::to_owned)
}
/// Раскрывает всех закрытых родителей узла `id`, выбирает его и передаёт
/// фокус дереву.
///
/// Возвращает `false`, если узла с таким идентификатором нет.
#[lua(infallible)]
pub fn focus(&self, id: String) -> bool {
self.state.borrow_mut().focus(&id)
}
}
#[mlua::userdata_impl]
impl PanelApi {
/// Открывает панель.
#[lua(infallible)]
pub fn open(&self) {
self.state.borrow_mut().open();
}
/// Закрывает панель.
#[lua(infallible)]
pub fn close(&self) {
self.state.borrow_mut().close();
}
/// Переключает панель и возвращает новое состояние.
#[lua(infallible)]
pub fn toggle(&self) -> bool {
self.state.borrow_mut().toggle()
}
/// Открыта ли панель прямо сейчас.
#[lua(infallible)]
pub fn is_open(&self) -> bool {
self.state.borrow().is_open()
}
/// Добавляет в панель блок дерева и возвращает его API.
///
/// `title` — заголовок вкладки нижней панели. Боковые панели заголовок
/// сохраняют, но не рисуют.
///
/// ```lua
/// local tree = app.left_panel:add_tree()
/// tree:append({ { id = "a", label = "a.txt" } }, "")
///
/// local diagnostics = app.bottom_panel:add_tree("Диагностика")
/// ```
#[lua(infallible, name = "add_tree")]
pub fn add_tree(&self, title: Option<String>) -> TreeApi {
TreeApi::new(self.state.borrow_mut().add_tree(title))
}
}
#[mlua::userdata_impl]
impl AppApi {
/// Делает переданные документы и карту клавиш текущими для
/// [`AppApi::global`].
#[lua(skip)]
pub fn install(workspace: Rc<RefCell<Workspace>>, keymap: Rc<RefCell<KeyMap>>) -> Self {
let api = Self {
workspace,
keymap,
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()),
diagnostics_handler: Rc::new(RefCell::new(None)),
};
INSTANCE.with(|slot| *slot.borrow_mut() = Some(api.clone()));
api
}
/// Глобальный экземпляр API.
///
/// # Panics
/// Если [`AppApi::install`] ещё не вызывался.
#[lua(skip)]
pub fn global() -> Self {
INSTANCE.with(|slot| {
slot.borrow()
.clone()
.expect("AppApi не инициализирован: сначала вызовите AppApi::install")
})
}
/// Состояние левой панели (для вида).
#[lua(skip)]
pub fn left_panel_handle(&self) -> PanelHandle {
self.left_panel.clone()
}
/// Состояние правой панели (для вида).
#[lua(skip)]
pub fn right_panel_handle(&self) -> PanelHandle {
self.right_panel.clone()
}
/// Состояние нижней панели (для вида).
#[lua(skip)]
pub fn bottom_panel_handle(&self) -> PanelHandle {
self.bottom_panel.clone()
}
/// Менеджер LSP-серверов (для вида и тестов).
#[lua(skip)]
pub fn lsp_handle(&self) -> Arc<LspManager> {
self.lsp.clone()
}
/// Все открытые вкладки в порядке слева направо.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn tabs(&self) -> Vec<TabInfo> {
self.workspace
.borrow()
.documents()
.iter()
.enumerate()
.map(|(index, doc)| TabInfo {
index,
title: doc.name.clone(),
icon: doc.icon_name().to_owned(),
})
.collect()
}
/// Индекс активной вкладки в списке [`AppApi::tabs`].
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible, name = "active_tab")]
pub fn active_tab_index(&self) -> usize {
self.workspace.borrow().active_index()
}
/// Переключает на следующую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn next_tab(&self) -> usize {
self.workspace.borrow_mut().cycle_active(1)
}
/// Переключает на предыдущую вкладку (по кругу) и возвращает её индекс.
#[allow(dead_code)]
// Часть публичного API приложения: вызывается внешним кодом и тестами.
#[lua(infallible)]
pub fn previous_tab(&self) -> usize {
self.workspace.borrow_mut().cycle_active(-1)
}
/// Закрывает вкладку по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет. Если закрывается
/// последняя вкладка, вместо неё открывается пустой документ.
#[lua(infallible)]
pub fn close_tab(&self, index: usize) -> bool {
let mut workspace = self.workspace.borrow_mut();
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
}
/// Сохраняет содержимое активной вкладки в файл, названный как сама вкладка.
///
/// Путь считается относительно текущего рабочего каталога процесса.
/// При успешной записи с вкладки снимается маркер несохранённых изменений.
#[lua(skip)]
pub fn save(&self) -> std::io::Result<()> {
let (path, content) = {
let workspace = self.workspace.borrow();
let doc = workspace.active();
(doc.name.clone(), doc.content.clone())
};
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(())
}
/// Lua-версия [`AppApi::save`]: ошибка ввода-вывода становится ошибкой Lua.
#[lua(name = "save")]
fn lua_save(&self) -> mlua::Result<()> {
self.save()
.map_err(|err| mlua::Error::RuntimeError(err.to_string()))
}
/// Назначает обработчик на нажатие или отпускание клавиши.
///
/// `keys` записывается в духе nvim:
///
/// * `a`, `1` — нажатие клавиши без модификаторов;
/// * `<S-c>`, `<C-z>`, `<A-r>`, `<D-s>` — нажатие с Shift, Ctrl, Alt или
/// платформенным модификатором (Cmd/Super);
/// * `!a` — отпускание клавиши;
/// * `<A-!r>` — отпускание клавиши при зажатом Alt.
///
/// Второй аргумент — Lua-функция, имя встроенной команды (`save`,
/// `next_tab`, `toggle_left_panel` и т. п.) или `nil`, чтобы удалить
/// привязку. Повторное назначение той же клавиши заменяет обработчик.
///
/// # Errors
/// Возвращает ошибку Lua, если запись привязки или имя команды некорректны.
pub fn map_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Global, &keys, handler)
}
/// Назначает обработчик клавиши, пока открыт список автодополнения.
///
/// Синтаксис совпадает с [`AppApi::map_key`]. Встроенные команды этого
/// слоя: `completion_trigger`, `completion_next`, `completion_previous`,
/// `completion_page_up`, `completion_page_down`, `completion_accept`,
/// `completion_cancel`, `completion_dismiss`.
#[lua(name = "map_completion_key")]
pub fn map_completion_key(&self, keys: String, handler: mlua::Value) -> mlua::Result<()> {
self.bind_key(KeyContext::Completion, &keys, handler)
}
#[lua(skip)]
fn bind_key(&self, context: KeyContext, keys: &str, handler: mlua::Value) -> mlua::Result<()> {
let binding = KeyBinding::parse(keys).map_err(mlua::Error::RuntimeError)?;
match parse_key_handler(context, handler)? {
Some(handler) => self.keymap.borrow_mut().set(context, binding, handler),
None => {
self.keymap.borrow_mut().unbind(context, &binding);
}
}
Ok(())
}
/// Содержимое активной вкладки.
#[lua(infallible)]
pub fn active_text(&self) -> String {
self.workspace.borrow().active().content.clone()
}
/// Заменяет содержимое активной вкладки.
#[lua(skip)]
pub fn set_active_text(&self, text: impl Into<String>) {
self.workspace.borrow_mut().set_active_text(text);
}
/// Lua-версия [`AppApi::set_active_text`] (generic-методы не экспортируются).
#[lua(infallible, name = "set_active_text")]
fn lua_set_active_text(&self, text: String) {
self.set_active_text(text);
}
/// Открывает новую пустую вкладку, делает её активной и возвращает её индекс.
#[lua(infallible)]
pub fn open_tab(&self) -> usize {
let mut workspace = self.workspace.borrow_mut();
workspace.open_new();
workspace.active_index()
}
/// Открывает файл с диска на редактирование (Rust-версия `app:open`).
///
/// Если файла нет, создаётся пустая вкладка с таким именем — как в vim.
/// Язык подсветки определяется по расширению, если для него есть грамматика.
/// Фокус редактора не меняется: для этого есть [`AppApi::open_focused`]
/// и Lua-форма `app:open(path, {...})`.
#[lua(skip)]
pub fn open(&self, path: impl AsRef<Path>) -> std::io::Result<usize> {
self.open_impl(path, None, false)
}
/// Открывает файл и переводит фокус в редактор (Rust-версия
/// `app:open(path, position)`).
///
/// `position` — строка и столбец с нуля, как в диагностике LSP; столбец
/// считается в UTF-16. `None` оставляет курсор там, где он был.
#[lua(skip)]
pub fn open_focused(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
) -> std::io::Result<usize> {
self.open_impl(path, position, true)
}
#[lua(skip)]
fn open_impl(
&self,
path: impl AsRef<Path>,
position: Option<(u32, u32)>,
focus_editor: bool,
) -> 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 cursor = position.map(|(line, column)| {
position_to_offset(&content, lsp_types::Position::new(line, column))
});
let focus = if focus_editor {
FocusAfterSync::Editor(cursor)
} else {
FocusAfterSync::Keep
};
let name = path.to_string_lossy().into_owned();
let language = Language::from_path(&name);
let mut workspace = self.workspace.borrow_mut();
let index = workspace.open_file(name, content, language);
workspace.set_focus_after_sync(focus);
Ok(index)
}
/// Lua-версия [`AppApi::open`]: `app:open("main.go")`.
///
/// Второй аргумент необязателен:
///
/// * `nil`/нет аргумента — открыть вкладку, не трогая фокус;
/// * `{}` — перевести фокус в редактор, курсор оставить на месте;
/// * `{line, column}` — перевести фокус и поставить курсор (с нуля).
///
/// Возвращает номер вкладки (с нуля). Ошибка чтения становится ошибкой Lua.
#[lua(name = "open")]
fn lua_open(&self, path: String, position: mlua::Value) -> mlua::Result<usize> {
match parse_open_focus(position)? {
OpenFocus::Keep => self.open(&path),
OpenFocus::Editor(position) => self.open_focused(&path, position),
}
.map_err(|err| mlua::Error::RuntimeError(format!("не удалось открыть {path}: {err}")))
}
/// Читает содержимое каталога (Rust-версия `app:list_dir`).
///
/// Пустой путь или `"."` означают текущий рабочий каталог процесса.
/// Элементы сортируются по имени без учёта регистра. В `id` попадает путь
/// относительно текущего каталога, если `path` тоже относительный.
#[lua(skip)]
pub fn list_dir(&self, path: impl AsRef<Path>) -> std::io::Result<Vec<DirEntryInfo>> {
let raw = path.as_ref().as_os_str().to_string_lossy().into_owned();
let relative_to_cwd = raw.is_empty() || raw == ".";
let directory = if raw.is_empty() {
Path::new(".")
} else {
path.as_ref()
};
let mut entries = Vec::new();
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false);
let id = if relative_to_cwd {
name.clone()
} else {
Path::new(&raw)
.join(&name)
.to_string_lossy()
.replace('\\', "/")
};
entries.push(DirEntryInfo { id, name, is_dir });
}
entries.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
/// Список содержимого каталога `path` относительно текущего каталога.
///
/// Каждый элемент — таблица `{ id = "src/main.rs", name = "main.rs",
/// is_dir = false }`. Пустая строка или `"."` — текущий каталог. Метод
/// предназначен для построения деревьев и списков из Lua.
#[lua(name = "list_dir")]
fn lua_list_dir(&self, path: Option<String>) -> mlua::Result<Vec<DirEntryInfo>> {
let path = path.unwrap_or_default();
self.list_dir(&path).map_err(|err| {
let shown = if path.is_empty() { "." } else { path.as_str() };
mlua::Error::RuntimeError(format!("не удалось прочитать каталог {shown}: {err}"))
})
}
/// Делает вкладку активной по её номеру в списке [`AppApi::tabs`].
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn set_active_tab(&self, index: usize) -> bool {
let mut workspace = self.workspace.borrow_mut();
if index >= workspace.documents().len() {
return false;
}
workspace.set_active(index);
true
}
/// Назначает иконку вкладке `index`.
///
/// `icon` — имя иконки Lucide: `file`, `file-code`, `book-open`, `settings`
/// и т. п. Регистр не важен; `_` и пробелы равны дефису, префикс `icons/`
/// и расширение `.svg` можно не писать.
///
/// Возвращает `false`, если вкладки нет или иконка с таким именем не входит
/// в каталог.
#[lua(skip)]
pub fn set_tab_icon(&self, index: usize, icon: &str) -> bool {
let Some(name) = icons::canonical_name(icon) else {
return false;
};
self.workspace.borrow_mut().set_tab_icon(index, name)
}
/// Lua-версия [`AppApi::set_tab_icon`].
#[lua(infallible, name = "set_tab_icon")]
fn lua_set_tab_icon(&self, index: usize, icon: String) -> bool {
self.set_tab_icon(index, &icon)
}
/// Возвращает вкладке иконку по умолчанию (`file`).
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn clear_tab_icon(&self, index: usize) -> bool {
self.workspace.borrow_mut().clear_tab_icon(index)
}
/// Имя иконки вкладки: назначенное через [`AppApi::set_tab_icon`] или
/// `file` по умолчанию. `nil`, если вкладки нет.
#[lua(infallible)]
pub fn tab_icon(&self, index: usize) -> Option<String> {
self.workspace
.borrow()
.document(index)
.map(|doc| doc.icon_name().to_owned())
}
/// Включает подсветку синтаксиса для вкладки `index`.
///
/// `language` — название языка: `rust`, `go`, `js`, `json` или `python`
/// (принимаются также `rs`, `golang`, `javascript`, `py`; регистр не важен).
/// Подсветка по умолчанию выключена и включается только этим методом.
///
/// Возвращает `false`, если вкладки нет или язык не поддерживается.
#[lua(skip)]
pub fn set_language(&self, index: usize, language: &str) -> bool {
let Some(language) = Language::from_name(language) else {
return false;
};
self.workspace.borrow_mut().set_language(index, language)
}
/// Lua-версия [`AppApi::set_language`].
#[lua(infallible, name = "set_language")]
fn lua_set_language(&self, index: usize, language: String) -> bool {
self.set_language(index, &language)
}
/// Язык подсветки вкладки: `"rust"`, `"go"`, `"js"`, `"json"`, `"python"`
/// или `nil`, если подсветка выключена / вкладки нет.
#[lua(infallible)]
pub fn language(&self, index: usize) -> Option<String> {
self.workspace
.borrow()
.document(index)
.and_then(|doc| doc.language)
.map(|language| language.name().to_owned())
}
/// Выключает подсветку синтаксиса для вкладки.
///
/// Возвращает `false`, если вкладки с таким номером нет.
#[lua(infallible)]
pub fn clear_language(&self, index: usize) -> bool {
self.workspace.borrow_mut().clear_language(index)
}
/// Левая панель окна.
///
/// В Lua доступна как `app.left_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn left_panel(&self) -> PanelApi {
PanelApi::new(self.left_panel.clone())
}
/// Правая панель окна.
///
/// В Lua доступна как `app.right_panel` с методами `open`, `close`,
/// `toggle` и `is_open`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn right_panel(&self) -> PanelApi {
PanelApi::new(self.right_panel.clone())
}
/// Нижняя панель окна.
///
/// В Lua доступна как `app.bottom_panel` с теми же методами, что и
/// `app.right_panel`.
#[allow(dead_code)]
#[lua(getter, infallible)]
pub fn bottom_panel(&self) -> PanelApi {
PanelApi::new(self.bottom_panel.clone())
}
/// Закрывает окно приложения.
#[lua(infallible)]
pub fn close(&self) {
push_window_command(WindowCommand::Close);
}
/// Разворачивает окно на весь экран или возвращает его прежний размер.
#[lua(infallible)]
pub fn toggle_maximize(&self) {
push_window_command(WindowCommand::ToggleMaximize);
}
/// Разворачивает (`true`) или восстанавливает (`false`) окно.
#[lua(infallible)]
pub fn set_maximized(&self, maximized: bool) {
push_window_command(WindowCommand::SetMaximized(maximized));
}
/// Сворачивает окно.
#[lua(infallible)]
pub fn minimize(&self) {
push_window_command(WindowCommand::Minimize);
}
/// Развёрнуто ли окно прямо сейчас.
#[lua(infallible)]
pub fn is_maximized(&self) -> bool {
WINDOW_MAXIMIZED.with(Cell::get)
}
/// Запускает LSP-сервер по описанию (Rust-версия Lua-метода).
#[lua(skip)]
pub fn run_lsp(&self, config: LspConfig) -> Result<(), String> {
self.lsp.start(config)
}
/// Запускает LSP-сервер.
///
/// Пример из Lua:
///
/// ```lua
/// app:run_lsp({
/// name = "gopls",
/// cmd = "/usr/local/bin/gopls",
/// args = { "serve" },
/// env = { GOFLAGS = "-mod=mod" },
/// languages = { "go" },
/// watcher = "**/*.go",
/// initialization_options = { usePlaceholders = true },
/// })
/// ```
///
/// Возвращает `true`, если сервер запущен. Ошибка запуска или таймаут
/// `initialize` превращается в ошибку Lua.
#[lua(name = "run_lsp")]
fn lua_run_lsp(&self, config: mlua::Table) -> mlua::Result<bool> {
let config = parse_lsp_config(&config)?;
self.run_lsp(config)
.map(|()| true)
.map_err(mlua::Error::RuntimeError)
}
/// Имена всех запущенных LSP-серверов.
#[lua(infallible)]
pub fn lsp_servers(&self) -> Vec<String> {
self.lsp.server_names()
}
/// Останавливает LSP-сервер по имени.
///
/// Возвращает `false`, если сервера с таким именем не было.
#[lua(infallible)]
pub fn stop_lsp(&self, name: String) -> bool {
self.lsp.stop(&name)
}
/// Перезапускает LSP-сервер с прежними настройками.
///
/// Возвращает `false`, если сервера не было или новый процесс не поднялся.
#[lua(infallible)]
pub fn restart_lsp(&self, name: String) -> bool {
self.lsp.restart(&name)
}
/// Регистрирует обработчик диагностики всех LSP-серверов.
///
/// `delay_ms` — пауза тишины в миллисекундах: обработчик вызывается после
/// последнего обновления диагностики. Если серверы присылают сообщения
/// чаще задержки, промежуточные вызовы пропускаются, а последний вызов
/// выполняется после того, как обновления прекратились.
///
/// Обработчик получает один аргумент — массив записей
/// `{ server, uri, path, diagnostics = { ... } }`. У сообщения есть
/// `line`, `character`, `end_line`, `end_character` (с нуля, как в LSP),
/// `severity` (1–4), `severity_name`, `message`, `source`, `code`, `tags`.
/// Передача `nil` вместо функции удаляет обработчик.
///
/// ```lua
/// app:on_lsp_diagnostics(1000, function(servers)
/// for _, entry in ipairs(servers) do
/// print(entry.server, entry.path, #entry.diagnostics)
/// end
/// end)
/// ```
#[lua(infallible, name = "on_lsp_diagnostics")]
pub fn on_lsp_diagnostics(&self, delay_ms: u64, callback: Option<mlua::Function>) {
let delay = Duration::from_millis(delay_ms);
*self.diagnostics_handler.borrow_mut() =
callback.map(|callback| DiagnosticsHandler { delay, callback });
// Обработчик должен получить уже накопленную диагностику, а не ждать
// следующего ответа LSP-сервера.
self.lsp.refresh_diagnostics();
}
/// Задержка зарегистрированного обработчика диагностики, если он есть.
#[lua(skip)]
pub fn diagnostics_delay(&self) -> Option<Duration> {
self.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.delay)
}
/// Вызывает Lua-обработчик диагностики со свежим снимком.
///
/// Возвращает `false`, если обработчик не зарегистрирован.
#[lua(skip)]
pub fn emit_diagnostics(&self, report: Vec<DocumentDiagnostics>) -> bool {
let Some(callback) = self
.diagnostics_handler
.borrow()
.as_ref()
.map(|handler| handler.callback.clone())
else {
return false;
};
let Some(lua) = callback.weak_lua().try_upgrade() else {
return false;
};
let value = match report.into_lua(&lua) {
Ok(value) => value,
Err(err) => {
eprintln!("Lua: не удалось передать диагностику: {err}");
return false;
}
};
if let Err(err) = callback.call::<()>(value) {
eprintln!("Lua: ошибка в обработчике диагностики: {err}");
}
true
}
}
/// Второй аргумент `app:open`.
enum OpenFocus {
/// Фокус не меняется.
Keep,
/// Фокус переходит в редактор; `None` сохраняет текущий курсор.
Editor(Option<(u32, u32)>),
}
/// Разбирает необязательный второй аргумент `app:open`.
fn parse_open_focus(value: mlua::Value) -> mlua::Result<OpenFocus> {
let table = match value {
mlua::Value::Nil => return Ok(OpenFocus::Keep),
mlua::Value::Table(table) => table,
_ => {
return Err(mlua::Error::RuntimeError(
"во втором аргументе app:open нужна таблица {строка, столбец} или {}".to_owned(),
));
}
};
let line = table.get::<Option<i64>>(1)?;
let column = table.get::<Option<i64>>(2)?;
match (line, column) {
(None, None) => Ok(OpenFocus::Editor(None)),
(Some(line), Some(column)) if line >= 0 && column >= 0 => {
Ok(OpenFocus::Editor(Some((line as u32, column as u32))))
}
_ => Err(mlua::Error::RuntimeError(
"позиция в app:open — два неотрицательных числа: {строка, столбец}".to_owned(),
)),
}
}
/// Разбирает 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: String = table
.get("cmd")
.map_err(|_| mlua::Error::RuntimeError("в run_lsp нужен строковый cmd".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(),
};
// Корень нормализуется в `LspManager::start`: относительный путь там
// отсчитывается от текущего каталога процесса.
let root = match table.get::<String>("root") {
Ok(root) => PathBuf::from(root),
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
};
let languages = match table.get::<mlua::Value>("languages") {
Ok(value) => lua_strings(value)?,
Err(_) => Vec::new(),
};
let initialization_options = match table.get::<mlua::Value>("initialization_options") {
Ok(mlua::Value::Nil) | Err(_) => None,
Ok(value) => Some(lua_to_json(value)?),
};
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 = match table.get::<mlua::Value>("watcher") {
Ok(mlua::Value::Nil) | Err(_) => return Ok(None),
Ok(value) => value,
};
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 = match config.get::<mlua::Value>("pattern") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
let ignore = match config.get::<mlua::Value>("ignore") {
Ok(mlua::Value::Nil) | Err(_) => Vec::new(),
Ok(value) => lua_strings(value)?,
};
if patterns.is_empty() {
Ok(None)
} else {
Ok(Some(WatcherConfig { patterns, ignore }))
}
}
_ => Err(mlua::Error::RuntimeError(
"watcher должен быть строкой, таблицей или boolean".to_owned(),
)),
}
}
/// Разбирает обработчик, переданный в `app:map_key` / `app:map_completion_key`.
///
/// `nil` означает «удалить привязку».
fn parse_key_handler(
context: KeyContext,
handler: mlua::Value,
) -> mlua::Result<Option<KeyHandler>> {
match handler {
mlua::Value::Nil => Ok(None),
mlua::Value::Function(callback) => Ok(Some(KeyHandler::Lua(callback))),
mlua::Value::String(name) => {
let name = name.to_string_lossy();
let parsed = match context {
KeyContext::Global => AppCommand::parse(&name).map(KeyHandler::App),
KeyContext::Completion => {
CompletionCommand::parse(&name).map(KeyHandler::Completion)
}
};
parsed.map(Some).ok_or_else(|| {
let available = match context {
KeyContext::Global => AppCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
KeyContext::Completion => CompletionCommand::ALL
.iter()
.map(|command| command.name())
.collect::<Vec<_>>(),
};
mlua::Error::RuntimeError(format!(
"неизвестная команда клавиши: {name}; доступны: {}",
available.join(", ")
))
})
}
_ => Err(mlua::Error::RuntimeError(
"обработчик должен быть функцией, именем команды или nil".to_owned(),
)),
}
}
/// Разбирает массив узлов дерева, переданный в `TreeApi::append`.
fn parse_tree_nodes(nodes: &mlua::Table) -> mlua::Result<Vec<TreeNode>> {
let mut parsed = Vec::new();
for pair in nodes.sequence_values::<mlua::Table>() {
let table = pair?;
let id: String = table.get("id")?;
let label = table.get::<String>("label").unwrap_or_else(|_| id.clone());
let icon = table
.get::<Option<String>>("icon")?
.and_then(|name| icons::canonical_name(&name).map(str::to_owned));
let expandable = table.get::<Option<bool>>("expandable")?.unwrap_or(false);
let expanded = table.get::<Option<bool>>("expanded")?.unwrap_or(false);
parsed.push(TreeNode {
id,
label,
icon,
expandable,
expanded,
});
}
Ok(parsed)
}
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),
}
}