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, DocumentSymbol, Location, Position, Range,
8    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
39impl Default for LspConfig {
40    fn default() -> Self {
41        Self {
42            command: String::new(),
43            args: Vec::new(),
44            root_uri: None,
45            file_extensions: Vec::new(),
46            initialization_options: None,
47            timeout_ms: default_timeout(),
48        }
49    }
50}
51
52/// JSON-RPC request for LSP
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct JsonRpcRequest {
55    pub jsonrpc: String,
56    pub id: i64,
57    pub method: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub params: Option<Value>,
60}
61
62impl JsonRpcRequest {
63    pub fn new(id: i64, method: &str, params: Option<Value>) -> Self {
64        Self {
65            jsonrpc: "2.0".to_string(),
66            id,
67            method: method.to_string(),
68            params,
69        }
70    }
71}
72
73/// JSON-RPC response for LSP
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct JsonRpcResponse {
76    pub jsonrpc: String,
77    pub id: i64,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub result: Option<Value>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub error: Option<JsonRpcError>,
82}
83
84/// JSON-RPC error
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct JsonRpcError {
87    pub code: i64,
88    pub message: String,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub data: Option<Value>,
91}
92
93/// JSON-RPC notification for LSP
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct JsonRpcNotification {
96    pub jsonrpc: String,
97    pub method: String,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub params: Option<Value>,
100}
101
102impl JsonRpcNotification {
103    pub fn new(method: &str, params: Option<Value>) -> Self {
104        Self {
105            jsonrpc: "2.0".to_string(),
106            method: method.to_string(),
107            params,
108        }
109    }
110}
111
112/// Initialize parameters
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct InitializeParams {
116    pub process_id: Option<i64>,
117    pub client_info: ClientInfo,
118    pub locale: Option<String>,
119    pub root_path: Option<String>,
120    pub root_uri: Option<String>,
121    pub initialization_options: Option<Value>,
122    pub capabilities: ClientCapabilities,
123    pub trace: Option<String>,
124    pub workspace_folders: Option<Vec<WorkspaceFolder>>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ClientInfo {
129    pub name: String,
130    pub version: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct WorkspaceFolder {
135    pub uri: String,
136    pub name: String,
137}
138
139/// Initialize result
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct InitializeResult {
143    pub capabilities: ServerCapabilities,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub server_info: Option<ServerInfo>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ServerInfo {
150    pub name: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub version: Option<String>,
153}
154
155/// DidOpenTextDocument parameters
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct DidOpenTextDocumentParams {
159    pub text_document: TextDocumentItem,
160}
161
162/// DidCloseTextDocument parameters
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct DidCloseTextDocumentParams {
166    pub text_document: TextDocumentIdentifier,
167}
168
169/// DidChangeTextDocument parameters
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct DidChangeTextDocumentParams {
173    pub text_document: VersionedTextDocumentIdentifier,
174    pub content_changes: Vec<TextDocumentContentChangeEvent>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct VersionedTextDocumentIdentifier {
180    pub uri: String,
181    pub version: i32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct TextDocumentContentChangeEvent {
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub range: Option<Range>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub range_length: Option<u32>,
191    pub text: String,
192}
193
194/// Reference context for find references
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct ReferenceContext {
198    pub include_declaration: bool,
199}
200
201/// Reference parameters
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct ReferenceParams {
205    pub text_document: TextDocumentIdentifier,
206    pub position: Position,
207    pub context: ReferenceContext,
208}
209
210/// Workspace symbol parameters
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct WorkspaceSymbolParams {
214    pub query: String,
215}
216
217/// LSP action result - unified response type for the tool
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(tag = "type", rename_all = "camelCase")]
220pub enum LspActionResult {
221    /// Go to definition result
222    Definition { locations: Vec<LocationInfo> },
223    /// Find references result
224    References { locations: Vec<LocationInfo> },
225    /// Hover result
226    Hover {
227        contents: String,
228        range: Option<RangeInfo>,
229    },
230    /// Document symbols result
231    DocumentSymbols { symbols: Vec<SymbolInfo> },
232    /// Workspace symbols result
233    WorkspaceSymbols { symbols: Vec<SymbolInfo> },
234    /// Go to implementation result
235    Implementation { locations: Vec<LocationInfo> },
236    /// Completion result
237    Completion { items: Vec<CompletionItemInfo> },
238    /// Error result
239    Error { message: String },
240}
241
242/// Simplified location info for tool output
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct LocationInfo {
245    pub uri: String,
246    pub range: RangeInfo,
247}
248
249impl From<Location> for LocationInfo {
250    fn from(loc: Location) -> Self {
251        Self {
252            uri: loc.uri.to_string(),
253            range: RangeInfo::from(loc.range),
254        }
255    }
256}
257
258/// Simplified range info
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct RangeInfo {
261    pub start: PositionInfo,
262    pub end: PositionInfo,
263}
264
265impl From<Range> for RangeInfo {
266    fn from(range: Range) -> Self {
267        Self {
268            start: PositionInfo::from(range.start),
269            end: PositionInfo::from(range.end),
270        }
271    }
272}
273
274/// Simplified position info
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct PositionInfo {
277    pub line: u32,
278    pub character: u32,
279}
280
281impl From<Position> for PositionInfo {
282    fn from(pos: Position) -> Self {
283        Self {
284            line: pos.line,
285            character: pos.character,
286        }
287    }
288}
289
290/// Simplified symbol info
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct SymbolInfo {
293    pub name: String,
294    #[serde(rename = "type")]
295    pub kind: String,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub detail: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub uri: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub range: Option<RangeInfo>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub container_name: Option<String>,
304}
305
306impl From<DocumentSymbol> for SymbolInfo {
307    fn from(sym: DocumentSymbol) -> Self {
308        Self {
309            name: sym.name,
310            kind: format!("{:?}", sym.kind),
311            detail: sym.detail,
312            uri: None,
313            range: Some(RangeInfo::from(sym.range)),
314            container_name: None,
315        }
316    }
317}
318
319impl From<SymbolInformation> for SymbolInfo {
320    fn from(sym: SymbolInformation) -> Self {
321        Self {
322            name: sym.name,
323            kind: format!("{:?}", sym.kind),
324            detail: None,
325            uri: Some(sym.location.uri.to_string()),
326            range: Some(RangeInfo::from(sym.location.range)),
327            container_name: sym.container_name,
328        }
329    }
330}
331
332/// Simplified completion item
333#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct CompletionItemInfo {
335    pub label: String,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub kind: Option<String>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub detail: Option<String>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub documentation: Option<String>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub insert_text: Option<String>,
344}
345
346impl From<CompletionItem> for CompletionItemInfo {
347    fn from(item: CompletionItem) -> Self {
348        Self {
349            label: item.label,
350            kind: item.kind.map(|k| format!("{:?}", k)),
351            detail: item.detail,
352            documentation: item.documentation.map(|d| match d {
353                lsp_types::Documentation::String(s) => s,
354                lsp_types::Documentation::MarkupContent(mc) => mc.value,
355            }),
356            insert_text: item.insert_text,
357        }
358    }
359}
360
361/// Known language server configurations
362pub fn get_language_server_config(language: &str) -> Option<LspConfig> {
363    match language {
364        "rust" => Some(LspConfig {
365            command: "rust-analyzer".to_string(),
366            args: vec![],
367            file_extensions: vec!["rs".to_string()],
368            ..Default::default()
369        }),
370        "typescript" | "javascript" => Some(LspConfig {
371            command: "typescript-language-server".to_string(),
372            args: vec!["--stdio".to_string()],
373            file_extensions: vec![
374                "ts".to_string(),
375                "tsx".to_string(),
376                "js".to_string(),
377                "jsx".to_string(),
378            ],
379            ..Default::default()
380        }),
381        "python" => Some(LspConfig {
382            command: "pylsp".to_string(),
383            args: vec![],
384            file_extensions: vec!["py".to_string()],
385            ..Default::default()
386        }),
387        "go" => Some(LspConfig {
388            command: "gopls".to_string(),
389            args: vec!["serve".to_string()],
390            file_extensions: vec!["go".to_string()],
391            ..Default::default()
392        }),
393        "c" | "cpp" | "c++" => Some(LspConfig {
394            command: "clangd".to_string(),
395            args: vec![],
396            file_extensions: vec![
397                "c".to_string(),
398                "cpp".to_string(),
399                "cc".to_string(),
400                "cxx".to_string(),
401                "h".to_string(),
402                "hpp".to_string(),
403            ],
404            ..Default::default()
405        }),
406        _ => None,
407    }
408}
409
410/// Returns the install command for a language server binary, if known.
411fn install_command_for(command: &str) -> Option<&'static [&'static str]> {
412    match command {
413        "rust-analyzer" => Some(&["rustup", "component", "add", "rust-analyzer"]),
414        "typescript-language-server" => Some(&[
415            "npm",
416            "install",
417            "-g",
418            "typescript-language-server",
419            "typescript",
420        ]),
421        "pylsp" => Some(&["pip", "install", "--user", "python-lsp-server"]),
422        "gopls" => Some(&["go", "install", "golang.org/x/tools/gopls@latest"]),
423        "clangd" => None, // system package manager varies
424        _ => None,
425    }
426}
427
428/// Ensure a language server binary is available, installing it if possible.
429pub async fn ensure_server_installed(config: &LspConfig) -> Result<()> {
430    // Check if the binary is already on PATH
431    if which::which(&config.command).is_ok() {
432        return Ok(());
433    }
434
435    let Some(install_args) = install_command_for(&config.command) else {
436        return Err(anyhow::anyhow!(
437            "Language server '{}' not found and no auto-install available. \
438             Install it manually.",
439            config.command,
440        ));
441    };
442
443    info!(command = %config.command, "Language server not found, installing...");
444
445    let status = tokio::process::Command::new(install_args[0])
446        .args(&install_args[1..])
447        .stdout(std::process::Stdio::piped())
448        .stderr(std::process::Stdio::piped())
449        .status()
450        .await?;
451
452    if !status.success() {
453        return Err(anyhow::anyhow!(
454            "Failed to install '{}' (exit code {:?}). Install it manually.",
455            config.command,
456            status.code(),
457        ));
458    }
459
460    // Verify installation succeeded
461    if which::which(&config.command).is_err() {
462        warn!(command = %config.command, "Install succeeded but binary still not found on PATH");
463    } else {
464        info!(command = %config.command, "Language server installed successfully");
465    }
466
467    Ok(())
468}
469
470/// Detect language from file extension
471pub fn detect_language_from_path(path: &str) -> Option<&'static str> {
472    let ext = path.rsplit('.').next()?;
473    match ext {
474        "rs" => Some("rust"),
475        "ts" | "tsx" => Some("typescript"),
476        "js" | "jsx" => Some("javascript"),
477        "py" => Some("python"),
478        "go" => Some("go"),
479        "c" => Some("c"),
480        "cpp" | "cc" | "cxx" => Some("cpp"),
481        "h" => Some("c"),
482        "hpp" => Some("cpp"),
483        _ => None,
484    }
485}