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, Default)]
69pub struct LspRefinementStats {
70 pub total_call_edges: usize,
72 pub refined: usize,
74 pub removed: usize,
76 pub failed: usize,
78 pub skipped: usize,
80 pub languages_used: Vec<String>,
82 pub references_queried: usize,
84 pub references_edges_added: usize,
86 pub implementations_queried: usize,
88 pub implementation_edges_added: usize,
90}
91
92#[derive(Debug, Default)]
94pub struct LspEnrichmentStats {
95 pub nodes_queried: usize,
97 pub new_edges_added: usize,
99 pub already_existed: usize,
101 pub failed: usize,
103 pub languages_used: Vec<String>,
105}
106
107#[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 pub fn detect_available() -> Vec<Self> {
119 let mut configs = Vec::new();
120
121 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 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 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 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 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; }
226 }
227 Err(e) => {
228 let _ = msg_tx.send(Err(e.to_string()));
229 break;
230 }
231 }
232 }
233 });
234
235 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 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); 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 client
311 .send_notification("initialized", json!({}))
312 .context("LSP initialized notification")?;
313
314 Ok(client)
315 }
316
317 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 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 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 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 !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 if saw_any_progress && !self.progress_tokens.is_empty() {
427 let all_ended = self.progress_tokens.values().all(|status| status.ended);
428 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 _ => {} }
453 } else {
454 all_ended_since = None;
455 }
456 }
457
458 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 }
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 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(()), };
486
487 match method {
488 "window/workDoneProgress/create" => {
490 if let Some(id) = msg.get("id") {
491 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 let resp = json!({
510 "jsonrpc": "2.0",
511 "id": id,
512 "result": null
513 });
514 self.write_message(&resp)?;
515 }
516 }
517
518 "$/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 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 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 "client/registerCapability" => {
584 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 self._notifications.push(msg.clone());
598 }
599 }
600
601 Ok(())
602 }
603
604 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 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 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 let loc = &locations[0];
650
651 let (target_uri, target_line, target_char) =
653 if let Some(target_range) = loc.get("targetRange") {
654 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 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 let root_prefix = format!("{}/", self.root_uri);
690 if !target_uri.starts_with(&root_prefix) {
691 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 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 let (target_uri, target_line, target_char) =
726 if let Some(target_range) = loc.get("targetRange") {
727 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 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 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 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 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 pub fn shutdown(mut self) -> Result<()> {
820 let _ = self.send_request("shutdown", Value::Null);
822
823 let _ = self.send_notification("exit", Value::Null);
825
826 std::thread::sleep(Duration::from_millis(200));
828 let _ = self.process.kill();
829 let _ = self.process.wait();
830
831 Ok(())
832 }
833
834 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 if let Some(id) = msg.get("id") {
894 if msg.get("method").is_some() {
896 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 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 if msg.get("method").is_some() {
919 self.handle_server_message(&msg)?;
920 }
921 }
922 }
923
924 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
937fn read_lsp_message(reader: &mut BufReader<std::process::ChildStdout>) -> Result<Value> {
940 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 }
963
964 if content_length == 0 {
965 bail!("Missing Content-Length header");
966 }
967
968 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 let _ = self.process.kill();
980 }
981}
982
983pub 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
994pub fn open_project_files(
996 client: &mut LspClient,
997 files: &[(String, String)], 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
1008pub 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
1024pub fn find_closest_node(
1028 file_index: &HashMap<u32, String>,
1029 target_line: u32,
1030 tolerance: u32,
1031) -> Option<String> {
1032 if let Some(id) = file_index.get(&target_line) {
1034 return Some(id.clone());
1035 }
1036
1037 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 assert_eq!(
1078 find_closest_node(&index, 10, 3),
1079 Some("func_a".to_string())
1080 );
1081
1082 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 assert_eq!(find_closest_node(&index, 15, 3), None);
1094
1095 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 let configs = LspServerConfig::detect_available();
1106 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}