//! Минимальный LSP-клиент на stdio. //! //! Модуль отвечает только за «жизненный цикл» языка: //! //! * запуск и остановку процесса LSP-сервера; //! * JSON-RPC 2.0 с кадрами `Content-Length`; //! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`); //! * запросы автодополнения, подсказок и `completionItem/resolve`; //! * хранение диагностики `textDocument/publishDiagnostics` по документам; //! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`. //! //! Всё остальное — какие серверы запускать, с какими аргументами и когда — //! решает Lua-сценарий через [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp). //! //! Обмен с сервером идёт в фоновых потоках: поток-читатель разбирает ответы и //! складывает их в каналы конкретных запросов, а UI ждёт ответ в //! `background_executor` GPUI и не блокирует кадр. use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::io::{BufRead, BufReader, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::sync::{Arc, Weak}; use std::time::Duration; use globset::{Glob, GlobSet, GlobSetBuilder}; use lsp_types::{ CompletionItem, Diagnostic, DiagnosticSeverity, DiagnosticTag, NumberOrString, Position, Uri, }; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use parking_lot::Mutex; use serde_json::{Value, json}; /// Диагностика одного сервера для одного документа. /// /// Эту структуру Lua получает в обработчике `app:on_lsp_diagnostics(...)`: /// она содержит имя приславшего сервера, URI и путь файла, а также список /// сообщений. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DocumentDiagnostics { /// Имя LSP-сервера, который прислал диагностику. pub server: String, /// URI документа в виде, в котором его прислал сервер. pub uri: String, /// Абсолютный путь файла для `file://`-URI; `None` для остальных схем. pub path: Option, /// Сообщения диагностики в порядке, присланном сервером. pub diagnostics: Vec, } /// Одно сообщение диагностики. /// /// Позиции, как и в LSP, нумеруются с нуля; `line` — строка, `character` — /// столбец в UTF-16. Для человекочитаемого номера строки прибавьте единицу. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiagnosticInfo { /// Начальная строка (с нуля). pub line: u32, /// Начальный столбец в UTF-16 (с нуля). pub character: u32, /// Конечная строка (с нуля). pub end_line: u32, /// Конечный столбец в UTF-16 (с нуля). pub end_character: u32, /// Числовой уровень: 1 — ошибка, 2 — предупреждение, 3 — информация, /// 4 — подсказка. pub severity: Option, /// Имя уровня: `error`, `warning`, `information` или `hint`. pub severity_name: Option, /// Текст сообщения. pub message: String, /// Источник сообщения (обычно имя сервера). pub source: Option, /// Код ошибки, если сервер его прислал. pub code: Option, /// Теги: `unnecessary`, `deprecated`. pub tags: Vec, } impl DiagnosticInfo { /// Переводит сообщение протокола в структуру для Lua. pub fn from_lsp(diagnostic: &Diagnostic) -> Self { let (severity, severity_name) = match diagnostic.severity { Some(value) if value == DiagnosticSeverity::ERROR => (Some(1), Some("error")), Some(value) if value == DiagnosticSeverity::WARNING => (Some(2), Some("warning")), Some(value) if value == DiagnosticSeverity::INFORMATION => { (Some(3), Some("information")) } Some(value) if value == DiagnosticSeverity::HINT => (Some(4), Some("hint")), _ => (None, None), }; let code = diagnostic.code.as_ref().map(|code| match code { NumberOrString::Number(code) => code.to_string(), NumberOrString::String(code) => code.clone(), }); let tags = diagnostic .tags .as_deref() .unwrap_or_default() .iter() .filter_map(|tag| { if tag == &DiagnosticTag::UNNECESSARY { Some("unnecessary".to_owned()) } else if tag == &DiagnosticTag::DEPRECATED { Some("deprecated".to_owned()) } else { None } }) .collect(); Self { line: diagnostic.range.start.line, character: diagnostic.range.start.character, end_line: diagnostic.range.end.line, end_character: diagnostic.range.end.character, severity, severity_name: severity_name.map(str::to_owned), message: diagnostic.message.clone(), source: diagnostic.source.clone(), code, tags, } } } /// Настройки файлового вотчера для одного LSP-сервера. #[derive(Clone, Debug, Default)] pub struct WatcherConfig { /// Шаблоны вроде `**/*.go`. Пустой список означает «не следить». pub patterns: Vec, /// Дополнительные шаблоны игнорирования. pub ignore: Vec, } /// Описание запускаемого LSP-сервера. /// /// Создаётся из Lua-таблицы в [`EditorApi::run_lsp`](crate::api::EditorApi::run_lsp). #[derive(Clone, Debug)] pub struct LspConfig { /// Условное имя, по которому сервер останавливают и перезапускают. pub name: String, /// Путь к исполняемому файлу или имя в `PATH`. pub command: String, pub args: Vec, pub env: Vec<(String, String)>, /// Корень рабочей области (по умолчанию текущий каталог процесса). pub root: PathBuf, /// Языки (`languageId`), которые обслуживает этот сервер. /// /// Пустой список — сервер считается универсальным и получает активный /// документ любого языка. pub languages: Vec, pub watcher: Option, /// Необязательные `initializationOptions` для сервера. pub initialization_options: Option, } /// Открытый документ, о котором знает менеджер. #[derive(Clone, Debug)] struct ActiveDocument { uri: Uri, language_id: String, version: i32, text: String, } type PendingMap = Mutex>>>; type LogBuffer = Arc>>; /// Диагностика, опубликованная серверами, сгруппированная по документу. /// /// Внутренняя карта хранит сообщения по имени сервера: так остановка одного /// сервера убирает только его сообщения, а не всю диагностику документа. struct DiagnosticsStore { by_uri: Mutex>>>, /// Номер поколения: растёт при каждом изменении. revision: AtomicU64, /// Задача вида, которую надо разбудить после изменения. /// /// Диагностика приходит в фоновом потоке, поэтому вместо блокирующего /// ожидания канала UI подписывается на `Waker`. waker: Mutex>, } impl DiagnosticsStore { fn new() -> Self { Self { by_uri: Mutex::new(HashMap::new()), revision: AtomicU64::new(0), waker: Mutex::new(None), } } /// Заменяет набор диагностики сервера для документа. /// /// Пустой список (сервер прислал `[]`) удаляет сообщения сервера. fn replace(&self, server: &str, uri: &str, diagnostics: Vec) { { let mut by_uri = self.by_uri.lock(); if diagnostics.is_empty() { let Some(by_server) = by_uri.get_mut(uri) else { return; }; if by_server.remove(server).is_none() { return; } if by_server.is_empty() { by_uri.remove(uri); } } else { by_uri .entry(uri.to_owned()) .or_default() .insert(server.to_owned(), diagnostics); } } self.notify(); } /// Удаляет диагностику документа целиком (например, при закрытии вкладки). fn clear_uri(&self, uri: &str) { if self.by_uri.lock().remove(uri).is_some() { self.notify(); } } /// Удаляет диагностику остановленного сервера во всех документах. fn clear_server(&self, server: &str) { let changed = { let mut by_uri = self.by_uri.lock(); let mut changed = false; by_uri.retain(|_, by_server| { if by_server.remove(server).is_some() { changed = true; } !by_server.is_empty() }); changed }; if changed { self.notify(); } } /// Все известные сообщения по документу, от всех серверов. fn diagnostics(&self, uri: &str) -> Vec { self.by_uri .lock() .get(uri) .map(|by_server| by_server.values().flatten().cloned().collect::>()) .unwrap_or_default() } /// Снимок всей диагностики: по одной записи на пару «документ, сервер». /// /// Записи сортируются по пути и имени сервера, чтобы Lua-сценарий получал /// стабильный порядок независимо от порядка `HashMap`. fn snapshot(&self) -> Vec { let by_uri = self.by_uri.lock(); let mut entries = Vec::new(); for (uri, by_server) in by_uri.iter() { for (server, diagnostics) in by_server.iter() { if diagnostics.is_empty() { continue; } entries.push(DocumentDiagnostics { server: server.clone(), uri: uri.clone(), path: uri_to_path(uri).map(|path| path.to_string_lossy().into_owned()), diagnostics: diagnostics.iter().map(DiagnosticInfo::from_lsp).collect(), }); } } entries.sort_by(|a, b| { a.path .cmp(&b.path) .then_with(|| a.uri.cmp(&b.uri)) .then_with(|| a.server.cmp(&b.server)) }); entries } fn notify(&self) { self.revision.fetch_add(1, Ordering::SeqCst); if let Some(waker) = self.waker.lock().take() { waker.wake(); } } fn revision(&self) -> u64 { self.revision.load(Ordering::SeqCst) } fn register_waker(&self, waker: std::task::Waker) { *self.waker.lock() = Some(waker); } } /// Менеджер всех запущенных LSP-серверов. /// /// Живёт в `Arc`, потому что его одновременно читают UI, Lua-API и фоновые /// задачи автодополнения. pub struct LspManager { servers: Mutex>>, active: Mutex>, log: LogBuffer, diagnostics: Arc, } impl Default for LspManager { fn default() -> Self { Self::new() } } impl LspManager { pub fn new() -> Self { Self { servers: Mutex::new(BTreeMap::new()), active: Mutex::new(None), log: Arc::new(Mutex::new(VecDeque::new())), diagnostics: Arc::new(DiagnosticsStore::new()), } } /// Запускает сервер и выполняет `initialize`/`initialized`. /// /// Операция синхронная: Lua-сценарий получает ошибку, если процесс не /// стартовал или не ответил на `initialize` за 30 секунд. pub fn start(&self, config: LspConfig) -> Result<(), String> { if self.servers.lock().contains_key(&config.name) { return Err(format!("LSP-сервер «{}» уже запущен", config.name)); } // Вотчер сопоставляет абсолютные пути от notify с корнем, поэтому // корень должен быть абсолютным и нормализованным. let mut config = config; if !config.root.is_absolute() { config.root = std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(&config.root); } config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root); let server = LspServer::spawn(config.clone(), self.diagnostics.clone())?; // `initialize` — единственный запрос, который ждём прямо здесь: // без ответа сервер нельзя считать рабочим. let initialize = server.request("initialize", initialize_params(&config))?; if let Err(err) = wait_response(initialize, Duration::from_secs(30)) { server.shutdown(); return Err(format!("initialize «{}»: {err}", config.name)); } if let Err(err) = server.notify("initialized", json!({})) { server.shutdown(); return Err(format!("initialized «{}»: {err}", config.name)); } // Если документ уже открыт в редакторе, сразу отдаём его серверу. if let Some(document) = self.active.lock().clone() && server.serves(&document.language_id) { server.did_open( &document.uri, &document.language_id, document.version, &document.text, ); } if let Err(err) = server.start_watcher() { server.log(format!("файловый вотчер не запущен: {err}")); } let mut servers = self.servers.lock(); if servers.contains_key(&config.name) { drop(servers); server.shutdown(); return Err(format!("LSP-сервер «{}» уже запущен", config.name)); } servers.insert(config.name.clone(), server); drop(servers); self.log(format!("запущен LSP-сервер «{}»", config.name)); Ok(()) } /// Останавливает сервер по имени. Возвращает `false`, если его не было. pub fn stop(&self, name: &str) -> bool { let server = self.servers.lock().remove(name); let Some(server) = server else { return false; }; server.shutdown(); self.diagnostics.clear_server(name); self.log(format!("остановлен LSP-сервер «{name}»")); true } /// Перезапускает сервер с прежними настройками. pub fn restart(&self, name: &str) -> bool { let config = self .servers .lock() .get(name) .map(|server| server.config.clone()); let Some(config) = config else { return false; }; self.stop(name); match self.start(config) { Ok(()) => true, Err(err) => { self.log(format!("не удалось перезапустить «{name}»: {err}")); false } } } /// Имена запущенных серверов по алфавиту. pub fn server_names(&self) -> Vec { self.servers.lock().keys().cloned().collect() } /// Есть ли хотя бы один запущенный сервер. pub fn is_empty(&self) -> bool { self.servers.lock().is_empty() } /// Синхронизирует активный документ со всеми подходящими серверами. /// /// Первый вызов для документа отправляет `didOpen`, последующие с /// изменившимся текстом — `didChange` с новой версией. pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) { let version = { let mut active = self.active.lock(); match active.as_mut() { Some(document) if document.uri == uri => { if document.text == text { if document.language_id != language_id { document.language_id = language_id; } return; } document.version = document.version.wrapping_add(1); document.text = text.to_owned(); document.language_id = language_id.clone(); document.version } _ => { *active = Some(ActiveDocument { uri: uri.clone(), language_id: language_id.clone(), version: 1, text: text.to_owned(), }); 1 } } }; for server in self.servers.lock().values() { if !server.serves(&language_id) { continue; } if server.is_opened(&uri) { server.did_change(&uri, version, text); } else { server.did_open(&uri, &language_id, version, text); } } } /// Сообщает серверам, что документ сохранён на диск. pub fn did_save(&self, uri: &Uri, text: &str) { for server in self.servers.lock().values() { if server.is_opened(uri) { server.did_save(uri, text); } } } /// Закрывает документ на всех серверах, которые его открыли. pub fn close_document(&self, uri: &Uri) { for server in self.servers.lock().values() { server.did_close(uri); } // Сервер обычно присылает пустой список сам, но закрытая вкладка // не должна оставлять подчёркивания при повторном открытии файла. self.diagnostics.clear_uri(uri.as_str()); } /// Отправляет `textDocument/completion` в подходящий сервер. /// /// Возвращает канал, по которому придёт ответ. Ждать его нужно в фоновом /// исполнителе, чтобы не блокировать UI. pub fn completion(&self, offset: usize) -> Result>, String> { let document = self .active .lock() .clone() .ok_or_else(|| "нет открытого документа".to_owned())?; let server = self .server_for(&document.language_id) .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; server.request( "textDocument/completion", json!({ "textDocument": { "uri": document.uri.as_str() }, "position": offset_to_position(&document.text, offset), "context": { "triggerKind": 1 }, }), ) } /// Отправляет `textDocument/hover` в подходящий сервер. pub fn hover(&self, offset: usize) -> Result>, String> { let document = self .active .lock() .clone() .ok_or_else(|| "нет открытого документа".to_owned())?; let server = self .server_for(&document.language_id) .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; server.request( "textDocument/hover", json!({ "textDocument": { "uri": document.uri.as_str() }, "position": offset_to_position(&document.text, offset), }), ) } /// Дозапрашивает документацию элемента автодополнения. pub fn resolve_completion( &self, item: &CompletionItem, ) -> Result>, String> { let language_id = self .active .lock() .as_ref() .map(|document| document.language_id.clone()) .ok_or_else(|| "нет открытого документа".to_owned())?; let server = self .server_for(&language_id) .ok_or_else(|| "нет LSP-сервера для текущего языка".to_owned())?; let params = serde_json::to_value(item).map_err(|err| err.to_string())?; server.request("completionItem/resolve", params) } /// Номер поколения диагностики: меняется при каждом обновлении. /// /// Вид запоминает его и повторно проверяет после регистрации `Waker`, /// чтобы не потерять обновление между проверкой и ожиданием. pub fn diagnostics_revision(&self) -> u64 { self.diagnostics.revision() } /// Запоминает задачу, которую надо разбудить при обновлении диагностики. pub fn register_diagnostics_waker(&self, waker: std::task::Waker) { self.diagnostics.register_waker(waker); } /// Диагностика, известная для документа с указанным URI. pub fn diagnostics(&self, uri: &str) -> Vec { self.diagnostics.diagnostics(uri) } /// Снимок всей диагностики всех серверов. /// /// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает /// файлы, серверы и сообщения целиком, а не только для активной вкладки. pub fn diagnostics_snapshot(&self) -> Vec { self.diagnostics.snapshot() } /// Будит подписчиков диагностики, даже если новых сообщений не было. /// /// Нужен при регистрации Lua-обработчика: он должен получить уже /// накопленную диагностику, не дожидаясь следующего ответа сервера. pub fn refresh_diagnostics(&self) { self.diagnostics.notify(); } /// Кладёт диагностику в хранилище от имени тестового сервера. #[cfg(test)] pub fn set_diagnostics_for_test(&self, uri: &str, diagnostics: Vec) { self.diagnostics.replace("test", uri, diagnostics); } /// Последние строки журнала серверов. pub fn log_lines(&self) -> Vec { self.log.lock().iter().cloned().collect() } fn log(&self, message: impl Into) { push_log(&self.log, "lsp", &message.into()); } fn server_for(&self, language_id: &str) -> Option> { let servers = self.servers.lock(); let mut fallback = None; for server in servers.values() { if server.config.languages.is_empty() { fallback.get_or_insert_with(|| server.clone()); } else if server.serves(language_id) { return Some(server.clone()); } } fallback } } /// Один запущенный процесс LSP-сервера. struct LspServer { config: LspConfig, name: String, root_uri: String, stdin: Arc>, child: Mutex>, next_id: AtomicU64, pending: PendingMap, opened: Mutex>, watcher: Mutex>, alive: AtomicBool, log: LogBuffer, diagnostics: Arc, } impl LspServer { fn spawn(config: LspConfig, diagnostics: Arc) -> Result, String> { let mut command = Command::new(&config.command); command .args(&config.args) .current_dir(&config.root) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if !config.env.is_empty() { command.envs(config.env.iter().cloned()); } let mut child = command .spawn() .map_err(|err| format!("не удалось запустить «{}»: {err}", config.command))?; let stdin = child .stdin .take() .ok_or_else(|| "LSP-сервер не отдал stdin".to_owned())?; let stdout = child .stdout .take() .ok_or_else(|| "LSP-сервер не отдал stdout".to_owned())?; let stderr = child .stderr .take() .ok_or_else(|| "LSP-сервер не отдал stderr".to_owned())?; let log = Arc::new(Mutex::new(VecDeque::new())); let name = config.name.clone(); let root_uri = path_to_uri(&config.root).as_str().to_owned(); let server = Arc::new(Self { config, name, root_uri, stdin: Arc::new(Mutex::new(stdin)), child: Mutex::new(Some(child)), next_id: AtomicU64::new(1), pending: Mutex::new(HashMap::new()), opened: Mutex::new(HashSet::new()), watcher: Mutex::new(None), alive: AtomicBool::new(true), log: log.clone(), diagnostics, }); spawn_stdout_reader(Arc::downgrade(&server), stdout); spawn_stderr_reader(log, server.name.clone(), stderr); Ok(server) } fn serves(&self, language_id: &str) -> bool { self.config.languages.is_empty() || self .config .languages .iter() .any(|language| language.eq_ignore_ascii_case(language_id)) } fn is_opened(&self, uri: &Uri) -> bool { self.opened.lock().contains(uri.as_str()) } fn did_open(&self, uri: &Uri, language_id: &str, version: i32, text: &str) { let params = json!({ "textDocument": { "uri": uri.as_str(), "languageId": language_id, "version": version, "text": text, } }); if self.notify("textDocument/didOpen", params).is_ok() { self.opened.lock().insert(uri.as_str().to_owned()); } } fn did_change(&self, uri: &Uri, version: i32, text: &str) { let params = json!({ "textDocument": { "uri": uri.as_str(), "version": version }, "contentChanges": [{ "text": text }], }); let _ = self.notify("textDocument/didChange", params); } fn did_save(&self, uri: &Uri, text: &str) { let params = json!({ "textDocument": { "uri": uri.as_str() }, "text": text, }); let _ = self.notify("textDocument/didSave", params); } fn did_close(&self, uri: &Uri) { if self.opened.lock().remove(uri.as_str()) { let params = json!({ "textDocument": { "uri": uri.as_str() } }); let _ = self.notify("textDocument/didClose", params); } } fn request( &self, method: &str, params: Value, ) -> Result>, String> { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (sender, receiver) = mpsc::channel(); self.pending.lock().insert(id, sender); let message = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params, }); if let Err(err) = self.send_value(&message) { self.pending.lock().remove(&id); return Err(err); } Ok(receiver) } fn notify(&self, method: &str, params: Value) -> Result<(), String> { self.send_value(&json!({ "jsonrpc": "2.0", "method": method, "params": params, })) } fn respond(&self, id: Value, result: Value) { let _ = self.send_value(&json!({ "jsonrpc": "2.0", "id": id, "result": result, })); } fn send_value(&self, message: &Value) -> Result<(), String> { let mut stdin = self.stdin.lock(); write_message(&mut stdin, message) } fn handle_message(&self, message: Value) { let method = message .get("method") .and_then(Value::as_str) .map(str::to_owned); let has_id = message.get("id").is_some(); match (method, has_id) { (None, true) => self.handle_response(message), (Some(method), true) => self.handle_server_request(&method, message), (Some(method), false) => self.handle_notification(&method, &message), _ => {} } } fn handle_response(&self, message: Value) { let Some(id) = message.get("id").and_then(Value::as_u64) else { return; }; let Some(sender) = self.pending.lock().remove(&id) else { return; }; let result = if let Some(error) = message.get("error") { let text = error .get("message") .and_then(Value::as_str) .unwrap_or("ошибка LSP-сервера") .to_owned(); Err(text) } else { Ok(message.get("result").cloned().unwrap_or(Value::Null)) }; let _ = sender.send(result); } fn handle_server_request(&self, method: &str, message: Value) { let id = message.get("id").cloned().unwrap_or(Value::Null); let result = match method { "workspace/configuration" => { let count = message .pointer("/params/items") .and_then(Value::as_array) .map_or(0, Vec::len); Value::Array(vec![Value::Null; count]) } "workspace/workspaceFolders" => json!([{ "uri": self.root_uri, "name": self.name, }]), "workspace/applyEdit" => json!({ "applied": false }), "client/registerCapability" => Value::Null, "window/workDoneProgress/create" => Value::Null, "window/showMessageRequest" => Value::Null, _ => { self.log(format!("запрос сервера «{method}»: ответ null")); Value::Null } }; self.respond(id, result); } fn handle_notification(&self, method: &str, message: &Value) { match method { "textDocument/publishDiagnostics" => { let Some(uri) = message.pointer("/params/uri").and_then(Value::as_str) else { return; }; let diagnostics = message .pointer("/params/diagnostics") .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(|item| serde_json::from_value(item.clone()).ok()) .collect::>() }) .unwrap_or_default(); if !diagnostics.is_empty() { self.log(format!("диагностика {uri}: {}", diagnostics.len())); } self.diagnostics.replace(&self.name, uri, diagnostics); } "window/logMessage" | "window/showMessage" | "window/logTrace" => { if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) { self.log(text.to_owned()); } } _ => {} } } fn start_watcher(&self) -> Result<(), String> { let Some(config) = self.config.watcher.clone() else { return Ok(()); }; if config.patterns.is_empty() { return Ok(()); } let mut builder = GlobSetBuilder::new(); for pattern in &config.patterns { builder.add(Glob::new(pattern).map_err(|err| format!("шаблон «{pattern}»: {err}"))?); // `**/*.go` удобно писать для вложенных каталогов, но GlobSet // трактует `**/` как минимум один каталог. Добавляем вариант без // префикса, чтобы корневые файлы тоже попадали в вотчер. if let Some(rest) = pattern.strip_prefix("**/") && !rest.is_empty() { builder.add( Glob::new(rest) .map_err(|err| format!("шаблон «{rest}» из «{pattern}»: {err}"))?, ); } } let matcher = Arc::new( builder .build() .map_err(|err| format!("не удалось собрать файловый вотчер: {err}"))?, ); let mut ignore_builder = GlobSetBuilder::new(); for pattern in &config.ignore { if let Ok(glob) = Glob::new(pattern) { ignore_builder.add(glob); } } let ignore = Arc::new( ignore_builder .build() .map_err(|err| format!("не удалось собрать игнор-список: {err}"))?, ); let root = self.config.root.clone(); let stdin = self.stdin.clone(); let log = self.log.clone(); let name = self.name.clone(); let mut watcher = notify::recommended_watcher(move |result: notify::Result| { let Ok(event) = result else { return; }; let change_type = match event.kind { EventKind::Create(_) => 1, EventKind::Modify(_) => 2, EventKind::Remove(_) => 3, _ => return, }; let mut changes = Vec::new(); for path in event.paths { let Ok(relative) = path.strip_prefix(&root) else { continue; }; if is_ignored(relative, &ignore) { continue; } let relative = relative.to_string_lossy().replace('\\', "/"); if !matcher.is_match(&relative) { continue; } changes.push(json!({ "uri": path_to_uri(&path).as_str(), "type": change_type, })); } if changes.is_empty() { return; } let message = json!({ "jsonrpc": "2.0", "method": "workspace/didChangeWatchedFiles", "params": { "changes": changes }, }); let mut stdin = stdin.lock(); if let Err(err) = write_message(&mut stdin, &message) { drop(stdin); push_log( &log, &name, &format!("не удалось отправить didChangeWatchedFiles: {err}"), ); } }) .map_err(|err| format!("файловый вотчер: {err}"))?; watcher .watch(&self.config.root, RecursiveMode::Recursive) .map_err(|err| { format!( "не удалось следить за {}: {err}", self.config.root.display() ) })?; *self.watcher.lock() = Some(watcher); self.log("файловый вотчер запущен".to_owned()); Ok(()) } fn shutdown(&self) { if !self.alive.swap(false, Ordering::SeqCst) { return; } // Сначала отключаем вотчер, чтобы его события не писали в закрытый stdin. *self.watcher.lock() = None; self.opened.lock().clear(); if let Ok(receiver) = self.request("shutdown", Value::Null) { let _ = wait_response(receiver, Duration::from_secs(2)); } let _ = self.notify("exit", Value::Null); if let Some(mut child) = self.child.lock().take() { for _ in 0..20 { match child.try_wait() { Ok(Some(_)) => return, Ok(None) => std::thread::sleep(Duration::from_millis(50)), Err(_) => break, } } let _ = child.kill(); let _ = child.wait(); } } fn log(&self, message: impl Into) { push_log(&self.log, &self.name, &message.into()); } } impl Drop for LspServer { fn drop(&mut self) { *self.watcher.lock() = None; if let Some(mut child) = self.child.lock().take() { let _ = child.kill(); let _ = child.wait(); } } } /// Ждёт ответ на запрос в текущем потоке. /// /// Для UI используйте `background_executor`, чтобы не блокировать кадр. pub fn wait_response( receiver: Receiver>, timeout: Duration, ) -> Result { match receiver.recv_timeout(timeout) { Ok(result) => result, Err(RecvTimeoutError::Timeout) => Err("таймаут ответа LSP".to_owned()), Err(RecvTimeoutError::Disconnected) => Err("LSP-сервер закрыл соединение".to_owned()), } } fn spawn_stdout_reader(server: Weak, stdout: ChildStdout) { let _ = std::thread::Builder::new() .name("lsp-stdout".to_owned()) .spawn(move || { let mut reader = BufReader::new(stdout); loop { let message = match read_message(&mut reader) { Ok(Some(message)) => message, Ok(None) | Err(_) => break, }; let Some(server) = server.upgrade() else { break; }; server.handle_message(message); } }); } fn spawn_stderr_reader(log: LogBuffer, name: String, stderr: std::process::ChildStderr) { let _ = std::thread::Builder::new() .name("lsp-stderr".to_owned()) .spawn(move || { let mut reader = BufReader::new(stderr); let mut line = String::new(); loop { line.clear(); match reader.read_line(&mut line) { Ok(0) | Err(_) => break, Ok(_) => { let text = line.trim_end(); if !text.is_empty() { push_log(&log, &name, text); } } } } }); } fn read_message(reader: &mut R) -> std::io::Result> { let mut content_length = None; let mut line = String::new(); loop { line.clear(); let read = reader.read_line(&mut line)?; if read == 0 { return Ok(None); } let line = line.trim_end_matches(['\r', '\n']); if line.is_empty() { break; } if let Some(value) = line.strip_prefix("Content-Length:") { content_length = value.trim().parse::().ok(); } } let Some(length) = content_length else { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "LSP-сообщение без Content-Length", )); }; let mut body = vec![0; length]; reader.read_exact(&mut body)?; let value = serde_json::from_slice(&body) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; Ok(Some(value)) } fn write_message(stdin: &mut ChildStdin, message: &Value) -> Result<(), String> { let body = serde_json::to_vec(message).map_err(|err| err.to_string())?; write!(stdin, "Content-Length: {}\r\n\r\n", body.len()) .map_err(|err| format!("запись LSP: {err}"))?; stdin .write_all(&body) .map_err(|err| format!("запись LSP: {err}"))?; stdin.flush().map_err(|err| format!("запись LSP: {err}")) } fn push_log(log: &LogBuffer, name: &str, message: &str) { let line = format!("[{name}] {message}"); eprintln!("[lsp] {line}"); let mut log = log.lock(); if log.len() >= 200 { log.pop_front(); } log.push_back(line); } fn is_ignored(relative: &Path, ignore: &GlobSet) -> bool { let has_ignored_component = relative.components().any(|component| { matches!( component, Component::Normal(name) if matches!( name.to_str(), Some(".git" | "target" | "node_modules" | ".venv" | "dist" | "build") ) ) }); if has_ignored_component { return true; } let relative = relative.to_string_lossy().replace('\\', "/"); ignore.is_match(&relative) } fn initialize_params(config: &LspConfig) -> Value { let root_uri = path_to_uri(&config.root); let mut params = serde_json::Map::new(); params.insert("processId".into(), json!(std::process::id())); params.insert( "clientInfo".into(), json!({ "name": "qode", "version": env!("CARGO_PKG_VERSION"), }), ); params.insert("rootUri".into(), json!(root_uri.as_str())); params.insert( "rootPath".into(), json!(config.root.to_string_lossy().to_string()), ); params.insert( "workspaceFolders".into(), json!([{ "uri": root_uri.as_str(), "name": config.name }]), ); params.insert( "capabilities".into(), json!({ "workspace": { "workspaceFolders": true, "configuration": true, "didChangeWatchedFiles": { "dynamicRegistration": false }, "didChangeConfiguration": { "dynamicRegistration": false }, }, "textDocument": { "synchronization": { "dynamicRegistration": false, "willSave": false, "didSave": true, }, "completion": { "dynamicRegistration": false, "completionItem": { "snippetSupport": false, "documentationFormat": ["markdown", "plaintext"], "insertReplaceSupport": true, "resolveSupport": { "properties": ["documentation", "detail"] }, "labelDetailsSupport": true, }, "completionItemKind": { "valueSet": (1..=25).collect::>(), }, }, "hover": { "dynamicRegistration": false, "contentFormat": ["markdown", "plaintext"], }, "publishDiagnostics": { "relatedInformation": true, "versionSupport": true, }, }, }), ); if let Some(options) = &config.initialization_options { params.insert("initializationOptions".into(), options.clone()); } Value::Object(params) } /// Превращает путь в `file://`-URI с процентным кодированием. pub fn path_to_uri(path: &Path) -> Uri { let mut path = path.to_string_lossy().replace('\\', "/"); if !path.starts_with('/') { path.insert(0, '/'); } let mut encoded = String::from("file://"); for byte in path.bytes() { let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b':' | b'-' | b'_' | b'.' | b'~'); if keep { encoded.push(byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } // Всё, что мы построили, является корректным URI: путь уже абсолютный, // недопустимые для пути байты закодированы. Uri::from_str(&encoded).expect("путь превращается в корректный file:// URI") } /// Абсолютный путь из `file://`-URI с раскодированием процентов. /// /// Для URI других схем возвращает `None`: диагностика может относиться к /// виртуальному документу, у которого нет файла на диске. fn uri_to_path(uri: &str) -> Option { let rest = uri.strip_prefix("file://")?; let mut path = percent_decode(rest); // На Windows file:///C:/dir после снятия схемы выглядит как /C:/dir. if cfg!(windows) { let bytes = path.as_bytes(); if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { path.remove(0); } } Some(PathBuf::from(path)) } /// Раскодирует `%XX` в строке URI. fn percent_decode(input: &str) -> String { fn hex(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } let bytes = input.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { if bytes[index] == b'%' { let high = bytes.get(index + 1).copied().and_then(hex); let low = bytes.get(index + 2).copied().and_then(hex); if let (Some(high), Some(low)) = (high, low) { decoded.push(high * 16 + low); index += 3; continue; } } decoded.push(bytes[index]); index += 1; } String::from_utf8_lossy(&decoded).into_owned() } /// URI документа по его имени во вкладке редактора. /// /// Относительные имена считаются от текущего рабочего каталога — именно /// относительно него работают `save` и запуск LSP-сервера. pub fn document_uri(name: &str) -> Option { let path = Path::new(name); let path = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().ok()?.join(path) }; Some(path_to_uri(&path)) } /// Переводит байтовое смещение в LSP-позицию (`line`/`character` в UTF-16). pub fn offset_to_position(text: &str, offset: usize) -> Position { let mut offset = offset.min(text.len()); while offset > 0 && !text.is_char_boundary(offset) { offset -= 1; } let mut line = 0u32; let mut line_start = 0usize; for (index, ch) in text.char_indices() { if index >= offset { break; } if ch == '\n' { line += 1; line_start = index + 1; } } let character = text[line_start..offset].encode_utf16().count() as u32; Position::new(line, character) } /// Переводит LSP-позицию в байтовое смещение. pub fn position_to_offset(text: &str, position: Position) -> usize { let line = position.line as usize; let mut line_start = 0usize; let mut current_line = 0usize; for (index, ch) in text.char_indices() { if current_line == line { break; } if ch == '\n' { current_line += 1; line_start = index + 1; } } if current_line != line { return text.len(); } let line_end = text[line_start..] .find('\n') .map_or(text.len(), |index| line_start + index); let mut utf16 = 0usize; let mut offset = line_start; for ch in text[line_start..line_end].chars() { if utf16 >= position.character as usize { break; } utf16 += ch.len_utf16(); offset += ch.len_utf8(); } offset } #[cfg(test)] mod tests { use super::*; use std::io::Cursor; #[test] fn reads_content_length_framed_message() { let input = b"Content-Length: 13\r\n\r\n{\"jsonrpc\":1}"; let mut reader = Cursor::new(input); let message = read_message(&mut reader).unwrap().unwrap(); assert_eq!(message["jsonrpc"], 1); let mut empty = Cursor::new(Vec::new()); assert!(read_message(&mut empty).unwrap().is_none()); } #[test] fn diagnostics_store_keeps_servers_separate_and_signals() { let store = DiagnosticsStore::new(); let diagnostic = |message: &str| lsp_types::Diagnostic { range: lsp_types::Range::new(Position::new(0, 0), Position::new(0, 1)), message: message.to_owned(), ..Default::default() }; let revision = store.revision(); assert_eq!(revision, 0); store.replace("rust-analyzer", "file:///a.rs", vec![diagnostic("error")]); assert!(store.revision() > revision); assert_eq!(store.diagnostics("file:///a.rs").len(), 1); // Второй сервер добавляет своё, не затирая чужое. store.replace("other", "file:///a.rs", vec![diagnostic("warning")]); assert_eq!(store.diagnostics("file:///a.rs").len(), 2); // Пустой список удаляет только сообщения этого сервера. store.replace("rust-analyzer", "file:///a.rs", Vec::new()); let left = store.diagnostics("file:///a.rs"); assert_eq!(left.len(), 1); assert_eq!(left[0].message, "warning"); store.clear_server("other"); assert!(store.diagnostics("file:///a.rs").is_empty()); } #[test] fn positions_use_utf16_columns() { let text = "a\nβγ\n🙂x"; let beta = text.find('β').unwrap(); assert_eq!(offset_to_position(text, beta), Position::new(1, 0)); let gamma = text.find('γ').unwrap(); assert_eq!(offset_to_position(text, gamma), Position::new(1, 1)); assert_eq!(position_to_offset(text, Position::new(1, 1)), gamma); let emoji = text.find('🙂').unwrap(); assert_eq!(offset_to_position(text, emoji + 4), Position::new(2, 2)); assert_eq!(position_to_offset(text, Position::new(2, 2)), emoji + 4); } #[test] fn file_uri_encodes_spaces_and_cyrillic() { let uri = path_to_uri(Path::new("/tmp/файл с пробелом.go")); assert!(uri.as_str().starts_with("file:///tmp/")); assert!(!uri.as_str().contains(' ')); } /// Проверяет реальный цикл жизни с clangd. /// /// Тест помечен `ignore`, потому что требует установленного `clangd`. /// Запуск: `cargo test --offline clangd_smoke -- --ignored --nocapture`. #[test] #[ignore = "требует установленный clangd"] fn clangd_smoke() { if Command::new("clangd").arg("--version").output().is_err() { return; } let directory = temp_smoke_dir("clangd"); let manager = LspManager::new(); manager .start(LspConfig { name: "clangd".to_owned(), command: "clangd".to_owned(), args: vec!["--background-index=false".to_owned()], env: Vec::new(), root: directory.clone(), languages: vec!["c".to_owned()], watcher: None, initialization_options: None, }) .expect("clangd запускается"); let path = directory.join("main.c"); let text = "int main(void) { return 0; }\n"; std::fs::write(&path, text).unwrap(); manager.sync_document(path_to_uri(&path), "c".to_owned(), text); let receiver = manager .completion(text.find("return").unwrap()) .expect("запрос автодополнения"); let value = wait_response(receiver, Duration::from_secs(20)).expect("ответ clangd"); assert!( value.is_array() || value.get("items").is_some(), "неожиданный ответ completion: {value}" ); assert!(manager.stop("clangd")); assert!(manager.server_names().is_empty()); let _ = std::fs::remove_dir_all(&directory); } /// Проверяет `gopls` с теми же настройками, что стоят в `init.lua`. /// /// Запуск: `cargo test --offline gopls_smoke -- --ignored --nocapture`. #[test] #[ignore = "требует установленный gopls"] fn gopls_smoke() { if Command::new("gopls").arg("version").output().is_err() { return; } let directory = temp_smoke_dir("gopls"); std::fs::write(directory.join("go.mod"), "module smoke\n\ngo 1.21\n").unwrap(); let cache = directory.join("cache"); let gopath = directory.join("gopath"); std::fs::create_dir_all(&cache).unwrap(); std::fs::create_dir_all(&gopath).unwrap(); // В sandbox домашний каталог только для чтения, поэтому все кэши go/gopls // направляем во временный каталог. На обычной машине это не нужно. let env = vec![ ("HOME".to_owned(), directory.to_string_lossy().into_owned()), ( "XDG_CACHE_HOME".to_owned(), cache.to_string_lossy().into_owned(), ), ("GOPATH".to_owned(), gopath.to_string_lossy().into_owned()), ( "GOCACHE".to_owned(), directory.join("gocache").to_string_lossy().into_owned(), ), ( "GOMODCACHE".to_owned(), directory.join("gomodcache").to_string_lossy().into_owned(), ), ("GOTOOLCHAIN".to_owned(), "local".to_owned()), ]; let manager = LspManager::new(); manager .start(LspConfig { name: "gopls".to_owned(), command: "gopls".to_owned(), args: vec!["serve".to_owned()], env, root: directory.clone(), languages: vec!["go".to_owned()], watcher: None, initialization_options: Some(json!({ "usePlaceholders": true, "completeUnimported": true, })), }) .expect("gopls запускается"); let text = "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.\n}\n"; let path = directory.join("main.go"); std::fs::write(&path, text).unwrap(); manager.sync_document(path_to_uri(&path), "go".to_owned(), text); let offset = text.find("fmt.").unwrap() + "fmt.".len(); let receiver = manager.completion(offset).expect("запрос автодополнения"); let value = wait_response(receiver, Duration::from_secs(60)).expect("ответ gopls"); let count = if let Some(items) = value.as_array() { items.len() } else { value .get("items") .and_then(Value::as_array) .map_or(0, Vec::len) }; assert!(count > 0, "gopls не вернул автодополнение: {value}"); assert!(manager.stop("gopls")); assert!(manager.server_names().is_empty()); let _ = std::fs::remove_dir_all(&directory); } fn temp_smoke_dir(prefix: &str) -> PathBuf { let directory = std::env::temp_dir().join(format!( "gpui-lsp-{prefix}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&directory).unwrap(); directory } }