1use crate::analyzer::Analyzer;
2use std::cell::RefCell;
3use std::fmt::Write as _;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
7use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
8use std::thread::JoinHandle;
9use std::time::{Duration, Instant};
10
11use crate::api::{PropagationResult, PropagationSource};
12use crate::treesitter::TreeSitterAnalyzer;
13
14const LSP_INITIALIZE_TIMEOUT: Duration = Duration::from_mins(2);
15const LSP_REQUEST_TIMEOUT: Duration = Duration::from_mins(2);
16const LSP_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
17
18type LspResponse = Result<serde_json::Value, String>;
19type SpawnedLsp = (
20 Child,
21 BufWriter<ChildStdin>,
22 Receiver<LspResponse>,
23 JoinHandle<()>,
24);
25
26pub trait LspServerConfig: Send + Sync {
34 fn server_name(&self) -> &str;
36
37 fn binary_name(&self) -> &str;
39
40 fn language_id(&self) -> &str;
42
43 fn cached_binary_name(&self) -> String;
45
46 fn download_url(&self) -> Option<String>;
48
49 fn init_params_extra(&self, _root_uri: &str) -> serde_json::Value {
51 serde_json::json!({})
52 }
53
54 fn post_init_delay_secs(&self) -> u64 {
56 3
57 }
58
59 fn spawn_args(&self) -> Vec<String> {
61 vec![]
62 }
63
64 fn install_command(&self) -> Option<(String, Vec<String>)> {
68 None
69 }
70
71 fn setup_hints(&self) -> String {
74 format!(
75 "LSP server '{}' (binary: '{}') for language '{}'. ",
76 self.server_name(),
77 self.binary_name(),
78 self.language_id()
79 )
80 }
81}
82
83pub struct RustAnalyzerConfig;
86
87const RA_VERSION: &str = "2025-05-05";
88
89impl LspServerConfig for RustAnalyzerConfig {
90 fn server_name(&self) -> &'static str {
91 "rust-analyzer"
92 }
93 fn binary_name(&self) -> &'static str {
94 "rust-analyzer"
95 }
96 fn language_id(&self) -> &'static str {
97 "rust"
98 }
99 fn cached_binary_name(&self) -> String {
100 format!("rust-analyzer-{RA_VERSION}")
101 }
102 fn download_url(&self) -> Option<String> {
103 Some(format!(
104 "https://github.com/rust-lang/rust-analyzer/releases/download/{RA_VERSION}/rust-analyzer-x86_64-apple-darwin"
105 ))
106 }
107
108 fn setup_hints(&self) -> String {
109 "For Rust: rust-analyzer is auto-downloaded by scope-engine. No manual setup needed."
110 .to_string()
111 }
112}
113
114pub struct PyrightConfig;
117
118impl LspServerConfig for PyrightConfig {
119 fn server_name(&self) -> &'static str {
120 "pyright-langserver"
121 }
122 fn binary_name(&self) -> &'static str {
123 "pyright-langserver"
124 }
125 fn language_id(&self) -> &'static str {
126 "python"
127 }
128 fn cached_binary_name(&self) -> String {
129 "pyright-langserver".to_string()
130 }
131 fn download_url(&self) -> Option<String> {
132 None
133 } fn spawn_args(&self) -> Vec<String> {
135 vec!["--stdio".to_string()]
136 }
137 fn post_init_delay_secs(&self) -> u64 {
138 2
139 }
140
141 fn setup_hints(&self) -> String {
142 "For Python: install pyright-langserver via 'npm install -g pyright' or 'pip install pyright'.".to_string()
143 }
144}
145
146pub struct TsJsConfig;
149
150impl LspServerConfig for TsJsConfig {
151 fn server_name(&self) -> &'static str {
152 "typescript-language-server"
153 }
154 fn binary_name(&self) -> &'static str {
155 "typescript-language-server"
156 }
157 fn language_id(&self) -> &'static str {
158 "typescript"
159 }
160 fn cached_binary_name(&self) -> String {
161 "typescript-language-server".to_string()
162 }
163 fn download_url(&self) -> Option<String> {
164 None
165 } fn spawn_args(&self) -> Vec<String> {
167 vec!["--stdio".to_string()]
168 }
169 fn post_init_delay_secs(&self) -> u64 {
170 3
171 }
172
173 fn setup_hints(&self) -> String {
174 "For TypeScript/JavaScript: install typescript-language-server via 'npm install -g typescript-language-server typescript'.".to_string()
175 }
176}
177
178const GOPLS_VERSION: &str = "v0.21.1";
181
182pub struct GoplsConfig;
183
184impl LspServerConfig for GoplsConfig {
185 fn server_name(&self) -> &'static str {
186 "gopls"
187 }
188 fn binary_name(&self) -> &'static str {
189 "gopls"
190 }
191 fn language_id(&self) -> &'static str {
192 "go"
193 }
194 fn cached_binary_name(&self) -> String {
195 format!("gopls-{GOPLS_VERSION}")
196 }
197 fn download_url(&self) -> Option<String> {
198 None
199 }
200 fn spawn_args(&self) -> Vec<String> {
201 vec!["serve".to_string()]
202 }
203 fn post_init_delay_secs(&self) -> u64 {
204 4
205 }
206 fn install_command(&self) -> Option<(String, Vec<String>)> {
207 Some((
208 "go".to_string(),
209 vec![
210 "install".to_string(),
211 format!("golang.org/x/tools/gopls@{GOPLS_VERSION}"),
212 ],
213 ))
214 }
215}
216
217pub struct JdtlsConfig;
220
221impl LspServerConfig for JdtlsConfig {
222 fn server_name(&self) -> &'static str {
223 "jdtls"
224 }
225 fn binary_name(&self) -> &'static str {
226 "jdtls"
227 }
228 fn language_id(&self) -> &'static str {
229 "java"
230 }
231 fn cached_binary_name(&self) -> String {
232 "jdtls".to_string()
233 }
234 fn download_url(&self) -> Option<String> {
235 None
236 }
237 fn spawn_args(&self) -> Vec<String> {
238 vec![]
239 }
240 fn post_init_delay_secs(&self) -> u64 {
241 5
242 }
243
244 fn setup_hints(&self) -> String {
245 "For Java: install Eclipse JDT Language Server (jdtls). On macOS: 'brew install eclipse-jdtls'. On Linux: download from https://download.eclipse.org/jdtls/snapshots/ and add 'jdtls' to PATH. Requires JDK 17+.".to_string()
246 }
247}
248
249struct LspClientInner {
253 process: Option<Child>,
254 stdin_writer: Option<BufWriter<ChildStdin>>,
255 response_receiver: Option<Receiver<LspResponse>>,
256 reader_thread: Option<JoinHandle<()>>,
257 next_id: u64,
258 initialized: bool,
259 language_id: String,
261}
262
263pub struct LspClient {
275 inner: RefCell<LspClientInner>,
276}
277
278struct BufWriter<W: Write>(W);
280
281impl<W: Write> Write for BufWriter<W> {
282 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
283 self.0.write(buf)
284 }
285 fn flush(&mut self) -> std::io::Result<()> {
286 self.0.flush()
287 }
288}
289
290impl LspClient {
291 pub fn new(project_root: &Path, config: &dyn LspServerConfig) -> Self {
292 let project_root = project_root.to_path_buf();
293 let language_id = config.language_id().to_string();
294
295 let binary_path = match Self::locate_or_download(config) {
296 Ok(p) => p,
297 Err(e) => {
298 eprintln!(
299 "[scope-engine/lsp] cannot locate {}: {e}",
300 config.server_name()
301 );
302 return Self {
303 inner: RefCell::new(LspClientInner {
304 process: None,
305 stdin_writer: None,
306 response_receiver: None,
307 reader_thread: None,
308 next_id: 0,
309 initialized: false,
310 language_id,
311 }),
312 };
313 }
314 };
315
316 match Self::spawn_and_initialize(&binary_path, &project_root, config) {
317 Ok((process, stdin_w, response_receiver, reader_thread)) => Self {
318 inner: RefCell::new(LspClientInner {
319 process: Some(process),
320 stdin_writer: Some(stdin_w),
321 response_receiver: Some(response_receiver),
322 reader_thread: Some(reader_thread),
323 next_id: 1,
324 initialized: true,
325 language_id,
326 }),
327 },
328 Err(e) => {
329 eprintln!(
330 "[scope-engine/lsp] failed to spawn/initialize {}: {e}",
331 config.server_name()
332 );
333 Self {
334 inner: RefCell::new(LspClientInner {
335 process: None,
336 stdin_writer: None,
337 response_receiver: None,
338 reader_thread: None,
339 next_id: 0,
340 initialized: false,
341 language_id,
342 }),
343 }
344 }
345 }
346 }
347
348 fn find_binary_on_path(binary_name: &str) -> Option<PathBuf> {
351 let output = Command::new("which").arg(binary_name).output().ok()?;
352 if !output.status.success() {
353 return None;
354 }
355 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
356 (!path.is_empty()).then(|| PathBuf::from(path))
357 }
358
359 fn find_go_binary(config: &dyn LspServerConfig) -> Option<PathBuf> {
360 let candidate_dirs = [
361 std::env::var("GOBIN").ok().map(PathBuf::from),
362 std::env::var("GOPATH")
363 .ok()
364 .map(|go_path| PathBuf::from(go_path).join("bin")),
365 Command::new("go")
366 .args(["env", "GOPATH"])
367 .output()
368 .ok()
369 .map(|output| {
370 PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()).join("bin")
371 }),
372 Command::new("go")
373 .args(["env", "GOBIN"])
374 .output()
375 .ok()
376 .and_then(|output| {
377 let directory = String::from_utf8_lossy(&output.stdout).trim().to_string();
378 (!directory.is_empty()).then(|| PathBuf::from(directory))
379 }),
380 ];
381 candidate_dirs.into_iter().flatten().find_map(|directory| {
382 let candidate = directory.join(config.binary_name());
383 candidate.is_file().then_some(candidate)
384 })
385 }
386
387 fn install_server(config: &dyn LspServerConfig) -> Result<PathBuf, String> {
388 let (command, arguments) = config.install_command().ok_or_else(|| {
389 format!(
390 "{} not found on PATH and no download URL or install command configured",
391 config.server_name()
392 )
393 })?;
394 eprintln!(
395 "[scope-engine/lsp] attempting to install {} via: {} {}",
396 config.server_name(),
397 command,
398 arguments.join(" ")
399 );
400 let output = Command::new(&command)
401 .args(&arguments)
402 .output()
403 .map_err(|error| {
404 format!(
405 "failed to run install command '{} {}': {error}",
406 command,
407 arguments.join(" ")
408 )
409 })?;
410 if !output.status.success() {
411 return Err(format!(
412 "install command for {} failed: {}",
413 config.server_name(),
414 String::from_utf8_lossy(&output.stderr)
415 ));
416 }
417 if let Some(path) = Self::find_binary_on_path(config.binary_name()) {
418 return Ok(path);
419 }
420 if let Some(path) = Self::find_go_binary(config) {
421 return Ok(path);
422 }
423 Err(format!(
424 "{} was installed via '{}' but could not be found on PATH, GOPATH/bin, or GOBIN",
425 config.server_name(),
426 command
427 ))
428 }
429
430 fn locate_or_download(config: &dyn LspServerConfig) -> Result<PathBuf, String> {
431 if let Some(path) = Self::find_binary_on_path(config.binary_name()) {
432 eprintln!(
433 "[scope-engine/lsp] found {} on PATH: {}",
434 config.server_name(),
435 path.display()
436 );
437 return Ok(path);
438 }
439
440 let cache_dir = Self::cache_dir()?;
441 let cached = cache_dir.join(config.cached_binary_name());
442 if cached.is_file() {
443 eprintln!(
444 "[scope-engine/lsp] found cached {}: {}",
445 config.server_name(),
446 cached.display()
447 );
448 return Ok(cached);
449 }
450
451 config.download_url().map_or_else(
452 || Self::install_server(config),
453 |url| Self::download_binary(&cache_dir, &cached, &url, config.server_name()),
454 )
455 }
456
457 fn cache_dir() -> Result<PathBuf, String> {
458 let base =
459 dirs::cache_dir().ok_or_else(|| "cannot determine cache directory".to_string())?;
460 let dir = base.join("daat-locus").join("lsp-binaries");
461 std::fs::create_dir_all(&dir)
462 .map_err(|e| format!("cannot create cache dir {}: {e}", dir.display()))?;
463 Ok(dir)
464 }
465
466 #[cfg(feature = "download-ra")]
467 fn download_binary(
468 cache_dir: &Path,
469 target: &Path,
470 url: &str,
471 name: &str,
472 ) -> Result<PathBuf, String> {
473 eprintln!("[scope-engine/lsp] downloading {name}...");
474 let tmp = cache_dir.join("download.tmp");
475 let mut resp = reqwest::blocking::Client::builder()
476 .timeout(std::time::Duration::from_mins(2))
477 .build()
478 .map_err(|e| format!("HTTP client build failed: {e}"))?
479 .get(url)
480 .send()
481 .map_err(|e| format!("download failed: {e}"))?;
482
483 if !resp.status().is_success() {
484 return Err(format!("download returned HTTP {}", resp.status()));
485 }
486
487 let mut file =
488 std::fs::File::create(&tmp).map_err(|e| format!("cannot create tmp file: {e}"))?;
489 resp.copy_to(&mut file)
490 .map_err(|e| format!("download write failed: {e}"))?;
491 drop(file);
492
493 std::fs::rename(&tmp, target)
494 .map_err(|e| format!("cannot rename tmp to final path: {e}"))?;
495
496 #[cfg(unix)]
497 {
498 use std::os::unix::fs::PermissionsExt;
499 std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o755))
500 .map_err(|e| format!("cannot chmod: {e}"))?;
501 }
502
503 eprintln!(
504 "[scope-engine/lsp] downloaded {} to {}",
505 name,
506 target.display()
507 );
508 Ok(target.to_path_buf())
509 }
510
511 #[cfg(not(feature = "download-ra"))]
512 fn download_binary(
513 _cache_dir: &Path,
514 _target: &Path,
515 _url: &str,
516 name: &str,
517 ) -> Result<PathBuf, String> {
518 Err(format!(
519 "{name} download not available (feature 'download-ra' disabled)"
520 ))
521 }
522
523 fn spawn_and_initialize(
526 binary_path: &Path,
527 project_root: &Path,
528 config: &dyn LspServerConfig,
529 ) -> Result<SpawnedLsp, String> {
530 let mut child = Command::new(binary_path)
531 .args(config.spawn_args())
532 .current_dir(project_root)
533 .stdin(Stdio::piped())
534 .stdout(Stdio::piped())
535 .stderr(Stdio::null())
536 .spawn()
537 .map_err(|e| format!("cannot spawn {}: {e}", config.server_name()))?;
538
539 let stdin = child.stdin.take().ok_or("cannot take stdin")?;
540 let stdout = child.stdout.take().ok_or("cannot take stdout")?;
541
542 let mut writer = BufWriter(stdin);
543 let mut reader = std::io::BufReader::new(stdout);
544 let (response_sender, response_receiver) = mpsc::channel();
545 let reader_thread = std::thread::spawn(move || {
546 loop {
547 let response = Self::read_message(&mut reader);
548 let failed = response.is_err();
549 if response_sender.send(response).is_err() || failed {
550 break;
551 }
552 }
553 });
554
555 let root_uri = path_to_file_uri(project_root);
557 let mut init_params = serde_json::json!({
558 "rootUri": root_uri,
559 "capabilities": {},
560 });
561 let extra = config.init_params_extra(&root_uri);
563 if let serde_json::Value::Object(extra_map) = extra
564 && let serde_json::Value::Object(ref mut params_map) = init_params
565 {
566 for (k, v) in extra_map {
567 params_map.insert(k, v);
568 }
569 }
570
571 let resp = Self::send_request_raw(
572 &mut writer,
573 &response_receiver,
574 0,
575 "initialize",
576 &init_params,
577 LSP_INITIALIZE_TIMEOUT,
578 )
579 .map_err(|e| {
580 let _ = child.kill();
581 format!("initialize failed: {e}")
582 })?;
583
584 if let Some(err) = resp.get("error") {
585 let _ = child.kill();
586 return Err(format!("initialize error: {err}"));
587 }
588
589 Self::send_notification(&mut writer, "initialized", &serde_json::json!({})).map_err(
591 |e| {
592 let _ = child.kill();
593 format!("initialized notification failed: {e}")
594 },
595 )?;
596
597 eprintln!(
598 "[scope-engine/lsp] {} initialized for {}",
599 config.server_name(),
600 project_root.display()
601 );
602 std::thread::sleep(std::time::Duration::from_secs(
603 config.post_init_delay_secs(),
604 ));
605 Ok((child, writer, response_receiver, reader_thread))
606 }
607
608 pub fn notify_did_open(&self, file_path: &Path, text: &str) {
611 let mut inner = self.inner.borrow_mut();
612 if !inner.initialized {
613 return;
614 }
615 let uri = path_to_file_uri(file_path);
616 let lang_id = inner.language_id.clone();
617 let params = serde_json::json!({
618 "textDocument": {
619 "uri": uri,
620 "languageId": lang_id,
621 "version": 0,
622 "text": text,
623 }
624 });
625 if let Some(ref mut writer) = inner.stdin_writer {
626 let _ = Self::send_notification(writer, "textDocument/didOpen", ¶ms);
627 }
628 }
629
630 pub fn notify_did_change(&self, file_path: &Path, version: i32, text: &str) {
631 let mut inner = self.inner.borrow_mut();
632 if !inner.initialized {
633 return;
634 }
635 let uri = path_to_file_uri(file_path);
636 let params = serde_json::json!({
637 "textDocument": { "uri": uri, "version": version },
638 "contentChanges": [{ "text": text }]
639 });
640 if let Some(ref mut writer) = inner.stdin_writer {
641 let _ = Self::send_notification(writer, "textDocument/didChange", ¶ms);
642 }
643 }
644
645 pub fn notify_did_close(&self, file_path: &Path) {
646 let mut inner = self.inner.borrow_mut();
647 if !inner.initialized {
648 return;
649 }
650 let uri = path_to_file_uri(file_path);
651 let params = serde_json::json!({
652 "textDocument": { "uri": uri }
653 });
654 if let Some(ref mut writer) = inner.stdin_writer {
655 let _ = Self::send_notification(writer, "textDocument/didClose", ¶ms);
656 }
657 }
658
659 fn request_references(
662 inner: &mut LspClientInner,
663 params: &serde_json::Value,
664 ) -> Option<Vec<serde_json::Value>> {
665 let (Some(writer), Some(response_receiver)) =
666 (&mut inner.stdin_writer, &inner.response_receiver)
667 else {
668 return None;
669 };
670 let id = inner.next_id;
671 inner.next_id += 1;
672 let response = Self::send_request_raw(
673 writer,
674 response_receiver,
675 id,
676 "textDocument/references",
677 params,
678 LSP_REQUEST_TIMEOUT,
679 )
680 .map_err(|error| {
681 eprintln!("[scope-engine/lsp] textDocument/references failed: {error}");
682 error
683 })
684 .ok()?;
685 if let Some(error) = response.get("error") {
686 eprintln!("[scope-engine/lsp] textDocument/references error: {error}");
687 return Some(Vec::new());
688 }
689 response
690 .get("result")
691 .and_then(serde_json::Value::as_array)
692 .cloned()
693 }
694
695 fn reference_results_from_locations(
696 locations: &[serde_json::Value],
697 project_root: &Path,
698 ) -> Vec<PropagationResult> {
699 let analyzer = TreeSitterAnalyzer::new();
700 locations
701 .iter()
702 .filter_map(|location| {
703 Self::reference_result_from_location(&analyzer, location, project_root)
704 })
705 .collect()
706 }
707
708 fn reference_result_from_location(
709 analyzer: &TreeSitterAnalyzer,
710 location: &serde_json::Value,
711 project_root: &Path,
712 ) -> Option<PropagationResult> {
713 let uri = location.get("uri")?.as_str()?;
714 let line = location
715 .get("range")?
716 .get("start")?
717 .get("line")?
718 .as_u64()
719 .and_then(|line| usize::try_from(line).ok())?;
720 let path = uri_to_path(uri);
721 if analyzer.is_import_only_reference(&path, line + 1) {
722 return None;
723 }
724 let relative_path = path.strip_prefix(project_root).ok().map_or_else(
725 || path.to_string_lossy().to_string(),
726 |relative| relative.to_string_lossy().to_string(),
727 );
728 let context_line = std::fs::read_to_string(&path)
729 .ok()
730 .and_then(|content| content.lines().nth(line).map(str::to_string))
731 .unwrap_or_default();
732 let selector = analyzer
733 .find_containing_symbol(&path, line + 1, project_root)
734 .unwrap_or_else(|| format!("{relative_path}::line {}", line + 1));
735 let reference = (selector.clone(), line + 1, context_line);
736 Some(PropagationResult {
737 selector,
738 reason: format!("LSP reference found at {relative_path}:{}", line + 1),
739 source: PropagationSource::Lsp,
740 lsp_references: Some(vec![reference]),
741 diff_summary: None,
742 file_snippet: None,
743 project_files: None,
744 })
745 }
746
747 pub fn find_references_for_symbol(
748 &self,
749 file_path: &Path,
750 line: usize,
751 character: usize,
752 project_root: &Path,
753 ) -> Vec<PropagationResult> {
754 let mut guard = self.inner.borrow_mut();
755 let inner = &mut *guard;
756 if !inner.initialized {
757 return Vec::new();
758 }
759 let params = serde_json::json!({
760 "textDocument": { "uri": path_to_file_uri(file_path) },
761 "position": { "line": line.saturating_sub(1), "character": character },
762 "context": { "includeDeclaration": false }
763 });
764 let Some(locations) = Self::request_references(inner, ¶ms) else {
765 Self::disable(inner);
766 return Vec::new();
767 };
768 let locations = if locations.is_empty() {
769 eprintln!("[scope-engine/lsp] references returned empty, waiting and retrying...");
770 std::thread::sleep(Duration::from_secs(2));
771 Self::request_references(inner, ¶ms).unwrap_or_default()
772 } else {
773 locations
774 };
775 eprintln!(
776 "[scope-engine/lsp] found {} reference locations",
777 locations.len()
778 );
779 Self::reference_results_from_locations(&locations, project_root)
780 }
781
782 fn send_request_raw(
785 writer: &mut BufWriter<ChildStdin>,
786 response_receiver: &Receiver<LspResponse>,
787 id: u64,
788 method: &str,
789 params: &serde_json::Value,
790 timeout: Duration,
791 ) -> Result<serde_json::Value, String> {
792 let request = serde_json::json!({
793 "jsonrpc": "2.0",
794 "id": id,
795 "method": method,
796 "params": params,
797 });
798 Self::write_message(writer, &request)?;
799 Self::wait_for_response(response_receiver, id, timeout)
800 }
801
802 fn send_notification(
803 writer: &mut BufWriter<ChildStdin>,
804 method: &str,
805 params: &serde_json::Value,
806 ) -> Result<(), String> {
807 let notification = serde_json::json!({
808 "jsonrpc": "2.0",
809 "method": method,
810 "params": params,
811 });
812 Self::write_message(writer, ¬ification)
813 }
814
815 fn write_message(
816 writer: &mut BufWriter<ChildStdin>,
817 msg: &serde_json::Value,
818 ) -> Result<(), String> {
819 let body = serde_json::to_string(msg).map_err(|e| format!("json serialize failed: {e}"))?;
820 let header = format!("Content-Length: {}\r\n\r\n", body.len());
821 writer
822 .write_all(header.as_bytes())
823 .map_err(|e| format!("write header failed: {e}"))?;
824 writer
825 .write_all(body.as_bytes())
826 .map_err(|e| format!("write body failed: {e}"))?;
827 writer.flush().map_err(|e| format!("flush failed: {e}"))?;
828 Ok(())
829 }
830
831 fn read_message(
832 reader: &mut std::io::BufReader<ChildStdout>,
833 ) -> Result<serde_json::Value, String> {
834 let mut header = String::new();
835 loop {
836 let mut byte = [0u8; 1];
837 reader
838 .read_exact(&mut byte)
839 .map_err(|e| format!("read header byte failed: {e}"))?;
840 header.push(byte[0] as char);
841 if header.ends_with("\r\n\r\n") {
842 break;
843 }
844 if header.len() > 4096 {
845 return Err("header too long, possibly malformed LSP response".to_string());
846 }
847 }
848
849 let content_length: usize = header
850 .lines()
851 .find_map(|line| {
852 line.strip_prefix("Content-Length: ")
853 .and_then(|value| value.trim().parse().ok())
854 })
855 .ok_or("missing Content-Length header")?;
856
857 let mut body = vec![0u8; content_length];
858 reader
859 .read_exact(&mut body)
860 .map_err(|e| format!("read body failed: {e}"))?;
861 serde_json::from_slice(&body).map_err(|e| format!("json parse failed: {e}"))
862 }
863
864 fn wait_for_response(
865 response_receiver: &Receiver<LspResponse>,
866 expected_id: u64,
867 timeout: Duration,
868 ) -> Result<serde_json::Value, String> {
869 let deadline = Instant::now() + timeout;
870 loop {
871 let remaining = deadline.saturating_duration_since(Instant::now());
872 if remaining.is_zero() {
873 return Err(format!(
874 "request {expected_id} timed out after {} ms",
875 timeout.as_millis()
876 ));
877 }
878
879 match response_receiver.recv_timeout(remaining) {
880 Ok(Ok(message)) => {
881 let is_response =
882 message.get("result").is_some() || message.get("error").is_some();
883 if message.get("id").and_then(serde_json::Value::as_u64) == Some(expected_id)
884 && is_response
885 {
886 return Ok(message);
887 }
888 }
889 Ok(Err(error)) => return Err(error),
890 Err(RecvTimeoutError::Timeout) => {
891 return Err(format!(
892 "request {expected_id} timed out after {} ms",
893 timeout.as_millis()
894 ));
895 }
896 Err(RecvTimeoutError::Disconnected) => {
897 return Err("LSP response channel disconnected".to_string());
898 }
899 }
900 }
901 }
902
903 fn disable(inner: &mut LspClientInner) {
904 inner.initialized = false;
905 inner.stdin_writer = None;
906 inner.response_receiver = None;
907 inner.reader_thread = None;
908 if let Some(mut child) = inner.process.take() {
909 let _ = child.kill();
910 let _ = child.wait();
911 }
912 }
913}
914
915impl Drop for LspClient {
916 fn drop(&mut self) {
917 let inner = self.inner.get_mut();
918 if !inner.initialized {
919 return;
920 }
921 if let (Some(writer), Some(response_receiver)) =
922 (&mut inner.stdin_writer, &inner.response_receiver)
923 {
924 let _ = Self::send_request_raw(
925 writer,
926 response_receiver,
927 inner.next_id,
928 "shutdown",
929 &serde_json::json!(null),
930 LSP_SHUTDOWN_TIMEOUT,
931 );
932 let _ = Self::send_notification(writer, "exit", &serde_json::json!(null));
933 }
934 Self::disable(inner);
935 eprintln!("[scope-engine/lsp] language server shut down");
936 }
937}
938
939fn path_to_file_uri(path: &Path) -> String {
942 let abs = if path.is_absolute() {
943 path.to_path_buf()
944 } else {
945 std::env::current_dir().unwrap_or_default().join(path)
946 };
947 file_uri_from_absolute_path_string(&abs.to_string_lossy())
948}
949
950fn file_uri_from_absolute_path_string(path: &str) -> String {
951 let mut normalized = path.replace('\\', "/");
952
953 if let Some(rest) = normalized.strip_prefix("//?/UNC/") {
954 return unc_path_to_file_uri(rest);
955 }
956 if let Some(rest) = normalized.strip_prefix("//?/") {
957 normalized = rest.to_string();
958 } else if let Some(rest) = normalized.strip_prefix("//./") {
959 normalized = rest.to_string();
960 }
961
962 if let Some(rest) = normalized.strip_prefix("//") {
963 return unc_path_to_file_uri(rest);
964 }
965
966 if has_windows_drive_prefix(&normalized) {
967 return format!("file:///{}", percent_encode_file_path(&normalized));
968 }
969
970 if normalized.starts_with('/') {
971 return format!("file://{}", percent_encode_file_path(&normalized));
972 }
973
974 format!("file:///{}", percent_encode_file_path(&normalized))
975}
976
977fn unc_path_to_file_uri(path: &str) -> String {
978 let (host, rest) = path.split_once('/').unwrap_or((path, ""));
979 if rest.is_empty() {
980 format!("file://{}", percent_encode_file_path(host))
981 } else {
982 format!(
983 "file://{}/{}",
984 percent_encode_file_path(host),
985 percent_encode_file_path(rest)
986 )
987 }
988}
989
990fn has_windows_drive_prefix(path: &str) -> bool {
991 let bytes = path.as_bytes();
992 bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
993}
994
995fn percent_encode_file_path(path: &str) -> String {
996 let mut encoded = String::new();
997 for &byte in path.as_bytes() {
998 match byte {
999 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
1000 encoded.push(byte as char);
1001 }
1002 _ => {
1003 write!(encoded, "%{byte:02X}").expect("writing to String cannot fail");
1004 }
1005 }
1006 }
1007 encoded
1008}
1009
1010fn percent_decode_file_path(path: &str) -> String {
1011 let bytes = path.as_bytes();
1012 let mut decoded = Vec::with_capacity(bytes.len());
1013 let mut i = 0;
1014 while i < bytes.len() {
1015 if bytes[i] == b'%'
1016 && i + 2 < bytes.len()
1017 && let (Some(hi), Some(lo)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
1018 {
1019 decoded.push((hi << 4) | lo);
1020 i += 3;
1021 } else {
1022 decoded.push(bytes[i]);
1023 i += 1;
1024 }
1025 }
1026 String::from_utf8_lossy(&decoded).into_owned()
1027}
1028
1029const fn hex_value(byte: u8) -> Option<u8> {
1030 match byte {
1031 b'0'..=b'9' => Some(byte - b'0'),
1032 b'a'..=b'f' => Some(byte - b'a' + 10),
1033 b'A'..=b'F' => Some(byte - b'A' + 10),
1034 _ => None,
1035 }
1036}
1037
1038fn uri_to_path(uri: &str) -> PathBuf {
1039 PathBuf::from(file_uri_to_path_string(uri))
1040}
1041
1042fn file_uri_to_path_string(uri: &str) -> String {
1043 let Some(rest) = uri.strip_prefix("file://") else {
1044 return uri.to_string();
1045 };
1046 let decoded = percent_decode_file_path(rest);
1047 if let Some(stripped) = decoded.strip_prefix('/') {
1048 if has_windows_drive_prefix(stripped) {
1049 return stripped.to_string();
1050 }
1051 return decoded;
1052 }
1053 format!("//{decoded}")
1054}
1055
1056impl Analyzer for LspClient {
1059 fn find_references_for_symbol(
1060 &self,
1061 file_path: &Path,
1062 line: usize,
1063 character: usize,
1064 project_root: &Path,
1065 ) -> Vec<PropagationResult> {
1066 Self::find_references_for_symbol(self, file_path, line, character, project_root)
1067 }
1068
1069 fn notify_did_open(&self, file_path: &Path, text: &str) {
1070 Self::notify_did_open(self, file_path, text);
1071 }
1072
1073 fn notify_did_change(&self, file_path: &Path, version: i32, text: &str) {
1074 Self::notify_did_change(self, file_path, version, text);
1075 }
1076
1077 fn notify_did_close(&self, file_path: &Path) {
1078 Self::notify_did_close(self, file_path);
1079 }
1080
1081 fn is_initialized(&self) -> bool {
1082 self.inner.borrow().initialized
1083 }
1084}
1085
1086pub type LspAnalyzer = LspClient;
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::{
1094 LspClient, LspResponse, file_uri_from_absolute_path_string, file_uri_to_path_string,
1095 };
1096 use std::sync::mpsc;
1097 use std::time::Duration;
1098
1099 #[test]
1100 fn windows_verbatim_paths_become_valid_file_uris() {
1101 let uri =
1102 file_uri_from_absolute_path_string(r"\\?\C:\Users\Name With Space\src\main#test.rs");
1103
1104 assert_eq!(
1105 uri,
1106 "file:///C:/Users/Name%20With%20Space/src/main%23test.rs"
1107 );
1108 }
1109
1110 #[test]
1111 fn unix_paths_become_valid_file_uris() {
1112 let uri = file_uri_from_absolute_path_string("/tmp/name with space/main#test.rs");
1113
1114 assert_eq!(uri, "file:///tmp/name%20with%20space/main%23test.rs");
1115 }
1116
1117 #[test]
1118 fn file_uris_decode_windows_drive_paths() {
1119 let path =
1120 file_uri_to_path_string("file:///C:/Users/Name%20With%20Space/src/main%23test.rs");
1121
1122 assert_eq!(path, "C:/Users/Name With Space/src/main#test.rs");
1123 }
1124
1125 #[test]
1126 fn response_wait_ignores_unrelated_messages() {
1127 let (sender, receiver) = mpsc::channel::<LspResponse>();
1128 sender
1129 .send(Ok(serde_json::json!({"method": "window/logMessage"})))
1130 .unwrap();
1131 sender
1132 .send(Ok(serde_json::json!({"id": 6, "result": []})))
1133 .unwrap();
1134 sender
1135 .send(Ok(serde_json::json!({"id": 7, "result": ["match"]})))
1136 .unwrap();
1137
1138 let response = LspClient::wait_for_response(&receiver, 7, Duration::from_millis(50))
1139 .expect("matching response should be returned");
1140
1141 assert_eq!(response["result"], serde_json::json!(["match"]));
1142 }
1143
1144 #[test]
1145 fn response_wait_times_out() {
1146 let (_sender, receiver) = mpsc::channel::<LspResponse>();
1147
1148 let error = LspClient::wait_for_response(&receiver, 9, Duration::from_millis(10))
1149 .expect_err("missing response should time out");
1150
1151 assert!(error.contains("request 9 timed out"));
1152 }
1153}