Skip to main content

gid_core/
lsp_client.rs

1//! LSP client for precise call edge resolution.
2//!
3//! Spawns language server processes (tsserver, rust-analyzer, pyright) via stdio transport
4//! and uses `textDocument/definition`, `textDocument/references`, and `textDocument/implementation`
5//! to resolve call sites, find callers, and discover trait implementations.
6//! This replaces name-matching heuristics with compiler-level precision (~99% accuracy).
7
8use std::collections::{HashMap, HashSet};
9use std::io::{BufRead, BufReader, Read, Write};
10use std::path::{Path, PathBuf};
11use std::process::{Child, Command, Stdio};
12use std::sync::mpsc;
13use std::time::{Duration, Instant};
14
15use anyhow::{bail, Context, Result};
16use serde_json::{json, Value};
17
18/// Tracks the status of a progress token from the LSP server.
19#[derive(Debug, Clone)]
20struct ProgressTokenStatus {
21    ended: bool,
22    percentage: Option<u32>,
23}
24
25impl ProgressTokenStatus {
26    fn new() -> Self {
27        Self { ended: false, percentage: None }
28    }
29    
30    fn ended() -> Self {
31        Self { ended: true, percentage: Some(100) }
32    }
33}
34
35/// LSP client over stdio transport (JSON-RPC 2.0).
36pub struct LspClient {
37    process: Child,
38    /// Channel receiver for messages from the reader thread
39    msg_rx: mpsc::Receiver<Result<Value, String>>,
40    writer: std::process::ChildStdin,
41    next_id: u64,
42    root_uri: String,
43    _root_dir: PathBuf,
44    /// Buffered notifications received while waiting for responses
45    _notifications: Vec<Value>,
46    /// Server capabilities received from initialize response
47    _capabilities: Value,
48    /// Timeout per request
49    timeout: Duration,
50    /// Files that have been opened via didOpen
51    opened_files: HashSet<String>,
52    /// Active work-done-progress tokens (token → true if "end" received)
53    progress_tokens: HashMap<String, ProgressTokenStatus>,
54}
55
56/// A resolved definition location from LSP.
57#[derive(Debug, Clone)]
58pub struct LspLocation {
59    /// File path relative to project root
60    pub file_path: String,
61    /// 0-indexed line number
62    pub line: u32,
63    /// 0-indexed column (UTF-16 offset per LSP spec)
64    pub character: u32,
65}
66
67/// A language server that's needed but not installed.
68#[derive(Debug, Clone)]
69pub struct LspMissingServer {
70    /// Language ID (e.g., "rust", "python", "typescript")
71    pub language_id: String,
72    /// Number of files in this language
73    pub file_count: usize,
74    /// Number of call edges that can't be refined
75    pub edge_count: usize,
76    /// Suggested install command
77    pub install_command: String,
78}
79
80/// Statistics from LSP refinement of call edges.
81#[derive(Debug, Default)]
82pub struct LspRefinementStats {
83    /// Total call edges considered
84    pub total_call_edges: usize,
85    /// Edges where LSP confirmed + possibly updated target
86    pub refined: usize,
87    /// Edges removed (target is external/nonexistent in project)
88    pub removed: usize,
89    /// LSP request failed or timed out
90    pub failed: usize,
91    /// No LSP available for this language, kept tree-sitter edge
92    pub skipped: usize,
93    /// Language servers that were successfully used
94    pub languages_used: Vec<String>,
95    /// Language servers needed but not installed
96    pub missing_servers: Vec<LspMissingServer>,
97    /// Number of reference lookups performed
98    pub references_queried: usize,
99    /// New call edges discovered via references
100    pub references_edges_added: usize,
101    /// Number of implementation lookups performed
102    pub implementations_queried: usize,
103    /// New implementation edges discovered
104    pub implementation_edges_added: usize,
105}
106
107/// Statistics from LSP enrichment passes (references + implementations).
108#[derive(Debug, Default)]
109pub struct LspEnrichmentStats {
110    /// Number of nodes queried via LSP
111    pub nodes_queried: usize,
112    /// New edges discovered and added
113    pub new_edges_added: usize,
114    /// Edges that already existed (skipped)
115    pub already_existed: usize,
116    /// LSP queries that failed or timed out
117    pub failed: usize,
118    /// Language servers that were successfully used
119    pub languages_used: Vec<String>,
120}
121
122/// Language server configuration for a specific language.
123#[derive(Debug, Clone)]
124pub struct LspServerConfig {
125    pub command: String,
126    pub args: Vec<String>,
127    pub language_id: String,
128    pub extensions: Vec<String>,
129}
130
131impl LspServerConfig {
132    /// Detect available language servers on the system.
133    pub fn detect_available() -> Vec<Self> {
134        let mut configs = Vec::new();
135
136        // TypeScript/JavaScript — typescript-language-server
137        // Try npx first, which wraps tsserver
138        let ts_result = Command::new("npx")
139            .args(["--yes", "typescript-language-server", "--version"])
140            .stdout(Stdio::piped())
141            .stderr(Stdio::piped())
142            .output();
143
144        let ts_available = match &ts_result {
145            Ok(output) => {
146                output.status.success()
147            }
148            Err(e) => {
149                tracing::debug!("[LSP detect] tsserver spawn failed: {}", e);
150                false
151            }
152        };
153
154        if ts_available {
155            configs.push(Self {
156                command: "npx".to_string(),
157                args: vec![
158                    "--yes".to_string(),
159                    "typescript-language-server".to_string(),
160                    "--stdio".to_string(),
161                ],
162                language_id: "typescript".to_string(),
163                extensions: vec![
164                    "ts".to_string(),
165                    "tsx".to_string(),
166                    "js".to_string(),
167                    "jsx".to_string(),
168                ],
169            });
170        }
171
172        // Rust — rust-analyzer
173        if which_exists("rust-analyzer") {
174            configs.push(Self {
175                command: "rust-analyzer".to_string(),
176                args: vec![],
177                language_id: "rust".to_string(),
178                extensions: vec!["rs".to_string()],
179            });
180        }
181
182        // Python — pyright or pylsp
183        if which_exists("pyright-langserver") {
184            configs.push(Self {
185                command: "pyright-langserver".to_string(),
186                args: vec!["--stdio".to_string()],
187                language_id: "python".to_string(),
188                extensions: vec!["py".to_string()],
189            });
190        } else if which_exists("pylsp") {
191            configs.push(Self {
192                command: "pylsp".to_string(),
193                args: vec![],
194                language_id: "python".to_string(),
195                extensions: vec!["py".to_string()],
196            });
197        }
198
199        configs
200    }
201
202    /// Return the install command for a given language ID.
203    /// Used when an LSP server is needed but not detected.
204    pub fn install_suggestion(language_id: &str) -> String {
205        match language_id {
206            "rust" => "rustup component add rust-analyzer".to_string(),
207            "typescript" | "javascript" => "npm install -g typescript-language-server typescript".to_string(),
208            "python" => "pip install pyright".to_string(),
209            _ => format!("(no known LSP server for '{}')", language_id),
210        }
211    }
212
213    /// Check which languages in the project have no LSP server available.
214    /// Returns a list of missing servers with install suggestions.
215    ///
216    /// `languages_in_project` is a map of language_id → (file_count, call_edge_count).
217    pub fn check_coverage(
218        available: &[Self],
219        languages_in_project: &HashMap<String, (usize, usize)>,
220    ) -> Vec<LspMissingServer> {
221        let available_langs: HashSet<&str> = available
222            .iter()
223            .map(|c| c.language_id.as_str())
224            .collect();
225
226        let mut missing = Vec::new();
227        for (lang, &(file_count, edge_count)) in languages_in_project {
228            // Skip plaintext — no LSP for that
229            if lang == "plaintext" {
230                continue;
231            }
232            // Merge JS into TS (tsserver handles both)
233            let check_lang = if lang == "javascript" { "typescript" } else { lang.as_str() };
234            if !available_langs.contains(check_lang) {
235                missing.push(LspMissingServer {
236                    language_id: lang.clone(),
237                    file_count,
238                    edge_count,
239                    install_command: Self::install_suggestion(lang),
240                });
241            }
242        }
243        missing.sort_by(|a, b| b.edge_count.cmp(&a.edge_count));
244        missing
245    }
246}
247
248fn which_exists(cmd: &str) -> bool {
249    Command::new("which")
250        .arg(cmd)
251        .stdout(Stdio::null())
252        .stderr(Stdio::null())
253        .status()
254        .map(|s| s.success())
255        .unwrap_or(false)
256}
257
258impl LspClient {
259    /// Start an LSP server process and perform the initialize handshake.
260    pub fn start(config: &LspServerConfig, root_dir: &Path) -> Result<Self> {
261        let root_dir = root_dir.canonicalize().context("canonicalize root_dir")?;
262        let root_uri = format!("file://{}", root_dir.display());
263
264        let mut process = Command::new(&config.command)
265            .args(&config.args)
266            .stdin(Stdio::piped())
267            .stdout(Stdio::piped())
268            .stderr(Stdio::piped())
269            .current_dir(&root_dir)
270            .spawn()
271            .with_context(|| format!("spawn LSP: {} {:?}", config.command, config.args))?;
272
273        let writer = process.stdin.take().context("take stdin")?;
274        let stdout = process.stdout.take().context("take stdout")?;
275        
276        // Spawn reader thread: reads LSP messages and sends them through a channel
277        let (msg_tx, msg_rx) = mpsc::channel();
278        std::thread::spawn(move || {
279            let mut reader = BufReader::new(stdout);
280            loop {
281                match read_lsp_message(&mut reader) {
282                    Ok(msg) => {
283                        if msg_tx.send(Ok(msg)).is_err() {
284                            break; // Receiver dropped
285                        }
286                    }
287                    Err(e) => {
288                        let _ = msg_tx.send(Err(e.to_string()));
289                        break;
290                    }
291                }
292            }
293        });
294        
295        // Spawn stderr reader thread to capture server errors  
296        let stderr = process.stderr.take().context("take stderr")?;
297        let _stderr_handle = std::thread::spawn(move || {
298            let reader = BufReader::new(stderr);
299            for line in reader.lines().flatten() {
300                if line.contains("error") || line.contains("Error") || line.contains("FATAL")
301                    || line.contains("WARN") || line.contains("panic")
302                {
303                    tracing::warn!("[LSP stderr] {}", line);
304                } else {
305                    tracing::debug!("[LSP stderr] {}", line);
306                }
307            }
308        });
309
310        let mut client = Self {
311            process,
312            msg_rx,
313            writer,
314            next_id: 1,
315            root_uri: root_uri.clone(),
316            _root_dir: root_dir,
317            _notifications: Vec::new(),
318            _capabilities: Value::Null,
319            timeout: Duration::from_secs(30),
320            opened_files: HashSet::new(),
321            progress_tokens: HashMap::new(),
322        };
323
324        // Initialize handshake — use a longer timeout because rust-analyzer
325        // can take minutes to process `initialize` for large workspaces
326        // (e.g. 587 crates). The normal 30s request timeout is not enough.
327        let init_params = json!({
328            "processId": std::process::id(),
329            "rootUri": root_uri,
330            "capabilities": {
331                "window": {
332                    "workDoneProgress": true
333                },
334                "textDocument": {
335                    "definition": {
336                        "dynamicRegistration": false,
337                        "linkSupport": false
338                    },
339                    "references": {
340                        "dynamicRegistration": false
341                    },
342                    "implementation": {
343                        "dynamicRegistration": false,
344                        "linkSupport": false
345                    },
346                    "synchronization": {
347                        "didOpen": true,
348                        "didClose": true
349                    }
350                }
351            },
352            "workspaceFolders": [{
353                "uri": root_uri,
354                "name": "root"
355            }]
356        });
357
358        let saved_timeout = client.timeout;
359        client.timeout = Duration::from_secs(600); // 10 min for initialize (large projects need full type analysis)
360        let resp = client
361            .send_request("initialize", init_params)
362            .context("LSP initialize")?;
363        client.timeout = saved_timeout;
364
365        if let Some(caps) = resp.get("capabilities") {
366            client._capabilities = caps.clone();
367        }
368
369        // Send initialized notification
370        client
371            .send_notification("initialized", json!({}))
372            .context("LSP initialized notification")?;
373
374        Ok(client)
375    }
376
377    /// Open a file in the language server (required before definition queries).
378    pub fn open_file(&mut self, rel_path: &str, content: &str, language_id: &str) -> Result<()> {
379        if self.opened_files.contains(rel_path) {
380            return Ok(());
381        }
382
383        let uri = format!("{}/{}", self.root_uri, rel_path);
384        self.send_notification(
385            "textDocument/didOpen",
386            json!({
387                "textDocument": {
388                    "uri": uri,
389                    "languageId": language_id,
390                    "version": 1,
391                    "text": content
392                }
393            }),
394        )?;
395
396        self.opened_files.insert(rel_path.to_string());
397        Ok(())
398    }
399
400    /// Close a file in the language server.
401    pub fn close_file(&mut self, rel_path: &str) -> Result<()> {
402        if !self.opened_files.remove(rel_path) {
403            return Ok(());
404        }
405
406        let uri = format!("{}/{}", self.root_uri, rel_path);
407        self.send_notification(
408            "textDocument/didClose",
409            json!({
410                "textDocument": {
411                    "uri": uri
412                }
413            }),
414        )?;
415
416        Ok(())
417    }
418
419    /// Wait for the language server to finish indexing the project.
420    ///
421    /// Uses the LSP `$/progress` notification protocol:
422    /// 1. Server sends `window/workDoneProgress/create` to register a progress token
423    /// 2. Server sends `$/progress` with `kind: "begin"` when work starts
424    /// 3. Server sends `$/progress` with `kind: "report"` for updates
425    /// 4. Server sends `$/progress` with `kind: "end"` when work completes
426    ///
427    /// We wait until all active progress tokens have reached "end".
428    /// Fallback: if no progress notifications arrive within `initial_wait`, we assume
429    /// the server either doesn't support progress or finished instantly.
430    /// Get a summary of progress tokens for debugging
431    pub fn progress_token_summary(&self) -> String {
432        if self.progress_tokens.is_empty() {
433            return "no tokens".to_string();
434        }
435        let active: Vec<_> = self.progress_tokens.iter()
436            .filter(|(_, status)| !status.ended && status.percentage != Some(100))
437            .map(|(token, status)| format!("{}({}%)", token, status.percentage.unwrap_or(0)))
438            .collect();
439        let done: Vec<_> = self.progress_tokens.iter()
440            .filter(|(_, status)| status.ended || status.percentage == Some(100))
441            .map(|(token, _)| token.clone())
442            .collect();
443        format!("{} done, {} active (active: {:?})", done.len(), active.len(), active)
444    }
445
446    pub fn wait_until_ready(&mut self, max_wait: Duration) -> Result<()> {
447        let deadline = Instant::now() + max_wait;
448        // After all tokens have received END, wait this long for new tokens to appear.
449        // rust-analyzer fires multiple phases (Roots Scanned → cachePriming → flycheck)
450        // with gaps between them, so we need a generous quiescence window.
451        let quiescence_duration = Duration::from_secs(15);
452        let initial_wait = Duration::from_secs(10);
453        let initial_deadline = Instant::now() + initial_wait;
454        let mut saw_any_progress = false;
455        let mut all_ended_since: Option<Instant> = None;
456
457        eprintln!("[LSP] Waiting for server indexing (max {}s, quiescence {}s)...", 
458            max_wait.as_secs(), quiescence_duration.as_secs());
459
460        loop {
461            let now = Instant::now();
462            if now > deadline {
463                let active: Vec<_> = self.progress_tokens.iter()
464                    .filter(|(_, status)| !status.ended)
465                    .map(|(token, status)| format!("{}({}%)", token, status.percentage.unwrap_or(0)))
466                    .collect();
467                if !active.is_empty() {
468                    eprintln!(
469                        "[LSP] Indexing timeout after {}s, {} tokens still active: {:?}",
470                        max_wait.as_secs(), active.len(), active
471                    );
472                }
473                break;
474            }
475
476            // If we haven't seen any progress and past initial wait, assume ready
477            if !saw_any_progress && now > initial_deadline {
478                eprintln!("[LSP] No progress notifications received in {}s, assuming ready", initial_wait.as_secs());
479                break;
480            }
481
482            // Check if ALL tokens have received END notification.
483            // Important: percentage==100 is NOT enough — rust-analyzer may fire new
484            // phases (cachePriming, flycheck) after Roots Scanned reaches 100%.
485            // Only END notifications are authoritative.
486            if saw_any_progress && !self.progress_tokens.is_empty() {
487                let all_ended = self.progress_tokens.values().all(|status| status.ended);
488                // Debug: show blocking tokens periodically
489                if !all_ended {
490                    let elapsed = max_wait.as_secs().saturating_sub(deadline.saturating_duration_since(now).as_secs());
491                    if elapsed % 15 == 0 && elapsed > 0 {
492                        for (name, status) in &self.progress_tokens {
493                            if !status.ended {
494                                eprintln!("[LSP] Waiting for: '{}' pct={:?}", name, status.percentage);
495                            }
496                        }
497                    }
498                }
499                if all_ended {
500                    match all_ended_since {
501                        None => {
502                            all_ended_since = Some(now);
503                            eprintln!("[LSP] All {} tokens ended, waiting {}s for new phases...", 
504                                self.progress_tokens.len(), quiescence_duration.as_secs());
505                        }
506                        Some(since) if now.duration_since(since) >= quiescence_duration => {
507                            eprintln!("[LSP] Quiescence achieved ({}s silence), server is ready ({} tokens seen)", 
508                                quiescence_duration.as_secs(), self.progress_tokens.len());
509                            break;
510                        }
511                        _ => {} // Still in quiescence wait
512                    }
513                } else {
514                    all_ended_since = None;
515                }
516            }
517
518            // Try to read a message with a short timeout
519            match self.read_message_timeout(Duration::from_millis(200)) {
520                Ok(Some(msg)) => {
521                    self.handle_server_message(&msg)?;
522                    if msg.get("method").and_then(|m| m.as_str()) == Some("$/progress") {
523                        saw_any_progress = true;
524                    }
525                }
526                Ok(None) => {
527                    // Timeout, no message available — continue polling
528                }
529                Err(e) => {
530                    eprintln!("[LSP] Error reading message during wait: {}", e);
531                    std::thread::sleep(Duration::from_millis(100));
532                }
533            }
534        }
535
536        Ok(())
537    }
538
539    /// Handle a server-initiated message (notification or request).
540    /// Processes `window/workDoneProgress/create` requests and `$/progress` notifications.
541    fn handle_server_message(&mut self, msg: &Value) -> Result<()> {
542        let method = match msg.get("method").and_then(|m| m.as_str()) {
543            Some(m) => m,
544            None => return Ok(()), // Not a notification/request
545        };
546
547        match method {
548            // Server requests to create a progress token — we must respond
549            "window/workDoneProgress/create" => {
550                if let Some(id) = msg.get("id") {
551                    // Extract token
552                    let token = msg.get("params")
553                        .and_then(|p| p.get("token"))
554                        .and_then(|t| {
555                            if let Some(s) = t.as_str() {
556                                Some(s.to_string())
557                            } else {
558                                t.as_u64().map(|n| n.to_string())
559                            }
560                        })
561                        .unwrap_or_default();
562
563                    if !token.is_empty() {
564                        tracing::debug!("[LSP] Progress token created: {}", token);
565                        self.progress_tokens.insert(token.clone(), ProgressTokenStatus::new());
566                    }
567
568                    // Respond with success (null result)
569                    let resp = json!({
570                        "jsonrpc": "2.0",
571                        "id": id,
572                        "result": null
573                    });
574                    self.write_message(&resp)?;
575                }
576            }
577
578            // Progress notification — track begin/report/end
579            "$/progress" => {
580                if let Some(params) = msg.get("params") {
581                    let token = params.get("token")
582                        .and_then(|t| {
583                            if let Some(s) = t.as_str() {
584                                Some(s.to_string())
585                            } else {
586                                t.as_u64().map(|n| n.to_string())
587                            }
588                        })
589                        .unwrap_or_default();
590
591                    let kind = params.get("value")
592                        .and_then(|v| v.get("kind"))
593                        .and_then(|k| k.as_str())
594                        .unwrap_or("");
595
596                    let title = params.get("value")
597                        .and_then(|v| v.get("title"))
598                        .and_then(|t| t.as_str())
599                        .unwrap_or("");
600
601                    let message = params.get("value")
602                        .and_then(|v| v.get("message"))
603                        .and_then(|m| m.as_str())
604                        .unwrap_or("");
605
606                    match kind {
607                        "begin" => {
608                            eprintln!("[DEBUG-PROGRESS] BEGIN token='{}' title='{}'", token, title);
609                            self.progress_tokens.insert(token, ProgressTokenStatus::new());
610                        }
611                        "report" => {
612                            let pct = params.get("value")
613                                .and_then(|v| v.get("percentage"))
614                                .and_then(|p| p.as_u64())
615                                .map(|p| p as u32);
616                            if let Some(pct_val) = pct {
617                                eprintln!("[DEBUG-PROGRESS] REPORT token='{}' {}% {}", token, pct_val, message);
618                            } else {
619                                eprintln!("[DEBUG-PROGRESS] REPORT token='{}' {}", token, message);
620                            }
621                            // Update percentage tracking
622                            if let Some(status) = self.progress_tokens.get_mut(&token) {
623                                if let Some(p) = pct {
624                                    status.percentage = Some(p);
625                                }
626                            } else {
627                                // Token not previously seen — create entry with percentage
628                                let mut status = ProgressTokenStatus::new();
629                                status.percentage = pct;
630                                self.progress_tokens.insert(token, status);
631                            }
632                        }
633                        "end" => {
634                            eprintln!("[DEBUG-PROGRESS] END token='{}' msg='{}'", token, message);
635                            self.progress_tokens.insert(token, ProgressTokenStatus::ended());
636                        }
637                        _ => {}
638                    }
639                }
640            }
641
642            // Other server requests we might need to handle
643            "client/registerCapability" => {
644                // Respond with success to capability registration requests
645                if let Some(id) = msg.get("id") {
646                    let resp = json!({
647                        "jsonrpc": "2.0",
648                        "id": id,
649                        "result": null
650                    });
651                    self.write_message(&resp)?;
652                }
653            }
654
655            _ => {
656                // Buffer other notifications
657                self._notifications.push(msg.clone());
658            }
659        }
660
661        Ok(())
662    }
663
664    /// Get definition location for a symbol at the given position.
665    /// Returns None if no definition found or definition is outside project.
666    pub fn get_definition(
667        &mut self,
668        rel_path: &str,
669        line: u32,
670        character: u32,
671    ) -> Result<Option<LspLocation>> {
672        let uri = format!("{}/{}", self.root_uri, rel_path);
673
674        let params = json!({
675            "textDocument": { "uri": uri },
676            "position": { "line": line, "character": character }
677        });
678
679        let resp = self.send_request("textDocument/definition", params)?;
680
681        // Debug counter for tracking removal reasons
682        static DEBUG_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
683        let count = DEBUG_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
684
685        // Response can be Location | Location[] | LocationLink[] | null
686        let locations = if resp.is_null() {
687            if count < 10 {
688                eprintln!("[DEBUG-DEF] NULL response for {}:{}:{}", rel_path, line, character);
689            }
690            return Ok(None);
691        } else if let Some(arr) = resp.as_array() {
692            let arr = arr.to_vec();
693            if count < 10 {
694                eprintln!("[DEBUG-DEF] Array response (len={}) for {}:{}:{}", arr.len(), rel_path, line, character);
695            }
696            arr
697        } else {
698            if count < 10 {
699                eprintln!("[DEBUG-DEF] Single response for {}:{}:{}", rel_path, line, character);
700            }
701            vec![resp]
702        };
703
704        if locations.is_empty() {
705            return Ok(None);
706        }
707
708        // Take the first location
709        let loc = &locations[0];
710
711        // Handle both Location and LocationLink formats
712        let (target_uri, target_line, target_char) =
713            if let Some(target_range) = loc.get("targetRange") {
714                // LocationLink format
715                let uri = loc
716                    .get("targetUri")
717                    .and_then(|v| v.as_str())
718                    .unwrap_or("");
719                let line = target_range
720                    .get("start")
721                    .and_then(|s| s.get("line"))
722                    .and_then(|l| l.as_u64())
723                    .unwrap_or(0) as u32;
724                let char = target_range
725                    .get("start")
726                    .and_then(|s| s.get("character"))
727                    .and_then(|c| c.as_u64())
728                    .unwrap_or(0) as u32;
729                (uri.to_string(), line, char)
730            } else {
731                // Location format
732                let uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
733                let line = loc
734                    .get("range")
735                    .and_then(|r| r.get("start"))
736                    .and_then(|s| s.get("line"))
737                    .and_then(|l| l.as_u64())
738                    .unwrap_or(0) as u32;
739                let char = loc
740                    .get("range")
741                    .and_then(|r| r.get("start"))
742                    .and_then(|s| s.get("character"))
743                    .and_then(|c| c.as_u64())
744                    .unwrap_or(0) as u32;
745                (uri.to_string(), line, char)
746            };
747
748        // Convert URI to relative path
749        let root_prefix = format!("{}/", self.root_uri);
750        if !target_uri.starts_with(&root_prefix) {
751            // Definition is outside project (stdlib, node_modules, etc.)
752            static OUTSIDE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
753            let oc = OUTSIDE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
754            if oc < 10 {
755                eprintln!("[DEBUG-DEF] OUTSIDE project: target_uri={}, root_prefix={}", target_uri, root_prefix);
756            }
757            return Ok(None);
758        }
759
760        let file_path = target_uri[root_prefix.len()..].to_string();
761
762        Ok(Some(LspLocation {
763            file_path,
764            line: target_line,
765            character: target_char,
766        }))
767    }
768
769    /// Parse a list of Location / LocationLink values from an LSP response,
770    /// filtering to locations within the project root.
771    fn parse_locations(&self, resp: Value) -> Vec<LspLocation> {
772        let raw = if resp.is_null() {
773            return Vec::new();
774        } else if let Some(arr) = resp.as_array() {
775            arr.to_vec()
776        } else {
777            vec![resp]
778        };
779
780        let root_prefix = format!("{}/", self.root_uri);
781        let mut results = Vec::new();
782
783        for loc in &raw {
784            // Handle both Location and LocationLink formats
785            let (target_uri, target_line, target_char) =
786                if let Some(target_range) = loc.get("targetRange") {
787                    // LocationLink format
788                    let uri = loc
789                        .get("targetUri")
790                        .and_then(|v| v.as_str())
791                        .unwrap_or("");
792                    let line = target_range
793                        .get("start")
794                        .and_then(|s| s.get("line"))
795                        .and_then(|l| l.as_u64())
796                        .unwrap_or(0) as u32;
797                    let ch = target_range
798                        .get("start")
799                        .and_then(|s| s.get("character"))
800                        .and_then(|c| c.as_u64())
801                        .unwrap_or(0) as u32;
802                    (uri.to_string(), line, ch)
803                } else {
804                    // Location format
805                    let uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
806                    let line = loc
807                        .get("range")
808                        .and_then(|r| r.get("start"))
809                        .and_then(|s| s.get("line"))
810                        .and_then(|l| l.as_u64())
811                        .unwrap_or(0) as u32;
812                    let ch = loc
813                        .get("range")
814                        .and_then(|r| r.get("start"))
815                        .and_then(|s| s.get("character"))
816                        .and_then(|c| c.as_u64())
817                        .unwrap_or(0) as u32;
818                    (uri.to_string(), line, ch)
819                };
820
821            // Convert URI to relative path, skip locations outside project
822            if !target_uri.starts_with(&root_prefix) {
823                continue;
824            }
825
826            let file_path = target_uri[root_prefix.len()..].to_string();
827            results.push(LspLocation {
828                file_path,
829                line: target_line,
830                character: target_char,
831            });
832        }
833
834        results
835    }
836
837    /// Find all references to the symbol at the given position.
838    /// Returns locations of all call sites / usages within the project.
839    /// `include_declaration` controls whether the definition itself is included.
840    pub fn get_references(
841        &mut self,
842        rel_path: &str,
843        line: u32,
844        character: u32,
845        include_declaration: bool,
846    ) -> Result<Vec<LspLocation>> {
847        let uri = format!("{}/{}", self.root_uri, rel_path);
848
849        let params = json!({
850            "textDocument": { "uri": uri },
851            "position": { "line": line, "character": character },
852            "context": { "includeDeclaration": include_declaration }
853        });
854
855        let resp = self.send_request("textDocument/references", params)?;
856        Ok(self.parse_locations(resp))
857    }
858
859    /// Find all implementations of a trait method or interface method at the given position.
860    /// Returns locations of all concrete implementations within the project.
861    pub fn get_implementations(
862        &mut self,
863        rel_path: &str,
864        line: u32,
865        character: u32,
866    ) -> Result<Vec<LspLocation>> {
867        let uri = format!("{}/{}", self.root_uri, rel_path);
868
869        let params = json!({
870            "textDocument": { "uri": uri },
871            "position": { "line": line, "character": character }
872        });
873
874        let resp = self.send_request("textDocument/implementation", params)?;
875        Ok(self.parse_locations(resp))
876    }
877
878    /// Graceful shutdown of the language server.
879    pub fn shutdown(mut self) -> Result<()> {
880        // Send shutdown request
881        let _ = self.send_request("shutdown", Value::Null);
882
883        // Send exit notification
884        let _ = self.send_notification("exit", Value::Null);
885
886        // Wait briefly for process to exit, then kill
887        std::thread::sleep(Duration::from_millis(200));
888        let _ = self.process.kill();
889        let _ = self.process.wait();
890
891        Ok(())
892    }
893
894    // ─── JSON-RPC Transport ────────────────────────────────────────
895
896    fn send_request(&mut self, method: &str, params: Value) -> Result<Value> {
897        let id = self.next_id;
898        self.next_id += 1;
899
900        let msg = json!({
901            "jsonrpc": "2.0",
902            "id": id,
903            "method": method,
904            "params": params
905        });
906
907        self.write_message(&msg)?;
908        self.read_response(id)
909    }
910
911    fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
912        let msg = json!({
913            "jsonrpc": "2.0",
914            "method": method,
915            "params": params
916        });
917
918        self.write_message(&msg)
919    }
920
921    fn write_message(&mut self, msg: &Value) -> Result<()> {
922        let body = serde_json::to_string(msg)?;
923        let header = format!("Content-Length: {}\r\n\r\n", body.len());
924
925        self.writer.write_all(header.as_bytes())?;
926        self.writer.write_all(body.as_bytes())?;
927        self.writer.flush()?;
928
929        Ok(())
930    }
931
932    fn read_response(&mut self, expected_id: u64) -> Result<Value> {
933        let deadline = Instant::now() + self.timeout;
934
935        loop {
936            let remaining = deadline.saturating_duration_since(Instant::now());
937            if remaining.is_zero() {
938                bail!("LSP response timeout for request id={}", expected_id);
939            }
940
941            let msg = match self.msg_rx.recv_timeout(remaining) {
942                Ok(Ok(msg)) => msg,
943                Ok(Err(e)) => bail!("LSP reader error: {}", e),
944                Err(mpsc::RecvTimeoutError::Timeout) => {
945                    bail!("LSP response timeout for request id={}", expected_id);
946                }
947                Err(mpsc::RecvTimeoutError::Disconnected) => {
948                    bail!("LSP server closed connection");
949                }
950            };
951
952            // Check if this is our response
953            if let Some(id) = msg.get("id") {
954                // It has an id — could be our response or a server request
955                if msg.get("method").is_some() {
956                    // Server request (has both id and method) — handle it
957                    self.handle_server_message(&msg)?;
958                    continue;
959                }
960
961                let msg_id = id.as_u64().unwrap_or(0);
962                if msg_id == expected_id {
963                    // Check for error
964                    if let Some(error) = msg.get("error") {
965                        let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
966                        let message = error
967                            .get("message")
968                            .and_then(|m| m.as_str())
969                            .unwrap_or("unknown error");
970                        bail!("LSP error (code {}): {}", code, message);
971                    }
972
973                    return Ok(msg.get("result").cloned().unwrap_or(Value::Null));
974                }
975            }
976
977            // It's a notification — handle progress, buffer others
978            if msg.get("method").is_some() {
979                self.handle_server_message(&msg)?;
980            }
981        }
982    }
983
984    /// Try to read a message with a timeout. Returns None if timeout expires.
985    fn read_message_timeout(&mut self, timeout: Duration) -> Result<Option<Value>> {
986        match self.msg_rx.recv_timeout(timeout) {
987            Ok(Ok(msg)) => Ok(Some(msg)),
988            Ok(Err(e)) => bail!("LSP reader error: {}", e),
989            Err(mpsc::RecvTimeoutError::Timeout) => Ok(None),
990            Err(mpsc::RecvTimeoutError::Disconnected) => {
991                bail!("LSP server closed connection");
992            }
993        }
994    }
995}
996
997/// Read one LSP JSON-RPC message from a buffered reader.
998/// This is a standalone function used by the reader thread.
999fn read_lsp_message(reader: &mut BufReader<std::process::ChildStdout>) -> Result<Value> {
1000    // Read headers until empty line
1001    let mut content_length: usize = 0;
1002    let mut header_line = String::new();
1003
1004    loop {
1005        header_line.clear();
1006        let bytes_read = reader.read_line(&mut header_line)?;
1007        if bytes_read == 0 {
1008            bail!("LSP server closed connection");
1009        }
1010
1011        let trimmed = header_line.trim();
1012        if trimmed.is_empty() {
1013            break;
1014        }
1015
1016        if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
1017            content_length = len_str
1018                .parse()
1019                .context("parse Content-Length")?;
1020        }
1021        // Ignore other headers (Content-Type, etc.)
1022    }
1023
1024    if content_length == 0 {
1025        bail!("Missing Content-Length header");
1026    }
1027
1028    // Read exactly content_length bytes
1029    let mut body = vec![0u8; content_length];
1030    reader.read_exact(&mut body)?;
1031
1032    let msg: Value = serde_json::from_slice(&body).context("parse LSP JSON body")?;
1033    Ok(msg)
1034}
1035
1036impl Drop for LspClient {
1037    fn drop(&mut self) {
1038        // Best-effort cleanup: kill the server process
1039        let _ = self.process.kill();
1040    }
1041}
1042
1043/// Map file extension to LSP language ID.
1044pub fn extension_to_language_id(ext: &str) -> &str {
1045    match ext {
1046        "ts" | "tsx" => "typescript",
1047        "js" | "jsx" => "javascript",
1048        "rs" => "rust",
1049        "py" => "python",
1050        _ => "plaintext",
1051    }
1052}
1053
1054/// Batch-open files for a language server, returning the count opened.
1055pub fn open_project_files(
1056    client: &mut LspClient,
1057    files: &[(String, String)], // (rel_path, content)
1058    language_id: &str,
1059) -> Result<usize> {
1060    let mut count = 0;
1061    for (rel_path, content) in files {
1062        client.open_file(rel_path, content, language_id)?;
1063        count += 1;
1064    }
1065    Ok(count)
1066}
1067
1068/// Incrementally refine an LSP client by notifying it of file changes from a FileDelta.
1069/// - Closes deleted files
1070/// - For modified files: close + re-open with new content
1071/// - Opens newly added files
1072/// Returns the count of files processed.
1073pub fn refine_files(
1074    client: &mut LspClient,
1075    delta: &super::code_graph::FileDelta,
1076    root_dir: &Path,
1077) -> Result<usize> {
1078    let mut processed: usize = 0;
1079
1080    // Close deleted files
1081    for rel_path in &delta.deleted {
1082        client.close_file(rel_path)?;
1083        processed += 1;
1084    }
1085
1086    // Modified files: close then re-open with new content
1087    for rel_path in &delta.modified {
1088        client.close_file(rel_path)?;
1089
1090        let abs_path = root_dir.join(rel_path);
1091        let content = std::fs::read_to_string(&abs_path)
1092            .with_context(|| format!("read modified file: {}", rel_path))?;
1093        let ext = Path::new(rel_path)
1094            .extension()
1095            .and_then(|e| e.to_str())
1096            .unwrap_or("");
1097        let lang_id = extension_to_language_id(ext);
1098        client.open_file(rel_path, &content, lang_id)?;
1099        processed += 1;
1100    }
1101
1102    // Open added files
1103    for rel_path in &delta.added {
1104        let abs_path = root_dir.join(rel_path);
1105        let content = std::fs::read_to_string(&abs_path)
1106            .with_context(|| format!("read added file: {}", rel_path))?;
1107        let ext = Path::new(rel_path)
1108            .extension()
1109            .and_then(|e| e.to_str())
1110            .unwrap_or("");
1111        let lang_id = extension_to_language_id(ext);
1112        client.open_file(rel_path, &content, lang_id)?;
1113        processed += 1;
1114    }
1115
1116    Ok(processed)
1117}
1118
1119/// Build a lookup table: (file_path, line) → node_id for resolving LSP definition targets.
1120pub fn build_definition_target_index(
1121    nodes: &[super::code_graph::CodeNode],
1122) -> HashMap<String, HashMap<u32, String>> {
1123    let mut index: HashMap<String, HashMap<u32, String>> = HashMap::new();
1124    for node in nodes {
1125        if let Some(line) = node.line {
1126            index
1127                .entry(node.file_path.clone())
1128                .or_default()
1129                .insert(line as u32, node.id.clone());
1130        }
1131    }
1132    index
1133}
1134
1135/// Find the closest node to a given line in a file.
1136/// LSP definition might point to line N, but our node might be at line N-1 or N+1
1137/// (due to decorators, doc comments, etc.).
1138pub fn find_closest_node(
1139    file_index: &HashMap<u32, String>,
1140    target_line: u32,
1141    tolerance: u32,
1142) -> Option<String> {
1143    // Exact match first
1144    if let Some(id) = file_index.get(&target_line) {
1145        return Some(id.clone());
1146    }
1147
1148    // Search within tolerance
1149    let mut best: Option<(u32, String)> = None;
1150    for (&line, id) in file_index {
1151        let dist = if line > target_line {
1152            line - target_line
1153        } else {
1154            target_line - line
1155        };
1156        if dist <= tolerance {
1157            if best.as_ref().map_or(true, |(d, _)| dist < *d) {
1158                best = Some((dist, id.clone()));
1159            }
1160        }
1161    }
1162
1163    best.map(|(_, id)| id)
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168    use super::*;
1169
1170    #[test]
1171    fn test_extension_to_language_id() {
1172        assert_eq!(extension_to_language_id("ts"), "typescript");
1173        assert_eq!(extension_to_language_id("tsx"), "typescript");
1174        assert_eq!(extension_to_language_id("js"), "javascript");
1175        assert_eq!(extension_to_language_id("rs"), "rust");
1176        assert_eq!(extension_to_language_id("py"), "python");
1177        assert_eq!(extension_to_language_id("go"), "plaintext");
1178    }
1179
1180    #[test]
1181    fn test_find_closest_node() {
1182        let mut index = HashMap::new();
1183        index.insert(10, "func_a".to_string());
1184        index.insert(20, "func_b".to_string());
1185        index.insert(30, "func_c".to_string());
1186
1187        // Exact match
1188        assert_eq!(
1189            find_closest_node(&index, 10, 3),
1190            Some("func_a".to_string())
1191        );
1192
1193        // Within tolerance
1194        assert_eq!(
1195            find_closest_node(&index, 11, 3),
1196            Some("func_a".to_string())
1197        );
1198        assert_eq!(
1199            find_closest_node(&index, 9, 3),
1200            Some("func_a".to_string())
1201        );
1202
1203        // Out of tolerance
1204        assert_eq!(find_closest_node(&index, 15, 3), None);
1205
1206        // Closest wins
1207        assert_eq!(
1208            find_closest_node(&index, 19, 3),
1209            Some("func_b".to_string())
1210        );
1211    }
1212
1213    #[test]
1214    fn test_detect_available_servers() {
1215        // This test just verifies detect doesn't panic
1216        let configs = LspServerConfig::detect_available();
1217        // On CI, might be empty; on dev machines, usually has tsserver
1218        for config in &configs {
1219            assert!(!config.command.is_empty());
1220            assert!(!config.extensions.is_empty());
1221        }
1222    }
1223
1224    #[test]
1225    fn test_lsp_location_format() {
1226        let loc = LspLocation {
1227            file_path: "src/main.ts".to_string(),
1228            line: 42,
1229            character: 8,
1230        };
1231        assert_eq!(loc.file_path, "src/main.ts");
1232        assert_eq!(loc.line, 42);
1233    }
1234
1235    #[test]
1236    fn test_install_suggestion_known_languages() {
1237        assert!(LspServerConfig::install_suggestion("rust").contains("rust-analyzer"));
1238        assert!(LspServerConfig::install_suggestion("typescript").contains("typescript-language-server"));
1239        assert!(LspServerConfig::install_suggestion("javascript").contains("typescript-language-server"));
1240        assert!(LspServerConfig::install_suggestion("python").contains("pyright"));
1241    }
1242
1243    #[test]
1244    fn test_install_suggestion_unknown_language() {
1245        let suggestion = LspServerConfig::install_suggestion("cobol");
1246        assert!(suggestion.contains("no known LSP"));
1247    }
1248
1249    #[test]
1250    fn test_check_coverage_all_covered() {
1251        let configs = vec![
1252            LspServerConfig {
1253                command: "rust-analyzer".to_string(),
1254                args: vec![],
1255                language_id: "rust".to_string(),
1256                extensions: vec!["rs".to_string()],
1257            },
1258        ];
1259        let mut langs = std::collections::HashMap::new();
1260        langs.insert("rust".to_string(), (10usize, 50usize));
1261        let missing = LspServerConfig::check_coverage(&configs, &langs);
1262        assert!(missing.is_empty());
1263    }
1264
1265    #[test]
1266    fn test_check_coverage_missing_server() {
1267        let configs = vec![]; // No LSP servers available
1268        let mut langs = std::collections::HashMap::new();
1269        langs.insert("rust".to_string(), (10, 50));
1270        langs.insert("python".to_string(), (5, 20));
1271        let missing = LspServerConfig::check_coverage(&configs, &langs);
1272        assert_eq!(missing.len(), 2);
1273        // Sorted by edge_count descending
1274        assert_eq!(missing[0].language_id, "rust");
1275        assert_eq!(missing[0].edge_count, 50);
1276        assert_eq!(missing[1].language_id, "python");
1277        assert_eq!(missing[1].edge_count, 20);
1278        assert!(missing[0].install_command.contains("rust-analyzer"));
1279        assert!(missing[1].install_command.contains("pyright"));
1280    }
1281
1282    #[test]
1283    fn test_check_coverage_js_covered_by_tsserver() {
1284        let configs = vec![
1285            LspServerConfig {
1286                command: "npx".to_string(),
1287                args: vec!["typescript-language-server".to_string()],
1288                language_id: "typescript".to_string(),
1289                extensions: vec!["ts".to_string(), "js".to_string()],
1290            },
1291        ];
1292        let mut langs = std::collections::HashMap::new();
1293        langs.insert("javascript".to_string(), (8, 30));
1294        let missing = LspServerConfig::check_coverage(&configs, &langs);
1295        // JS should be covered by tsserver
1296        assert!(missing.is_empty());
1297    }
1298
1299    #[test]
1300    fn test_check_coverage_skips_plaintext() {
1301        let configs = vec![];
1302        let mut langs = std::collections::HashMap::new();
1303        langs.insert("plaintext".to_string(), (100, 0));
1304        let missing = LspServerConfig::check_coverage(&configs, &langs);
1305        assert!(missing.is_empty());
1306    }
1307
1308    #[test]
1309    fn test_lsp_missing_server_fields() {
1310        let m = LspMissingServer {
1311            language_id: "rust".to_string(),
1312            file_count: 42,
1313            edge_count: 1500,
1314            install_command: "rustup component add rust-analyzer".to_string(),
1315        };
1316        assert_eq!(m.language_id, "rust");
1317        assert_eq!(m.file_count, 42);
1318        assert_eq!(m.edge_count, 1500);
1319    }
1320}