refactoring

This commit is contained in:
2026-10-31 01:06:21 +03:00
parent 27385a3edb
commit 19e05e7ed0
17 changed files with 2673 additions and 671 deletions
+404 -51
View File
@@ -6,6 +6,7 @@
//! * JSON-RPC 2.0 с кадрами `Content-Length`;
//! * синхронизацию открытого документа (`didOpen`/`didChange`/`didSave`);
//! * запросы автодополнения, подсказок и `completionItem/resolve`;
//! * хранение диагностики `textDocument/publishDiagnostics` по документам;
//! * файловый вотчер, который пересылает серверу `workspace/didChangeWatchedFiles`.
//!
//! Всё остальное — какие серверы запускать, с какими аргументами и когда —
@@ -26,11 +27,108 @@ use std::sync::{Arc, Weak};
use std::time::Duration;
use globset::{Glob, GlobSet, GlobSetBuilder};
use lsp_types::{CompletionItem, Position, Uri};
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 {
@@ -75,6 +173,135 @@ struct ActiveDocument {
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 и фоновые
@@ -83,6 +310,7 @@ pub struct LspManager {
servers: Mutex<BTreeMap<String, Arc<LspServer>>>,
active: Mutex<Option<ActiveDocument>>,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
impl Default for LspManager {
@@ -97,6 +325,7 @@ impl LspManager {
servers: Mutex::new(BTreeMap::new()),
active: Mutex::new(None),
log: Arc::new(Mutex::new(VecDeque::new())),
diagnostics: Arc::new(DiagnosticsStore::new()),
}
}
@@ -119,7 +348,7 @@ impl LspManager {
}
config.root = std::fs::canonicalize(&config.root).unwrap_or(config.root);
let server = LspServer::spawn(config.clone())?;
let server = LspServer::spawn(config.clone(), self.diagnostics.clone())?;
// `initialize` — единственный запрос, который ждём прямо здесь:
// без ответа сервер нельзя считать рабочим.
@@ -170,6 +399,7 @@ impl LspManager {
return false;
};
server.shutdown();
self.diagnostics.clear_server(name);
self.log(format!("остановлен LSP-сервер «{name}»"));
true
}
@@ -209,33 +439,33 @@ impl LspManager {
/// Первый вызов для документа отправляет `didOpen`, последующие с
/// изменившимся текстом — `didChange` с новой версией.
pub fn sync_document(&self, uri: Uri, language_id: String, text: &str) {
let version;
{
let version = {
let mut active = self.active.lock();
if let Some(document) = active.as_mut()
&& document.uri == uri
&& document.text == text
{
if document.language_id != language_id {
document.language_id = language_id;
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
}
return;
}
let same_uri = active.as_ref().is_some_and(|document| document.uri == uri);
version = if same_uri {
active.as_ref().map_or(1, |document| document.version + 1)
} else {
1
};
*active = Some(ActiveDocument {
uri: uri.clone(),
language_id: language_id.clone(),
version,
text: text.to_owned(),
});
}
};
for server in self.servers.lock().values() {
if !server.serves(&language_id) {
@@ -263,6 +493,9 @@ impl LspManager {
for server in self.servers.lock().values() {
server.did_close(uri);
}
// Сервер обычно присылает пустой список сам, но закрытая вкладка
// не должна оставлять подчёркивания при повторном открытии файла.
self.diagnostics.clear_uri(uri.as_str());
}
/// Отправляет `textDocument/completion` в подходящий сервер.
@@ -327,6 +560,46 @@ impl LspManager {
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()
@@ -340,17 +613,11 @@ impl LspManager {
let servers = self.servers.lock();
let mut fallback = None;
for server in servers.values() {
if server
.config
.languages
.iter()
.any(|language| language.eq_ignore_ascii_case(language_id))
{
if server.config.languages.is_empty() {
fallback.get_or_insert_with(|| server.clone());
} else if server.serves(language_id) {
return Some(server.clone());
}
if server.config.languages.is_empty() && fallback.is_none() {
fallback = Some(server.clone());
}
}
fallback
}
@@ -369,10 +636,11 @@ struct LspServer {
watcher: Mutex<Option<RecommendedWatcher>>,
alive: AtomicBool,
log: LogBuffer,
diagnostics: Arc<DiagnosticsStore>,
}
impl LspServer {
fn spawn(config: LspConfig) -> Result<Arc<Self>, String> {
fn spawn(config: LspConfig, diagnostics: Arc<DiagnosticsStore>) -> Result<Arc<Self>, String> {
let mut command = Command::new(&config.command);
command
.args(&config.args)
@@ -418,6 +686,7 @@ impl LspServer {
watcher: Mutex::new(None),
alive: AtomicBool::new(true),
log: log.clone(),
diagnostics,
});
spawn_stdout_reader(Arc::downgrade(&server), stdout);
@@ -583,24 +852,25 @@ impl LspServer {
fn handle_notification(&self, method: &str, message: &Value) {
match method {
"textDocument/publishDiagnostics" => {
let uri = message
.pointer("/params/uri")
.and_then(Value::as_str)
.unwrap_or("?");
let count = message
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_or(0, Vec::len);
if count > 0 {
self.log(format!("диагностика {uri}: {count}"));
.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" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
self.log(text.to_owned());
}
}
"window/logTrace" => {
"window/logMessage" | "window/showMessage" | "window/logTrace" => {
if let Some(text) = message.pointer("/params/message").and_then(Value::as_str) {
self.log(text.to_owned());
}
@@ -894,7 +1164,7 @@ fn initialize_params(config: &LspConfig) -> Value {
params.insert(
"clientInfo".into(),
json!({
"name": "gpui-editor",
"name": "qode",
"version": env!("CARGO_PKG_VERSION"),
}),
);
@@ -979,6 +1249,59 @@ pub fn path_to_uri(path: &Path) -> 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 документа по его имени во вкладке редактора.
///
/// Относительные имена считаются от текущего рабочего каталога — именно
@@ -1068,6 +1391,36 @@ mod tests {
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";