Files
qode/src/lsp.rs
T
2026-10-31 11:20:28 +03:00

1581 lines
59 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.
//! Минимальный 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<String>,
/// Сообщения диагностики в порядке, присланном сервером.
pub diagnostics: Vec<DiagnosticInfo>,
}
/// Одно сообщение диагностики.
///
/// Позиции, как и в 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<i32>,
/// Имя уровня: `error`, `warning`, `information` или `hint`.
pub severity_name: Option<String>,
/// Текст сообщения.
pub message: String,
/// Источник сообщения (обычно имя сервера).
pub source: Option<String>,
/// Код ошибки, если сервер его прислал.
pub code: Option<String>,
/// Теги: `unnecessary`, `deprecated`.
pub tags: Vec<String>,
}
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<String>,
/// Дополнительные шаблоны игнорирования.
pub ignore: Vec<String>,
}
/// Описание запускаемого 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<String>,
pub env: Vec<(String, String)>,
/// Корень рабочей области (по умолчанию текущий каталог процесса).
pub root: PathBuf,
/// Языки (`languageId`), которые обслуживает этот сервер.
///
/// Пустой список — сервер считается универсальным и получает активный
/// документ любого языка.
pub languages: Vec<String>,
pub watcher: Option<WatcherConfig>,
/// Необязательные `initializationOptions` для сервера.
pub initialization_options: Option<Value>,
}
/// Открытый документ, о котором знает менеджер.
#[derive(Clone, Debug)]
struct ActiveDocument {
uri: Uri,
language_id: String,
version: i32,
text: String,
}
type PendingMap = Mutex<HashMap<u64, mpsc::Sender<Result<Value, String>>>>;
type LogBuffer = Arc<Mutex<VecDeque<String>>>;
/// Диагностика, опубликованная серверами, сгруппированная по документу.
///
/// Внутренняя карта хранит сообщения по имени сервера: так остановка одного
/// сервера убирает только его сообщения, а не всю диагностику документа.
struct DiagnosticsStore {
by_uri: Mutex<HashMap<String, HashMap<String, Vec<lsp_types::Diagnostic>>>>,
/// Номер поколения: растёт при каждом изменении.
revision: AtomicU64,
/// Задача вида, которую надо разбудить после изменения.
///
/// Диагностика приходит в фоновом потоке, поэтому вместо блокирующего
/// ожидания канала UI подписывается на `Waker`.
waker: Mutex<Option<std::task::Waker>>,
}
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<lsp_types::Diagnostic>) {
{
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<lsp_types::Diagnostic> {
self.by_uri
.lock()
.get(uri)
.map(|by_server| by_server.values().flatten().cloned().collect::<Vec<_>>())
.unwrap_or_default()
}
/// Снимок всей диагностики: по одной записи на пару «документ, сервер».
///
/// Записи сортируются по пути и имени сервера, чтобы Lua-сценарий получал
/// стабильный порядок независимо от порядка `HashMap`.
fn snapshot(&self) -> Vec<DocumentDiagnostics> {
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<BTreeMap<String, Arc<LspServer>>>,
active: Mutex<Option<ActiveDocument>>,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
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<String> {
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<Receiver<Result<Value, String>>, 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<Receiver<Result<Value, String>>, 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<Receiver<Result<Value, String>>, 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<lsp_types::Diagnostic> {
self.diagnostics.diagnostics(uri)
}
/// Снимок всей диагностики всех серверов.
///
/// Используется обработчиком `app:on_lsp_diagnostics(...)`: он получает
/// файлы, серверы и сообщения целиком, а не только для активной вкладки.
pub fn diagnostics_snapshot(&self) -> Vec<DocumentDiagnostics> {
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<lsp_types::Diagnostic>) {
self.diagnostics.replace("test", uri, diagnostics);
}
/// Последние строки журнала серверов.
pub fn log_lines(&self) -> Vec<String> {
self.log.lock().iter().cloned().collect()
}
fn log(&self, message: impl Into<String>) {
push_log(&self.log, "lsp", &message.into());
}
fn server_for(&self, language_id: &str) -> Option<Arc<LspServer>> {
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<Mutex<ChildStdin>>,
child: Mutex<Option<Child>>,
next_id: AtomicU64,
pending: PendingMap,
opened: Mutex<HashSet<String>>,
watcher: Mutex<Option<RecommendedWatcher>>,
alive: AtomicBool,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
impl LspServer {
fn spawn(config: LspConfig, diagnostics: Arc<DiagnosticsStore>) -> Result<Arc<Self>, 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<Receiver<Result<Value, String>>, 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::<Vec<lsp_types::Diagnostic>>()
})
.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<notify::Event>| {
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<String>) {
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<Result<Value, String>>,
timeout: Duration,
) -> Result<Value, String> {
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<LspServer>, 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<R: BufRead>(reader: &mut R) -> std::io::Result<Option<Value>> {
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::<usize>().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::<Vec<_>>(),
},
},
"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<PathBuf> {
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<u8> {
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<Uri> {
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
}
}