1use anyhow::Result;
6use lsp_types::{
7 ClientCapabilities, CompletionItem, DiagnosticSeverity, DocumentSymbol, Location, Position,
8 Range, ServerCapabilities, SymbolInformation, TextDocumentIdentifier, TextDocumentItem,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tracing::{info, warn};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LspConfig {
17 pub command: String,
19 #[serde(default)]
21 pub args: Vec<String>,
22 pub root_uri: Option<String>,
24 #[serde(default)]
26 pub file_extensions: Vec<String>,
27 #[serde(default)]
29 pub initialization_options: Option<Value>,
30 #[serde(default = "default_timeout")]
32 pub timeout_ms: u64,
33}
34
35fn default_timeout() -> u64 {
36 30000
37}
38
39fn rust_timeout() -> u64 {
40 120000
41}
42
43impl Default for LspConfig {
44 fn default() -> Self {
45 Self {
46 command: String::new(),
47 args: Vec::new(),
48 root_uri: None,
49 file_extensions: Vec::new(),
50 initialization_options: None,
51 timeout_ms: default_timeout(),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct JsonRpcRequest {
59 pub jsonrpc: String,
60 pub id: i64,
61 pub method: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub params: Option<Value>,
64}
65
66impl JsonRpcRequest {
67 pub fn new(id: i64, method: &str, params: Option<Value>) -> Self {
68 Self {
69 jsonrpc: "2.0".to_string(),
70 id,
71 method: method.to_string(),
72 params,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct JsonRpcResponse {
80 pub jsonrpc: String,
81 pub id: i64,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub result: Option<Value>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub error: Option<JsonRpcError>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct JsonRpcError {
91 pub code: i64,
92 pub message: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub data: Option<Value>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct JsonRpcNotification {
100 pub jsonrpc: String,
101 pub method: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub params: Option<Value>,
104}
105
106impl JsonRpcNotification {
107 pub fn new(method: &str, params: Option<Value>) -> Self {
108 Self {
109 jsonrpc: "2.0".to_string(),
110 method: method.to_string(),
111 params,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct InitializeParams {
120 pub process_id: Option<i64>,
121 pub client_info: ClientInfo,
122 pub locale: Option<String>,
123 pub root_path: Option<String>,
124 pub root_uri: Option<String>,
125 pub initialization_options: Option<Value>,
126 pub capabilities: ClientCapabilities,
127 pub trace: Option<String>,
128 pub workspace_folders: Option<Vec<WorkspaceFolder>>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ClientInfo {
133 pub name: String,
134 pub version: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct WorkspaceFolder {
139 pub uri: String,
140 pub name: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct InitializeResult {
147 pub capabilities: ServerCapabilities,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub server_info: Option<ServerInfo>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct ServerInfo {
154 pub name: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub version: Option<String>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct DidOpenTextDocumentParams {
163 pub text_document: TextDocumentItem,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169#[allow(dead_code)]
170pub struct DidCloseTextDocumentParams {
171 pub text_document: TextDocumentIdentifier,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177#[allow(dead_code)]
178pub struct DidChangeTextDocumentParams {
179 pub text_document: VersionedTextDocumentIdentifier,
180 pub content_changes: Vec<TextDocumentContentChangeEvent>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185#[allow(dead_code)]
186pub struct VersionedTextDocumentIdentifier {
187 pub uri: String,
188 pub version: i32,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193#[allow(dead_code)]
194pub struct TextDocumentContentChangeEvent {
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub range: Option<Range>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub range_length: Option<u32>,
199 pub text: String,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ReferenceContext {
206 pub include_declaration: bool,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct ReferenceParams {
213 pub text_document: TextDocumentIdentifier,
214 pub position: Position,
215 pub context: ReferenceContext,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct WorkspaceSymbolParams {
222 pub query: String,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(tag = "type", rename_all = "camelCase")]
228pub enum LspActionResult {
229 Definition { locations: Vec<LocationInfo> },
231 References { locations: Vec<LocationInfo> },
233 Hover {
235 contents: String,
236 range: Option<RangeInfo>,
237 },
238 DocumentSymbols { symbols: Vec<SymbolInfo> },
240 WorkspaceSymbols { symbols: Vec<SymbolInfo> },
242 Implementation { locations: Vec<LocationInfo> },
244 Completion { items: Vec<CompletionItemInfo> },
246 Diagnostics { diagnostics: Vec<DiagnosticInfo> },
248 Error { message: String },
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct LocationInfo {
255 pub uri: String,
256 pub range: RangeInfo,
257}
258
259impl From<Location> for LocationInfo {
260 fn from(loc: Location) -> Self {
261 Self {
262 uri: loc.uri.to_string(),
263 range: RangeInfo::from(loc.range),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct RangeInfo {
271 pub start: PositionInfo,
272 pub end: PositionInfo,
273}
274
275impl From<Range> for RangeInfo {
276 fn from(range: Range) -> Self {
277 Self {
278 start: PositionInfo::from(range.start),
279 end: PositionInfo::from(range.end),
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct PositionInfo {
287 pub line: u32,
288 pub character: u32,
289}
290
291impl From<Position> for PositionInfo {
292 fn from(pos: Position) -> Self {
293 Self {
294 line: pos.line,
295 character: pos.character,
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SymbolInfo {
303 pub name: String,
304 #[serde(rename = "type")]
305 pub kind: String,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub detail: Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub uri: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub range: Option<RangeInfo>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub container_name: Option<String>,
314}
315
316impl From<DocumentSymbol> for SymbolInfo {
317 fn from(sym: DocumentSymbol) -> Self {
318 Self {
319 name: sym.name,
320 kind: format!("{:?}", sym.kind),
321 detail: sym.detail,
322 uri: None,
323 range: Some(RangeInfo::from(sym.range)),
324 container_name: None,
325 }
326 }
327}
328
329impl From<SymbolInformation> for SymbolInfo {
330 fn from(sym: SymbolInformation) -> Self {
331 Self {
332 name: sym.name,
333 kind: format!("{:?}", sym.kind),
334 detail: None,
335 uri: Some(sym.location.uri.to_string()),
336 range: Some(RangeInfo::from(sym.location.range)),
337 container_name: sym.container_name,
338 }
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct CompletionItemInfo {
345 pub label: String,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub kind: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 pub detail: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 pub documentation: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 pub insert_text: Option<String>,
354}
355
356impl From<CompletionItem> for CompletionItemInfo {
357 fn from(item: CompletionItem) -> Self {
358 Self {
359 label: item.label,
360 kind: item.kind.map(|k| format!("{:?}", k)),
361 detail: item.detail,
362 documentation: item.documentation.map(|d| match d {
363 lsp_types::Documentation::String(s) => s,
364 lsp_types::Documentation::MarkupContent(mc) => mc.value,
365 }),
366 insert_text: item.insert_text,
367 }
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct DiagnosticInfo {
374 pub uri: String,
375 pub range: RangeInfo,
376 pub severity: Option<String>,
377 pub code: Option<String>,
378 pub source: Option<String>,
379 pub message: String,
380}
381
382impl DiagnosticInfo {
383 pub fn severity_rank(&self) -> u8 {
384 match self.severity.as_deref() {
385 Some("error") => 1,
386 Some("warning") => 2,
387 Some("information") => 3,
388 Some("hint") => 4,
389 _ => 5,
390 }
391 }
392}
393
394impl From<(String, lsp_types::Diagnostic)> for DiagnosticInfo {
395 fn from((uri, diagnostic): (String, lsp_types::Diagnostic)) -> Self {
396 let severity = diagnostic.severity.map(|severity| match severity {
397 DiagnosticSeverity::ERROR => "error".to_string(),
398 DiagnosticSeverity::WARNING => "warning".to_string(),
399 DiagnosticSeverity::INFORMATION => "information".to_string(),
400 DiagnosticSeverity::HINT => "hint".to_string(),
401 _ => "unknown".to_string(),
402 });
403
404 let code = diagnostic.code.map(|code| match code {
405 lsp_types::NumberOrString::Number(n) => n.to_string(),
406 lsp_types::NumberOrString::String(s) => s,
407 });
408
409 Self {
410 uri,
411 range: RangeInfo::from(diagnostic.range),
412 severity,
413 code,
414 source: diagnostic.source,
415 message: diagnostic.message,
416 }
417 }
418}
419
420pub fn get_language_server_config(language: &str) -> Option<LspConfig> {
422 match language {
423 "rust" => Some(LspConfig {
424 command: rust_analyzer_command(),
425 args: rust_analyzer_args(),
426 file_extensions: vec!["rs".to_string()],
427 timeout_ms: rust_timeout(),
428 ..Default::default()
429 }),
430 "typescript" | "javascript" => Some(LspConfig {
431 command: "typescript-language-server".to_string(),
432 args: vec!["--stdio".to_string()],
433 file_extensions: vec![
434 "ts".to_string(),
435 "tsx".to_string(),
436 "js".to_string(),
437 "jsx".to_string(),
438 ],
439 ..Default::default()
440 }),
441 "python" => Some(LspConfig {
442 command: "pylsp".to_string(),
443 args: vec![],
444 file_extensions: vec!["py".to_string()],
445 ..Default::default()
446 }),
447 "go" => Some(LspConfig {
448 command: "gopls".to_string(),
449 args: vec!["serve".to_string()],
450 file_extensions: vec!["go".to_string()],
451 ..Default::default()
452 }),
453 "c" | "cpp" | "c++" => Some(LspConfig {
454 command: "clangd".to_string(),
455 args: vec![],
456 file_extensions: vec![
457 "c".to_string(),
458 "cpp".to_string(),
459 "cc".to_string(),
460 "cxx".to_string(),
461 "h".to_string(),
462 "hpp".to_string(),
463 ],
464 ..Default::default()
465 }),
466 _ => None,
467 }
468}
469
470fn rust_analyzer_command() -> String {
471 if which::which("rust-analyzer").is_ok() {
472 "rust-analyzer".to_string()
473 } else {
474 "rustup".to_string()
475 }
476}
477
478fn rust_analyzer_args() -> Vec<String> {
479 if which::which("rust-analyzer").is_ok() {
480 Vec::new()
481 } else {
482 vec![
483 "run".to_string(),
484 "stable".to_string(),
485 "rust-analyzer".to_string(),
486 ]
487 }
488}
489
490fn install_command_for(command: &str) -> Option<&'static [&'static str]> {
492 match command {
493 "rust-analyzer" => Some(&["rustup", "component", "add", "rust-analyzer"]),
494 "typescript-language-server" => Some(&[
495 "npm",
496 "install",
497 "-g",
498 "typescript-language-server",
499 "typescript",
500 ]),
501 "pylsp" => Some(&["pip", "install", "--user", "python-lsp-server"]),
502 "gopls" => Some(&["go", "install", "golang.org/x/tools/gopls@latest"]),
503 "clangd" => None, _ => None,
505 }
506}
507
508pub async fn ensure_server_installed(config: &LspConfig) -> Result<()> {
510 if which::which(&config.command).is_ok() {
512 return Ok(());
513 }
514
515 if config.command == "rust-analyzer" {
518 let rustup_status = tokio::process::Command::new("rustup")
519 .args(["run", "stable", "rust-analyzer", "--version"])
520 .stdout(std::process::Stdio::null())
521 .stderr(std::process::Stdio::null())
522 .status()
523 .await;
524
525 if let Ok(status) = rustup_status
526 && status.success()
527 {
528 return Ok(());
529 }
530 }
531
532 let Some(install_args) = install_command_for(&config.command) else {
533 return Err(anyhow::anyhow!(
534 "Language server '{}' not found and no auto-install available. Install it manually.",
535 config.command,
536 ));
537 };
538
539 info!(command = %config.command, "Language server not found, installing...");
540
541 let output = tokio::process::Command::new(install_args[0])
542 .args(&install_args[1..])
543 .stdout(std::process::Stdio::piped())
544 .stderr(std::process::Stdio::piped())
545 .output()
546 .await?;
547
548 if !output.status.success() {
549 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
550 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
551 return Err(anyhow::anyhow!(
552 "Failed to install '{}' (exit code {:?}). stdout: {} stderr: {}",
553 config.command,
554 output.status.code(),
555 stdout,
556 stderr,
557 ));
558 }
559
560 if which::which(&config.command).is_err() {
562 if config.command == "rust-analyzer" {
563 let rustup_status = tokio::process::Command::new("rustup")
564 .args(["run", "stable", "rust-analyzer", "--version"])
565 .stdout(std::process::Stdio::null())
566 .stderr(std::process::Stdio::null())
567 .status()
568 .await;
569 if let Ok(status) = rustup_status
570 && status.success()
571 {
572 info!(command = %config.command, "Language server installed and available via rustup run stable");
573 return Ok(());
574 }
575 }
576 warn!(command = %config.command, "Install succeeded but binary still not found on PATH");
577 } else {
578 info!(command = %config.command, "Language server installed successfully");
579 }
580
581 Ok(())
582}
583
584pub fn detect_language_from_path(path: &str) -> Option<&'static str> {
586 let ext = path.rsplit('.').next()?;
587 match ext {
588 "rs" => Some("rust"),
589 "ts" | "tsx" => Some("typescript"),
590 "js" | "jsx" => Some("javascript"),
591 "py" => Some("python"),
592 "go" => Some("go"),
593 "c" => Some("c"),
594 "cpp" | "cc" | "cxx" => Some("cpp"),
595 "h" => Some("c"),
596 "hpp" => Some("cpp"),
597 _ => None,
598 }
599}
600
601pub fn get_linter_server_config(name: &str) -> Option<LspConfig> {
604 match name {
605 "eslint" => Some(LspConfig {
606 command: "vscode-eslint-language-server".to_string(),
607 args: vec!["--stdio".to_string()],
608 file_extensions: vec![
609 "js".to_string(),
610 "jsx".to_string(),
611 "ts".to_string(),
612 "tsx".to_string(),
613 "mjs".to_string(),
614 "cjs".to_string(),
615 ],
616 ..Default::default()
617 }),
618 "biome" => Some(LspConfig {
619 command: "biome".to_string(),
620 args: vec!["lsp-proxy".to_string()],
621 file_extensions: vec![
622 "js".to_string(),
623 "jsx".to_string(),
624 "ts".to_string(),
625 "tsx".to_string(),
626 "json".to_string(),
627 "css".to_string(),
628 ],
629 ..Default::default()
630 }),
631 "ruff" => Some(LspConfig {
632 command: "ruff".to_string(),
633 args: vec!["server".to_string()],
634 file_extensions: vec!["py".to_string(), "pyi".to_string()],
635 ..Default::default()
636 }),
637 "stylelint" => Some(LspConfig {
638 command: "stylelint-lsp".to_string(),
639 args: vec!["--stdio".to_string()],
640 file_extensions: vec!["css".to_string(), "scss".to_string(), "less".to_string()],
641 ..Default::default()
642 }),
643 _ => None,
644 }
645}
646
647impl LspConfig {
649 pub fn from_server_entry(
650 entry: &crate::config::LspServerEntry,
651 root_uri: Option<String>,
652 ) -> Self {
653 Self {
654 command: entry.command.clone(),
655 args: entry.args.clone(),
656 root_uri,
657 file_extensions: entry.file_extensions.clone(),
658 initialization_options: entry.initialization_options.clone(),
659 timeout_ms: entry.timeout_ms,
660 }
661 }
662
663 pub fn from_linter_entry(
664 name: &str,
665 entry: &crate::config::LspLinterEntry,
666 root_uri: Option<String>,
667 ) -> Option<Self> {
668 let mut base = if let Some(builtin) = get_linter_server_config(name) {
671 builtin
672 } else {
673 let command = entry.command.as_ref()?;
675 LspConfig {
676 command: command.clone(),
677 ..Default::default()
678 }
679 };
680
681 if let Some(cmd) = &entry.command {
683 base.command = cmd.clone();
684 }
685 if !entry.args.is_empty() {
686 base.args = entry.args.clone();
687 }
688 if !entry.file_extensions.is_empty() {
689 base.file_extensions = entry.file_extensions.clone();
690 }
691 if entry.initialization_options.is_some() {
692 base.initialization_options = entry.initialization_options.clone();
693 }
694 base.root_uri = root_uri;
695 Some(base)
696 }
697}
698
699pub fn linter_extensions(name: &str) -> &'static [&'static str] {
701 match name {
702 "eslint" => &["js", "jsx", "ts", "tsx", "mjs", "cjs"],
703 "biome" => &["js", "jsx", "ts", "tsx", "json", "css"],
704 "ruff" => &["py", "pyi"],
705 "stylelint" => &["css", "scss", "less"],
706 _ => &[],
707 }
708}