1581 lines
59 KiB
Rust
1581 lines
59 KiB
Rust
//! Минимальный 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
|
||
}
|
||
}
|