feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "llimphi-widget-text-editor-lsp"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-widget-text-editor-lsp — trait LspClient + NoopLspClient como foundation. El cliente real (rust-analyzer/pylsp con tokio + jsonrpc) queda como TODO para una sesión dedicada."
[dependencies]
llimphi-widget-text-editor = { workspace = true }
lsp-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-text-editor-lsp
> [`text-editor`](../text-editor/README.md) + LSP para [llimphi](../../README.md).
Wrapper que conecta el editor a un servidor LSP (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, diagnostics inline, formatter al guardar.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-widget-text-editor-lsp
> [`text-editor`](../text-editor/README.md) + LSP for [llimphi](../../README.md).
Wrapper connecting the editor to an LSP server (rust-analyzer, pyright, ...). Hover, goto-definition, autocomplete, inline diagnostics, format on save.
+501
View File
@@ -0,0 +1,501 @@
use super::*;
pub struct RustAnalyzerClient {
/// Diagnostics activos por path. Lo escribe la task reader.
state: SharedState,
/// Sender al writer task. `None` si el spawn falló (modo no-op).
tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
/// Contador monotónico de request IDs.
next_id: i64,
/// Versiones por documento — el server las requiere en didChange.
versions: HashMap<PathBuf, i32>,
/// Runtime tokio dedicado — vive todo lo que viva el client.
/// `None` si el spawn falló.
_runtime: Option<Arc<tokio::runtime::Runtime>>,
}
impl RustAnalyzerClient {
/// Spawn `rust-analyzer` en `workspace_root`. Si el binary no está
/// en PATH, devuelve un client en modo no-op (sin error).
pub fn start(workspace_root: PathBuf) -> Self {
Self::with_command(workspace_root, "rust-analyzer")
}
/// Como `start` pero permite indicar el binary (`pylsp`, etc.).
pub fn with_command(workspace_root: PathBuf, command: &str) -> Self {
let state: SharedState = Arc::new(Mutex::new(SharedInner::default()));
let runtime = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => Arc::new(rt),
Err(_) => {
return Self {
state,
tx: None,
next_id: 1,
versions: HashMap::new(),
_runtime: None,
};
}
};
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let state_clone = state.clone();
let workspace_root_clone = workspace_root.clone();
let command_string = command.to_string();
runtime.spawn(async move {
if let Err(e) = run_server(workspace_root_clone, command_string, rx, state_clone).await
{
eprintln!("lsp: server task terminó con error: {e}");
}
});
let mut client = Self {
state,
tx: Some(tx),
next_id: 1,
versions: HashMap::new(),
_runtime: Some(runtime),
};
client.send_initialize(&workspace_root);
client
}
fn send_initialize(&mut self, root: &Path) {
let id = self.alloc_id();
let params = serde_json::json!({
"processId": std::process::id(),
"rootUri": format!("file://{}", root.display()),
"capabilities": {
"textDocument": {
"publishDiagnostics": { "relatedInformation": false }
}
},
"clientInfo": { "name": "llimphi-text-editor-lsp", "version": "0.1.0" }
});
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": params
});
self.send_raw(req.to_string());
// El handshake termina con la notification `initialized` que
// mandamos sin esperar la response — el reader la procesará.
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
});
self.send_raw(notif.to_string());
}
fn alloc_id(&mut self) -> i64 {
let id = self.next_id;
self.next_id += 1;
id
}
fn send_raw(&self, msg: String) {
if let Some(tx) = &self.tx {
let _ = tx.send(msg);
}
}
fn lsp_language_id(language: &str) -> &str {
match language {
"rust" | "rs" => "rust",
"python" | "py" => "python",
other => other,
}
}
}
impl LspClient for RustAnalyzerClient {
fn diagnostics(&self, path: &Path) -> Vec<Diagnostic> {
self.state
.lock()
.ok()
.and_then(|s| s.diagnostics.get(path).cloned())
.unwrap_or_default()
}
fn request_completions(&mut self, path: &Path, line: usize, col: usize) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_completion_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col }
}
});
self.send_raw(req.to_string());
}
fn latest_completions(&self) -> Vec<CompletionItem> {
self.state
.lock()
.map(|s| s.completions.clone())
.unwrap_or_default()
}
fn clear_completions(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.completions.clear();
}
}
fn request_hover(&mut self, path: &Path, line: usize, col: usize) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_hover_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/hover",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col }
}
});
self.send_raw(req.to_string());
}
fn latest_hover(&self) -> Option<HoverInfo> {
self.state.lock().ok().and_then(|s| s.hover.clone())
}
fn clear_hover(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.hover = None;
}
}
fn request_definition(&mut self, path: &Path, line: usize, col: usize) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_definition_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/definition",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col }
}
});
self.send_raw(req.to_string());
}
fn latest_definition(&self) -> Option<DefinitionLocation> {
self.state.lock().ok().and_then(|s| s.definition.clone())
}
fn clear_definition(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.definition = None;
}
}
fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_formatting_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/formatting",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"options": {
"tabSize": tab_size,
"insertSpaces": insert_spaces
}
}
});
self.send_raw(req.to_string());
}
fn latest_text_edits(&self) -> Vec<TextEdit> {
self.state.lock().map(|s| s.text_edits.clone()).unwrap_or_default()
}
fn clear_text_edits(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.text_edits.clear();
}
}
fn request_signature_help(&mut self, path: &Path, line: usize, col: usize) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_signature_help_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/signatureHelp",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col }
}
});
self.send_raw(req.to_string());
}
fn latest_signature_help(&self) -> Option<SignatureHelpInfo> {
self.state.lock().ok().and_then(|s| s.signature_help.clone())
}
fn clear_signature_help(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.signature_help = None;
}
}
fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_references_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/references",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col },
"context": { "includeDeclaration": include_decl }
}
});
self.send_raw(req.to_string());
}
fn latest_references(&self) -> Vec<DefinitionLocation> {
self.state.lock().map(|s| s.references.clone()).unwrap_or_default()
}
fn clear_references(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.references.clear();
}
}
fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_rename_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/rename",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) },
"position": { "line": line, "character": col },
"newName": new_name
}
});
self.send_raw(req.to_string());
}
fn latest_workspace_edit(&self) -> std::collections::HashMap<PathBuf, Vec<TextEdit>> {
self.state.lock().map(|s| s.workspace_edit.clone()).unwrap_or_default()
}
fn clear_workspace_edit(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.workspace_edit.clear();
}
}
fn request_document_symbols(&mut self, path: &Path) {
let id = self.alloc_id();
if let Ok(mut s) = self.state.lock() {
s.pending_document_symbols_ids.insert(id);
}
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/documentSymbol",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) }
}
});
self.send_raw(req.to_string());
}
fn latest_document_symbols(&self) -> Vec<DocumentSymbolEntry> {
self.state.lock().map(|s| s.document_symbols.clone()).unwrap_or_default()
}
fn clear_document_symbols(&mut self) {
if let Ok(mut s) = self.state.lock() {
s.document_symbols.clear();
}
}
fn did_open(&mut self, path: &Path, language: &str, text: &str) {
self.versions.insert(path.to_path_buf(), 1);
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": format!("file://{}", path.display()),
"languageId": Self::lsp_language_id(language),
"version": 1,
"text": text,
}
}
});
self.send_raw(notif.to_string());
}
fn did_change(&mut self, path: &Path, new_text: &str) {
let version = {
let v = self.versions.entry(path.to_path_buf()).or_insert(1);
*v += 1;
*v
};
// Full-document change. Más eficiente sería incremental, pero
// requiere trackear los EditDeltas del editor — futuro.
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": {
"uri": format!("file://{}", path.display()),
"version": version,
},
"contentChanges": [{ "text": new_text }]
}
});
self.send_raw(notif.to_string());
}
fn did_close(&mut self, path: &Path) {
self.versions.remove(path);
let notif = serde_json::json!({
"jsonrpc": "2.0",
"method": "textDocument/didClose",
"params": {
"textDocument": { "uri": format!("file://{}", path.display()) }
}
});
self.send_raw(notif.to_string());
if let Ok(mut s) = self.state.lock() {
s.diagnostics.remove(path);
}
}
}
// ---------------------------------------------------------------------
// Task tokio que corre el server + bombea I/O
// ---------------------------------------------------------------------
async fn run_server(
_workspace_root: PathBuf,
command: String,
mut rx: tokio::sync::mpsc::UnboundedReceiver<String>,
state: SharedState,
) -> std::io::Result<()> {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
let mut child = match Command::new(&command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
eprintln!("lsp: no pude spawn `{command}`: {e}");
return Ok(());
}
};
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped");
// Writer task: consume el rx y manda al stdin con headers LSP.
let writer = tokio::spawn(async move {
let mut stdin = stdin;
while let Some(msg) = rx.recv().await {
let header = format!("Content-Length: {}\r\n\r\n", msg.len());
if stdin.write_all(header.as_bytes()).await.is_err() {
break;
}
if stdin.write_all(msg.as_bytes()).await.is_err() {
break;
}
let _ = stdin.flush().await;
}
});
// Reader task: parsea mensajes del stdout, procesa publishDiagnostics.
let reader = tokio::spawn({
let state = state.clone();
async move {
let mut reader = BufReader::new(stdout);
loop {
let mut content_length: Option<usize> = None;
// Headers — terminan con línea vacía.
loop {
let mut line = String::new();
match reader.read_line(&mut line).await {
Ok(0) => return, // EOF
Ok(_) => {}
Err(_) => return,
}
let line = line.trim_end_matches(['\r', '\n']);
if line.is_empty() {
break;
}
if let Some(rest) = line.strip_prefix("Content-Length:") {
if let Ok(n) = rest.trim().parse::<usize>() {
content_length = Some(n);
}
}
}
let Some(len) = content_length else { continue };
let mut buf = vec![0u8; len];
if reader.read_exact(&mut buf).await.is_err() {
return;
}
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&buf) else {
continue;
};
if json.get("method").and_then(|m| m.as_str())
== Some("textDocument/publishDiagnostics")
{
handle_publish_diagnostics(&json, &state);
} else if let Some(id) = json.get("id").and_then(|i| i.as_i64()) {
handle_response(id, &json, &state);
}
}
}
});
// Esperamos a que se cierre cualquiera de los dos lados o el child.
tokio::select! {
_ = writer => {}
_ = reader => {}
_ = child.wait() => {}
}
let _ = child.kill().await;
Ok(())
}
+275
View File
@@ -0,0 +1,275 @@
//! `llimphi-widget-text-editor-lsp` — cliente LSP para alimentar
//! diagnostics al editor.
//!
//! Implementación real basada en `tokio::process::Command` +
//! `lsp-types` + JSON-RPC sobre stdin/stdout del language server.
//!
//! Flujo:
//!
//! 1. `RustAnalyzerClient::start(workspace_root)` spawn `rust-analyzer`
//! (o el binary que se le pase con `with_command`) y arranca dos
//! tasks tokio:
//! - **writer**: consume mensajes del `mpsc::Sender`, los serializa
//! con headers `Content-Length: N\r\n\r\n` y los manda al stdin.
//! - **reader**: parsea el stdout del server (mismo formato),
//! atiende `textDocument/publishDiagnostics` y guarda los
//! diagnostics en el state compartido.
//! 2. El handshake `initialize` se envía sincronicamente desde `start`
//! y se espera la respuesta antes de mandar `initialized` +
//! procesar más mensajes.
//! 3. `did_open` / `did_change` / `did_close` mandan las notifications
//! correspondientes — sin esperar respuesta.
//! 4. `diagnostics(path)` lee del state sin contactar al server.
//!
//! El client maneja **una sola conexión por instancia**. Para
//! multi-proyecto el caller crea varios clients.
//!
//! Si el server no se puede spawnear (binary no instalado), el client
//! cae en modo no-op transparentemente — `diagnostics` devuelve vacío.
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use llimphi_widget_text_editor::{Diagnostic, DiagnosticRange, Pos, Severity};
/// Item de completion — mirror minimal de `lsp_types::CompletionItem`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
/// Texto a insertar. Si `None`, se usa `label`.
pub insert_text: Option<String>,
/// Tipo del símbolo según LSP (Function, Variable, etc.) — para
/// mostrar un ícono. Aquí lo guardamos como string corto.
pub kind: Option<String>,
/// Documentación corta — el primer renglón típicamente.
pub detail: Option<String>,
}
impl CompletionItem {
pub fn text_to_insert(&self) -> &str {
self.insert_text.as_deref().unwrap_or(self.label.as_str())
}
}
/// Contrato que un client LSP debe cumplir para alimentar al editor.
pub trait LspClient: Send {
fn diagnostics(&self, path: &Path) -> Vec<Diagnostic>;
fn did_open(&mut self, path: &Path, language: &str, text: &str);
fn did_change(&mut self, path: &Path, new_text: &str);
fn did_close(&mut self, path: &Path);
/// Dispara una petición de completions en `(line, col)` del path.
/// Fire-and-forget; la respuesta se lee con `latest_completions`.
fn request_completions(&mut self, path: &Path, line: usize, col: usize);
/// Última lista de completions recibida (cualquier path/pos).
/// Vacío hasta que el server responda. El client la limpia cuando
/// el caller llama `clear_completions`.
fn latest_completions(&self) -> Vec<CompletionItem>;
/// Borra el cache de completions — útil al cerrar el popup.
fn clear_completions(&mut self);
/// Dispara textDocument/hover. Fire-and-forget; el caller polla
/// `latest_hover` para leer la respuesta.
fn request_hover(&mut self, path: &Path, line: usize, col: usize);
/// Última hover info recibida (cualquier path/pos).
fn latest_hover(&self) -> Option<HoverInfo>;
/// Borra el cache de hover.
fn clear_hover(&mut self);
/// Dispara textDocument/definition. Fire-and-forget; el caller
/// polla `latest_definition`.
fn request_definition(&mut self, path: &Path, line: usize, col: usize);
/// Última definition recibida (path destino + pos de inicio).
fn latest_definition(&self) -> Option<DefinitionLocation>;
fn clear_definition(&mut self);
/// Dispara textDocument/formatting. Cuando llega la response, el
/// caller polla `latest_text_edits` y los aplica al buffer.
fn request_formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool);
/// Última lista de TextEdits recibida (de formatting o rename).
fn latest_text_edits(&self) -> Vec<TextEdit>;
fn clear_text_edits(&mut self);
/// Dispara textDocument/signatureHelp. Cuando llega, el popup
/// muestra la firma activa con el parámetro current resaltado.
fn request_signature_help(&mut self, path: &Path, line: usize, col: usize);
fn latest_signature_help(&self) -> Option<SignatureHelpInfo>;
fn clear_signature_help(&mut self);
/// Dispara textDocument/references. `include_decl` controla si la
/// declaración misma aparece en los resultados.
fn request_references(&mut self, path: &Path, line: usize, col: usize, include_decl: bool);
fn latest_references(&self) -> Vec<DefinitionLocation>;
fn clear_references(&mut self);
/// Dispara textDocument/rename con `new_name` como nuevo identificador.
fn request_rename(&mut self, path: &Path, line: usize, col: usize, new_name: &str);
/// Última WorkspaceEdit recibida (rename o code actions). Mapeado a
/// `path → Vec<TextEdit>` por simplicidad.
fn latest_workspace_edit(&self) -> std::collections::HashMap<PathBuf, Vec<TextEdit>>;
fn clear_workspace_edit(&mut self);
/// Dispara textDocument/documentSymbol. La respuesta llega
/// asincrónica; el caller la recoge con [`latest_document_symbols`].
fn request_document_symbols(&mut self, path: &Path);
/// Última respuesta de documentSymbol — lista plana flattening del
/// árbol jerárquico que devuelve el server. Orden: top-down,
/// children en orden de aparición. `depth` refleja la profundidad
/// para que el caller indente visualmente.
fn latest_document_symbols(&self) -> Vec<DocumentSymbolEntry>;
fn clear_document_symbols(&mut self);
}
/// Una entrada flattening del árbol `DocumentSymbol` del LSP. Espejo
/// mínimo que evita arrastrar `lsp_types::SymbolKind` a los hosts —
/// `kind` viene ya como string corta (`"fn"`, `"struct"`, `"method"`, …).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentSymbolEntry {
pub name: String,
pub kind: String,
pub line: usize,
pub col: usize,
pub container: Option<String>,
pub depth: u32,
}
/// Info de signatureHelp activa.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignatureHelpInfo {
/// Firma activa (label completa, ej. "fn foo(x: i32, y: String) -> u64").
pub label: String,
/// Documentación de la firma activa.
pub doc: Option<String>,
/// Índice del parámetro current (0-based).
pub active_param: usize,
/// Labels de los parámetros — para resaltar el activo.
pub param_labels: Vec<String>,
}
/// Edit estilo LSP: reemplazar el rango `[start..end)` por `new_text`.
/// Para apply: ordenar desc por `start` y aplicar uno por uno.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextEdit {
pub start_line: usize,
pub start_col: usize,
pub end_line: usize,
pub end_col: usize,
pub new_text: String,
}
/// Resultado de un goto-definition: archivo destino + posición.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefinitionLocation {
pub path: PathBuf,
pub line: usize,
pub col: usize,
}
/// Información de hover — espejo simplificado de `lsp_types::Hover`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoverInfo {
/// Markdown / plaintext del símbolo bajo el cursor. El render del
/// caller lo muestra tal cual (sin parsear markdown todavía).
pub contents: String,
}
/// Stub que no hace nada — útil cuando no hay LSP configurado o para tests.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopLspClient;
impl LspClient for NoopLspClient {
fn diagnostics(&self, _: &Path) -> Vec<Diagnostic> {
Vec::new()
}
fn did_open(&mut self, _: &Path, _: &str, _: &str) {}
fn did_change(&mut self, _: &Path, _: &str) {}
fn did_close(&mut self, _: &Path) {}
fn request_completions(&mut self, _: &Path, _: usize, _: usize) {}
fn latest_completions(&self) -> Vec<CompletionItem> {
Vec::new()
}
fn clear_completions(&mut self) {}
fn request_hover(&mut self, _: &Path, _: usize, _: usize) {}
fn latest_hover(&self) -> Option<HoverInfo> {
None
}
fn clear_hover(&mut self) {}
fn request_definition(&mut self, _: &Path, _: usize, _: usize) {}
fn latest_definition(&self) -> Option<DefinitionLocation> {
None
}
fn clear_definition(&mut self) {}
fn request_formatting(&mut self, _: &Path, _: u32, _: bool) {}
fn latest_text_edits(&self) -> Vec<TextEdit> {
Vec::new()
}
fn clear_text_edits(&mut self) {}
fn request_signature_help(&mut self, _: &Path, _: usize, _: usize) {}
fn latest_signature_help(&self) -> Option<SignatureHelpInfo> {
None
}
fn clear_signature_help(&mut self) {}
fn request_references(&mut self, _: &Path, _: usize, _: usize, _: bool) {}
fn latest_references(&self) -> Vec<DefinitionLocation> {
Vec::new()
}
fn clear_references(&mut self) {}
fn request_rename(&mut self, _: &Path, _: usize, _: usize, _: &str) {}
fn latest_workspace_edit(&self) -> std::collections::HashMap<PathBuf, Vec<TextEdit>> {
std::collections::HashMap::new()
}
fn clear_workspace_edit(&mut self) {}
fn request_document_symbols(&mut self, _: &Path) {}
fn latest_document_symbols(&self) -> Vec<DocumentSymbolEntry> {
Vec::new()
}
fn clear_document_symbols(&mut self) {}
}
// ---------------------------------------------------------------------
// Rust-analyzer client real
// ---------------------------------------------------------------------
/// State compartido: paths → versión + diagnostics actuales + última
/// lista de completions recibida.
#[derive(Default)]
struct SharedInner {
diagnostics: HashMap<PathBuf, Vec<Diagnostic>>,
/// Última respuesta de completions — sobreescribe cualquier
/// request previo. El caller decide cuándo limpiar.
completions: Vec<CompletionItem>,
/// Última hover info recibida.
hover: Option<HoverInfo>,
/// Última definition recibida.
definition: Option<DefinitionLocation>,
/// Última lista de TextEdits (formatting / rename).
text_edits: Vec<TextEdit>,
/// Última signature help.
signature_help: Option<SignatureHelpInfo>,
/// Última lista de references.
references: Vec<DefinitionLocation>,
/// Última WorkspaceEdit (de rename). Mapeo path → edits.
workspace_edit: HashMap<PathBuf, Vec<TextEdit>>,
/// Última lista de document symbols (flattened del árbol que devuelve
/// el server). Se sobreescribe en cada request.
document_symbols: Vec<DocumentSymbolEntry>,
/// IDs de requests pendientes para distinguir responses; el reader
/// usa estos sets para routear cada response al handler correcto.
pending_completion_ids: std::collections::HashSet<i64>,
pending_hover_ids: std::collections::HashSet<i64>,
pending_definition_ids: std::collections::HashSet<i64>,
pending_formatting_ids: std::collections::HashSet<i64>,
pending_signature_help_ids: std::collections::HashSet<i64>,
pending_references_ids: std::collections::HashSet<i64>,
pending_rename_ids: std::collections::HashSet<i64>,
pending_document_symbols_ids: std::collections::HashSet<i64>,
}
type SharedState = Arc<Mutex<SharedInner>>;
// Cliente y protocolo partidos del monolito (regla dura #1, 1660 LOC):
// `client` (RustAnalyzerClient + impls), `protocol` (parsers/handlers JSON-RPC).
mod client;
mod protocol;
pub use client::RustAnalyzerClient;
pub(crate) use protocol::*;
#[cfg(test)]
mod tests;
+515
View File
@@ -0,0 +1,515 @@
//! Parsers y handlers JSON-RPC de las respuestas/notificaciones LSP.
use super::*;
pub(crate) fn handle_publish_diagnostics(json: &serde_json::Value, state: &SharedState) {
let Some(params) = json.get("params") else { return };
let Some(uri) = params.get("uri").and_then(|u| u.as_str()) else { return };
let path = match uri.strip_prefix("file://") {
Some(p) => PathBuf::from(p),
None => return,
};
let Some(diags_arr) = params.get("diagnostics").and_then(|d| d.as_array()) else {
return;
};
let diagnostics: Vec<Diagnostic> = diags_arr
.iter()
.filter_map(parse_lsp_diagnostic)
.collect();
if let Ok(mut s) = state.lock() {
s.diagnostics.insert(path, diagnostics);
}
}
/// Routea una response del server al handler correspondiente según
/// qué set de pendientes la contenía.
pub(crate) fn handle_response(id: i64, json: &serde_json::Value, state: &SharedState) {
let flags = {
let Ok(mut s) = state.lock() else { return };
(
s.pending_completion_ids.remove(&id),
s.pending_hover_ids.remove(&id),
s.pending_definition_ids.remove(&id),
s.pending_formatting_ids.remove(&id),
s.pending_signature_help_ids.remove(&id),
s.pending_references_ids.remove(&id),
s.pending_rename_ids.remove(&id),
s.pending_document_symbols_ids.remove(&id),
)
};
let (was_completion, was_hover, was_def, was_fmt, was_sig, was_refs, was_rename, was_doc_sym) =
flags;
if was_completion {
handle_completion_response(json, state);
}
if was_hover {
handle_hover_response(json, state);
}
if was_def {
handle_definition_response(json, state);
}
if was_fmt {
handle_text_edits_response(json, state);
}
if was_sig {
handle_signature_help_response(json, state);
}
if was_refs {
handle_references_response(json, state);
}
if was_rename {
handle_rename_response(json, state);
}
if was_doc_sym {
handle_document_symbols_response(json, state);
}
}
/// Parsea la respuesta de `textDocument/documentSymbol`. Devuelve dos
/// formatos posibles según la versión del server:
///
/// - `DocumentSymbol[]` (jerárquico, moderno) — el que usa rust-analyzer.
/// - `SymbolInformation[]` (plano, legacy) — fallback razonable.
///
/// Ambos se flatten a `Vec<DocumentSymbolEntry>` con depth para que el
/// caller pueda indentar visualmente.
pub(crate) fn handle_document_symbols_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
if let Ok(mut s) = state.lock() {
s.document_symbols.clear();
}
return;
}
let mut out: Vec<DocumentSymbolEntry> = Vec::new();
if let Some(arr) = result.as_array() {
for item in arr {
// Distingue por la presencia de "selectionRange" (sólo en
// DocumentSymbol). SymbolInformation tiene "location" en
// su lugar.
if item.get("selectionRange").is_some() {
flatten_document_symbol(item, None, 0, &mut out);
} else if item.get("location").is_some() {
if let Some(entry) = parse_symbol_information(item) {
out.push(entry);
}
}
}
}
if let Ok(mut s) = state.lock() {
s.document_symbols = out;
}
}
/// Flatten recursivo de `DocumentSymbol`. `parent` es el nombre del
/// contenedor (para que `container` quede poblado en métodos/campos).
pub(crate) fn flatten_document_symbol(
node: &serde_json::Value,
parent: Option<&str>,
depth: u32,
out: &mut Vec<DocumentSymbolEntry>,
) {
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("?").to_string();
let kind_num = node.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
let kind = symbol_kind_label(kind_num);
// `selectionRange.start` es la pos del identificador (lo que el
// usuario quiere ver al saltar). `range.start` apuntaría al `{` de
// la definición — menos útil para outline.
let (line, col) = node
.get("selectionRange")
.and_then(|r| r.get("start"))
.and_then(parse_position)
.or_else(|| node.get("range").and_then(|r| r.get("start")).and_then(parse_position))
.unwrap_or((0, 0));
out.push(DocumentSymbolEntry {
name: name.clone(),
kind,
line,
col,
container: parent.map(|s| s.to_string()),
depth,
});
if let Some(children) = node.get("children").and_then(|c| c.as_array()) {
for child in children {
flatten_document_symbol(child, Some(&name), depth + 1, out);
}
}
}
pub(crate) fn parse_symbol_information(item: &serde_json::Value) -> Option<DocumentSymbolEntry> {
let name = item.get("name")?.as_str()?.to_string();
let kind_num = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
let location = item.get("location")?;
let (line, col) = location.get("range").and_then(|r| r.get("start")).and_then(parse_position)?;
let container = item
.get("containerName")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
Some(DocumentSymbolEntry {
name,
kind: symbol_kind_label(kind_num),
line,
col,
container,
depth: 0,
})
}
pub(crate) fn parse_position(p: &serde_json::Value) -> Option<(usize, usize)> {
let line = p.get("line")?.as_u64()? as usize;
let col = p.get("character")?.as_u64()? as usize;
Some((line, col))
}
/// Mapea el `SymbolKind` numérico del LSP a la etiqueta corta que el
/// outline pinta. Sólo cubre las que el usuario suele ver — el resto
/// va a `"sym"`. Lista canónica: <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind>
pub(crate) fn symbol_kind_label(kind: u64) -> String {
match kind {
2 => "mod",
5 => "class",
6 => "method",
7 => "property",
8 => "field",
9 => "ctor",
10 => "enum",
11 => "iface",
12 => "fn",
13 => "var",
14 => "const",
15 => "str",
18 => "arr",
22 => "variant",
23 => "struct",
26 => "type",
_ => "sym",
}
.to_string()
}
pub(crate) fn handle_rename_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
return;
}
let mut map: HashMap<PathBuf, Vec<TextEdit>> = HashMap::new();
// changes: { uri → TextEdit[] }
if let Some(changes) = result.get("changes").and_then(|c| c.as_object()) {
for (uri, edits_val) in changes {
let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue };
let Some(arr) = edits_val.as_array() else { continue };
let edits: Vec<TextEdit> = arr.iter().filter_map(parse_text_edit).collect();
map.insert(path, edits);
}
}
// documentChanges: [{ textDocument: { uri }, edits: [...] }] — más nuevo.
if let Some(docs) = result.get("documentChanges").and_then(|c| c.as_array()) {
for doc in docs {
let Some(uri) = doc
.get("textDocument")
.and_then(|t| t.get("uri"))
.and_then(|u| u.as_str())
else {
continue;
};
let Some(path) = uri.strip_prefix("file://").map(PathBuf::from) else { continue };
let Some(arr) = doc.get("edits").and_then(|e| e.as_array()) else { continue };
let edits: Vec<TextEdit> = arr.iter().filter_map(parse_text_edit).collect();
map.entry(path).or_default().extend(edits);
}
}
if let Ok(mut s) = state.lock() {
s.workspace_edit = map;
}
}
pub(crate) fn handle_references_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
if let Ok(mut s) = state.lock() {
s.references.clear();
}
return;
}
let Some(arr) = result.as_array() else { return };
let refs: Vec<DefinitionLocation> = arr.iter().filter_map(parse_location).collect();
if let Ok(mut s) = state.lock() {
s.references = refs;
}
}
/// Parsea una `Location` LSP: { uri, range } → DefinitionLocation.
pub(crate) fn parse_location(loc: &serde_json::Value) -> Option<DefinitionLocation> {
let uri = loc.get("uri")?.as_str()?;
let path = uri.strip_prefix("file://").map(PathBuf::from)?;
let range = loc.get("range")?;
let start = range.get("start")?;
let line = start.get("line")?.as_u64()? as usize;
let col = start.get("character")?.as_u64()? as usize;
Some(DefinitionLocation { path, line, col })
}
pub(crate) fn handle_signature_help_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
if let Ok(mut s) = state.lock() {
s.signature_help = None;
}
return;
}
let info = parse_signature_help(result);
if let Ok(mut s) = state.lock() {
s.signature_help = info;
}
}
pub(crate) fn parse_signature_help(result: &serde_json::Value) -> Option<SignatureHelpInfo> {
let sigs = result.get("signatures")?.as_array()?;
if sigs.is_empty() {
return None;
}
let active_sig = result.get("activeSignature").and_then(|n| n.as_u64()).unwrap_or(0) as usize;
let sig = sigs.get(active_sig).or_else(|| sigs.first())?;
let label = sig.get("label")?.as_str()?.to_string();
let doc = sig
.get("documentation")
.map(stringify_hover_contents)
.filter(|s| !s.is_empty());
let active_param = sig
.get("activeParameter")
.or_else(|| result.get("activeParameter"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as usize;
let param_labels = sig
.get("parameters")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| {
let lbl = p.get("label")?;
if let Some(s) = lbl.as_str() {
Some(s.to_string())
} else if let Some(arr2) = lbl.as_array() {
let s = arr2.first()?.as_u64()? as usize;
let e = arr2.get(1)?.as_u64()? as usize;
label.get(s..e).map(String::from)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
Some(SignatureHelpInfo { label, doc, active_param, param_labels })
}
pub(crate) fn handle_text_edits_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
return;
}
let Some(arr) = result.as_array() else { return };
let edits: Vec<TextEdit> = arr.iter().filter_map(parse_text_edit).collect();
if let Ok(mut s) = state.lock() {
s.text_edits = edits;
}
}
pub(crate) fn parse_text_edit(v: &serde_json::Value) -> Option<TextEdit> {
let range = v.get("range")?;
let start = range.get("start")?;
let end = range.get("end")?;
let start_line = start.get("line")?.as_u64()? as usize;
let start_col = start.get("character")?.as_u64()? as usize;
let end_line = end.get("line")?.as_u64()? as usize;
let end_col = end.get("character")?.as_u64()? as usize;
let new_text = v.get("newText")?.as_str()?.to_string();
Some(TextEdit { start_line, start_col, end_line, end_col, new_text })
}
pub(crate) fn handle_definition_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
return;
}
// `result` puede ser:
// - Location { uri, range }
// - Location[]
// - LocationLink[] { targetUri, targetSelectionRange }
// Tomamos la primera location en cualquier caso.
let loc_value = if result.is_array() {
result.as_array().and_then(|a| a.first()).cloned()
} else {
Some(result.clone())
};
let Some(loc) = loc_value else { return };
let (uri, range) = if let Some(u) = loc.get("uri") {
(u, loc.get("range"))
} else if let Some(u) = loc.get("targetUri") {
(
u,
loc.get("targetSelectionRange").or_else(|| loc.get("targetRange")),
)
} else {
return;
};
let Some(uri) = uri.as_str() else { return };
let path = match uri.strip_prefix("file://") {
Some(p) => PathBuf::from(p),
None => return,
};
let Some(range) = range else { return };
let Some(start) = range.get("start") else { return };
let line = start.get("line").and_then(|n| n.as_u64()).unwrap_or(0) as usize;
let col = start.get("character").and_then(|n| n.as_u64()).unwrap_or(0) as usize;
if let Ok(mut s) = state.lock() {
s.definition = Some(DefinitionLocation { path, line, col });
}
}
pub(crate) fn handle_completion_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
let items_arr = if let Some(arr) = result.as_array() {
arr.clone()
} else if let Some(items) = result.get("items").and_then(|i| i.as_array()) {
items.clone()
} else {
return;
};
let completions: Vec<CompletionItem> = items_arr.iter().filter_map(parse_completion).collect();
if let Ok(mut s) = state.lock() {
s.completions = completions;
}
}
pub(crate) fn handle_hover_response(json: &serde_json::Value, state: &SharedState) {
let Some(result) = json.get("result") else { return };
if result.is_null() {
if let Ok(mut s) = state.lock() {
s.hover = None;
}
return;
}
let info = parse_hover(result);
if let Ok(mut s) = state.lock() {
s.hover = info;
}
}
/// `contents` en LSP puede ser:
/// - String
/// - { kind: "markdown"|"plaintext", value: String }
/// - Array de los anteriores (deprecated pero algunos servers lo mandan)
/// - { language: ..., value: ... } (legacy MarkedString)
pub(crate) fn parse_hover(result: &serde_json::Value) -> Option<HoverInfo> {
let contents = result.get("contents")?;
let text = stringify_hover_contents(contents);
if text.is_empty() {
None
} else {
Some(HoverInfo { contents: text })
}
}
pub(crate) fn stringify_hover_contents(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(map) => {
// { kind, value } o { language, value }
map.get("value")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string()
}
serde_json::Value::Array(arr) => arr
.iter()
.map(stringify_hover_contents)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
pub(crate) fn parse_completion(v: &serde_json::Value) -> Option<CompletionItem> {
let label = v.get("label")?.as_str()?.to_string();
let insert_text = v
.get("insertText")
.and_then(|s| s.as_str())
.map(String::from);
let kind = v
.get("kind")
.and_then(|k| k.as_u64())
.map(|n| completion_kind_label(n).to_string());
let detail = v
.get("detail")
.and_then(|d| d.as_str())
.map(String::from);
Some(CompletionItem { label, insert_text, kind, detail })
}
/// Etiqueta corta para el CompletionItemKind de LSP (1..25).
pub(crate) fn completion_kind_label(k: u64) -> &'static str {
match k {
1 => "Text",
2 => "Method",
3 => "Function",
4 => "Ctor",
5 => "Field",
6 => "Var",
7 => "Class",
8 => "Iface",
9 => "Mod",
10 => "Prop",
11 => "Unit",
12 => "Value",
13 => "Enum",
14 => "Keyword",
15 => "Snip",
16 => "Color",
17 => "File",
18 => "Ref",
19 => "Folder",
20 => "EnumMember",
21 => "Const",
22 => "Struct",
23 => "Event",
24 => "Op",
25 => "TypeParam",
_ => "?",
}
}
pub(crate) fn parse_lsp_diagnostic(d: &serde_json::Value) -> Option<Diagnostic> {
let range = d.get("range")?;
let start = range.get("start")?;
let end = range.get("end")?;
let sl = start.get("line")?.as_u64()? as usize;
let sc = start.get("character")?.as_u64()? as usize;
let el = end.get("line")?.as_u64()? as usize;
let ec = end.get("character")?.as_u64()? as usize;
let severity = match d.get("severity").and_then(|s| s.as_u64()) {
Some(1) => Severity::Error,
Some(2) => Severity::Warning,
Some(3) => Severity::Information,
Some(4) => Severity::Hint,
_ => Severity::Information,
};
let message = d
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let source = d.get("source").and_then(|s| s.as_str()).map(String::from);
Some(Diagnostic {
range: DiagnosticRange {
start: Pos::new(sl, sc),
end: Pos::new(el, ec),
},
severity,
message,
source,
})
}
+380
View File
@@ -0,0 +1,380 @@
use super::*;
#[test]
fn noop_devuelve_vacio() {
let c = NoopLspClient;
assert!(c.diagnostics(&PathBuf::from("x")).is_empty());
}
#[test]
fn noop_no_panic_en_eventos() {
let mut c = NoopLspClient;
c.did_open(&PathBuf::from("x"), "rust", "fn main() {}");
c.did_change(&PathBuf::from("x"), "fn main() { 1 }");
c.did_close(&PathBuf::from("x"));
}
#[test]
fn parse_diagnostic_minimo() {
let json = serde_json::json!({
"range": {
"start": { "line": 3, "character": 5 },
"end": { "line": 3, "character": 12 }
},
"severity": 1,
"message": "no es así",
"source": "rustc"
});
let d = parse_lsp_diagnostic(&json).unwrap();
assert_eq!(d.range.start, Pos::new(3, 5));
assert_eq!(d.range.end, Pos::new(3, 12));
assert_eq!(d.severity, Severity::Error);
assert_eq!(d.message, "no es así");
assert_eq!(d.source.as_deref(), Some("rustc"));
}
#[test]
fn parse_diagnostic_sin_severidad_es_info() {
let json = serde_json::json!({
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 1 }
},
"message": "x"
});
let d = parse_lsp_diagnostic(&json).unwrap();
assert_eq!(d.severity, Severity::Information);
}
#[test]
fn parse_completion_minimo() {
let v = serde_json::json!({
"label": "to_string",
"insertText": "to_string()",
"kind": 2,
"detail": "fn(&self) -> String"
});
let c = parse_completion(&v).unwrap();
assert_eq!(c.label, "to_string");
assert_eq!(c.insert_text.as_deref(), Some("to_string()"));
assert_eq!(c.kind.as_deref(), Some("Method"));
assert_eq!(c.detail.as_deref(), Some("fn(&self) -> String"));
}
#[test]
fn parse_hover_string_simple() {
let v = serde_json::json!({ "contents": "hola" });
let h = parse_hover(&v).unwrap();
assert_eq!(h.contents, "hola");
}
#[test]
fn parse_hover_marked_object() {
let v = serde_json::json!({
"contents": { "kind": "markdown", "value": "**fn**(x: i32) -> i32" }
});
let h = parse_hover(&v).unwrap();
assert_eq!(h.contents, "**fn**(x: i32) -> i32");
}
#[test]
fn parse_hover_array_concatena() {
let v = serde_json::json!({
"contents": ["primero", { "value": "segundo" }, ""]
});
let h = parse_hover(&v).unwrap();
assert_eq!(h.contents, "primero\nsegundo");
}
#[test]
fn parse_hover_vacio_devuelve_none() {
let v = serde_json::json!({ "contents": "" });
assert!(parse_hover(&v).is_none());
}
#[test]
fn parse_completion_sin_insert_text_usa_label() {
let v = serde_json::json!({ "label": "main" });
let c = parse_completion(&v).unwrap();
assert_eq!(c.text_to_insert(), "main");
}
fn make_state() -> SharedState {
Arc::new(Mutex::new(SharedInner::default()))
}
#[test]
fn handle_rename_changes_map() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": {
"changes": {
"file:///tmp/a.rs": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "bar" }
],
"file:///tmp/b.rs": [
{ "range": { "start": { "line": 5, "character": 4 }, "end": { "line": 5, "character": 7 } }, "newText": "bar" },
{ "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } }, "newText": "bar" }
]
}
}
});
handle_rename_response(&json, &s);
let we = s.lock().unwrap().workspace_edit.clone();
assert_eq!(we.len(), 2);
assert_eq!(we.get(&PathBuf::from("/tmp/a.rs")).unwrap().len(), 1);
assert_eq!(we.get(&PathBuf::from("/tmp/b.rs")).unwrap().len(), 2);
}
#[test]
fn handle_rename_document_changes() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": {
"documentChanges": [
{
"textDocument": { "uri": "file:///tmp/x.rs", "version": 2 },
"edits": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "foo" }
]
}
]
}
});
handle_rename_response(&json, &s);
let we = s.lock().unwrap().workspace_edit.clone();
assert_eq!(we.len(), 1);
assert_eq!(we.get(&PathBuf::from("/tmp/x.rs")).unwrap().len(), 1);
}
#[test]
fn handle_document_symbols_jerarquico() {
let s = make_state();
// Estructura: struct Foo { fn bar(), fn baz() } + fn top()
let json = serde_json::json!({
"id": 1,
"result": [
{
"name": "Foo",
"kind": 23, // struct
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 10, "character": 1 } },
"selectionRange": { "start": { "line": 0, "character": 7 }, "end": { "line": 0, "character": 10 } },
"children": [
{
"name": "bar",
"kind": 6, // method
"range": { "start": { "line": 2, "character": 4 }, "end": { "line": 4, "character": 5 } },
"selectionRange": { "start": { "line": 2, "character": 7 }, "end": { "line": 2, "character": 10 } }
},
{
"name": "baz",
"kind": 6,
"range": { "start": { "line": 6, "character": 4 }, "end": { "line": 8, "character": 5 } },
"selectionRange": { "start": { "line": 6, "character": 7 }, "end": { "line": 6, "character": 10 } }
}
]
},
{
"name": "top",
"kind": 12, // function
"range": { "start": { "line": 12, "character": 0 }, "end": { "line": 14, "character": 1 } },
"selectionRange": { "start": { "line": 12, "character": 3 }, "end": { "line": 12, "character": 6 } }
}
]
});
handle_document_symbols_response(&json, &s);
let syms = s.lock().unwrap().document_symbols.clone();
assert_eq!(syms.len(), 4, "esperaba 4 entradas flattening");
assert_eq!(syms[0].name, "Foo");
assert_eq!(syms[0].kind, "struct");
assert_eq!(syms[0].line, 0);
assert_eq!(syms[0].depth, 0);
assert_eq!(syms[0].container, None);
assert_eq!(syms[1].name, "bar");
assert_eq!(syms[1].kind, "method");
assert_eq!(syms[1].line, 2);
assert_eq!(syms[1].depth, 1);
assert_eq!(syms[1].container.as_deref(), Some("Foo"));
assert_eq!(syms[2].name, "baz");
assert_eq!(syms[2].depth, 1);
assert_eq!(syms[2].container.as_deref(), Some("Foo"));
assert_eq!(syms[3].name, "top");
assert_eq!(syms[3].kind, "fn");
assert_eq!(syms[3].depth, 0);
}
#[test]
fn handle_document_symbols_legacy_symbolinformation() {
let s = make_state();
// Formato viejo: SymbolInformation[] (plano + location).
let json = serde_json::json!({
"id": 1,
"result": [
{
"name": "main",
"kind": 12,
"location": {
"uri": "file:///tmp/x.rs",
"range": { "start": { "line": 0, "character": 3 }, "end": { "line": 0, "character": 7 } }
}
},
{
"name": "helper",
"kind": 12,
"containerName": "main",
"location": {
"uri": "file:///tmp/x.rs",
"range": { "start": { "line": 5, "character": 3 }, "end": { "line": 5, "character": 9 } }
}
}
]
});
handle_document_symbols_response(&json, &s);
let syms = s.lock().unwrap().document_symbols.clone();
assert_eq!(syms.len(), 2);
assert_eq!(syms[1].name, "helper");
assert_eq!(syms[1].container.as_deref(), Some("main"));
}
#[test]
fn handle_references_response_array() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": [
{ "uri": "file:///tmp/a.rs", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 5 } } },
{ "uri": "file:///tmp/b.rs", "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 3 } } }
]
});
handle_references_response(&json, &s);
let refs = s.lock().unwrap().references.clone();
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].path, PathBuf::from("/tmp/a.rs"));
assert_eq!(refs[0].line, 1);
assert_eq!(refs[1].path, PathBuf::from("/tmp/b.rs"));
assert_eq!(refs[1].line, 10);
}
#[test]
fn parse_signature_help_basic() {
let result = serde_json::json!({
"signatures": [{
"label": "fn foo(x: i32, y: String) -> u64",
"parameters": [
{ "label": "x: i32" },
{ "label": "y: String" }
]
}],
"activeSignature": 0,
"activeParameter": 1
});
let info = parse_signature_help(&result).unwrap();
assert_eq!(info.label, "fn foo(x: i32, y: String) -> u64");
assert_eq!(info.active_param, 1);
assert_eq!(info.param_labels, vec!["x: i32", "y: String"]);
}
#[test]
fn parse_signature_help_offset_label() {
// Label como [start, end] dentro del label de la firma.
let result = serde_json::json!({
"signatures": [{
"label": "foo(x, y)",
"parameters": [
{ "label": [4, 5] },
{ "label": [7, 8] }
]
}]
});
let info = parse_signature_help(&result).unwrap();
assert_eq!(info.param_labels, vec!["x", "y"]);
}
#[test]
fn parse_text_edit_basic() {
let v = serde_json::json!({
"range": {
"start": { "line": 1, "character": 0 },
"end": { "line": 1, "character": 4 }
},
"newText": "let "
});
let e = parse_text_edit(&v).unwrap();
assert_eq!(e.start_line, 1);
assert_eq!(e.start_col, 0);
assert_eq!(e.end_line, 1);
assert_eq!(e.end_col, 4);
assert_eq!(e.new_text, "let ");
}
#[test]
fn handle_text_edits_response_array() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": [
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } }, "newText": "fn " },
{ "range": { "start": { "line": 1, "character": 4 }, "end": { "line": 1, "character": 5 } }, "newText": "" }
]
});
handle_text_edits_response(&json, &s);
let edits = s.lock().unwrap().text_edits.clone();
assert_eq!(edits.len(), 2);
}
#[test]
fn handle_definition_location_simple() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": {
"uri": "file:///tmp/x.rs",
"range": {
"start": { "line": 10, "character": 4 },
"end": { "line": 10, "character": 9 }
}
}
});
handle_definition_response(&json, &s);
let d = s.lock().unwrap().definition.clone().unwrap();
assert_eq!(d.path, PathBuf::from("/tmp/x.rs"));
assert_eq!(d.line, 10);
assert_eq!(d.col, 4);
}
#[test]
fn handle_definition_location_link_array() {
let s = make_state();
let json = serde_json::json!({
"id": 1,
"result": [
{
"targetUri": "file:///tmp/y.rs",
"targetSelectionRange": {
"start": { "line": 0, "character": 7 },
"end": { "line": 0, "character": 12 }
}
}
]
});
handle_definition_response(&json, &s);
let d = s.lock().unwrap().definition.clone().unwrap();
assert_eq!(d.path, PathBuf::from("/tmp/y.rs"));
assert_eq!(d.line, 0);
assert_eq!(d.col, 7);
}
#[test]
fn rust_analyzer_client_sin_binary_no_panic() {
// Si rust-analyzer no está instalado, el spawn falla en silencio
// y el client queda en modo no-op (state vacío).
let c = RustAnalyzerClient::with_command(PathBuf::from("/tmp"), "rust-analyzer-missing-99999");
// diagnostics() siempre devuelve vacío hasta que el server responde.
assert!(c.diagnostics(&PathBuf::from("/tmp/x")).is_empty());
}