1use 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
21type PendingRequests = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>;
23type DiagnosticsMap = Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>;
25
26#[derive(Debug, Clone)]
28pub struct DocumentSymbolInfo {
29 pub name: String,
31 pub kind: String,
33 pub range: lsp_types::Range,
35 pub detail: Option<String>,
37}
38
39pub struct LspClient {
41 stdin: Option<Arc<Mutex<ChildStdin>>>,
43 request_id: AtomicU64,
45 pending_requests: Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>>,
47 diagnostics: Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>,
49 server_name: String,
51 language_id: String,
53 process: Option<Child>,
55 initialized: bool,
57}
58
59impl LspClient {
60 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 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 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" => 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 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 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 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 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 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 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, 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 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 self.initialize(workspace_root).await?;
216
217 Ok(())
218 }
219
220 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 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 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 let path = uri.trim_start_matches("file://");
250
251 let mut diag_map = diagnostics.lock().await;
254
255 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 #[allow(deprecated)]
272 async fn initialize(&mut self, workspace_root: &Path) -> Result<InitializeResult> {
273 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 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 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 {
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 let mut pending = self.pending_requests.lock().await;
327 pending.remove(&id);
328 return Err(e);
329 }
330
331 let result = rx.await??;
333 Ok(serde_json::from_value(result)?)
334 }
335
336 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(¬ification).await
345 }
346
347 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 pub async fn get_diagnostics(&self, path: &str) -> Vec<Diagnostic> {
365 let map = self.diagnostics.lock().await;
366
367 let mut cached = Vec::new();
369
370 if let Some(diags) = map.get(path) {
372 cached = diags.clone();
373 }
374 else if let Some(diags) = map.get(path.trim_start_matches("file://")) {
376 cached = diags.clone();
377 }
378 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 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 !cached.is_empty() {
400 return cached;
401 }
402
403 if self.server_name == "ty" {
407 drop(map);
409 return self.run_type_check(path).await;
410 }
411
412 Vec::new()
413 }
414
415 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 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 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 fn parse_ty_output(&self, output: &str, _path: &str) -> Vec<Diagnostic> {
450 let mut diagnostics = Vec::new();
451
452 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 if line.contains("error") || line.contains("warning") {
464 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 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 let mut line_num = 0;
485 let mut col_num = 0;
486
487 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 if let Some(parts) = next_line.split("-->").nth(1) {
495 let parts: Vec<&str> = parts.trim().split(':').collect();
496 if parts.len() >= 3 {
497 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 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, })
548 .sum()
549 }
550
551 pub fn is_ready(&self) -> bool {
553 self.initialized && self.process.is_some()
554 }
555
556 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 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 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 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 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 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 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 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 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 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 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 if let Some(ref children) = sym.children {
814 result.extend(Self::flatten_document_symbols(children));
815 }
816 }
817 result
818 }
819
820 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}