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