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