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:
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user