Skip to main content

codetether_agent/lsp/
types.rs

1//! LSP type definitions based on LSP 3.17 specification
2//!
3//! These types map directly to the LSP protocol types.
4
5use 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/// LSP client configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LspConfig {
17    /// Command to spawn the language server
18    pub command: String,
19    /// Arguments to pass to the language server
20    #[serde(default)]
21    pub args: Vec<String>,
22    /// Root URI for the workspace
23    pub root_uri: Option<String>,
24    /// File extensions this server handles
25    #[serde(default)]
26    pub file_extensions: Vec<String>,
27    /// Initialization options to pass to the server
28    #[serde(default)]
29    pub initialization_options: Option<Value>,
30    /// Timeout for requests in milliseconds
31    #[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/// JSON-RPC request for LSP
57#[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/// JSON-RPC response for LSP
78#[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/// JSON-RPC error
89#[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/// JSON-RPC notification for LSP
98#[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/// Initialize parameters
117#[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/// Initialize result
144#[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/// DidOpenTextDocument parameters
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct DidOpenTextDocumentParams {
163    pub text_document: TextDocumentItem,
164}
165
166/// DidCloseTextDocument parameters
167#[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/// DidChangeTextDocument parameters
175#[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/// Reference context for find references
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct ReferenceContext {
206    pub include_declaration: bool,
207}
208
209/// Reference parameters
210#[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/// Workspace symbol parameters
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct WorkspaceSymbolParams {
222    pub query: String,
223}
224
225/// LSP action result - unified response type for the tool
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(tag = "type", rename_all = "camelCase")]
228pub enum LspActionResult {
229    /// Go to definition result
230    Definition { locations: Vec<LocationInfo> },
231    /// Find references result
232    References { locations: Vec<LocationInfo> },
233    /// Hover result
234    Hover {
235        contents: String,
236        range: Option<RangeInfo>,
237    },
238    /// Document symbols result
239    DocumentSymbols { symbols: Vec<SymbolInfo> },
240    /// Workspace symbols result
241    WorkspaceSymbols { symbols: Vec<SymbolInfo> },
242    /// Go to implementation result
243    Implementation { locations: Vec<LocationInfo> },
244    /// Completion result
245    Completion { items: Vec<CompletionItemInfo> },
246    /// Diagnostics result
247    Diagnostics { diagnostics: Vec<DiagnosticInfo> },
248    /// Error result
249    Error { message: String },
250}
251
252/// Simplified location info for tool output
253#[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/// Simplified range info
269#[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/// Simplified position info
285#[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/// Simplified symbol info
301#[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/// Simplified completion item
343#[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/// Simplified diagnostic info for tool output and proactive session context
372#[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
420/// Known language server configurations
421pub 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
490/// Returns the install command for a language server binary, if known.
491fn 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, // system package manager varies
504        _ => None,
505    }
506}
507
508/// Ensure a language server binary is available, installing it if possible.
509pub async fn ensure_server_installed(config: &LspConfig) -> Result<()> {
510    // Check if the binary is already on PATH.
511    if which::which(&config.command).is_ok() {
512        return Ok(());
513    }
514
515    // rust-analyzer is commonly installed via rustup but may not be visible on PATH
516    // in the current process environment. Fall back to `rustup run <toolchain> rust-analyzer`.
517    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    // Verify installation succeeded
561    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
584/// Detect language from file extension
585pub 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
601/// Built-in linter server configurations.
602/// Returns an `LspConfig` for well-known linter language servers.
603pub 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
647/// Convert a config `LspServerEntry` into an `LspConfig`.
648impl 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        // Start from the built-in config if the linter name is known,
669        // then overlay any user overrides.
670        let mut base = if let Some(builtin) = get_linter_server_config(name) {
671            builtin
672        } else {
673            // Fully custom linter — command is required.
674            let command = entry.command.as_ref()?;
675            LspConfig {
676                command: command.clone(),
677                ..Default::default()
678            }
679        };
680
681        // User overrides
682        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
699/// Returns all file extensions handled by a named linter (built-in defaults).
700pub 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}