Skip to main content

scope_engine/
lsp.rs

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