Skip to main content

xei_core/
lsp.rs

1//! Minimal but stable LSP client over stdio.
2//!
3//! Stability goals:
4//! - absolute `file://` URIs
5//! - proper initialize → initialized → didOpen order
6//! - document version bump on didChange
7//! - route responses by request id
8//! - clear diagnostics on empty publish
9//! - project root detection (Cargo.toml, package.json, …)
10//! - binary presence check before spawn
11
12use std::collections::HashMap;
13use std::io::{BufRead, BufReader, Read, Write};
14use std::path::{Path, PathBuf};
15use std::process::{Child, ChildStdin, Command, Stdio};
16use std::sync::mpsc::{self, Receiver, TryRecvError};
17use std::thread;
18
19use crate::config;
20use crate::highlight::{self, TokenKind};
21
22/// Semantic highlight span: (kind, start_col, end_col, row) — **char** columns.
23pub type SemanticToken = (TokenKind, usize, usize, usize);
24
25pub struct LspClient {
26    stdin: Option<ChildStdin>,
27    rx: Option<Receiver<RawMsg>>,
28    _child: Option<Child>,
29    next_id: u64,
30    /// id → request kind for routing results
31    pending: HashMap<u64, PendingReq>,
32    doc_version: i64,
33    pub diagnostics: Vec<Diagnostic>,
34    pub server_running: bool,
35    pub server_name: String,
36    pub server_lang: String,
37    pub initialized: bool,
38    pending_didopen: Option<(String, String, String)>, // path, lang, escaped text
39    pub pending_definition: Option<Location>,
40    /// When true, next definition result feeds peek instead of jump.
41    pub definition_as_peek: bool,
42    pub pending_completions: Vec<CompletionItem>,
43    pub pending_hover: Option<String>,
44    pub pending_references: Vec<Location>,
45    /// Legacy single-string edit message (status / no-op notes).
46    pub pending_workspace_edit: Option<String>,
47    /// Multi-file full-text edits ready to apply (path → new content).
48    pub pending_edits: Vec<FileEdit>,
49    pub pending_symbols: Vec<SymbolItem>,
50    pub pending_code_actions: Vec<CodeActionItem>,
51    /// Call hierarchy items ready for the panel (after prepare + incoming/outgoing).
52    pub pending_call_hierarchy: Vec<crate::call_hierarchy::CallItem>,
53    /// Direction last requested (for UI).
54    pub pending_call_direction: Option<crate::call_hierarchy::CallDirection>,
55    pub call_hierarchy_ready: bool,
56    pub inlay_hints: Vec<InlayHint>,
57    pub inlay_supported: bool,
58    inlay_dirty: bool,
59    pub code_lenses: Vec<CodeLens>,
60    pub code_lens_supported: bool,
61    code_lens_dirty: bool,
62    /// Bumped per codeLens response; stale resolve replies are ignored.
63    code_lens_gen: u64,
64    /// Hard failure (init crash, disconnect). Status shows `LSP:err`.
65    pub error: Option<String>,
66    /// Soft notice (binary missing, method unsupported). Status shows dim hint.
67    pub soft_error: Option<String>,
68    /// Last few stderr lines from the server (debug / soft_error detail).
69    pub stderr_tail: String,
70    current_uri: String,
71    /// URI we last didOpen (for didClose on switch).
72    opened_uri: String,
73    root_uri: String,
74    /// Legend from initialize (token type names)
75    semantic_token_types: Vec<String>,
76    pub semantic_tokens_supported: bool,
77    /// Decoded semantic tokens for the current document (char columns)
78    pub semantic_tokens: Vec<SemanticToken>,
79    /// Full document text at last semantic-token decode (for UTF-16 → char)
80    semantic_doc_text: String,
81    /// Re-request semantic tokens after didChange / didOpen
82    semantic_dirty: bool,
83    last_semantic_req_version: i64,
84    /// Master switch from config.
85    pub enabled: bool,
86    /// Per-language command overrides (from ~/.xei.toml `lsp.*`).
87    pub server_overrides: HashMap<String, String>,
88}
89
90#[derive(Debug, Clone)]
91pub struct SymbolItem {
92    pub name: String,
93    pub kind: String,
94    pub path: String,
95    pub row: usize,
96    pub col: usize,
97    pub detail: String,
98}
99
100#[derive(Debug, Clone)]
101pub struct InlayHint {
102    pub row: usize,
103    pub col: usize, // char column
104    pub label: String,
105}
106
107/// Virtual text above / on a line from `textDocument/codeLens`.
108#[derive(Debug, Clone)]
109pub struct CodeLens {
110    pub row: usize,
111    pub col: usize,
112    pub title: String,
113}
114
115/// One file's new full text after applying a WorkspaceEdit / format / code action.
116#[derive(Debug, Clone)]
117pub struct FileEdit {
118    pub path: String,
119    pub text: String,
120}
121
122/// LSP code action / quickfix entry.
123#[derive(Debug, Clone)]
124pub struct CodeActionItem {
125    pub title: String,
126    pub kind: String,
127    pub edits: Vec<FileEdit>,
128    /// Optional command id (best-effort execute via workspace/executeCommand).
129    pub command: Option<String>,
130    pub command_args_json: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy)]
134enum PendingReq {
135    Definition,
136    Completion,
137    Hover,
138    References,
139    Rename,
140    Initialize,
141    SemanticTokens,
142    DocumentSymbol,
143    WorkspaceSymbol,
144    InlayHint,
145    Formatting,
146    CodeAction,
147    ExecuteCommand,
148    PrepareCallHierarchy,
149    IncomingCalls,
150    OutgoingCalls,
151    CodeLens,
152    /// codeLens/resolve — tagged with the generation of its codeLens response
153    /// so stale resolves (after an edit re-request) are dropped.
154    CodeLensResolve(u64),
155}
156
157struct RawMsg {
158    id: Option<u64>,
159    method: Option<String>,
160    body: String,
161}
162
163#[derive(Debug, Clone)]
164pub struct Diagnostic {
165    pub row: usize,
166    pub col_start: usize,
167    pub col_end: usize,
168    pub message: String,
169    pub severity: DiagnosticSeverity,
170}
171
172#[derive(Debug, Clone, PartialEq)]
173pub enum DiagnosticSeverity {
174    Error,
175    Warning,
176    Info,
177    Hint,
178}
179
180#[derive(Debug, Clone)]
181pub struct Location {
182    pub path: String,
183    pub row: usize,
184    pub col: usize,
185}
186
187#[derive(Debug, Clone)]
188pub struct CompletionItem {
189    pub label: String,
190    pub detail: Option<String>,
191}
192
193impl Default for LspClient {
194    fn default() -> Self {
195        Self {
196            stdin: None,
197            rx: None,
198            _child: None,
199            next_id: 1,
200            pending: HashMap::new(),
201            doc_version: 1,
202            diagnostics: Vec::new(),
203            server_running: false,
204            server_name: String::new(),
205            server_lang: String::new(),
206            initialized: false,
207            pending_didopen: None,
208            pending_definition: None,
209            definition_as_peek: false,
210            pending_completions: Vec::new(),
211            pending_hover: None,
212            pending_references: Vec::new(),
213            pending_workspace_edit: None,
214            pending_edits: Vec::new(),
215            pending_symbols: Vec::new(),
216            pending_code_actions: Vec::new(),
217            pending_call_hierarchy: Vec::new(),
218            pending_call_direction: None,
219            call_hierarchy_ready: false,
220            inlay_hints: Vec::new(),
221            inlay_supported: false,
222            inlay_dirty: false,
223            code_lenses: Vec::new(),
224            code_lens_supported: true, // probe via first request
225            code_lens_dirty: false,
226            code_lens_gen: 0,
227            error: None,
228            soft_error: None,
229            stderr_tail: String::new(),
230            current_uri: String::new(),
231            opened_uri: String::new(),
232            root_uri: String::new(),
233            semantic_token_types: Vec::new(),
234            semantic_tokens_supported: false,
235            semantic_tokens: Vec::new(),
236            semantic_doc_text: String::new(),
237            semantic_dirty: false,
238            last_semantic_req_version: 0,
239            enabled: true,
240            server_overrides: HashMap::new(),
241        }
242    }
243}
244
245impl LspClient {
246    pub fn new() -> Self {
247        Self::default()
248    }
249
250    pub fn start(&mut self, cmd: &str, root: &str, file_path: &str) {
251        self.start_with_text(cmd, root, file_path, None);
252    }
253
254    pub fn start_with_text(
255        &mut self,
256        cmd: &str,
257        root: &str,
258        file_path: &str,
259        text_override: Option<&str>,
260    ) {
261        self.shutdown_quiet();
262
263        let parts: Vec<&str> = cmd.split_whitespace().collect();
264        if parts.is_empty() {
265            return;
266        }
267
268        if !command_exists(parts[0]) {
269            // Soft: missing optional binary is not a hard error
270            self.soft_error = Some(install_hint(parts[0]));
271            self.error = None;
272            self.server_running = false;
273            return;
274        }
275
276        let abs_file = abs_path(file_path);
277        let abs_root = abs_path(root);
278        let root = find_project_root(&abs_root, &abs_file);
279
280        let mut child = match Command::new(parts[0])
281            .args(&parts[1..])
282            .current_dir(&root)
283            .stdin(Stdio::piped())
284            .stdout(Stdio::piped())
285            .stderr(Stdio::null())
286            .spawn()
287        {
288            Ok(c) => c,
289            Err(e) => {
290                self.error = Some(format!("LSP failed to start `{}`: {}", parts[0], e));
291                return;
292            }
293        };
294
295        let stdin = match child.stdin.take() {
296            Some(s) => s,
297            None => {
298                self.error = Some("LSP stdin unavailable".into());
299                return;
300            }
301        };
302        let stdout = match child.stdout.take() {
303            Some(s) => s,
304            None => {
305                self.error = Some("LSP stdout unavailable".into());
306                return;
307            }
308        };
309
310        let (tx, rx) = mpsc::channel();
311        thread::spawn(move || read_loop(stdout, tx));
312
313        self.stdin = Some(stdin);
314        self.rx = Some(rx);
315        self._child = Some(child);
316        self.server_name = parts[0].to_string();
317        self.current_uri = path_to_uri(&abs_file);
318        self.root_uri = path_to_uri(&root);
319        self.doc_version = 1;
320        self.initialized = false;
321        self.server_running = false; // true after initialize result
322        self.error = None;
323        self.soft_error = None;
324        self.stderr_tail.clear();
325        self.diagnostics.clear();
326        self.pending.clear();
327        self.opened_uri.clear();
328
329        let id = self.alloc_id(PendingReq::Initialize);
330        let pid = std::process::id();
331        let folder_name = Path::new(&root)
332            .file_name()
333            .and_then(|n| n.to_str())
334            .unwrap_or("workspace");
335        // Build initialize carefully — a single malformed brace kills the server
336        // and surfaces as sticky LSP:err (disconnected).
337        let init = build_initialize_request(
338            id,
339            pid,
340            &self.root_uri,
341            folder_name,
342        );
343        self.send_raw(&init);
344
345        let text = text_override
346            .map(|s| s.to_string())
347            .unwrap_or_else(|| std::fs::read_to_string(&abs_file).unwrap_or_default());
348        let lang = lang_id(&abs_file);
349        self.semantic_doc_text = text.clone();
350        self.pending_didopen = Some((
351            abs_file.clone(),
352            lang.to_string(),
353            escape_json(&text),
354        ));
355    }
356
357    pub fn auto_start(&mut self, file_path: &str) {
358        self.auto_start_with_text(file_path, None);
359    }
360
361    /// Apply config LSP settings (enabled + per-language commands).
362    pub fn apply_config(&mut self, enabled: bool, overrides: HashMap<String, String>) {
363        self.enabled = enabled;
364        self.server_overrides = overrides;
365        if !enabled && (self.server_running || self.stdin.is_some()) {
366            self.shutdown_quiet();
367            self.soft_error = Some("LSP disabled in settings".into());
368        }
369    }
370
371    /// Resolve command for an extension, honoring overrides + catalog.
372    pub fn resolve_server_cmd(&self, ext: &str) -> Option<String> {
373        if !self.enabled {
374            return None;
375        }
376        let lang = ext_to_lang_key(ext)?;
377        if let Some(cmd) = self.server_overrides.get(lang) {
378            if cmd.is_empty() {
379                return None; // explicitly off
380            }
381            return Some(cmd.clone());
382        }
383        default_server_for_ext(ext).map(|s| s.to_string())
384    }
385
386    /// Like [`auto_start`] but opens with live buffer text (avoids stale disk).
387    pub fn auto_start_with_text(&mut self, file_path: &str, text: Option<&str>) {
388        let abs = abs_path(file_path);
389        let ext = Path::new(&abs)
390            .extension()
391            .and_then(|e| e.to_str())
392            .unwrap_or("")
393            .to_lowercase();
394
395        let Some(cmd) = self.resolve_server_cmd(&ext) else {
396            // No server for this file type — clear paint state, keep soft notes.
397            self.diagnostics.clear();
398            self.semantic_tokens.clear();
399            self.inlay_hints.clear();
400            // Don't keep hard err from previous language when browsing md/txt
401            if !self.server_running {
402                self.error = None;
403            }
404            return;
405        };
406
407        // Same language + already running → re-open with live text
408        if self.server_running && self.server_lang == ext {
409            self.open_document_with_text(&abs, text);
410            return;
411        }
412
413        if self.server_running || self.stdin.is_some() {
414            self.shutdown_quiet();
415        }
416
417        let root = Path::new(&abs)
418            .parent()
419            .map(|p| p.display().to_string())
420            .unwrap_or_else(|| ".".into());
421        self.start_with_text(&cmd, &root, &abs, text);
422        self.server_lang = ext;
423    }
424
425    /// didOpen / re-open current file on an already running server (disk text).
426    pub fn open_document(&mut self, file_path: &str) {
427        self.open_document_with_text(file_path, None);
428    }
429
430    /// didOpen with optional live buffer contents.
431    pub fn open_document_with_text(&mut self, file_path: &str, text: Option<&str>) {
432        if !self.server_running {
433            return;
434        }
435        let abs = abs_path(file_path);
436        let new_uri = path_to_uri(&abs);
437        // Close previous document if switching files
438        if !self.opened_uri.is_empty() && self.opened_uri != new_uri {
439            let close = format!(
440                r#"{{"jsonrpc":"2.0","method":"textDocument/didClose","params":{{"textDocument":{{"uri":"{}"}}}}}}"#,
441                escape_json(&self.opened_uri)
442            );
443            self.send_raw(&close);
444        }
445        let text = text
446            .map(|s| s.to_string())
447            .unwrap_or_else(|| std::fs::read_to_string(&abs).unwrap_or_default());
448        let lang = lang_id(&abs);
449        self.current_uri = new_uri.clone();
450        self.opened_uri = new_uri;
451        self.doc_version = 1;
452        self.diagnostics.clear();
453        self.semantic_tokens.clear();
454        self.inlay_hints.clear();
455        self.code_lenses.clear();
456        self.semantic_doc_text = text.clone();
457        let msg = format!(
458            r#"{{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{{"textDocument":{{"uri":"{}","languageId":"{}","version":{},"text":"{}"}}}}}}"#,
459            escape_json(&self.current_uri),
460            lang,
461            self.doc_version,
462            escape_json(&text)
463        );
464        self.send_raw(&msg);
465        self.semantic_dirty = true;
466        self.maybe_request_semantic_tokens();
467        self.inlay_dirty = true;
468        self.code_lens_dirty = true;
469    }
470
471    pub fn notify_change(&mut self, path: &str, text: &str) {
472        if !self.server_running {
473            return;
474        }
475        let uri = path_to_uri(&abs_path(path));
476        self.current_uri = uri.clone();
477        self.doc_version = self.doc_version.saturating_add(1);
478        self.semantic_doc_text = text.to_string();
479        let msg = format!(
480            r#"{{"jsonrpc":"2.0","method":"textDocument/didChange","params":{{"textDocument":{{"uri":"{}","version":{}}},"contentChanges":[{{"text":"{}"}}]}}}}"#,
481            escape_json(&uri),
482            self.doc_version,
483            escape_json(text)
484        );
485        self.send_raw(&msg);
486        self.semantic_dirty = true;
487        self.maybe_request_semantic_tokens();
488        self.inlay_dirty = true;
489        self.code_lens_dirty = true;
490    }
491
492    /// Request full semantic tokens if the server advertises support.
493    pub fn request_semantic_tokens(&mut self) {
494        if !self.server_running || !self.semantic_tokens_supported {
495            return;
496        }
497        // Avoid flooding: one in-flight request per version
498        if self
499            .pending
500            .values()
501            .any(|k| matches!(k, PendingReq::SemanticTokens))
502        {
503            self.semantic_dirty = true;
504            return;
505        }
506        let id = self.alloc_id(PendingReq::SemanticTokens);
507        self.last_semantic_req_version = self.doc_version;
508        self.semantic_dirty = false;
509        let msg = format!(
510            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/semanticTokens/full","params":{{"textDocument":{{"uri":"{}"}}}}}}"#,
511            id,
512            escape_json(&self.current_uri)
513        );
514        self.send_raw(&msg);
515    }
516
517    fn maybe_request_semantic_tokens(&mut self) {
518        if self.semantic_dirty {
519            self.request_semantic_tokens();
520        }
521    }
522
523    pub fn request_definition(&mut self, path: &str, row: usize, col: usize) {
524        self.definition_as_peek = false;
525        self.request_position(PendingReq::Definition, "textDocument/definition", path, row, col);
526    }
527
528    pub fn request_peek_definition(&mut self, path: &str, row: usize, col: usize) {
529        self.definition_as_peek = true;
530        self.request_position(PendingReq::Definition, "textDocument/definition", path, row, col);
531    }
532
533    pub fn request_document_symbols(&mut self, path: &str) {
534        if !self.server_running {
535            return;
536        }
537        let uri = path_to_uri(&abs_path(path));
538        let id = self.alloc_id(PendingReq::DocumentSymbol);
539        let msg = format!(
540            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/documentSymbol","params":{{"textDocument":{{"uri":"{}"}}}}}}"#,
541            id,
542            escape_json(&uri)
543        );
544        self.send_raw(&msg);
545    }
546
547    pub fn request_workspace_symbols(&mut self, query: &str) {
548        if !self.server_running {
549            return;
550        }
551        let id = self.alloc_id(PendingReq::WorkspaceSymbol);
552        let msg = format!(
553            r#"{{"jsonrpc":"2.0","id":{},"method":"workspace/symbol","params":{{"query":"{}"}}}}"#,
554            id,
555            escape_json(query)
556        );
557        self.send_raw(&msg);
558    }
559
560    pub fn request_inlay_hints(&mut self, path: &str, end_row: usize) {
561        if !self.server_running || !self.inlay_supported {
562            return;
563        }
564        let uri = path_to_uri(&abs_path(path));
565        let id = self.alloc_id(PendingReq::InlayHint);
566        let msg = format!(
567            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/inlayHint","params":{{"textDocument":{{"uri":"{}"}},"range":{{"start":{{"line":0,"character":0}},"end":{{"line":{},"character":0}}}}}}}}"#,
568            id,
569            escape_json(&uri),
570            end_row.saturating_add(1)
571        );
572        self.send_raw(&msg);
573        self.inlay_dirty = false;
574    }
575
576    pub fn mark_inlay_dirty(&mut self) {
577        self.inlay_dirty = true;
578    }
579
580    pub fn maybe_request_inlays(&mut self, path: &str, end_row: usize) {
581        if self.inlay_dirty && self.inlay_supported && self.server_running {
582            self.request_inlay_hints(path, end_row);
583        }
584    }
585
586    pub fn request_code_lens(&mut self, path: &str) {
587        if !self.server_running || !self.code_lens_supported {
588            return;
589        }
590        // Coalesce
591        if self
592            .pending
593            .values()
594            .any(|k| matches!(k, PendingReq::CodeLens))
595        {
596            return;
597        }
598        let uri = path_to_uri(&abs_path(path));
599        let id = self.alloc_id(PendingReq::CodeLens);
600        let msg = format!(
601            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/codeLens","params":{{"textDocument":{{"uri":"{}"}}}}}}"#,
602            id,
603            escape_json(&uri)
604        );
605        self.send_raw(&msg);
606        self.code_lens_dirty = false;
607    }
608
609    pub fn mark_code_lens_dirty(&mut self) {
610        self.code_lens_dirty = true;
611    }
612
613    pub fn maybe_request_code_lens(&mut self, path: &str) {
614        if self.code_lens_dirty && self.code_lens_supported && self.server_running {
615            self.request_code_lens(path);
616        }
617    }
618
619    pub fn request_completion(&mut self, path: &str, row: usize, col: usize) {
620        self.request_position(PendingReq::Completion, "textDocument/completion", path, row, col);
621    }
622
623    pub fn request_hover(&mut self, path: &str, row: usize, col: usize) {
624        self.request_position(PendingReq::Hover, "textDocument/hover", path, row, col);
625    }
626
627    pub fn request_references(&mut self, path: &str, row: usize, col: usize) {
628        if !self.server_running {
629            return;
630        }
631        let uri = path_to_uri(&abs_path(path));
632        let col16 = self.char_col_to_utf16(row, col);
633        let id = self.alloc_id(PendingReq::References);
634        let msg = format!(
635            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/references","params":{{"textDocument":{{"uri":"{}"}},"position":{{"line":{},"character":{}}},"context":{{"includeDeclaration":true}}}}}}"#,
636            id,
637            escape_json(&uri),
638            row,
639            col16
640        );
641        self.send_raw(&msg);
642    }
643
644    pub fn request_rename(&mut self, path: &str, row: usize, col: usize, new_name: &str) {
645        if !self.server_running {
646            return;
647        }
648        let uri = path_to_uri(&abs_path(path));
649        let col16 = self.char_col_to_utf16(row, col);
650        let id = self.alloc_id(PendingReq::Rename);
651        let msg = format!(
652            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/rename","params":{{"textDocument":{{"uri":"{}"}},"position":{{"line":{},"character":{}}},"newName":"{}"}}}}"#,
653            id,
654            escape_json(&uri),
655            row,
656            col16,
657            escape_json(new_name)
658        );
659        self.send_raw(&msg);
660    }
661
662    /// Request document formatting; result applied via pending_edits.
663    pub fn request_formatting(&mut self, path: &str) {
664        if !self.server_running {
665            return;
666        }
667        let uri = path_to_uri(&abs_path(path));
668        let id = self.alloc_id(PendingReq::Formatting);
669        let msg = format!(
670            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/formatting","params":{{"textDocument":{{"uri":"{}"}},"options":{{"tabSize":4,"insertSpaces":true}}}}}}"#,
671            id,
672            escape_json(&uri)
673        );
674        self.send_raw(&msg);
675    }
676
677    /// Request code actions at cursor (quick fixes). Results → pending_code_actions.
678    /// Start call hierarchy: prepareCallHierarchy → incoming or outgoing calls.
679    pub fn request_call_hierarchy(
680        &mut self,
681        path: &str,
682        row: usize,
683        col: usize,
684        direction: crate::call_hierarchy::CallDirection,
685    ) {
686        if !self.server_running {
687            self.soft_error = Some("LSP not running".into());
688            return;
689        }
690        self.pending_call_hierarchy.clear();
691        self.call_hierarchy_ready = false;
692        self.pending_call_direction = Some(direction);
693        let abs = abs_path(path);
694        let uri = path_to_uri(&abs);
695        let col16 = self.char_col_to_utf16(row, col);
696        let id = self.alloc_id(PendingReq::PrepareCallHierarchy);
697        let msg = format!(
698            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/prepareCallHierarchy","params":{{"textDocument":{{"uri":"{}"}},"position":{{"line":{},"character":{}}}}}}}"#,
699            id,
700            escape_json(&uri),
701            row,
702            col16
703        );
704        self.send_raw(&msg);
705    }
706
707    fn request_calls_for_item(
708        &mut self,
709        item_json: &str,
710        direction: crate::call_hierarchy::CallDirection,
711    ) {
712        let (method, kind) = match direction {
713            crate::call_hierarchy::CallDirection::Incoming => {
714                ("callHierarchy/incomingCalls", PendingReq::IncomingCalls)
715            }
716            crate::call_hierarchy::CallDirection::Outgoing => {
717                ("callHierarchy/outgoingCalls", PendingReq::OutgoingCalls)
718            }
719        };
720        let id = self.alloc_id(kind);
721        let msg = format!(
722            r#"{{"jsonrpc":"2.0","id":{},"method":"{}","params":{{"item":{}}}}}"#,
723            id, method, item_json
724        );
725        self.send_raw(&msg);
726    }
727
728    /// Contiguous slice of semantic tokens on `row`. Kept row-sorted at decode
729    /// time so the renderer binary-searches instead of scanning the whole file
730    /// once per visible row per frame.
731    pub fn semantic_tokens_for_row(&self, row: usize) -> &[SemanticToken] {
732        let lo = self.semantic_tokens.partition_point(|t| t.3 < row);
733        let hi = self.semantic_tokens.partition_point(|t| t.3 <= row);
734        &self.semantic_tokens[lo..hi]
735    }
736
737    /// Contiguous slice of diagnostics on `row`. Kept row-sorted when published.
738    pub fn diagnostics_for_row(&self, row: usize) -> &[Diagnostic] {
739        let lo = self.diagnostics.partition_point(|d| d.row < row);
740        let hi = self.diagnostics.partition_point(|d| d.row <= row);
741        &self.diagnostics[lo..hi]
742    }
743
744    pub fn request_code_action(&mut self, path: &str, row: usize, col: usize) {
745        if !self.server_running {
746            return;
747        }
748        let uri = path_to_uri(&abs_path(path));
749        let col16 = self.char_col_to_utf16(row, col);
750        // Include diagnostics that touch this line for better quick-fixes
751        let mut diags_json = String::from("[");
752        let mut first = true;
753        for d in self.diagnostics.iter().filter(|d| d.row == row) {
754            if !first {
755                diags_json.push(',');
756            }
757            first = false;
758            let sev = match d.severity {
759                DiagnosticSeverity::Error => 1,
760                DiagnosticSeverity::Warning => 2,
761                DiagnosticSeverity::Info => 3,
762                DiagnosticSeverity::Hint => 4,
763            };
764            diags_json.push_str(&format!(
765                r#"{{"range":{{"start":{{"line":{},"character":{}}},"end":{{"line":{},"character":{}}}}},"severity":{},"message":"{}"}}"#,
766                d.row,
767                self.char_col_to_utf16(d.row, d.col_start),
768                d.row,
769                self.char_col_to_utf16(d.row, d.col_end),
770                sev,
771                escape_json(&d.message)
772            ));
773        }
774        diags_json.push(']');
775        let id = self.alloc_id(PendingReq::CodeAction);
776        let msg = format!(
777            r#"{{"jsonrpc":"2.0","id":{},"method":"textDocument/codeAction","params":{{"textDocument":{{"uri":"{}"}},"range":{{"start":{{"line":{},"character":{}}},"end":{{"line":{},"character":{}}}}},"context":{{"diagnostics":{},"only":["quickfix","refactor","source"]}}}}}}"#,
778            id,
779            escape_json(&uri),
780            row,
781            col16,
782            row,
783            col16,
784            diags_json
785        );
786        self.send_raw(&msg);
787    }
788
789    pub fn execute_command(&mut self, command: &str, args_json: Option<&str>) {
790        if !self.server_running {
791            return;
792        }
793        let id = self.alloc_id(PendingReq::ExecuteCommand);
794        let args = args_json.unwrap_or("[]");
795        let msg = format!(
796            r#"{{"jsonrpc":"2.0","id":{},"method":"workspace/executeCommand","params":{{"command":"{}","arguments":{}}}}}"#,
797            id,
798            escape_json(command),
799            args
800        );
801        self.send_raw(&msg);
802    }
803
804    fn request_position(
805        &mut self,
806        kind: PendingReq,
807        method: &str,
808        path: &str,
809        row: usize,
810        col: usize,
811    ) {
812        if !self.server_running {
813            return;
814        }
815        let uri = path_to_uri(&abs_path(path));
816        let col16 = self.char_col_to_utf16(row, col);
817        let id = self.alloc_id(kind);
818        let msg = format!(
819            r#"{{"jsonrpc":"2.0","id":{},"method":"{}","params":{{"textDocument":{{"uri":"{}"}},"position":{{"line":{},"character":{}}}}}}}"#,
820            id,
821            method,
822            escape_json(&uri),
823            row,
824            col16
825        );
826        self.send_raw(&msg);
827    }
828
829    /// Editor char column → LSP UTF-16 code units for `row`.
830    fn char_col_to_utf16(&self, row: usize, col: usize) -> usize {
831        let line = self
832            .semantic_doc_text
833            .split('\n')
834            .nth(row)
835            .unwrap_or("");
836        char_to_utf16_col(line, col)
837    }
838
839    fn alloc_id(&mut self, kind: PendingReq) -> u64 {
840        let id = self.next_id;
841        self.next_id = self.next_id.saturating_add(1);
842        self.pending.insert(id, kind);
843        id
844    }
845
846    fn send_raw(&mut self, msg: &str) {
847        if let Some(ref mut stdin) = self.stdin {
848            // Content-Length is **bytes**
849            let bytes = msg.as_bytes();
850            let header = format!("Content-Length: {}\r\n\r\n", bytes.len());
851            let _ = stdin.write_all(header.as_bytes());
852            let _ = stdin.write_all(bytes);
853            let _ = stdin.flush();
854        }
855    }
856
857    pub fn poll(&mut self) {
858        let mut batch = Vec::new();
859        if let Some(ref rx) = self.rx {
860            loop {
861                match rx.try_recv() {
862                    Ok(m) => batch.push(m),
863                    Err(TryRecvError::Empty) => break,
864                    Err(TryRecvError::Disconnected) => {
865                        self.server_running = false;
866                        self.initialized = false;
867                        self.error = Some("LSP server disconnected".into());
868                        break;
869                    }
870                }
871            }
872        }
873
874        for msg in batch {
875            self.handle_raw(msg);
876        }
877
878        // After initialize succeeded, send initialized + didOpen
879        if self.initialized {
880            if let Some((path, lang, text)) = self.pending_didopen.take() {
881                self.send_raw(r#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#);
882                self.doc_version = 1;
883                self.current_uri = path_to_uri(&path);
884                self.opened_uri = self.current_uri.clone();
885                // Prefer in-memory text we already stored; fall back to disk
886                if self.semantic_doc_text.is_empty() {
887                    self.semantic_doc_text = std::fs::read_to_string(&path).unwrap_or_default();
888                }
889                let msg = format!(
890                    r#"{{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{{"textDocument":{{"uri":"{}","languageId":"{}","version":{},"text":"{}"}}}}}}"#,
891                    escape_json(&self.current_uri),
892                    lang,
893                    self.doc_version,
894                    text // already escaped
895                );
896                self.send_raw(&msg);
897                self.semantic_dirty = true;
898                self.maybe_request_semantic_tokens();
899                self.inlay_dirty = true;
900                self.code_lens_dirty = true;
901            }
902        }
903
904        // Retry semantic tokens if a prior request was coalesced
905        if self.semantic_dirty && self.server_running {
906            self.maybe_request_semantic_tokens();
907        }
908    }
909
910    fn handle_raw(&mut self, msg: RawMsg) {
911        // Notifications
912        if let Some(method) = msg.method.as_deref() {
913            if method == "textDocument/publishDiagnostics" {
914                let uri = extract_str(&msg.body, "\"uri\":\"").unwrap_or_default();
915                let mut diags = parse_diagnostics(&msg.body);
916                // Server columns are UTF-16 code units; the editor uses chars.
917                diag_cols_utf16_to_chars(&mut diags, &self.semantic_doc_text);
918                // Accept if URI matches (normalized) or empty
919                if uri.is_empty() || uris_match(&uri, &self.current_uri) {
920                    // Keep row-sorted so `diagnostics_for_row` can binary-search.
921                    diags.sort_by_key(|d| d.row);
922                    self.diagnostics = diags; // empty clears
923                }
924                return;
925            }
926            // ignore other notifications
927            if msg.id.is_none() {
928                return;
929            }
930        }
931
932        let Some(id) = msg.id else {
933            return;
934        };
935        let Some(kind) = self.pending.remove(&id) else {
936            // Unknown id — try heuristic only for initialize-like
937            if msg.body.contains("\"capabilities\"") {
938                self.finish_initialize(&msg.body);
939            }
940            return;
941        };
942
943        // Error response — only Initialize is a hard sticky status error.
944        if is_jsonrpc_error(&msg.body) {
945            let m = extract_str(&msg.body, "\"message\":\"")
946                .unwrap_or_else(|| "request failed".into());
947            match kind {
948                PendingReq::Initialize => {
949                    self.error = Some(format!("LSP init: {m}"));
950                    self.server_running = false;
951                    self.initialized = false;
952                }
953                PendingReq::SemanticTokens | PendingReq::InlayHint | PendingReq::CodeLens => {
954                    // Optional features — demote to soft, don't red-badge
955                    self.soft_error = Some(format!("LSP: {m}"));
956                    if matches!(kind, PendingReq::SemanticTokens) {
957                        self.semantic_tokens_supported = false;
958                    }
959                    if matches!(kind, PendingReq::InlayHint) {
960                        self.inlay_supported = false;
961                    }
962                    if matches!(kind, PendingReq::CodeLens) {
963                        self.code_lens_supported = false;
964                        self.code_lenses.clear();
965                    }
966                }
967                PendingReq::CodeLensResolve(_) => {
968                    // Best-effort enrichment — a failed resolve is not news.
969                }
970                _ => {
971                    self.soft_error = Some(format!("LSP: {m}"));
972                }
973            }
974            return;
975        }
976
977        match kind {
978            PendingReq::Initialize => {
979                self.finish_initialize(&msg.body);
980            }
981            PendingReq::Definition => {
982                if let Some(loc) = parse_single_location(&msg.body) {
983                    self.pending_definition = Some(loc);
984                } else {
985                    self.soft_error = Some("No definition found".into());
986                }
987            }
988            PendingReq::Completion => {
989                self.pending_completions = parse_completions(&msg.body);
990            }
991            PendingReq::Hover => {
992                if let Some(h) = parse_hover(&msg.body) {
993                    self.pending_hover = Some(h);
994                }
995            }
996            PendingReq::References => {
997                self.pending_references = parse_locations(&msg.body);
998            }
999            PendingReq::Rename => {
1000                let edits = parse_workspace_edit_ctx(
1001                    &msg.body,
1002                    &self.current_uri,
1003                    &self.semantic_doc_text,
1004                );
1005                if edits.is_empty() {
1006                    self.pending_workspace_edit =
1007                        Some(parse_rename_message(&msg.body).unwrap_or_else(|| {
1008                            "Rename: no changes".into()
1009                        }));
1010                } else {
1011                    self.pending_edits = edits;
1012                }
1013            }
1014            PendingReq::Formatting => {
1015                if let Some(edit) =
1016                    parse_text_edits_as_full_replace(&msg.body, &self.semantic_doc_text)
1017                {
1018                    let path = uri_to_path(&self.current_uri);
1019                    self.pending_edits = vec![FileEdit { path, text: edit }];
1020                } else if msg.body.contains("\"result\":null")
1021                    || msg.body.contains("\"result\":[]")
1022                {
1023                    self.soft_error = Some("Format: nothing to change".into());
1024                }
1025            }
1026            PendingReq::CodeAction => {
1027                self.pending_code_actions = parse_code_actions_ctx(
1028                    &msg.body,
1029                    &self.current_uri,
1030                    &self.semantic_doc_text,
1031                );
1032                if self.pending_code_actions.is_empty() {
1033                    self.soft_error = Some("No code actions".into());
1034                }
1035            }
1036            PendingReq::ExecuteCommand => {
1037                let edits = parse_workspace_edit_ctx(
1038                    &msg.body,
1039                    &self.current_uri,
1040                    &self.semantic_doc_text,
1041                );
1042                if !edits.is_empty() {
1043                    self.pending_edits = edits;
1044                } else {
1045                    self.soft_error = Some("Command executed".into());
1046                }
1047            }
1048            PendingReq::SemanticTokens => {
1049                let data = parse_semantic_data(&msg.body);
1050                let lines: Vec<&str> = self.semantic_doc_text.split('\n').collect();
1051                let mut toks = decode_semantic_tokens(&data, &self.semantic_token_types, &lines);
1052                // LSP emits tokens in position order already, but guarantee the
1053                // row-sorted invariant `semantic_tokens_for_row` relies on.
1054                toks.sort_by_key(|t| t.3);
1055                self.semantic_tokens = toks;
1056                // If document changed while request was in flight, re-fetch
1057                if self.doc_version != self.last_semantic_req_version {
1058                    self.semantic_dirty = true;
1059                }
1060            }
1061            PendingReq::DocumentSymbol | PendingReq::WorkspaceSymbol => {
1062                self.pending_symbols = parse_symbols(&msg.body);
1063            }
1064            PendingReq::InlayHint => {
1065                let lines: Vec<&str> = self.semantic_doc_text.split('\n').collect();
1066                self.inlay_hints = parse_inlay_hints(&msg.body, &lines);
1067            }
1068            PendingReq::PrepareCallHierarchy => {
1069                let items = parse_call_hierarchy_items(&msg.body);
1070                if items.is_empty() {
1071                    self.soft_error = Some("No call hierarchy at cursor".into());
1072                    self.call_hierarchy_ready = true;
1073                    self.pending_call_hierarchy.clear();
1074                } else {
1075                    // Use first item; request incoming/outgoing
1076                    let dir = self
1077                        .pending_call_direction
1078                        .unwrap_or(crate::call_hierarchy::CallDirection::Incoming);
1079                    let raw = items[0].raw_json.clone();
1080                    // Surface root name immediately as a single-item fallback
1081                    self.pending_call_hierarchy = items;
1082                    self.request_calls_for_item(&raw, dir);
1083                }
1084            }
1085            PendingReq::IncomingCalls | PendingReq::OutgoingCalls => {
1086                let calls = parse_call_hierarchy_calls(&msg.body);
1087                self.pending_call_hierarchy = calls;
1088                self.call_hierarchy_ready = true;
1089            }
1090            PendingReq::CodeLens => {
1091                self.code_lens_gen = self.code_lens_gen.wrapping_add(1);
1092                let (resolved, unresolved) = parse_code_lenses(&msg.body);
1093                self.code_lenses = resolved;
1094                // Servers like rust-analyzer return lenses without a command;
1095                // resolve them individually (capped to avoid request storms).
1096                let generation = self.code_lens_gen;
1097                for lens in unresolved.into_iter().take(40) {
1098                    let id = self.alloc_id(PendingReq::CodeLensResolve(generation));
1099                    let req = format!(
1100                        r#"{{"jsonrpc":"2.0","id":{id},"method":"codeLens/resolve","params":{lens}}}"#
1101                    );
1102                    self.send_raw(&req);
1103                }
1104            }
1105            PendingReq::CodeLensResolve(generation) => {
1106                if generation == self.code_lens_gen {
1107                    if let Some(lens) = parse_resolved_code_lens(&msg.body) {
1108                        merge_code_lens(&mut self.code_lenses, lens);
1109                    }
1110                }
1111            }
1112        }
1113    }
1114
1115    fn finish_initialize(&mut self, body: &str) {
1116        self.initialized = true;
1117        self.server_running = true;
1118        self.error = None;
1119        // Parse semantic tokens legend + support flag
1120        let (types, supported) = parse_semantic_legend(body);
1121        self.semantic_token_types = types;
1122        self.semantic_tokens_supported = supported && !self.semantic_token_types.is_empty();
1123        // Default legend if server supports full but omitted types (rare)
1124        if supported && self.semantic_token_types.is_empty() {
1125            self.semantic_token_types = default_semantic_types();
1126            self.semantic_tokens_supported = true;
1127        }
1128        self.inlay_supported = body.contains("inlayHintProvider") || body.contains("\"inlayHint\"");
1129        if self.inlay_supported {
1130            self.inlay_dirty = true;
1131        }
1132    }
1133
1134    pub fn shutdown(&mut self) {
1135        if self.stdin.is_some() {
1136            self.send_raw(r#"{"jsonrpc":"2.0","id":999999,"method":"shutdown","params":null}"#);
1137            self.send_raw(r#"{"jsonrpc":"2.0","method":"exit","params":null}"#);
1138        }
1139        self.shutdown_quiet();
1140    }
1141
1142    fn shutdown_quiet(&mut self) {
1143        if let Some(mut child) = self._child.take() {
1144            let _ = child.kill();
1145            let _ = child.wait();
1146        }
1147        self.stdin = None;
1148        self.rx = None;
1149        self.server_running = false;
1150        self.initialized = false;
1151        self.pending.clear();
1152        self.pending_didopen = None;
1153        self.diagnostics.clear();
1154        self.semantic_tokens.clear();
1155        self.semantic_token_types.clear();
1156        self.semantic_tokens_supported = false;
1157        self.semantic_doc_text.clear();
1158        self.semantic_dirty = false;
1159        self.inlay_hints.clear();
1160        self.inlay_supported = false;
1161        self.inlay_dirty = false;
1162        self.pending_symbols.clear();
1163        self.pending_code_actions.clear();
1164        self.pending_edits.clear();
1165        self.definition_as_peek = false;
1166        self.opened_uri.clear();
1167        self.soft_error = None;
1168    }
1169
1170    /// Status label for the TUI: running server, soft miss, or hard error.
1171    pub fn status_label(&self) -> LspStatus {
1172        if self.server_running {
1173            LspStatus::Running {
1174                name: self.server_name.clone(),
1175                diags: self.diagnostics.len(),
1176            }
1177        } else if self.error.is_some() {
1178            LspStatus::HardError
1179        } else if self.soft_error.is_some() {
1180            LspStatus::Soft {
1181                msg: self.soft_error.clone().unwrap_or_default(),
1182            }
1183        } else {
1184            LspStatus::Idle
1185        }
1186    }
1187}
1188
1189#[derive(Debug, Clone)]
1190pub enum LspStatus {
1191    Idle,
1192    Running { name: String, diags: usize },
1193    Soft { msg: String },
1194    HardError,
1195}
1196
1197// ── Reader thread ───────────────────────────────────────
1198
1199fn read_loop(stdout: impl Read + Send + 'static, tx: mpsc::Sender<RawMsg>) {
1200    let mut reader = BufReader::new(stdout);
1201    loop {
1202        let mut content_len: Option<usize> = None;
1203        loop {
1204            let mut line = String::new();
1205            match reader.read_line(&mut line) {
1206                Ok(0) => return,
1207                Err(_) => return,
1208                Ok(_) => {
1209                    let t = line.trim_end_matches(['\r', '\n']);
1210                    if t.is_empty() {
1211                        break;
1212                    }
1213                    if let Some(rest) = t
1214                        .strip_prefix("Content-Length:")
1215                        .or_else(|| t.strip_prefix("content-length:"))
1216                    {
1217                        content_len = rest.trim().parse().ok();
1218                    }
1219                }
1220            }
1221        }
1222        let Some(len) = content_len else {
1223            continue;
1224        };
1225        if len == 0 || len > 50_000_000 {
1226            continue;
1227        }
1228        let mut body = vec![0u8; len];
1229        if reader.read_exact(&mut body).is_err() {
1230            return;
1231        }
1232        let text = String::from_utf8_lossy(&body).into_owned();
1233        let id = extract_json_id(&text);
1234        let method = extract_str(&text, "\"method\":\"");
1235        if tx
1236            .send(RawMsg {
1237                id,
1238                method,
1239                body: text,
1240            })
1241            .is_err()
1242        {
1243            return;
1244        }
1245    }
1246}
1247
1248// ── Parsing helpers ─────────────────────────────────────
1249
1250fn parse_diagnostics(text: &str) -> Vec<Diagnostic> {
1251    let mut diags = Vec::new();
1252    let Some(start) = text.find("\"diagnostics\":") else {
1253        return diags;
1254    };
1255    let rest = &text[start..];
1256    // Each diagnostic has a range
1257    for item in rest.split("\"range\"").skip(1) {
1258        let row = extract_int(item, "\"line\":").unwrap_or(0).max(0) as usize;
1259        // first character = start, second in end object
1260        let chars: Vec<i64> = {
1261            let mut v = Vec::new();
1262            let mut search = item;
1263            while let Some(pos) = search.find("\"character\":") {
1264                let after = &search[pos + "\"character\":".len()..];
1265                if let Some(n) = after
1266                    .chars()
1267                    .take_while(|c| c.is_ascii_digit())
1268                    .collect::<String>()
1269                    .parse()
1270                    .ok()
1271                {
1272                    v.push(n);
1273                }
1274                search = &search[pos + 12..];
1275                if v.len() >= 2 {
1276                    break;
1277                }
1278            }
1279            v
1280        };
1281        let col_start = chars.first().copied().unwrap_or(0).max(0) as usize;
1282        let col_end = chars
1283            .get(1)
1284            .copied()
1285            .unwrap_or((col_start as i64) + 1)
1286            .max(0) as usize;
1287        let msg = extract_str(item, "\"message\":\"")
1288            .unwrap_or_default()
1289            .replace('\n', " ");
1290        let severity = match extract_int(item, "\"severity\":") {
1291            Some(1) => DiagnosticSeverity::Error,
1292            Some(2) => DiagnosticSeverity::Warning,
1293            Some(3) => DiagnosticSeverity::Info,
1294            _ => DiagnosticSeverity::Hint,
1295        };
1296        if !msg.is_empty() {
1297            diags.push(Diagnostic {
1298                row,
1299                col_start,
1300                col_end: col_end.max(col_start + 1),
1301                message: msg,
1302                severity,
1303            });
1304        }
1305    }
1306    diags
1307}
1308
1309/// Rewrite diagnostic columns from UTF-16 code units to char indices, using
1310/// the last document text we synced to the server. ASCII lines short-circuit.
1311fn diag_cols_utf16_to_chars(diags: &mut [Diagnostic], doc: &str) {
1312    if diags.is_empty() || doc.is_empty() {
1313        return;
1314    }
1315    let lines: Vec<&str> = doc.split('\n').collect();
1316    for d in diags {
1317        let Some(line) = lines.get(d.row) else {
1318            continue;
1319        };
1320        if line.is_ascii() {
1321            continue;
1322        }
1323        d.col_start = utf16_to_char_col(line, d.col_start);
1324        d.col_end = utf16_to_char_col(line, d.col_end).max(d.col_start + 1);
1325    }
1326}
1327
1328fn parse_single_location(text: &str) -> Option<Location> {
1329    let locs = parse_locations(text);
1330    locs.into_iter().next()
1331}
1332
1333fn symbol_kind_name(k: i64) -> &'static str {
1334    match k {
1335        1 => "File",
1336        2 => "Module",
1337        3 => "Namespace",
1338        4 => "Package",
1339        5 => "Class",
1340        6 => "Method",
1341        7 => "Property",
1342        8 => "Field",
1343        9 => "Constructor",
1344        10 => "Enum",
1345        11 => "Interface",
1346        12 => "Function",
1347        13 => "Variable",
1348        14 => "Constant",
1349        15 => "String",
1350        16 => "Number",
1351        17 => "Boolean",
1352        18 => "Array",
1353        19 => "Object",
1354        20 => "Key",
1355        21 => "Null",
1356        22 => "EnumMember",
1357        23 => "Struct",
1358        24 => "Event",
1359        25 => "Operator",
1360        26 => "TypeParameter",
1361        _ => "Symbol",
1362    }
1363}
1364
1365fn parse_symbols(text: &str) -> Vec<SymbolItem> {
1366    let mut out = Vec::new();
1367    if text.contains("\"result\":null") {
1368        return out;
1369    }
1370    // DocumentSymbol has "name" + nested "range"; SymbolInformation has "location"
1371    for chunk in text.split("\"name\":\"").skip(1) {
1372        let name = chunk.split('"').next().unwrap_or("").to_string();
1373        if name.is_empty() {
1374            continue;
1375        }
1376        let kind_n = extract_int(chunk, "\"kind\":").unwrap_or(0);
1377        let kind = symbol_kind_name(kind_n).to_string();
1378        let path = if let Some(uri) = extract_str(chunk, "\"uri\":\"") {
1379            uri_to_path(&uri)
1380        } else {
1381            String::new()
1382        };
1383        // Prefer selectionRange / range start
1384        let row = extract_int(chunk, "\"line\":").unwrap_or(0).max(0) as usize;
1385        let col = extract_int(chunk, "\"character\":").unwrap_or(0).max(0) as usize;
1386        let detail = extract_str(chunk, "\"detail\":\"").unwrap_or_default();
1387        out.push(SymbolItem {
1388            name,
1389            kind,
1390            path,
1391            row,
1392            col,
1393            detail,
1394        });
1395        if out.len() >= 500 {
1396            break;
1397        }
1398    }
1399    out
1400}
1401
1402fn parse_inlay_hints(text: &str, lines: &[&str]) -> Vec<InlayHint> {
1403    let mut out = Vec::new();
1404    if text.contains("\"result\":null") || text.contains("\"result\":[]") {
1405        return out;
1406    }
1407    // Each hint: position.line/character + label (string or array of parts)
1408    for chunk in text.split("\"position\"").skip(1) {
1409        let row = extract_int(chunk, "\"line\":").unwrap_or(0).max(0) as usize;
1410        let col_u16 = extract_int(chunk, "\"character\":").unwrap_or(0).max(0) as usize;
1411        let col = if let Some(line) = lines.get(row) {
1412            utf16_to_char_col(line, col_u16)
1413        } else {
1414            col_u16
1415        };
1416        let label = if let Some(s) = extract_str(chunk, "\"label\":\"") {
1417            s
1418        } else {
1419            // label as array of {value: "..."}
1420            let mut parts = Vec::new();
1421            for part in chunk.split("\"value\":\"").skip(1).take(6) {
1422                let v = part.split('"').next().unwrap_or("");
1423                if !v.is_empty() {
1424                    parts.push(v.to_string());
1425                }
1426            }
1427            parts.join("")
1428        };
1429        let label = label.trim().to_string();
1430        if label.is_empty() {
1431            continue;
1432        }
1433        out.push(InlayHint { row, col, label });
1434        if out.len() >= 2000 {
1435            break;
1436        }
1437    }
1438    out.sort_by_key(|h| (h.row, h.col));
1439    out
1440}
1441
1442fn parse_locations(text: &str) -> Vec<Location> {
1443    let mut locs = Vec::new();
1444    // result:null
1445    if text.contains("\"result\":null") {
1446        return locs;
1447    }
1448    for chunk in text.split("\"uri\":\"").skip(1) {
1449        let uri = chunk.split('"').next().unwrap_or("");
1450        let path = uri_to_path(uri);
1451        if path.is_empty() {
1452            continue;
1453        }
1454        // Prefer start line/character inside this chunk
1455        let row = extract_int(chunk, "\"line\":").unwrap_or(0).max(0) as usize;
1456        let col = extract_int(chunk, "\"character\":").unwrap_or(0).max(0) as usize;
1457        locs.push(Location { path, row, col });
1458    }
1459    locs
1460}
1461
1462fn parse_completions(text: &str) -> Vec<CompletionItem> {
1463    let mut items = Vec::new();
1464    for chunk in text.split("\"label\":\"").skip(1) {
1465        let label = chunk.split('"').next().unwrap_or("").to_string();
1466        if label.is_empty() {
1467            continue;
1468        }
1469        let detail = extract_str(chunk, "\"detail\":\"").map(|s| s.to_string());
1470        items.push(CompletionItem { label, detail });
1471        if items.len() >= 200 {
1472            break;
1473        }
1474    }
1475    items
1476}
1477
1478fn parse_hover(text: &str) -> Option<String> {
1479    if text.contains("\"result\":null") {
1480        return None;
1481    }
1482    // extract_str already unescapes.
1483    if let Some(v) = extract_str(text, "\"value\":\"") {
1484        if !v.trim().is_empty() {
1485            return Some(v);
1486        }
1487    }
1488    extract_str(text, "\"contents\":\"").filter(|s| !s.trim().is_empty())
1489}
1490
1491/// Extract semanticTokensProvider legend.tokenTypes from initialize result.
1492fn parse_semantic_legend(body: &str) -> (Vec<String>, bool) {
1493    let supported = body.contains("semanticTokensProvider")
1494        || body.contains("\"semanticTokens\"");
1495    // Find tokenTypes array inside semanticTokensProvider if possible
1496    let search_from = body
1497        .find("semanticTokensProvider")
1498        .or_else(|| body.find("\"tokenTypes\""))
1499        .unwrap_or(0);
1500    let region = &body[search_from..];
1501    let Some(arr_start_rel) = region.find("\"tokenTypes\"") else {
1502        return (if supported { default_semantic_types() } else { Vec::new() }, supported);
1503    };
1504    let after = &region[arr_start_rel..];
1505    let Some(bracket) = after.find('[') else {
1506        return (if supported { default_semantic_types() } else { Vec::new() }, supported);
1507    };
1508    let rest = &after[bracket + 1..];
1509    let Some(end) = rest.find(']') else {
1510        return (Vec::new(), supported);
1511    };
1512    let arr = &rest[..end];
1513    let mut types = Vec::new();
1514    for part in arr.split(',') {
1515        let t = part
1516            .trim()
1517            .trim_matches('"')
1518            .trim()
1519            .to_string();
1520        if !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1521            types.push(t);
1522        }
1523    }
1524    if types.is_empty() && supported {
1525        types = default_semantic_types();
1526    }
1527    let nonempty = !types.is_empty();
1528    (types, supported || nonempty)
1529}
1530
1531fn default_semantic_types() -> Vec<String> {
1532    [
1533        "namespace",
1534        "type",
1535        "class",
1536        "enum",
1537        "interface",
1538        "struct",
1539        "typeParameter",
1540        "parameter",
1541        "variable",
1542        "property",
1543        "enumMember",
1544        "event",
1545        "function",
1546        "method",
1547        "macro",
1548        "keyword",
1549        "modifier",
1550        "comment",
1551        "string",
1552        "number",
1553        "regexp",
1554        "operator",
1555        "decorator",
1556    ]
1557    .into_iter()
1558    .map(String::from)
1559    .collect()
1560}
1561
1562/// Parse `"data":[n,n,...]` from semanticTokens/full result.
1563fn parse_semantic_data(body: &str) -> Vec<u32> {
1564    let Some(pos) = body.find("\"data\"") else {
1565        return Vec::new();
1566    };
1567    let after = &body[pos..];
1568    let Some(bracket) = after.find('[') else {
1569        return Vec::new();
1570    };
1571    let rest = &after[bracket + 1..];
1572    let Some(end) = rest.find(']') else {
1573        return Vec::new();
1574    };
1575    let arr = &rest[..end];
1576    let mut out = Vec::new();
1577    for part in arr.split(',') {
1578        let t = part.trim();
1579        if t.is_empty() {
1580            continue;
1581        }
1582        if let Ok(n) = t.parse::<u32>() {
1583            out.push(n);
1584        } else if let Ok(n) = t.parse::<i64>() {
1585            out.push(n.max(0) as u32);
1586        }
1587    }
1588    out
1589}
1590
1591/// Decode LSP relative semantic tokens into char-column spans.
1592fn decode_semantic_tokens(
1593    data: &[u32],
1594    legend: &[String],
1595    lines: &[&str],
1596) -> Vec<SemanticToken> {
1597    let mut tokens = Vec::new();
1598    if data.len() < 5 || legend.is_empty() {
1599        return tokens;
1600    }
1601    let mut line: u32 = 0;
1602    let mut start_utf16: u32 = 0;
1603    let mut i = 0;
1604    while i + 4 < data.len() {
1605        let delta_line = data[i];
1606        let delta_start = data[i + 1];
1607        let length = data[i + 2];
1608        let token_type = data[i + 3] as usize;
1609        // data[i+4] = modifiers (ignored for coloring)
1610        i += 5;
1611
1612        if delta_line > 0 {
1613            line = line.saturating_add(delta_line);
1614            start_utf16 = delta_start;
1615        } else {
1616            start_utf16 = start_utf16.saturating_add(delta_start);
1617        }
1618
1619        let row = line as usize;
1620        let Some(line_text) = lines.get(row) else {
1621            continue;
1622        };
1623        let scol = utf16_to_char_col(line_text, start_utf16 as usize);
1624        let ecol = utf16_to_char_col(line_text, start_utf16 as usize + length as usize);
1625        if scol >= ecol {
1626            continue;
1627        }
1628        let type_name = legend.get(token_type).map(|s| s.as_str()).unwrap_or("");
1629        if type_name.is_empty() {
1630            continue;
1631        }
1632        let kind = highlight::from_semantic_type(type_name);
1633        tokens.push((kind, scol, ecol, row));
1634    }
1635    tokens.sort_by_key(|(_, st, ed, row)| (*row, ed.saturating_sub(*st), *st));
1636    tokens
1637}
1638
1639/// Convert a UTF-16 code-unit column (LSP wire format) to a char index.
1640pub fn utf16_to_char_col(line: &str, utf16_col: usize) -> usize {
1641    if utf16_col == 0 {
1642        return 0;
1643    }
1644    let mut u16s = 0usize;
1645    for (i, c) in line.chars().enumerate() {
1646        if u16s >= utf16_col {
1647            return i;
1648        }
1649        u16s += c.len_utf16();
1650    }
1651    line.chars().count()
1652}
1653
1654/// Convert a char index to UTF-16 code-unit column (LSP wire format).
1655pub fn char_to_utf16_col(line: &str, char_col: usize) -> usize {
1656    line.chars().take(char_col).map(|c| c.len_utf16()).sum()
1657}
1658
1659/// True if body is a JSON-RPC error object (not a result that happens to
1660/// contain the word "error").
1661fn is_jsonrpc_error(body: &str) -> bool {
1662    // "error": { ... } at top level without a successful result
1663    if let Some(pos) = body.find("\"error\"") {
1664        // result:null with error is still an error
1665        let after = &body[pos..];
1666        if after.contains('{') {
1667            // Avoid matching "errorCodes" etc. inside capabilities
1668            if body.contains("\"result\":") {
1669                // Both present — error wins if result is null
1670                return body.contains("\"result\":null")
1671                    || body.find("\"error\"").unwrap_or(usize::MAX)
1672                        < body.find("\"result\"").unwrap_or(usize::MAX);
1673            }
1674            return true;
1675        }
1676    }
1677    false
1678}
1679
1680/// Apply LSP TextEdit[] as a full document replace when possible.
1681fn parse_text_edits_as_full_replace(body: &str, original: &str) -> Option<String> {
1682    if body.contains("\"result\":null") || body.contains("\"result\":[]") {
1683        return None;
1684    }
1685    // Prefer a single full-range edit
1686    let mut edits: Vec<(usize, usize, usize, usize, String)> = Vec::new();
1687    for chunk in body.split("\"range\"").skip(1) {
1688        let lines: Vec<i64> = {
1689            let mut v = Vec::new();
1690            let mut search = chunk;
1691            while let Some(pos) = search.find("\"line\":") {
1692                let after = &search[pos + 7..];
1693                if let Ok(n) = after
1694                    .chars()
1695                    .take_while(|c| c.is_ascii_digit() || *c == '-')
1696                    .collect::<String>()
1697                    .parse()
1698                {
1699                    v.push(n);
1700                }
1701                search = &search[pos + 7..];
1702                if v.len() >= 2 {
1703                    break;
1704                }
1705            }
1706            v
1707        };
1708        let chars: Vec<i64> = {
1709            let mut v = Vec::new();
1710            let mut search = chunk;
1711            while let Some(pos) = search.find("\"character\":") {
1712                let after = &search[pos + 12..];
1713                if let Ok(n) = after
1714                    .chars()
1715                    .take_while(|c| c.is_ascii_digit() || *c == '-')
1716                    .collect::<String>()
1717                    .parse()
1718                {
1719                    v.push(n);
1720                }
1721                search = &search[pos + 12..];
1722                if v.len() >= 2 {
1723                    break;
1724                }
1725            }
1726            v
1727        };
1728        let new_text = extract_str(chunk, "\"newText\":\"").unwrap_or_default();
1729        if lines.len() >= 2 && chars.len() >= 2 {
1730            edits.push((
1731                lines[0].max(0) as usize,
1732                chars[0].max(0) as usize,
1733                lines[1].max(0) as usize,
1734                chars[1].max(0) as usize,
1735                new_text,
1736            ));
1737        }
1738        if edits.len() > 50 {
1739            break;
1740        }
1741    }
1742    if edits.is_empty() {
1743        return None;
1744    }
1745    // Single edit covering whole file → take newText
1746    if edits.len() == 1 {
1747        let (r0, c0, r1, _c1, ref t) = edits[0];
1748        let line_count = original.lines().count().max(1);
1749        if r0 == 0 && c0 == 0 && r1 + 1 >= line_count {
1750            return Some(t.clone());
1751        }
1752        // Or one edit that replaces everything if newText is long
1753        if t.lines().count() >= line_count.saturating_sub(1) && r0 == 0 {
1754            return Some(t.clone());
1755        }
1756    }
1757    // Apply edits bottom-up on lines (char cols approximate for multi-edit)
1758    let mut lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
1759    if lines.is_empty() {
1760        lines.push(String::new());
1761    }
1762    edits.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
1763    for (r0, c0, r1, c1, new_t) in edits {
1764        let r0 = r0.min(lines.len().saturating_sub(1));
1765        let r1 = r1.min(lines.len().saturating_sub(1));
1766        if r0 == r1 {
1767            let line = &lines[r0];
1768            let chars: Vec<char> = line.chars().collect();
1769            let c0 = c0.min(chars.len());
1770            let c1 = c1.min(chars.len()).max(c0);
1771            let mut s: String = chars[..c0].iter().collect();
1772            s.push_str(&new_t);
1773            s.push_str(&chars[c1..].iter().collect::<String>());
1774            // new_t may be multi-line
1775            if s.contains('\n') {
1776                let parts: Vec<String> = s.split('\n').map(|x| x.to_string()).collect();
1777                lines.splice(r0..=r0, parts);
1778            } else {
1779                lines[r0] = s;
1780            }
1781        } else {
1782            // Multi-line replace: keep prefix of first, suffix of last
1783            let first: Vec<char> = lines[r0].chars().collect();
1784            let last: Vec<char> = lines[r1].chars().collect();
1785            let c0 = c0.min(first.len());
1786            let c1 = c1.min(last.len());
1787            let mut s: String = first[..c0].iter().collect();
1788            s.push_str(&new_t);
1789            s.push_str(&last[c1..].iter().collect::<String>());
1790            let parts: Vec<String> = s.split('\n').map(|x| x.to_string()).collect();
1791            lines.splice(r0..=r1, parts);
1792        }
1793    }
1794    let trailing = original.ends_with('\n');
1795    let mut out = lines.join("\n");
1796    if trailing && !out.ends_with('\n') {
1797        out.push('\n');
1798    }
1799    Some(out)
1800}
1801
1802fn parse_rename_message(text: &str) -> Option<String> {
1803    if text.contains("\"result\":null") {
1804        return Some("Rename: no changes".into());
1805    }
1806    let n = text.matches("\"newText\":\"").count();
1807    if n == 0 {
1808        return Some("Rename: no changes".into());
1809    }
1810    Some(format!("Rename: {n} edit(s)"))
1811}
1812
1813/// Parse WorkspaceEdit into full-file rewrites.
1814pub fn parse_workspace_edit(body: &str) -> Vec<FileEdit> {
1815    parse_workspace_edit_ctx(body, "", "")
1816}
1817
1818fn parse_workspace_edit_ctx(body: &str, current_uri: &str, current_text: &str) -> Vec<FileEdit> {
1819    if body.contains("\"result\":null") {
1820        return Vec::new();
1821    }
1822    let mut by_path: HashMap<String, Vec<RawTextEdit>> = HashMap::new();
1823
1824    // documentChanges: TextDocumentEdit has textDocument.uri + edits
1825    for chunk in body.split("\"textDocument\"").skip(1) {
1826        let uri = extract_str(chunk, "\"uri\":\"").unwrap_or_default();
1827        if uri.is_empty() {
1828            continue;
1829        }
1830        let path = uri_to_path(&uri);
1831        let edits = parse_text_edit_list(chunk);
1832        if !edits.is_empty() {
1833            by_path.entry(path).or_default().extend(edits);
1834        }
1835    }
1836
1837    // changes: { "file://...": [ TextEdit ] }
1838    if by_path.is_empty() {
1839        if let Some(start) = body.find("\"changes\"") {
1840            let rest = &body[start..];
1841            for chunk in rest.split("\"file:").skip(1) {
1842                let uri_body = chunk.split('"').next().unwrap_or("");
1843                let uri = format!("file:{uri_body}");
1844                let path = uri_to_path(&uri);
1845                let edits = parse_text_edit_list(chunk);
1846                if !edits.is_empty() {
1847                    by_path.entry(path).or_default().extend(edits);
1848                }
1849            }
1850        }
1851    }
1852
1853    if by_path.is_empty() {
1854        if let Some(uri) = extract_str(body, "\"uri\":\"") {
1855            let path = uri_to_path(&uri);
1856            let edits = parse_text_edit_list(body);
1857            if !edits.is_empty() {
1858                by_path.insert(path, edits);
1859            }
1860        }
1861    }
1862
1863    let current_path = if current_uri.is_empty() {
1864        String::new()
1865    } else {
1866        uri_to_path(current_uri)
1867    };
1868
1869    let mut out = Vec::new();
1870    for (path, mut edits) in by_path {
1871        if path.is_empty() {
1872            continue;
1873        }
1874        let original = if !current_path.is_empty() && path == current_path && !current_text.is_empty()
1875        {
1876            current_text.to_string()
1877        } else {
1878            std::fs::read_to_string(&path).unwrap_or_default()
1879        };
1880        let text = apply_raw_text_edits(&original, &mut edits);
1881        out.push(FileEdit { path, text });
1882    }
1883    out
1884}
1885
1886#[derive(Debug, Clone)]
1887struct RawTextEdit {
1888    r0: usize,
1889    c0: usize, // utf-16
1890    r1: usize,
1891    c1: usize, // utf-16
1892    new_text: String,
1893}
1894
1895fn parse_text_edit_list(chunk: &str) -> Vec<RawTextEdit> {
1896    let mut edits = Vec::new();
1897    for range_chunk in chunk.split("\"range\"").skip(1) {
1898        let (r0, c0, r1, c1) = extract_range_lines_chars(range_chunk);
1899        let new_text = extract_str(range_chunk, "\"newText\":\"").unwrap_or_else(|| {
1900            // newText may appear as next field after range object
1901            unescape_json_string_prefix(
1902                range_chunk
1903                    .split("\"newText\":\"")
1904                    .nth(1)
1905                    .unwrap_or(""),
1906            )
1907        });
1908        if new_text.is_empty() && r0 == r1 && c0 == c1 {
1909            // pure delete still valid — keep
1910        }
1911        edits.push(RawTextEdit {
1912            r0,
1913            c0,
1914            r1,
1915            c1,
1916            new_text,
1917        });
1918        if edits.len() > 200 {
1919            break;
1920        }
1921    }
1922    edits
1923}
1924
1925fn extract_range_lines_chars(chunk: &str) -> (usize, usize, usize, usize) {
1926    let mut lines = Vec::new();
1927    let mut chars = Vec::new();
1928    let mut search = chunk;
1929    while let Some(pos) = search.find("\"line\":") {
1930        let after = &search[pos + 7..];
1931        if let Ok(n) = after
1932            .chars()
1933            .take_while(|c| c.is_ascii_digit())
1934            .collect::<String>()
1935            .parse::<usize>()
1936        {
1937            lines.push(n);
1938        }
1939        search = &search[pos + 7..];
1940        if lines.len() >= 2 {
1941            break;
1942        }
1943    }
1944    search = chunk;
1945    while let Some(pos) = search.find("\"character\":") {
1946        let after = &search[pos + 12..];
1947        if let Ok(n) = after
1948            .chars()
1949            .take_while(|c| c.is_ascii_digit())
1950            .collect::<String>()
1951            .parse::<usize>()
1952        {
1953            chars.push(n);
1954        }
1955        search = &search[pos + 12..];
1956        if chars.len() >= 2 {
1957            break;
1958        }
1959    }
1960    (
1961        lines.first().copied().unwrap_or(0),
1962        chars.first().copied().unwrap_or(0),
1963        lines.get(1).copied().unwrap_or(0),
1964        chars.get(1).copied().unwrap_or(0),
1965    )
1966}
1967
1968fn unescape_json_string_prefix(part: &str) -> String {
1969    let mut raw = String::new();
1970    let mut chars = part.chars().peekable();
1971    while let Some(c) = chars.next() {
1972        if c == '\\' {
1973            if let Some(n) = chars.next() {
1974                match n {
1975                    'n' => raw.push('\n'),
1976                    't' => raw.push('\t'),
1977                    'r' => raw.push('\r'),
1978                    '"' => raw.push('"'),
1979                    '\\' => raw.push('\\'),
1980                    other => {
1981                        raw.push('\\');
1982                        raw.push(other);
1983                    }
1984                }
1985            }
1986        } else if c == '"' {
1987            break;
1988        } else {
1989            raw.push(c);
1990        }
1991    }
1992    raw
1993}
1994
1995fn apply_raw_text_edits(original: &str, edits: &mut [RawTextEdit]) -> String {
1996    if edits.is_empty() {
1997        return original.to_string();
1998    }
1999    // Convert utf-16 cols to char cols per line, apply bottom-up
2000    let mut lines: Vec<String> = original.lines().map(|l| l.to_string()).collect();
2001    if lines.is_empty() {
2002        lines.push(String::new());
2003    }
2004    // Sort by end position descending
2005    edits.sort_by(|a, b| b.r1.cmp(&a.r1).then(b.c1.cmp(&a.c1)));
2006    for e in edits.iter() {
2007        let r0 = e.r0.min(lines.len().saturating_sub(1));
2008        let r1 = e.r1.min(lines.len().saturating_sub(1));
2009        let c0 = utf16_to_char_col(&lines[r0], e.c0);
2010        let c1 = utf16_to_char_col(&lines[r1], e.c1);
2011        if r0 == r1 {
2012            let chs: Vec<char> = lines[r0].chars().collect();
2013            let c0 = c0.min(chs.len());
2014            let c1 = c1.min(chs.len()).max(c0);
2015            let mut s: String = chs[..c0].iter().collect();
2016            s.push_str(&e.new_text);
2017            s.push_str(&chs[c1..].iter().collect::<String>());
2018            if s.contains('\n') {
2019                let parts: Vec<String> = s.split('\n').map(|x| x.to_string()).collect();
2020                lines.splice(r0..=r0, parts);
2021            } else {
2022                lines[r0] = s;
2023            }
2024        } else {
2025            let first: Vec<char> = lines[r0].chars().collect();
2026            let last: Vec<char> = lines[r1].chars().collect();
2027            let c0 = c0.min(first.len());
2028            let c1 = c1.min(last.len());
2029            let mut s: String = first[..c0].iter().collect();
2030            s.push_str(&e.new_text);
2031            s.push_str(&last[c1..].iter().collect::<String>());
2032            let parts: Vec<String> = s.split('\n').map(|x| x.to_string()).collect();
2033            lines.splice(r0..=r1, parts);
2034        }
2035    }
2036    let trailing = original.ends_with('\n');
2037    let mut out = lines.join("\n");
2038    if trailing && !out.ends_with('\n') {
2039        out.push('\n');
2040    }
2041    out
2042}
2043
2044fn parse_code_actions_ctx(
2045    body: &str,
2046    current_uri: &str,
2047    current_text: &str,
2048) -> Vec<CodeActionItem> {
2049    let mut out = Vec::new();
2050    if body.contains("\"result\":null") || body.contains("\"result\":[]") {
2051        return out;
2052    }
2053    for chunk in body.split("\"title\":\"").skip(1) {
2054        let title = chunk.split('"').next().unwrap_or("").to_string();
2055        if title.is_empty() {
2056            continue;
2057        }
2058        let kind = extract_str(chunk, "\"kind\":\"").unwrap_or_default();
2059        let edits = if let Some(pos) = chunk.find("\"edit\"") {
2060            parse_workspace_edit_ctx(&chunk[pos..], current_uri, current_text)
2061        } else {
2062            Vec::new()
2063        };
2064        let (command, command_args_json) = if let Some(pos) = chunk.find("\"command\":{") {
2065            let sub = &chunk[pos..];
2066            let cmd = extract_str(sub, "\"command\":\"");
2067            let args = sub.find("\"arguments\":").map(|i| {
2068                let rest = &sub[i + 12..];
2069                extract_json_array(rest).unwrap_or_else(|| "[]".into())
2070            });
2071            (cmd, args)
2072        } else {
2073            let cmd = extract_str(chunk, "\"command\":\"").filter(|s| {
2074                // avoid matching "command" inside longer keys when it's a string id only
2075                !s.is_empty() && !s.contains('{')
2076            });
2077            (cmd, None)
2078        };
2079        out.push(CodeActionItem {
2080            title,
2081            kind,
2082            edits,
2083            command,
2084            command_args_json,
2085        });
2086        if out.len() >= 40 {
2087            break;
2088        }
2089    }
2090    out
2091}
2092
2093fn extract_json_array(s: &str) -> Option<String> {
2094    let start = s.find('[')?;
2095    let mut depth = 0i32;
2096    for (i, c) in s[start..].chars().enumerate() {
2097        match c {
2098            '[' => depth += 1,
2099            ']' => {
2100                depth -= 1;
2101                if depth == 0 {
2102                    return Some(s[start..start + i + 1].to_string());
2103                }
2104            }
2105            _ => {}
2106        }
2107    }
2108    None
2109}
2110
2111fn install_hint(bin: &str) -> String {
2112    let hint = match bin {
2113        "rust-analyzer" => "install: rustup component add rust-analyzer",
2114        "pyright-langserver" | "pyright" => "install: npm i -g pyright",
2115        "typescript-language-server" => "install: npm i -g typescript-language-server typescript",
2116        "clangd" => "install: brew install llvm  (or apt install clangd)",
2117        "gopls" => "install: go install golang.org/x/tools/gopls@latest",
2118        "lua-language-server" => "install: brew install lua-language-server",
2119        "marksman" => "install: brew install marksman",
2120        "yaml-language-server" => "install: npm i -g yaml-language-server",
2121        "taplo" => "install: cargo install taplo-cli --locked",
2122        "bash-language-server" => "install: npm i -g bash-language-server",
2123        "zls" => "install: see https://github.com/zigtools/zls",
2124        "jdtls" => "install: brew install jdtls",
2125        _ => "install the language server or :LspStart <cmd>",
2126    };
2127    format!("LSP `{bin}` not found — {hint}")
2128}
2129
2130/// Build a valid `initialize` request body (tested for brace-balance).
2131fn build_initialize_request(id: u64, pid: u32, root_uri: &str, folder_name: &str) -> String {
2132    let root = escape_json(root_uri);
2133    let folder = escape_json(folder_name);
2134    // Token types legend (flat array) — keep as one line for readability.
2135    let token_types = r#"["namespace","type","class","enum","interface","struct","typeParameter","parameter","variable","property","enumMember","event","function","method","macro","keyword","modifier","comment","string","number","regexp","operator","decorator"]"#;
2136    let token_mods = r#"["declaration","definition","readonly","static","deprecated","abstract","async","modification","documentation","defaultLibrary"]"#;
2137    format!(
2138        concat!(
2139            r#"{{"jsonrpc":"2.0","id":{id},"method":"initialize","params":{{"#,
2140            r#""processId":{pid},"rootUri":"{root}","#,
2141            r#""workspaceFolders":[{{"uri":"{root}","name":"{folder}"}}],"#,
2142            r#""capabilities":{{"#,
2143            r#""general":{{"positionEncodings":["utf-16"]}},"#,
2144            r#""textDocument":{{"#,
2145            r#""synchronization":{{"didSave":true,"dynamicRegistration":false}},"#,
2146            r#""publishDiagnostics":{{"relatedInformation":true}},"#,
2147            r#""hover":{{"contentFormat":["markdown","plaintext"]}},"#,
2148            r#""completion":{{"completionItem":{{"snippetSupport":false,"documentationFormat":["markdown","plaintext"]}}}},"#,
2149            r#""definition":{{"linkSupport":true}},"#,
2150            r#""references":{{}},"#,
2151            r#""rename":{{"prepareSupport":false}},"#,
2152            r#""formatting":{{}},"#,
2153            r#""codeAction":{{"codeActionLiteralSupport":{{"codeActionKind":{{"valueSet":["quickfix","refactor","source"]}}}}}},"#,
2154            r#""documentSymbol":{{"hierarchicalDocumentSymbolSupport":true}},"#,
2155            r#""inlayHint":{{"resolveSupport":{{"properties":["label.tooltip"]}}}},"#,
2156            r#""semanticTokens":{{"requests":{{"full":true}},"tokenTypes":{token_types},"tokenModifiers":{token_mods},"formats":["relative"],"overlappingTokenSupport":false,"multilineTokenSupport":true}}"#,
2157            r#"}},"#, // end textDocument object + comma
2158            r#""workspace":{{"workspaceFolders":true,"symbol":{{}},"applyEdit":true}}"#,
2159            // Close: capabilities, params, root object (each `}}` → one `}` in format!)
2160            r#"}}}}}}"#
2161        ),
2162        id = id,
2163        pid = pid,
2164        root = root,
2165        folder = folder,
2166        token_types = token_types,
2167        token_mods = token_mods,
2168    )
2169}
2170
2171fn extract_json_id(text: &str) -> Option<u64> {
2172    // "id": 123 or "id":123
2173    let key = "\"id\":";
2174    let mut search = text;
2175    while let Some(pos) = search.find(key) {
2176        let after = search[pos + key.len()..].trim_start();
2177        // skip if this is inside nested structure wrongly — take first numeric id at top-ish
2178        if after.starts_with('n') {
2179            // null
2180            search = &search[pos + key.len()..];
2181            continue;
2182        }
2183        if let Some(n) = after
2184            .chars()
2185            .take_while(|c| c.is_ascii_digit())
2186            .collect::<String>()
2187            .parse()
2188            .ok()
2189        {
2190            return Some(n);
2191        }
2192        search = &search[pos + key.len()..];
2193    }
2194    None
2195}
2196
2197/// Extract the JSON string value following `prefix`, unescaping as we go.
2198/// Stops at the first **unescaped** quote (the old slice-based version
2199/// truncated values containing `\"`).
2200fn extract_str(text: &str, prefix: &str) -> Option<String> {
2201    let start = text.find(prefix)? + prefix.len();
2202    let mut out = String::new();
2203    let mut chars = text[start..].chars();
2204    while let Some(c) = chars.next() {
2205        match c {
2206            '\\' => match chars.next() {
2207                Some('n') => out.push('\n'),
2208                Some('t') => out.push('\t'),
2209                Some('r') => out.push('\r'),
2210                Some('"') => out.push('"'),
2211                Some('\\') => out.push('\\'),
2212                Some('/') => out.push('/'),
2213                Some('u') => {
2214                    let hex: String = chars.by_ref().take(4).collect();
2215                    if let Some(ch) = u32::from_str_radix(&hex, 16)
2216                        .ok()
2217                        .and_then(char::from_u32)
2218                    {
2219                        out.push(ch);
2220                    }
2221                }
2222                Some(other) => {
2223                    out.push('\\');
2224                    out.push(other);
2225                }
2226                None => break,
2227            },
2228            '"' => break,
2229            c => out.push(c),
2230        }
2231    }
2232    Some(out)
2233}
2234
2235fn extract_int(text: &str, prefix: &str) -> Option<i64> {
2236    text.find(prefix).and_then(|i| {
2237        let s = text[i + prefix.len()..].trim_start();
2238        s.chars()
2239            .take_while(|c| c.is_ascii_digit())
2240            .collect::<String>()
2241            .parse()
2242            .ok()
2243    })
2244}
2245
2246fn escape_json(s: &str) -> String {
2247    let mut out = String::with_capacity(s.len() + 8);
2248    for ch in s.chars() {
2249        match ch {
2250            '\\' => out.push_str("\\\\"),
2251            '"' => out.push_str("\\\""),
2252            '\n' => out.push_str("\\n"),
2253            '\r' => out.push_str("\\r"),
2254            '\t' => out.push_str("\\t"),
2255            '\u{08}' => out.push_str("\\b"),
2256            '\u{0C}' => out.push_str("\\f"),
2257            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
2258            c => out.push(c),
2259        }
2260    }
2261    out
2262}
2263
2264fn abs_path(path: &str) -> String {
2265    let p = PathBuf::from(path);
2266    if p.is_absolute() {
2267        return p.display().to_string();
2268    }
2269    std::env::current_dir()
2270        .map(|c| c.join(p))
2271        .unwrap_or_else(|_| PathBuf::from(path))
2272        .display()
2273        .to_string()
2274}
2275
2276fn path_to_uri(path: &str) -> String {
2277    let abs = abs_path(path);
2278    let mut encoded = String::from("file://");
2279    // Ensure leading / on unix
2280    #[cfg(unix)]
2281    {
2282        if !abs.starts_with('/') {
2283            encoded.push('/');
2284        }
2285    }
2286    for c in abs.chars() {
2287        match c {
2288            ' ' => encoded.push_str("%20"),
2289            '\\' => encoded.push('/'),
2290            c if c.is_ascii_alphanumeric()
2291                || matches!(c, '/' | ':' | '-' | '_' | '.' | '~') =>
2292            {
2293                encoded.push(c);
2294            }
2295            c => {
2296                for b in c.to_string().as_bytes() {
2297                    encoded.push_str(&format!("%{:02X}", b));
2298                }
2299            }
2300        }
2301    }
2302    encoded
2303}
2304
2305/// (row, col, title-if-resolved) from one lens object.
2306fn code_lens_fields(lens: &serde_json::Value) -> (usize, usize, Option<String>) {
2307    let row = lens
2308        .get("range")
2309        .and_then(|r| r.get("start"))
2310        .and_then(|s| s.get("line"))
2311        .and_then(|l| l.as_u64())
2312        .unwrap_or(0) as usize;
2313    let col = lens
2314        .get("range")
2315        .and_then(|r| r.get("start"))
2316        .and_then(|s| s.get("character"))
2317        .and_then(|c| c.as_u64())
2318        .unwrap_or(0) as usize;
2319    let title = lens
2320        .get("command")
2321        .and_then(|c| c.get("title"))
2322        .and_then(|t| t.as_str())
2323        .filter(|t| !t.is_empty())
2324        .map(|t| t.to_string());
2325    (row, col, title)
2326}
2327
2328/// Returns (resolved lenses merged per row, raw unresolved lens objects for
2329/// `codeLens/resolve`).
2330fn parse_code_lenses(body: &str) -> (Vec<CodeLens>, Vec<serde_json::Value>) {
2331    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
2332        return (Vec::new(), Vec::new());
2333    };
2334    let Some(arr) = v.get("result").and_then(|r| r.as_array()) else {
2335        return (Vec::new(), Vec::new());
2336    };
2337    let mut out = Vec::new();
2338    let mut unresolved = Vec::new();
2339    for lens in arr {
2340        let (row, col, title) = code_lens_fields(lens);
2341        match title {
2342            Some(title) => out.push(CodeLens { row, col, title }),
2343            None => unresolved.push(lens.clone()),
2344        }
2345    }
2346    // Merge multiple lenses on same row: "a · b"
2347    out.sort_by_key(|l| (l.row, l.col));
2348    let mut merged: Vec<CodeLens> = Vec::new();
2349    for lens in out {
2350        if let Some(last) = merged.last_mut() {
2351            if last.row == lens.row {
2352                last.title = format!("{} · {}", last.title, lens.title);
2353                continue;
2354            }
2355        }
2356        merged.push(lens);
2357    }
2358    (merged, unresolved)
2359}
2360
2361/// Lens from a `codeLens/resolve` response (`result` is a single lens object).
2362fn parse_resolved_code_lens(body: &str) -> Option<CodeLens> {
2363    let v = serde_json::from_str::<serde_json::Value>(body).ok()?;
2364    let lens = v.get("result")?;
2365    let (row, col, title) = code_lens_fields(lens);
2366    title.map(|title| CodeLens { row, col, title })
2367}
2368
2369/// Insert keeping row order; same-row lenses join as "a · b".
2370fn merge_code_lens(list: &mut Vec<CodeLens>, lens: CodeLens) {
2371    if let Some(existing) = list.iter_mut().find(|l| l.row == lens.row) {
2372        existing.title = format!("{} · {}", existing.title, lens.title);
2373        return;
2374    }
2375    let pos = list.partition_point(|l| l.row < lens.row);
2376    list.insert(pos, lens);
2377}
2378
2379fn parse_call_hierarchy_items(body: &str) -> Vec<crate::call_hierarchy::CallItem> {
2380    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
2381        return Vec::new();
2382    };
2383    let result = v.get("result");
2384    let arr = match result {
2385        Some(serde_json::Value::Array(a)) => a.clone(),
2386        Some(obj) if obj.is_object() => vec![obj.clone()],
2387        _ => return Vec::new(),
2388    };
2389    arr.into_iter()
2390        .filter_map(|item| call_item_from_json(&item))
2391        .collect()
2392}
2393
2394fn parse_call_hierarchy_calls(body: &str) -> Vec<crate::call_hierarchy::CallItem> {
2395    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
2396        return Vec::new();
2397    };
2398    let Some(arr) = v.get("result").and_then(|r| r.as_array()) else {
2399        return Vec::new();
2400    };
2401    let mut out = Vec::new();
2402    for call in arr {
2403        // incoming: from, outgoing: to
2404        let item = call
2405            .get("from")
2406            .or_else(|| call.get("to"))
2407            .cloned()
2408            .unwrap_or(call.clone());
2409        if let Some(ci) = call_item_from_json(&item) {
2410            out.push(ci);
2411        }
2412    }
2413    out
2414}
2415
2416fn call_item_from_json(item: &serde_json::Value) -> Option<crate::call_hierarchy::CallItem> {
2417    let name = item
2418        .get("name")
2419        .and_then(|n| n.as_str())
2420        .unwrap_or("?")
2421        .to_string();
2422    let detail = item
2423        .get("detail")
2424        .and_then(|d| d.as_str())
2425        .unwrap_or("")
2426        .to_string();
2427    let kind_n = item.get("kind").and_then(|k| k.as_u64()).unwrap_or(12);
2428    let kind = symbol_kind_name(kind_n as i64).to_string();
2429    let uri = item
2430        .get("uri")
2431        .and_then(|u| u.as_str())
2432        .unwrap_or("");
2433    let path = uri_to_path(uri);
2434    let range = item
2435        .get("selectionRange")
2436        .or_else(|| item.get("range"));
2437    let row = range
2438        .and_then(|r| r.get("start"))
2439        .and_then(|s| s.get("line"))
2440        .and_then(|l| l.as_u64())
2441        .unwrap_or(0) as usize;
2442    let col = range
2443        .and_then(|r| r.get("start"))
2444        .and_then(|s| s.get("character"))
2445        .and_then(|c| c.as_u64())
2446        .unwrap_or(0) as usize;
2447    let raw_json = item.to_string();
2448    Some(crate::call_hierarchy::CallItem {
2449        name,
2450        detail,
2451        kind,
2452        path,
2453        row,
2454        col,
2455        raw_json,
2456    })
2457}
2458
2459fn uri_to_path(uri: &str) -> String {
2460    let rest = uri.strip_prefix("file://").unwrap_or(uri);
2461    // decode %20 etc. minimally
2462    let mut out = String::new();
2463    let bytes = rest.as_bytes();
2464    let mut i = 0;
2465    while i < bytes.len() {
2466        if bytes[i] == b'%' && i + 2 < bytes.len() {
2467            if let Ok(v) = u8::from_str_radix(
2468                std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("00"),
2469                16,
2470            ) {
2471                out.push(v as char);
2472                i += 3;
2473                continue;
2474            }
2475        }
2476        out.push(bytes[i] as char);
2477        i += 1;
2478    }
2479    // macOS sometimes has /Users — fine
2480    out
2481}
2482
2483fn uris_match(a: &str, b: &str) -> bool {
2484    if a == b {
2485        return true;
2486    }
2487    let pa = uri_to_path(a);
2488    let pb = uri_to_path(b);
2489    pa == pb || abs_path(&pa) == abs_path(&pb)
2490}
2491
2492fn find_project_root(hint: &str, file: &str) -> String {
2493    let start = Path::new(file)
2494        .parent()
2495        .map(|p| p.to_path_buf())
2496        .unwrap_or_else(|| PathBuf::from(hint));
2497    let markers = [
2498        "Cargo.toml",
2499        "package.json",
2500        "go.mod",
2501        "pyproject.toml",
2502        "setup.py",
2503        "compile_commands.json",
2504        "CMakeLists.txt",
2505        "Gemfile",
2506        ".git",
2507    ];
2508    let mut cur = start;
2509    for _ in 0..12 {
2510        for m in &markers {
2511            if cur.join(m).exists() {
2512                return cur.display().to_string();
2513            }
2514        }
2515        if !cur.pop() {
2516            break;
2517        }
2518    }
2519    Path::new(file)
2520        .parent()
2521        .map(|p| p.display().to_string())
2522        .unwrap_or_else(|| hint.to_string())
2523}
2524
2525fn command_exists(bin: &str) -> bool {
2526    // absolute path
2527    if bin.contains('/') && Path::new(bin).exists() {
2528        return true;
2529    }
2530    Command::new("which")
2531        .arg(bin)
2532        .stdout(Stdio::null())
2533        .stderr(Stdio::null())
2534        .status()
2535        .map(|s| s.success())
2536        .unwrap_or(false)
2537}
2538
2539/// Whether a language server is known for this file's extension (defaults only).
2540pub fn has_server_for(path: &str) -> bool {
2541    let ext = Path::new(path)
2542        .extension()
2543        .and_then(|e| e.to_str())
2544        .unwrap_or("")
2545        .to_lowercase();
2546    default_server_for_ext(&ext).is_some()
2547}
2548
2549/// Map file extension → settings language key.
2550pub fn ext_to_lang_key(ext: &str) -> Option<&'static str> {
2551    Some(match ext {
2552        "rs" => "rust",
2553        "py" | "pyi" => "python",
2554        "ts" | "tsx" | "mts" | "cts" => "typescript",
2555        "js" | "jsx" | "mjs" | "cjs" => "javascript",
2556        "c" | "h" | "cpp" | "hpp" | "cc" | "cxx" | "hh" | "hxx" => "c",
2557        "go" => "go",
2558        "java" => "java",
2559        "lua" => "lua",
2560        "json" | "jsonc" => "json",
2561        "yaml" | "yml" => "yaml",
2562        "toml" => "toml",
2563        "md" | "mdx" => "markdown",
2564        "sh" | "bash" | "zsh" => "bash",
2565        "zig" => "zig",
2566        "php" => "php",
2567        "rb" => "ruby",
2568        "swift" => "swift",
2569        "kt" | "kts" => "kotlin",
2570        "cs" => "csharp",
2571        "html" | "htm" => "html",
2572        "css" | "scss" | "less" => "css",
2573        "vue" => "vue",
2574        "svelte" => "svelte",
2575        "dart" => "dart",
2576        "hs" => "haskell",
2577        "ex" | "exs" => "elixir",
2578        "scala" => "scala",
2579        "nim" => "nim",
2580        _ => return None,
2581    })
2582}
2583
2584fn default_server_for_ext(ext: &str) -> Option<&'static str> {
2585    // Prefer catalog defaults when available
2586    if let Some(lang) = ext_to_lang_key(ext) {
2587        for (key, _, cmd) in config::lsp_lang_catalog() {
2588            if *key == lang {
2589                return Some(*cmd);
2590            }
2591        }
2592    }
2593    Some(match ext {
2594        "rs" => "rust-analyzer",
2595        "py" | "pyi" => "pyright-langserver --stdio",
2596        "ts" | "tsx" | "mts" | "cts" => "typescript-language-server --stdio",
2597        "js" | "jsx" | "mjs" | "cjs" => "typescript-language-server --stdio",
2598        "c" | "h" => "clangd",
2599        "cpp" | "hpp" | "cc" | "cxx" | "hh" | "hxx" => "clangd",
2600        "go" => "gopls",
2601        "java" => "jdtls",
2602        "lua" => "lua-language-server",
2603        "php" => "intelephense --stdio",
2604        "rb" => "solargraph stdio",
2605        "swift" => "sourcekit-lsp",
2606        "kt" | "kts" => "kotlin-language-server",
2607        "cs" => "csharp-ls",
2608        "html" | "htm" => "vscode-html-language-server --stdio",
2609        "css" | "scss" | "less" => "vscode-css-language-server --stdio",
2610        "json" | "jsonc" => "vscode-json-language-server --stdio",
2611        "yaml" | "yml" => "yaml-language-server --stdio",
2612        "toml" => "taplo lsp stdio",
2613        "md" | "mdx" => "marksman server",
2614        "sh" | "bash" | "zsh" => "bash-language-server start",
2615        "zig" => "zls",
2616        "nim" => "nimlsp",
2617        "ex" | "exs" => "elixir-ls",
2618        "hs" => "haskell-language-server-wrapper --lsp",
2619        "scala" => "metals",
2620        "vue" => "vue-language-server --stdio",
2621        "svelte" => "svelteserver --stdio",
2622        "dart" => "dart language-server",
2623        "r" | "R" => "r-languageserver",
2624        _ => return None,
2625    })
2626}
2627
2628/// Alias used in tests.
2629#[cfg(test)]
2630fn server_for_ext(ext: &str) -> Option<&'static str> {
2631    default_server_for_ext(ext)
2632}
2633
2634fn lang_id(path: &str) -> &'static str {
2635    let ext = Path::new(path)
2636        .extension()
2637        .and_then(|e| e.to_str())
2638        .unwrap_or("")
2639        .to_lowercase();
2640    match ext.as_str() {
2641        "rs" => "rust",
2642        "py" | "pyi" => "python",
2643        "ts" | "mts" | "cts" => "typescript",
2644        "tsx" => "typescriptreact",
2645        "js" | "mjs" | "cjs" => "javascript",
2646        "jsx" => "javascriptreact",
2647        "go" => "go",
2648        "c" => "c",
2649        "h" => "c",
2650        "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" => "cpp",
2651        "java" => "java",
2652        "lua" => "lua",
2653        "php" => "php",
2654        "rb" => "ruby",
2655        "swift" => "swift",
2656        "kt" | "kts" => "kotlin",
2657        "cs" => "csharp",
2658        "html" | "htm" => "html",
2659        "css" => "css",
2660        "scss" => "scss",
2661        "less" => "less",
2662        "json" | "jsonc" => "json",
2663        "yaml" | "yml" => "yaml",
2664        "toml" => "toml",
2665        "md" | "mdx" => "markdown",
2666        "sh" | "bash" | "zsh" => "shellscript",
2667        "zig" => "zig",
2668        "vue" => "vue",
2669        "svelte" => "svelte",
2670        "dart" => "dart",
2671        "hs" => "haskell",
2672        "ex" | "exs" => "elixir",
2673        "scala" => "scala",
2674        "nim" => "nim",
2675        _ => "plaintext",
2676    }
2677}
2678
2679#[cfg(test)]
2680mod tests {
2681    use super::*;
2682
2683    #[test]
2684    fn code_lens_split_resolved_unresolved() {
2685        let body = r#"{"jsonrpc":"2.0","id":1,"result":[
2686            {"range":{"start":{"line":3,"character":0},"end":{"line":3,"character":2}},
2687             "command":{"title":"2 references","command":"x"}},
2688            {"range":{"start":{"line":3,"character":4},"end":{"line":3,"character":6}},
2689             "command":{"title":"run test","command":"y"}},
2690            {"range":{"start":{"line":9,"character":0},"end":{"line":9,"character":1}},
2691             "data":{"kind":"references"}}
2692        ]}"#;
2693        let (resolved, unresolved) = parse_code_lenses(body);
2694        // Same-row lenses merge; the command-less lens is queued for resolve.
2695        assert_eq!(resolved.len(), 1);
2696        assert_eq!(resolved[0].row, 3);
2697        assert_eq!(resolved[0].title, "2 references · run test");
2698        assert_eq!(unresolved.len(), 1);
2699        assert_eq!(
2700            unresolved[0]["range"]["start"]["line"].as_u64(),
2701            Some(9)
2702        );
2703    }
2704
2705    #[test]
2706    fn code_lens_resolve_response_parses_and_merges() {
2707        let body = r#"{"jsonrpc":"2.0","id":7,"result":
2708            {"range":{"start":{"line":9,"character":0},"end":{"line":9,"character":1}},
2709             "command":{"title":"5 references","command":"x"}}}"#;
2710        let lens = parse_resolved_code_lens(body).unwrap();
2711        assert_eq!(lens.row, 9);
2712        assert_eq!(lens.title, "5 references");
2713
2714        let mut list = vec![CodeLens {
2715            row: 3,
2716            col: 0,
2717            title: "run test".into(),
2718        }];
2719        merge_code_lens(&mut list, lens);
2720        assert_eq!(list.len(), 2);
2721        assert_eq!(list[1].row, 9);
2722        // Same-row merge appends with the separator.
2723        merge_code_lens(
2724            &mut list,
2725            CodeLens {
2726                row: 3,
2727                col: 5,
2728                title: "debug".into(),
2729            },
2730        );
2731        assert_eq!(list[0].title, "run test · debug");
2732    }
2733
2734    #[test]
2735    fn code_lens_resolve_without_command_is_dropped() {
2736        let body = r#"{"jsonrpc":"2.0","id":7,"result":
2737            {"range":{"start":{"line":1,"character":0},"end":{"line":1,"character":1}}}}"#;
2738        assert!(parse_resolved_code_lens(body).is_none());
2739    }
2740
2741    #[test]
2742    fn escape_and_uri() {
2743        let e = escape_json("a\"b\nc");
2744        assert!(e.contains("\\\""));
2745        assert!(e.contains("\\n"));
2746        let u = path_to_uri("/tmp/foo bar.rs");
2747        assert!(u.starts_with("file://"));
2748        assert!(u.contains("%20"));
2749    }
2750
2751    #[test]
2752    fn parse_diag_empty_array() {
2753        let body = r#"{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///a.rs","diagnostics":[]}}"#;
2754        let d = parse_diagnostics(body);
2755        assert!(d.is_empty());
2756    }
2757
2758    #[test]
2759    fn server_map_known() {
2760        assert!(server_for_ext("rs").is_some());
2761        assert!(server_for_ext("zig").is_some());
2762        assert!(server_for_ext("xyz").is_none());
2763    }
2764
2765    #[test]
2766    fn content_length_bytes() {
2767        let msg = "{\"a\":\"한글\"}";
2768        assert_ne!(msg.len(), msg.chars().count());
2769        // header must use byte len
2770        assert_eq!(msg.as_bytes().len(), msg.len());
2771    }
2772
2773    #[test]
2774    fn decode_semantic_relative_tokens() {
2775        // legend index: 0=namespace, ... 12=function (default legend)
2776        let legend = default_semantic_types();
2777        let function_idx = legend.iter().position(|t| t == "function").unwrap() as u32;
2778        let keyword_idx = legend.iter().position(|t| t == "keyword").unwrap() as u32;
2779        // line 0: "fn main" — keyword at 0 len 2, function at 3 len 4
2780        // data: [dLine, dStart, len, type, mods]
2781        let data = vec![
2782            0, 0, 2, keyword_idx, 0, // "fn"
2783            0, 3, 4, function_idx, 0, // "main" (delta start 3 from 0)
2784        ];
2785        let lines = ["fn main() {}"];
2786        let toks = decode_semantic_tokens(&data, &legend, &lines);
2787        assert_eq!(toks.len(), 2);
2788        assert_eq!(toks[0].1, 0);
2789        assert_eq!(toks[0].2, 2);
2790        assert!(matches!(toks[0].0, TokenKind::Keyword));
2791        assert_eq!(toks[1].1, 3);
2792        assert_eq!(toks[1].2, 7);
2793        assert!(matches!(toks[1].0, TokenKind::Function));
2794    }
2795
2796    #[test]
2797    fn workspace_edit_changes_map() {
2798        let body = r#"{"jsonrpc":"2.0","id":1,"result":{"changes":{"file:///tmp/xei_we_test.rs":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":5}},"newText":"hello"}]}}}"#;
2799        // create file
2800        let path = "/tmp/xei_we_test.rs";
2801        let _ = std::fs::write(path, "world\n");
2802        let edits = parse_workspace_edit(body);
2803        assert!(!edits.is_empty(), "expected edits");
2804        assert!(edits[0].text.contains("hello"));
2805        let _ = std::fs::remove_file(path);
2806    }
2807
2808    #[test]
2809    fn install_hint_mentions_binary() {
2810        let h = install_hint("rust-analyzer");
2811        assert!(h.contains("rust-analyzer"));
2812        assert!(h.contains("install"));
2813    }
2814
2815    #[test]
2816    fn initialize_request_is_valid_json() {
2817        let s = build_initialize_request(1, 42, "file:///tmp/xei-proj", "xei");
2818        // brace balance
2819        let mut bal = 0i32;
2820        for c in s.chars() {
2821            match c {
2822                '{' => bal += 1,
2823                '}' => bal -= 1,
2824                _ => {}
2825            }
2826            assert!(bal >= 0, "negative brace balance in {s}");
2827        }
2828        assert_eq!(bal, 0, "unbalanced braces: {s}");
2829        // must contain core fields
2830        assert!(s.contains("\"method\":\"initialize\""));
2831        assert!(s.contains("\"processId\":42"));
2832        assert!(s.contains("semanticTokens"));
2833        assert!(s.contains("workspaceFolders"));
2834    }
2835
2836    #[test]
2837    fn initialize_survives_rust_analyzer() {
2838        if !command_exists("rust-analyzer") {
2839            return;
2840        }
2841        // Smoke: spawn RA, send our init, expect a result (not disconnect).
2842        use std::io::{Read, Write};
2843        use std::process::{Command, Stdio};
2844        use std::time::Duration;
2845        let mut child = Command::new("rust-analyzer")
2846            .stdin(Stdio::piped())
2847            .stdout(Stdio::piped())
2848            .stderr(Stdio::null())
2849            .spawn()
2850            .expect("spawn rust-analyzer");
2851        let mut stdin = child.stdin.take().unwrap();
2852        let mut stdout = child.stdout.take().unwrap();
2853        let body = build_initialize_request(1, std::process::id(), "file:///tmp", "tmp");
2854        let header = format!("Content-Length: {}\r\n\r\n", body.len());
2855        stdin.write_all(header.as_bytes()).unwrap();
2856        stdin.write_all(body.as_bytes()).unwrap();
2857        stdin.flush().unwrap();
2858        // read one message with timeout-ish
2859        let start = std::time::Instant::now();
2860        let mut buf = Vec::new();
2861        let mut tmp = [0u8; 4096];
2862        while start.elapsed() < Duration::from_secs(3) {
2863            if let Ok(n) = stdout.read(&mut tmp) {
2864                if n == 0 {
2865                    break;
2866                }
2867                buf.extend_from_slice(&tmp[..n]);
2868                if buf.windows(4).any(|w| w == b"\r\n\r\n") {
2869                    // got headers; try to find content-length and full body
2870                    if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
2871                        let header = std::str::from_utf8(&buf[..pos]).unwrap_or("");
2872                        let mut len = 0usize;
2873                        for line in header.lines() {
2874                            if let Some(rest) = line
2875                                .to_ascii_lowercase()
2876                                .strip_prefix("content-length:")
2877                            {
2878                                len = rest.trim().parse().unwrap_or(0);
2879                            }
2880                        }
2881                        let body_start = pos + 4;
2882                        if buf.len() >= body_start + len {
2883                            let body = std::str::from_utf8(&buf[body_start..body_start + len])
2884                                .unwrap_or("");
2885                            assert!(
2886                                body.contains("\"result\"") || body.contains("capabilities"),
2887                                "unexpected RA response: {body}"
2888                            );
2889                            assert!(
2890                                !is_jsonrpc_error(body),
2891                                "init should not be error: {body}"
2892                            );
2893                            let _ = child.kill();
2894                            return;
2895                        }
2896                    }
2897                }
2898            } else {
2899                std::thread::sleep(Duration::from_millis(20));
2900            }
2901        }
2902        let _ = child.kill();
2903        panic!("timed out waiting for rust-analyzer initialize response");
2904    }
2905
2906    #[test]
2907    fn char_utf16_roundtrip() {
2908        let line = "a𝕏b"; // 𝕏 is 2 utf-16 units
2909        assert_eq!(char_to_utf16_col(line, 0), 0);
2910        assert_eq!(char_to_utf16_col(line, 1), 1);
2911        assert_eq!(char_to_utf16_col(line, 2), 3); // after 𝕏
2912        assert_eq!(utf16_to_char_col(line, 3), 2);
2913    }
2914
2915    #[test]
2916    fn jsonrpc_error_detect() {
2917        assert!(is_jsonrpc_error(
2918            r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}"#
2919        ));
2920        assert!(!is_jsonrpc_error(
2921            r#"{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"errorCodes":[]}}}"#
2922        ));
2923    }
2924
2925    #[test]
2926    fn utf16_cjk_column() {
2927        let line = "a한b"; // a=1, 한=1 utf16? actually 한 is one BMP char = 1 utf16 unit
2928        assert_eq!(utf16_to_char_col(line, 0), 0);
2929        assert_eq!(utf16_to_char_col(line, 1), 1);
2930        assert_eq!(utf16_to_char_col(line, 2), 2);
2931        assert_eq!(utf16_to_char_col(line, 3), 3);
2932    }
2933
2934    #[test]
2935    fn extract_str_handles_escaped_quotes() {
2936        let body = r#"{"message":"expected \"foo\", found bar\nhere"}"#;
2937        let m = extract_str(body, "\"message\":\"").unwrap();
2938        assert_eq!(m, "expected \"foo\", found bar\nhere");
2939    }
2940
2941    #[test]
2942    fn diag_cols_convert_utf16_surrogate_pairs() {
2943        // '𝕏' (U+1D54F) is 2 UTF-16 units but 1 char.
2944        let doc = "𝕏ab";
2945        let mut diags = vec![Diagnostic {
2946            row: 0,
2947            col_start: 2, // UTF-16 col of 'a'
2948            col_end: 3,
2949            message: "m".into(),
2950            severity: DiagnosticSeverity::Error,
2951        }];
2952        diag_cols_utf16_to_chars(&mut diags, doc);
2953        assert_eq!(diags[0].col_start, 1); // char index of 'a'
2954        assert_eq!(diags[0].col_end, 2);
2955    }
2956
2957    #[test]
2958    fn parse_semantic_data_array() {
2959        let body = r#"{"jsonrpc":"2.0","id":1,"result":{"data":[0,0,2,15,0,0,3,4,12,0]}}"#;
2960        let d = parse_semantic_data(body);
2961        assert_eq!(d, vec![0, 0, 2, 15, 0, 0, 3, 4, 12, 0]);
2962    }
2963}