Skip to main content

perspt_agent/
lsp.rs

1//! Native LSP Client
2//!
3//! Lightweight JSON-RPC client for Language Server Protocol communication.
4//! Provides the "Sensor Architecture" for SRBN stability monitoring.
5
6use anyhow::{Context, Result};
7use lsp_types::{Diagnostic, DiagnosticSeverity, InitializeParams, InitializeResult};
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::collections::HashMap;
11use std::path::Path;
12use std::process::Stdio;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::sync::Arc;
15use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, Command};
17use tokio::sync::{oneshot, Mutex};
18
19use perspt_core::plugin::LspConfig;
20use perspt_sandbox::{ProcessPolicy, ProcessSandbox};
21
22/// Type alias for pending LSP requests map
23type PendingRequests = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>;
24/// Type alias for diagnostics map
25type DiagnosticsMap = Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>;
26
27/// Simplified document symbol information for agent use
28#[derive(Debug, Clone)]
29pub struct DocumentSymbolInfo {
30    /// Symbol name (e.g., function name, class name)
31    pub name: String,
32    /// Symbol kind (e.g., "Function", "Class", "Variable")
33    pub kind: String,
34    /// Range in the document
35    pub range: lsp_types::Range,
36    /// Optional detail (e.g., type signature)
37    pub detail: Option<String>,
38}
39
40/// LSP Client for real-time diagnostics and symbol information
41pub struct LspClient {
42    /// Server stdin writer
43    stdin: Option<Arc<Mutex<ChildStdin>>>,
44    /// Request ID counter
45    request_id: AtomicU64,
46    /// Pending requests (ID -> Sender)
47    pending_requests: Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>,
48    /// Cached diagnostics per file
49    diagnostics: Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>,
50    /// Server name (e.g., "rust-analyzer", "pyright")
51    server_name: String,
52    /// Language ID for textDocument/didOpen (e.g., "rust", "python")
53    language_id: String,
54    /// Keep track of the process to kill it on drop
55    process: Option<Child>,
56    /// Whether the server is initialized
57    initialized: bool,
58    /// Explicit reduced-isolation mode inherited from the agent run.
59    allow_unisolated: bool,
60}
61
62impl LspClient {
63    /// Create a new LSP client (not connected)
64    pub fn new(server_name: &str) -> Self {
65        Self {
66            stdin: None,
67            request_id: AtomicU64::new(1),
68            pending_requests: Arc::new(Mutex::new(HashMap::new())),
69            diagnostics: Arc::new(Mutex::new(HashMap::new())),
70            server_name: server_name.to_string(),
71            language_id: String::new(),
72            process: None,
73            initialized: false,
74            allow_unisolated: false,
75        }
76    }
77
78    /// Create a new LSP client from a plugin's LspConfig.
79    ///
80    /// Stores the language_id so `did_open` can tag documents correctly
81    /// without guessing from file extensions.
82    pub fn from_config(config: &LspConfig) -> Self {
83        Self {
84            stdin: None,
85            request_id: AtomicU64::new(1),
86            pending_requests: Arc::new(Mutex::new(HashMap::new())),
87            diagnostics: Arc::new(Mutex::new(HashMap::new())),
88            server_name: config.server_binary.clone(),
89            language_id: config.language_id.clone(),
90            process: None,
91            initialized: false,
92            allow_unisolated: false,
93        }
94    }
95
96    pub(crate) fn allow_unisolated(mut self, allow: bool) -> Self {
97        self.allow_unisolated = allow;
98        self
99    }
100
101    /// Get the command for a known language server
102    fn get_server_command(server_name: &str) -> Option<(&'static str, Vec<&'static str>)> {
103        match server_name {
104            "rust-analyzer" => Some(("rust-analyzer", vec![])),
105            "pyright" => Some(("pyright-langserver", vec!["--stdio"])),
106            // ty is installed via uv, so use uvx to run it
107            "ty" => Some(("uvx", vec!["ty", "server"])),
108            "typescript" => Some(("typescript-language-server", vec!["--stdio"])),
109            "gopls" => Some(("gopls", vec!["serve"])),
110            _ => None,
111        }
112    }
113
114    /// Start the language server process
115    pub async fn start(&mut self, workspace_root: &Path) -> Result<()> {
116        let (cmd, args) = Self::get_server_command(&self.server_name)
117            .context(format!("Unknown language server: {}", self.server_name))?;
118
119        let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
120        self.start_process(cmd, &args_owned, workspace_root).await
121    }
122
123    /// Start using a plugin's LspConfig.
124    ///
125    /// Preferred over `start()` when a `LanguagePlugin` is available, because
126    /// it uses the config's binary and args directly — no lookup table needed.
127    pub async fn start_with_config(
128        &mut self,
129        config: &LspConfig,
130        workspace_root: &Path,
131    ) -> Result<()> {
132        self.start_process(&config.server_binary, &config.args, workspace_root)
133            .await
134    }
135
136    /// Internal: spawn the LSP child process, wire up stdio, run initialize.
137    async fn start_process(
138        &mut self,
139        cmd: &str,
140        args: &[String],
141        workspace_root: &Path,
142    ) -> Result<()> {
143        log::info!("Starting LSP server: {} {:?}", cmd, args);
144
145        let policy = if self.allow_unisolated {
146            ProcessPolicy::inspection(workspace_root).best_effort()
147        } else {
148            ProcessPolicy::inspection(workspace_root)
149        };
150        let prepared = ProcessSandbox::new(cmd, args.to_vec(), policy)?.prepare_invocation()?;
151        let mut child = Command::new(&prepared.program);
152        child
153            .args(&prepared.args)
154            .current_dir(&prepared.working_dir)
155            .env_clear()
156            .envs(&prepared.environment)
157            .stdin(Stdio::piped())
158            .stdout(Stdio::piped())
159            .stderr(Stdio::piped());
160        let mut child = child.spawn().context(format!("Failed to start {}", cmd))?;
161
162        let stdin = child.stdin.take().context("No stdin")?;
163        let stdout = child.stdout.take().context("No stdout")?;
164        let stderr = child.stderr.take().context("No stderr")?;
165
166        // Handle stderr logging in background
167        tokio::spawn(async move {
168            let mut reader = BufReader::new(stderr).lines();
169            while let Ok(Some(line)) = reader.next_line().await {
170                log::debug!("[LSP stderr] {}", line);
171            }
172        });
173
174        // Handle stdout message loop
175        let pending_requests = self.pending_requests.clone();
176        let diagnostics = self.diagnostics.clone();
177
178        tokio::spawn(async move {
179            let mut reader = BufReader::new(stdout);
180            loop {
181                // Read headers
182                let mut content_length = 0;
183                loop {
184                    let mut line = String::new();
185                    match reader.read_line(&mut line).await {
186                        Ok(0) => return, // EOF
187                        Ok(_) => {
188                            if line == "\r\n" {
189                                break;
190                            }
191                            if line.starts_with("Content-Length:") {
192                                if let Ok(len) = line
193                                    .trim_start_matches("Content-Length:")
194                                    .trim()
195                                    .parse::<usize>()
196                                {
197                                    content_length = len;
198                                }
199                            }
200                        }
201                        Err(e) => {
202                            log::error!("Error reading LSP header: {}", e);
203                            return;
204                        }
205                    }
206                }
207
208                if content_length == 0 {
209                    continue;
210                }
211
212                // Read body
213                let mut body = vec![0u8; content_length];
214                match reader.read_exact(&mut body).await {
215                    Ok(_) => {
216                        if let Ok(value) = serde_json::from_slice::<Value>(&body) {
217                            Self::handle_message(value, &pending_requests, &diagnostics).await;
218                        }
219                    }
220                    Err(e) => {
221                        log::error!("Error reading LSP body: {}", e);
222                        return;
223                    }
224                }
225            }
226        });
227
228        self.stdin = Some(Arc::new(Mutex::new(stdin)));
229        self.process = Some(child);
230        self.initialized = false;
231
232        // Send initialize request
233        self.initialize(workspace_root).await?;
234
235        Ok(())
236    }
237
238    /// Handle incoming LSP message
239    async fn handle_message(
240        msg: Value,
241        pending_requests: &PendingRequests,
242        diagnostics: &DiagnosticsMap,
243    ) {
244        if let Some(id) = msg.get("id").and_then(|id| id.as_u64()) {
245            // It's a response
246            let mut pending = pending_requests.lock().await;
247            if let Some(tx) = pending.remove(&id) {
248                if let Some(error) = msg.get("error") {
249                    let _ = tx.send(Err(anyhow::anyhow!("LSP error: {}", error)));
250                } else if let Some(result) = msg.get("result") {
251                    let _ = tx.send(Ok(result.clone()));
252                } else {
253                    let _ = tx.send(Ok(Value::Null));
254                }
255            }
256        } else if let Some(method) = msg.get("method").and_then(|m| m.as_str()) {
257            // It's a notification
258            if method == "textDocument/publishDiagnostics" {
259                if let Some(params) = msg.get("params") {
260                    if let (Some(uri), Some(diags)) = (
261                        params.get("uri").and_then(|u| u.as_str()),
262                        params.get("diagnostics").and_then(|d| {
263                            serde_json::from_value::<Vec<Diagnostic>>(d.clone()).ok()
264                        }),
265                    ) {
266                        // Normalize URI to file path
267                        let path = uri.trim_start_matches("file://");
268
269                        // For ty/lsp, path might be absolute or relative, ensure we match broadly
270                        // Store exactly what we got for now
271                        let mut diag_map = diagnostics.lock().await;
272
273                        // Also try to simplify the key to just filename for easier lookup
274                        // This assumes unique filenames which is a simplification but useful
275                        if let Some(filename) = Path::new(path).file_name() {
276                            let filename_str = filename.to_string_lossy().to_string();
277                            diag_map.insert(filename_str, diags.clone());
278                        }
279
280                        diag_map.insert(path.to_string(), diags);
281                        log::info!("Updated diagnostics for {}", path);
282                    }
283                }
284            }
285        }
286    }
287
288    /// Send the initialize request
289    #[allow(deprecated)]
290    async fn initialize(&mut self, workspace_root: &Path) -> Result<InitializeResult> {
291        // Build a file:// URI from the path
292        let path_str = workspace_root.to_string_lossy();
293        #[cfg(target_os = "windows")]
294        let uri_string = format!("file:///{}", path_str.replace('\\', "/"));
295        #[cfg(not(target_os = "windows"))]
296        let uri_string = format!("file://{}", path_str);
297
298        let root_uri: lsp_types::Uri = uri_string
299            .parse()
300            .map_err(|e| anyhow::anyhow!("Failed to parse URI: {:?}", e))?;
301
302        let params = InitializeParams {
303            root_uri: Some(root_uri),
304            capabilities: lsp_types::ClientCapabilities::default(),
305            ..Default::default()
306        };
307
308        let result: InitializeResult = self
309            .send_request("initialize", serde_json::to_value(params)?)
310            .await?;
311
312        // Send initialized notification
313        self.send_notification("initialized", json!({})).await?;
314        self.initialized = true;
315
316        log::info!("LSP server initialized: {:?}", result.server_info);
317        Ok(result)
318    }
319
320    /// Send a JSON-RPC request
321    async fn send_request<T: for<'de> Deserialize<'de>>(
322        &mut self,
323        method: &str,
324        params: Value,
325    ) -> Result<T> {
326        let id = self.request_id.fetch_add(1, Ordering::SeqCst);
327        let (tx, rx) = oneshot::channel();
328
329        // Register pending request
330        {
331            let mut pending = self.pending_requests.lock().await;
332            pending.insert(id, tx);
333        }
334
335        let request = json!({
336            "jsonrpc": "2.0",
337            "id": id,
338            "method": method,
339            "params": params
340        });
341
342        if let Err(e) = self.write_message(&request).await {
343            // Cleanup on error
344            let mut pending = self.pending_requests.lock().await;
345            pending.remove(&id);
346            return Err(e);
347        }
348
349        // Wait for response
350        let result = rx.await??;
351        Ok(serde_json::from_value(result)?)
352    }
353
354    /// Send a JSON-RPC notification
355    async fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
356        let notification = json!({
357            "jsonrpc": "2.0",
358            "method": method,
359            "params": params
360        });
361
362        self.write_message(&notification).await
363    }
364
365    /// Write a message to the server stdin
366    async fn write_message(&mut self, msg: &Value) -> Result<()> {
367        let content = serde_json::to_string(msg)?;
368        let message = format!("Content-Length: {}\r\n\r\n{}", content.len(), content);
369
370        if let Some(ref stdin_arc) = self.stdin {
371            let mut stdin = stdin_arc.lock().await;
372            stdin.write_all(message.as_bytes()).await?;
373            stdin.flush().await?;
374            Ok(())
375        } else {
376            Err(anyhow::anyhow!("LSP stdin not available"))
377        }
378    }
379
380    /// Get diagnostics for a file
381    /// Get diagnostics for a file
382    pub async fn get_diagnostics(&self, path: &str) -> Vec<Diagnostic> {
383        let map = self.diagnostics.lock().await;
384
385        // Collect cached diagnostics from any matching key
386        let mut cached = Vec::new();
387
388        // 1. Exact match
389        if let Some(diags) = map.get(path) {
390            cached = diags.clone();
391        }
392        // 2. Clean path (no file://)
393        else if let Some(diags) = map.get(path.trim_start_matches("file://")) {
394            cached = diags.clone();
395        }
396        // 3. URI format
397        else if !path.starts_with("file://") {
398            let uri = format!("file://{}", path);
399            if let Some(diags) = map.get(&uri) {
400                cached = diags.clone();
401            }
402        }
403
404        // 4. Filename fallback (only if still empty/not found)
405        if cached.is_empty() {
406            if let Some(filename) = Path::new(path).file_name() {
407                let filename_str = filename.to_string_lossy();
408                if let Some(diags) = map.get(filename_str.as_ref()) {
409                    cached = diags.clone();
410                }
411            }
412        }
413
414        // If we found diagnostics, verify they aren't empty?
415        // Actually, valid code has empty diagnostics.
416        // But for 'ty', we want to be paranoid if it reports nothing.
417        if !cached.is_empty() {
418            return cached;
419        }
420
421        // Trust but Verify: If cached is empty (meaning LSP says "clean" or "unknown"),
422        // AND we are using `ty`, double-check with CLI.
423        // This is crucial for stability monitoring to avoid false negatives from async races.
424        if self.server_name == "ty" {
425            // Drop lock before await
426            drop(map);
427            return self.run_type_check(path).await;
428        }
429
430        Vec::new()
431    }
432
433    /// Run ty check CLI to get diagnostics for a file
434    async fn run_type_check(&self, path: &str) -> Vec<Diagnostic> {
435        use std::process::Command;
436
437        log::debug!("Running ty check on: {}", path);
438
439        // ty check doesn't support JSON output yet, so we parse the default output
440        let output = Command::new("uvx").args(["ty", "check", path]).output();
441
442        match output {
443            Ok(output) => {
444                let stdout = String::from_utf8_lossy(&output.stdout);
445                let stderr = String::from_utf8_lossy(&output.stderr);
446
447                log::debug!("ty check status: {}", output.status);
448                if !stdout.is_empty() {
449                    log::debug!("ty check stdout: {}", stdout);
450                }
451                if !stderr.is_empty() {
452                    log::debug!("ty check stderr: {}", stderr);
453                }
454
455                // Parse the output (ty outputs diagnostics to stderr in text format, but we check both)
456                let combined = format!("{}\n{}", stdout, stderr);
457                self.parse_ty_output(&combined, path)
458            }
459            Err(e) => {
460                log::warn!("Failed to run ty check: {}", e);
461                Vec::new()
462            }
463        }
464    }
465
466    /// Parse ty check text output into diagnostics
467    fn parse_ty_output(&self, output: &str, _path: &str) -> Vec<Diagnostic> {
468        let mut diagnostics = Vec::new();
469
470        // Look for lines like:
471        // error[invalid-return-type]: Return type does not match returned value
472        // ---> main.py:7:12
473
474        let lines: Vec<&str> = output.lines().collect();
475        let mut i = 0;
476
477        while i < lines.len() {
478            let line = lines[i];
479
480            // Check for error/warning prefix
481            if line.contains("error") || line.contains("warning") {
482                // Heuristic parsing for severity
483                let severity = if line.contains("error") {
484                    Some(DiagnosticSeverity::ERROR)
485                } else if line.contains("warning") {
486                    Some(DiagnosticSeverity::WARNING)
487                } else {
488                    Some(DiagnosticSeverity::INFORMATION)
489                };
490
491                // Extract message
492                // Try to find the message part after "error:" or "error[...]:"
493                let message = if let Some(idx) = line.find("]: ") {
494                    line[idx + 3..].to_string()
495                } else if let Some(idx) = line.find(": ") {
496                    line[idx + 2..].to_string()
497                } else {
498                    line.to_string()
499                };
500
501                // Look for location in next lines
502                let mut line_num = 0;
503                let mut col_num = 0;
504
505                // Scan up to 3 following lines for location
506                for j in 1..4 {
507                    if i + j < lines.len() {
508                        let next_line = lines[i + j];
509                        if next_line.trim().starts_with("-->") {
510                            // Parse:   --> main.py:7:12
511                            // or: --> main.py:7:12
512                            if let Some(parts) = next_line.split("-->").nth(1) {
513                                let parts: Vec<&str> = parts.trim().split(':').collect();
514                                if parts.len() >= 3 {
515                                    // parts[0] is filename
516                                    line_num = parts[1].parse().unwrap_or(0);
517                                    col_num = parts[2].parse().unwrap_or(0);
518                                }
519                            }
520                            break;
521                        }
522                    }
523                }
524
525                diagnostics.push(Diagnostic {
526                    range: lsp_types::Range {
527                        start: lsp_types::Position {
528                            line: if line_num > 0 { line_num - 1 } else { 0 },
529                            character: if col_num > 0 { col_num - 1 } else { 0 },
530                        },
531                        end: lsp_types::Position {
532                            line: if line_num > 0 { line_num - 1 } else { 0 },
533                            character: if col_num > 0 { col_num } else { 1 },
534                        },
535                    },
536                    severity,
537                    message,
538                    ..Default::default()
539                });
540            }
541
542            i += 1;
543        }
544
545        if !diagnostics.is_empty() {
546            log::info!("ty check found {} diagnostics", diagnostics.len());
547        }
548
549        diagnostics
550    }
551
552    /// Calculate syntactic energy from diagnostics
553    ///
554    /// V_syn = sum(severity_weight * count)
555    /// Error = 1.0, Warning = 0.1, Hint = 0.01
556    pub fn calculate_syntactic_energy(diagnostics: &[Diagnostic]) -> f32 {
557        diagnostics
558            .iter()
559            .map(|d| match d.severity {
560                Some(DiagnosticSeverity::ERROR) => 1.0,
561                Some(DiagnosticSeverity::WARNING) => 0.1,
562                Some(DiagnosticSeverity::INFORMATION) => 0.01,
563                Some(DiagnosticSeverity::HINT) => 0.001,
564                _ => 0.1, // Default for unknown or None
565            })
566            .sum()
567    }
568
569    /// Check if the server is running and initialized
570    pub fn is_ready(&self) -> bool {
571        self.initialized && self.process.is_some()
572    }
573
574    /// Notify language server that a file was opened
575    pub async fn did_open(&mut self, path: &std::path::Path, content: &str) -> Result<()> {
576        if !self.is_ready() {
577            return Ok(());
578        }
579
580        let uri = format!("file://{}", path.display());
581
582        // Prefer the language_id from plugin config; fall back to extension guess
583        let language_id = if !self.language_id.is_empty() {
584            self.language_id.as_str()
585        } else {
586            match path.extension().and_then(|e| e.to_str()) {
587                Some("py") => "python",
588                Some("rs") => "rust",
589                Some("js") => "javascript",
590                Some("ts") => "typescript",
591                Some("go") => "go",
592                _ => "plaintext",
593            }
594        };
595
596        self.send_notification(
597            "textDocument/didOpen",
598            json!({
599                "textDocument": {
600                    "uri": uri,
601                    "languageId": language_id,
602                    "version": 1,
603                    "text": content
604                }
605            }),
606        )
607        .await
608    }
609
610    /// Notify language server that a file changed
611    pub async fn did_change(
612        &mut self,
613        path: &std::path::Path,
614        content: &str,
615        version: i32,
616    ) -> Result<()> {
617        if !self.is_ready() {
618            return Ok(());
619        }
620
621        let uri = format!("file://{}", path.display());
622
623        self.send_notification(
624            "textDocument/didChange",
625            json!({
626                "textDocument": {
627                    "uri": uri,
628                    "version": version
629                },
630                "contentChanges": [{
631                    "text": content
632                }]
633            }),
634        )
635        .await
636    }
637
638    // =========================================================================
639    // Enhanced LSP Tools (PSP-4 Phase 2)
640    // =========================================================================
641
642    /// Go to definition of symbol at position
643    /// Uses textDocument/definition LSP request
644    pub async fn goto_definition(
645        &mut self,
646        path: &Path,
647        line: u32,
648        character: u32,
649    ) -> Option<Vec<lsp_types::Location>> {
650        if !self.is_ready() {
651            return None;
652        }
653
654        let uri = format!("file://{}", path.display());
655
656        let params = json!({
657            "textDocument": { "uri": uri },
658            "position": { "line": line, "character": character }
659        });
660
661        match self
662            .send_request::<Option<lsp_types::GotoDefinitionResponse>>(
663                "textDocument/definition",
664                params,
665            )
666            .await
667        {
668            Ok(Some(response)) => {
669                // Convert GotoDefinitionResponse to Vec<Location>
670                match response {
671                    lsp_types::GotoDefinitionResponse::Scalar(loc) => Some(vec![loc]),
672                    lsp_types::GotoDefinitionResponse::Array(locs) => Some(locs),
673                    lsp_types::GotoDefinitionResponse::Link(links) => Some(
674                        links
675                            .into_iter()
676                            .map(|l| lsp_types::Location {
677                                uri: l.target_uri,
678                                range: l.target_selection_range,
679                            })
680                            .collect(),
681                    ),
682                }
683            }
684            Ok(None) => None,
685            Err(e) => {
686                log::warn!("goto_definition failed: {}", e);
687                None
688            }
689        }
690    }
691
692    /// Find all references to symbol at position
693    /// Uses textDocument/references LSP request
694    pub async fn find_references(
695        &mut self,
696        path: &Path,
697        line: u32,
698        character: u32,
699        include_declaration: bool,
700    ) -> Vec<lsp_types::Location> {
701        if !self.is_ready() {
702            return Vec::new();
703        }
704
705        let uri = format!("file://{}", path.display());
706
707        let params = json!({
708            "textDocument": { "uri": uri },
709            "position": { "line": line, "character": character },
710            "context": { "includeDeclaration": include_declaration }
711        });
712
713        match self
714            .send_request::<Option<Vec<lsp_types::Location>>>("textDocument/references", params)
715            .await
716        {
717            Ok(Some(locs)) => locs,
718            Ok(None) => Vec::new(),
719            Err(e) => {
720                log::warn!("find_references failed: {}", e);
721                Vec::new()
722            }
723        }
724    }
725
726    /// Get hover information (type, docs) at position
727    /// Uses textDocument/hover LSP request
728    pub async fn hover(&mut self, path: &Path, line: u32, character: u32) -> Option<String> {
729        if !self.is_ready() {
730            return None;
731        }
732
733        let uri = format!("file://{}", path.display());
734
735        let params = json!({
736            "textDocument": { "uri": uri },
737            "position": { "line": line, "character": character }
738        });
739
740        match self
741            .send_request::<Option<lsp_types::Hover>>("textDocument/hover", params)
742            .await
743        {
744            Ok(Some(hover)) => {
745                // Extract text from hover contents
746                match hover.contents {
747                    lsp_types::HoverContents::Scalar(content) => {
748                        Some(Self::extract_marked_string(&content))
749                    }
750                    lsp_types::HoverContents::Array(contents) => Some(
751                        contents
752                            .iter()
753                            .map(Self::extract_marked_string)
754                            .collect::<Vec<_>>()
755                            .join("\n"),
756                    ),
757                    lsp_types::HoverContents::Markup(markup) => Some(markup.value),
758                }
759            }
760            Ok(None) => None,
761            Err(e) => {
762                log::warn!("hover failed: {}", e);
763                None
764            }
765        }
766    }
767
768    /// Extract text from MarkedString
769    fn extract_marked_string(content: &lsp_types::MarkedString) -> String {
770        match content {
771            lsp_types::MarkedString::String(s) => s.clone(),
772            lsp_types::MarkedString::LanguageString(ls) => {
773                format!("```{}\n{}\n```", ls.language, ls.value)
774            }
775        }
776    }
777
778    /// Get all symbols in a document
779    /// Uses textDocument/documentSymbol LSP request
780    pub async fn get_symbols(&mut self, path: &Path) -> Vec<DocumentSymbolInfo> {
781        if !self.is_ready() {
782            return Vec::new();
783        }
784
785        let uri = format!("file://{}", path.display());
786
787        let params = json!({
788            "textDocument": { "uri": uri }
789        });
790
791        match self
792            .send_request::<Option<lsp_types::DocumentSymbolResponse>>(
793                "textDocument/documentSymbol",
794                params,
795            )
796            .await
797        {
798            Ok(Some(response)) => match response {
799                lsp_types::DocumentSymbolResponse::Flat(symbols) => symbols
800                    .into_iter()
801                    .map(|s| DocumentSymbolInfo {
802                        name: s.name,
803                        kind: format!("{:?}", s.kind),
804                        range: s.location.range,
805                        detail: None,
806                    })
807                    .collect(),
808                lsp_types::DocumentSymbolResponse::Nested(symbols) => {
809                    Self::flatten_document_symbols(&symbols)
810                }
811            },
812            Ok(None) => Vec::new(),
813            Err(e) => {
814                log::warn!("get_symbols failed: {}", e);
815                Vec::new()
816            }
817        }
818    }
819
820    /// Flatten nested document symbols into a list
821    fn flatten_document_symbols(symbols: &[lsp_types::DocumentSymbol]) -> Vec<DocumentSymbolInfo> {
822        let mut result = Vec::new();
823        for sym in symbols {
824            result.push(DocumentSymbolInfo {
825                name: sym.name.clone(),
826                kind: format!("{:?}", sym.kind),
827                range: sym.range,
828                detail: sym.detail.clone(),
829            });
830            // Recursively add children
831            if let Some(ref children) = sym.children {
832                result.extend(Self::flatten_document_symbols(children));
833            }
834        }
835        result
836    }
837
838    /// Shutdown the language server
839    pub async fn shutdown(&mut self) -> Result<()> {
840        if let Some(ref mut process) = self.process {
841            let _ = process.kill().await;
842        }
843        self.process = None;
844        self.initialized = false;
845        Ok(())
846    }
847}
848
849impl Drop for LspClient {
850    fn drop(&mut self) {
851        if let Some(ref mut process) = self.process {
852            let _ = process.start_kill();
853        }
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use lsp_types::Range;
861
862    #[test]
863    fn test_syntactic_energy_calculation() {
864        let diagnostics = vec![
865            Diagnostic {
866                range: Range::default(),
867                severity: Some(DiagnosticSeverity::ERROR),
868                message: "error".to_string(),
869                ..Default::default()
870            },
871            Diagnostic {
872                range: Range::default(),
873                severity: Some(DiagnosticSeverity::WARNING),
874                message: "warning".to_string(),
875                ..Default::default()
876            },
877        ];
878
879        let energy = LspClient::calculate_syntactic_energy(&diagnostics);
880        assert!((energy - 1.1).abs() < 0.001);
881    }
882}