1use 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#[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
35pub struct LspClient {
37 process: Child,
38 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 _notifications: Vec<Value>,
46 _capabilities: Value,
48 timeout: Duration,
50 opened_files: HashSet<String>,
52 progress_tokens: HashMap<String, ProgressTokenStatus>,
54}
55
56#[derive(Debug, Clone)]
58pub struct LspLocation {
59 pub file_path: String,
61 pub line: u32,
63 pub character: u32,
65}
66
67#[derive(Debug, Clone)]
69pub struct LspMissingServer {
70 pub language_id: String,
72 pub file_count: usize,
74 pub edge_count: usize,
76 pub install_command: String,
78}
79
80#[derive(Debug, Default)]
82pub struct LspRefinementStats {
83 pub total_call_edges: usize,
85 pub refined: usize,
87 pub removed: usize,
89 pub failed: usize,
91 pub skipped: usize,
93 pub languages_used: Vec<String>,
95 pub missing_servers: Vec<LspMissingServer>,
97 pub references_queried: usize,
99 pub references_edges_added: usize,
101 pub implementations_queried: usize,
103 pub implementation_edges_added: usize,
105}
106
107#[derive(Debug, Default)]
109pub struct LspEnrichmentStats {
110 pub nodes_queried: usize,
112 pub new_edges_added: usize,
114 pub already_existed: usize,
116 pub failed: usize,
118 pub languages_used: Vec<String>,
120}
121
122#[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 pub fn detect_available() -> Vec<Self> {
134 let mut configs = Vec::new();
135
136 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 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 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 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 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 if lang == "plaintext" {
230 continue;
231 }
232 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 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 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; }
286 }
287 Err(e) => {
288 let _ = msg_tx.send(Err(e.to_string()));
289 break;
290 }
291 }
292 }
293 });
294
295 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 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); 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 client
371 .send_notification("initialized", json!({}))
372 .context("LSP initialized notification")?;
373
374 Ok(client)
375 }
376
377 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 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 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 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 !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 if saw_any_progress && !self.progress_tokens.is_empty() {
487 let all_ended = self.progress_tokens.values().all(|status| status.ended);
488 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 _ => {} }
513 } else {
514 all_ended_since = None;
515 }
516 }
517
518 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 }
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 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(()), };
546
547 match method {
548 "window/workDoneProgress/create" => {
550 if let Some(id) = msg.get("id") {
551 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 let resp = json!({
570 "jsonrpc": "2.0",
571 "id": id,
572 "result": null
573 });
574 self.write_message(&resp)?;
575 }
576 }
577
578 "$/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 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 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 "client/registerCapability" => {
644 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 self._notifications.push(msg.clone());
658 }
659 }
660
661 Ok(())
662 }
663
664 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 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 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 let loc = &locations[0];
710
711 let (target_uri, target_line, target_char) =
713 if let Some(target_range) = loc.get("targetRange") {
714 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 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 let root_prefix = format!("{}/", self.root_uri);
750 if !target_uri.starts_with(&root_prefix) {
751 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 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 let (target_uri, target_line, target_char) =
786 if let Some(target_range) = loc.get("targetRange") {
787 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 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 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 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 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 pub fn shutdown(mut self) -> Result<()> {
880 let _ = self.send_request("shutdown", Value::Null);
882
883 let _ = self.send_notification("exit", Value::Null);
885
886 std::thread::sleep(Duration::from_millis(200));
888 let _ = self.process.kill();
889 let _ = self.process.wait();
890
891 Ok(())
892 }
893
894 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 if let Some(id) = msg.get("id") {
954 if msg.get("method").is_some() {
956 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 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 if msg.get("method").is_some() {
979 self.handle_server_message(&msg)?;
980 }
981 }
982 }
983
984 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
997fn read_lsp_message(reader: &mut BufReader<std::process::ChildStdout>) -> Result<Value> {
1000 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 }
1023
1024 if content_length == 0 {
1025 bail!("Missing Content-Length header");
1026 }
1027
1028 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 let _ = self.process.kill();
1040 }
1041}
1042
1043pub 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
1054pub fn open_project_files(
1056 client: &mut LspClient,
1057 files: &[(String, String)], 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
1068pub 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 for rel_path in &delta.deleted {
1082 client.close_file(rel_path)?;
1083 processed += 1;
1084 }
1085
1086 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 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
1119pub 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
1135pub fn find_closest_node(
1139 file_index: &HashMap<u32, String>,
1140 target_line: u32,
1141 tolerance: u32,
1142) -> Option<String> {
1143 if let Some(id) = file_index.get(&target_line) {
1145 return Some(id.clone());
1146 }
1147
1148 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 assert_eq!(
1189 find_closest_node(&index, 10, 3),
1190 Some("func_a".to_string())
1191 );
1192
1193 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 assert_eq!(find_closest_node(&index, 15, 3), None);
1205
1206 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 let configs = LspServerConfig::detect_available();
1217 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![]; 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 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 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}