Skip to main content

scope_engine/
lsp.rs

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
26// ── LSP Server Configuration ──────────────────────────────────
27
28/// Configuration for a language server binary.
29///
30/// Each supported language provides an implementation that knows how to
31/// locate/download the server binary and what parameters to send during
32/// LSP initialization.
33pub trait LspServerConfig: Send + Sync {
34    /// Human-readable name for logging.
35    fn server_name(&self) -> &str;
36
37    /// The binary name to look for on PATH (e.g. "rust-analyzer", "pyright-langserver").
38    fn binary_name(&self) -> &str;
39
40    /// The LSP language ID for `textDocument/didOpen` (e.g. "rust", "python").
41    fn language_id(&self) -> &str;
42
43    /// Cache directory relative filename for the downloaded binary (if applicable).
44    fn cached_binary_name(&self) -> String;
45
46    /// URL to download the binary from (if PATH lookup fails and download feature enabled).
47    fn download_url(&self) -> Option<String>;
48
49    /// Extra initialization parameters to include in the LSP `initialize` request.
50    fn init_params_extra(&self, _root_uri: &str) -> serde_json::Value {
51        serde_json::json!({})
52    }
53
54    /// Seconds to sleep after initialization to let the server index.
55    fn post_init_delay_secs(&self) -> u64 {
56        3
57    }
58
59    /// Arguments to pass to the server binary when spawning.
60    fn spawn_args(&self) -> Vec<String> {
61        vec![]
62    }
63
64    /// Optional command to install the server when not found on PATH or cache.
65    /// Returns `(command, args)` — e.g. `("go", vec!["install", "golang.org/x/tools/gopls@v0.21.1"])`.
66    /// The `locate_or_download` logic will run this and then re-check PATH.
67    fn install_command(&self) -> Option<(String, Vec<String>)> {
68        None
69    }
70
71    /// Human-readable setup/installation hints for this LSP server.
72    /// Returns a description of how to install the server, for the agent to act on.
73    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
83// ── Rust LSP config ────────────────────────────────────────────
84
85pub 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
114// ── Python LSP config ──────────────────────────────────────────
115
116pub 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    } // installed via npm/pip
134    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
146// ── TypeScript/JavaScript LSP config ──────────────────────────
147
148pub 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    } // installed via npm
166    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
178// ── Go LSP config ──────────────────────────────────────────────
179
180const 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
217// ── Java LSP config (Eclipse JDT Language Server) ──────────────
218
219pub 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
249// ── LspClient (was LspAnalyzer) ───────────────────────────────
250
251/// Internal mutable state for `LspClient`, wrapped in `RefCell` for interior mutability.
252struct 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    /// The language ID for didOpen notifications.
260    language_id: String,
261}
262
263/// Manages an LSP language server subprocess and communicates via JSON-RPC 2.0.
264///
265/// Uses `RefCell<LspClientInner>` for interior mutability so that the
266/// `Analyzer` trait's `&self` methods can perform LSP I/O without needing
267/// `&mut self`.
268///
269/// Lifecycle:
270/// 1. `new()` — locate or download the server binary, spawn it, perform LSP `initialize`.
271/// 2. `notify_did_open()` / `notify_did_change()` / `notify_did_close()` — keep LSP in sync.
272/// 3. `find_references_for_symbol()` — query cross-file references via `textDocument/references`.
273/// 4. `Drop` — send `shutdown`, then kill the subprocess.
274pub struct LspClient {
275    inner: RefCell<LspClientInner>,
276}
277
278// Newtype wrappers so we can implement Read/Write on the inner types
279struct 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    // ── Binary location ─────────────────────────────────────────
349
350    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    // ── Subprocess management ──────────────────────────────────
524
525    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        // ── LSP initialize ─────────────────────────────────────
556        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        // Merge extra params from config
562        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        // ── initialized notification ────────────────────────────
590        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    // ── LSP file synchronization ────────────────────────────────
609
610    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", &params);
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", &params);
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", &params);
656        }
657    }
658
659    // ── LSP requests ──────────────────────────────────────────
660
661    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, &params) 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, &params).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    // ── JSON-RPC transport ─────────────────────────────────────
783
784    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, &notification)
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
939// ── URI helpers ──────────────────────────────────────────────
940
941fn 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
1056// ── Analyzer trait impl ──────────────────────────────────────
1057
1058impl 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
1086// ── Backward-compatible type alias ────────────────────────────
1087
1088/// `LspAnalyzer` is a type alias for `LspClient`, preserving API compatibility.
1089pub 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}