Skip to main content

mcpls_core/
error.rs

1//! Error types for mcpls-core.
2//!
3//! This module defines the canonical error type for the library,
4//! following the Microsoft Rust Guidelines for error handling.
5
6use std::path::PathBuf;
7
8use crate::config::{ServerId, ToolKind};
9
10/// Rewrites an LSP server's raw error message for display to the MCP caller,
11/// replacing rust-analyzer's "Invalid offset" internal error with a clean,
12/// client-appropriate message.
13///
14/// rust-analyzer returns this `Debug`-formatted internal error (embedding its
15/// `LineCol` struct and the line index's byte length, e.g. `"Invalid offset
16/// LineCol { line: 2291, col: 0 } (line index length: 100417)"`) when a
17/// position-based request's `line` or `character` falls outside the target
18/// document. Every other [`Error`] variant produces a clean message; this
19/// function keeps [`Error::LspServerError`]'s `Display` impl consistent with
20/// that convention instead of forwarding the upstream server's internals
21/// verbatim.
22///
23/// Matches via `contains` rather than `starts_with`: rust-analyzer's error
24/// travels through `anyhow`/`lsp_server` before reaching mcpls, so a future
25/// upstream `.context(...)` wrapper (or a truncation prefix added on the
26/// mcpls side) could prepend text ahead of `"Invalid offset LineCol"` without
27/// mcpls's control -- `contains` keeps the guard robust to that at no extra
28/// cost. Deliberately not also gated on the JSON-RPC error `code`: this error
29/// class has been observed under both `-32603` (internal error) and `-32803`
30/// (`RequestFailed`) across rust-analyzer versions, so a code condition would
31/// make the guard more fragile, not less.
32fn sanitize_lsp_server_message(message: &str) -> String {
33    if message.contains("Invalid offset LineCol") {
34        "position out of range for this document".to_string()
35    } else {
36        message.to_string()
37    }
38}
39
40/// Details of a single server spawn failure.
41#[derive(Debug, Clone)]
42pub struct ServerSpawnFailure {
43    /// Routing identity of the failed server.
44    pub server_id: ServerId,
45    /// Language ID of the failed server.
46    pub language_id: String,
47    /// Command that was attempted.
48    pub command: String,
49    /// Error message describing the failure.
50    pub message: String,
51}
52
53impl std::fmt::Display for ServerSpawnFailure {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(
56            f,
57            "{} [{}] ({}): {}",
58            self.server_id, self.language_id, self.command, self.message
59        )
60    }
61}
62
63/// The main error type for mcpls-core operations.
64///
65/// This enum is `#[non_exhaustive]`: downstream crates that match on it must
66/// include a wildcard arm. New variants (such as [`Error::ServerInitializing`])
67/// can then be added without further breaking changes.
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum Error {
71    /// LSP server failed to initialize.
72    #[error("LSP server initialization failed: {message}")]
73    LspInitFailed {
74        /// Description of the initialization failure.
75        message: String,
76    },
77
78    /// LSP server returned an error response.
79    #[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
80    LspServerError {
81        /// JSON-RPC error code.
82        code: i32,
83        /// Raw error message from the server, kept verbatim for diagnostics
84        /// (logging, `Debug`, pattern matching). The `Display` impl for this
85        /// variant rewrites known-internal upstream text before it reaches
86        /// an MCP caller, so this field is not always what the caller sees.
87        message: String,
88        /// Optional additional data from the JSON-RPC error object.
89        data: Option<serde_json::Value>,
90    },
91
92    /// MCP server error.
93    #[error("MCP server error: {0}")]
94    McpServer(String),
95
96    /// Document was not found or could not be opened.
97    #[error("document not found: {0}")]
98    DocumentNotFound(PathBuf),
99
100    /// No LSP server configured for the given language.
101    #[error("no LSP server configured for language: {0}")]
102    NoServerForLanguage(String),
103
104    /// A server is configured for the language, but no server claims this
105    /// specific tool (either no server lists it in `handles` and there is no
106    /// catch-all, or the server that claimed it failed to spawn with no live
107    /// catch-all to rebind to).
108    #[error("no server handles tool '{tool}' for language '{language_id}'")]
109    NoServerForTool {
110        /// Language ID the request was for.
111        language_id: String,
112        /// Tool that no server claims.
113        tool: ToolKind,
114    },
115
116    /// LSP server for the language is configured but still initializing.
117    #[error(
118        "LSP server '{server_id}' is still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
119    )]
120    ServerInitializing {
121        /// Routing identity of the server that has not yet registered.
122        server_id: ServerId,
123    },
124
125    /// A workspace-wide tool (one with no file to resolve a language from,
126    /// e.g. `workspace_symbol_search`) could not be routed because at least
127    /// one expected LSP server has not registered yet. Unlike
128    /// [`Error::ServerInitializing`], resolution never narrowed down to a
129    /// single candidate server, so no `server_id` is available.
130    #[error(
131        "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
132    )]
133    WorkspaceServersInitializing,
134
135    /// No LSP server is currently configured.
136    #[error("no LSP server configured")]
137    NoServerConfigured,
138
139    /// At least one server is configured somewhere in the workspace, but
140    /// none of them claims a workspace-wide tool that has no file to
141    /// resolve a language from (e.g. `workspace_symbol_search`). The
142    /// language-less counterpart of [`Error::NoServerForTool`].
143    #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
144    NoServerForWorkspaceTool {
145        /// Tool that no server claims anywhere in the workspace.
146        tool: ToolKind,
147    },
148
149    /// Configuration file not found.
150    #[error("configuration file not found: {0}")]
151    ConfigNotFound(PathBuf),
152
153    /// Invalid configuration format.
154    #[error("invalid configuration: {0}")]
155    InvalidConfig(String),
156
157    /// I/O error.
158    #[error("I/O error: {0}")]
159    Io(#[from] std::io::Error),
160
161    /// JSON serialization/deserialization error.
162    #[error("JSON error: {0}")]
163    Json(#[from] serde_json::Error),
164
165    /// TOML deserialization error.
166    #[error("TOML parsing error: {0}")]
167    TomlDe(#[from] toml::de::Error),
168
169    /// TOML serialization error.
170    #[error("TOML serialization error: {0}")]
171    TomlSer(#[from] toml::ser::Error),
172
173    /// LSP client transport error.
174    #[error("transport error: {0}")]
175    Transport(String),
176
177    /// Request timeout.
178    #[error("request timed out after {0} seconds")]
179    Timeout(u64),
180
181    /// LSP server failed to spawn.
182    #[error("failed to spawn LSP server '{command}': {source}")]
183    ServerSpawnFailed {
184        /// Command that failed to spawn.
185        command: String,
186        /// Underlying IO error.
187        #[source]
188        source: std::io::Error,
189    },
190
191    /// LSP protocol error during message parsing.
192    #[error("LSP protocol error: {0}")]
193    LspProtocolError(String),
194
195    /// Invalid URI format.
196    #[error("invalid URI: {0}")]
197    InvalidUri(String),
198
199    /// Server process terminated unexpectedly.
200    #[error("LSP server process terminated unexpectedly")]
201    ServerTerminated,
202
203    /// A crashed server could not be automatically respawned.
204    ///
205    /// Distinct from [`Self::ServerTerminated`] so a caller (or a log
206    /// reader) can tell "the connection just died" apart from "mcpls tried
207    /// to bring it back and could not" -- e.g. no respawn config was ever
208    /// registered for it, or it is crash-looping and is being backed off.
209    #[error("LSP server '{server_id}' is unavailable: {reason}")]
210    ServerUnavailable {
211        /// Routing identity of the server that could not be respawned.
212        server_id: ServerId,
213        /// Human-readable reason the respawn did not proceed.
214        reason: String,
215    },
216
217    /// Invalid tool parameters provided.
218    #[error("invalid tool parameters: {0}")]
219    InvalidToolParams(String),
220
221    /// File I/O error occurred.
222    #[error("file I/O error for {path:?}: {source}")]
223    FileIo {
224        /// Path to the file.
225        path: PathBuf,
226        /// Underlying I/O error.
227        #[source]
228        source: std::io::Error,
229    },
230
231    /// Path is outside allowed workspace boundaries.
232    #[error("path outside workspace: {0}")]
233    PathOutsideWorkspace(PathBuf),
234
235    /// Document limit exceeded.
236    #[error(
237        "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
238    )]
239    DocumentLimitExceeded {
240        /// Current number of documents.
241        current: usize,
242        /// Maximum allowed documents.
243        max: usize,
244    },
245
246    /// File size limit exceeded.
247    #[error(
248        "file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)"
249    )]
250    FileSizeLimitExceeded {
251        /// Actual file size.
252        size: u64,
253        /// Maximum allowed size.
254        max: u64,
255    },
256
257    /// All configured LSP servers failed to initialize.
258    #[error("all LSP servers failed to initialize ({count} configured)")]
259    AllServersFailedToInit {
260        /// Number of servers that were configured.
261        count: usize,
262        /// Details of each failure.
263        failures: Vec<ServerSpawnFailure>,
264    },
265
266    /// No LSP servers available (none configured or all failed).
267    #[error("{0}")]
268    NoServersAvailable(String),
269
270    /// The server routed for this request does not advertise support for the
271    /// requested LSP capability (e.g. no `renameProvider` in its
272    /// `ServerCapabilities`).
273    #[error("server '{server_id}' does not support capability '{capability}'")]
274    CapabilityNotSupported {
275        /// Routing identity of the server that lacks the capability.
276        server_id: ServerId,
277        /// Name of the missing LSP capability field (e.g. `"renameProvider"`).
278        capability: &'static str,
279    },
280}
281
282/// A specialized Result type for mcpls-core operations.
283pub type Result<T> = std::result::Result<T, Error>;
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn test_error_display_lsp_init_failed() {
291        let err = Error::LspInitFailed {
292            message: "server not found".to_string(),
293        };
294        assert_eq!(
295            err.to_string(),
296            "LSP server initialization failed: server not found"
297        );
298    }
299
300    #[test]
301    fn test_error_display_lsp_server_error() {
302        let err = Error::LspServerError {
303            code: -32600,
304            message: "Invalid request".to_string(),
305            data: None,
306        };
307        assert_eq!(
308            err.to_string(),
309            "LSP server error: -32600 - Invalid request"
310        );
311    }
312
313    #[test]
314    fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
315        let err = Error::LspServerError {
316            code: -32603,
317            message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
318                .to_string(),
319            data: None,
320        };
321        assert_eq!(
322            err.to_string(),
323            "LSP server error: -32603 - position out of range for this document"
324        );
325    }
326
327    #[test]
328    fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
329        // Guards the `contains` (not `starts_with`) match: an upstream
330        // wrapper (e.g. an `anyhow::Context`) or a future mcpls-side prefix
331        // could prepend text ahead of rust-analyzer's raw message.
332        let err = Error::LspServerError {
333            code: -32803,
334            message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
335                      (line index length: 3)"
336                .to_string(),
337            data: None,
338        };
339        assert_eq!(
340            err.to_string(),
341            "LSP server error: -32803 - position out of range for this document"
342        );
343    }
344
345    #[test]
346    fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
347        let err = Error::LspServerError {
348            code: -32602,
349            message: "Invalid params: expected object".to_string(),
350            data: None,
351        };
352        assert_eq!(
353            err.to_string(),
354            "LSP server error: -32602 - Invalid params: expected object"
355        );
356    }
357
358    #[test]
359    fn test_error_display_document_not_found() {
360        let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
361        assert!(err.to_string().contains("document not found"));
362        assert!(err.to_string().contains("file.rs"));
363    }
364
365    #[test]
366    fn test_error_display_no_server_for_language() {
367        let err = Error::NoServerForLanguage("rust".to_string());
368        assert_eq!(
369            err.to_string(),
370            "no LSP server configured for language: rust"
371        );
372    }
373
374    #[test]
375    fn test_error_display_workspace_servers_initializing() {
376        let err = Error::WorkspaceServersInitializing;
377        assert!(err.to_string().contains("still initializing"));
378    }
379
380    #[test]
381    fn test_error_display_no_server_for_workspace_tool() {
382        let err = Error::NoServerForWorkspaceTool {
383            tool: crate::config::ToolKind::WorkspaceSymbols,
384        };
385        assert!(err.to_string().contains("workspace_symbols"));
386        assert!(err.to_string().contains("no server's `handles` list"));
387    }
388
389    #[test]
390    fn test_error_display_timeout() {
391        let err = Error::Timeout(30);
392        assert_eq!(err.to_string(), "request timed out after 30 seconds");
393    }
394
395    #[test]
396    fn test_error_display_document_limit() {
397        let err = Error::DocumentLimitExceeded {
398            current: 150,
399            max: 100,
400        };
401        assert_eq!(
402            err.to_string(),
403            "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
404        );
405    }
406
407    #[test]
408    fn test_error_display_file_size_limit() {
409        let err = Error::FileSizeLimitExceeded {
410            size: 20_000_000,
411            max: 10_000_000,
412        };
413        assert_eq!(
414            err.to_string(),
415            "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
416        );
417    }
418
419    #[test]
420    fn test_error_from_io() {
421        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
422        let err: Error = io_err.into();
423        assert!(matches!(err, Error::Io(_)));
424    }
425
426    #[test]
427    #[allow(clippy::unwrap_used)]
428    fn test_error_from_json() {
429        let json_str = "{invalid json}";
430        let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
431        let err: Error = json_err.into();
432        assert!(matches!(err, Error::Json(_)));
433    }
434
435    #[test]
436    #[allow(clippy::unwrap_used)]
437    fn test_error_from_toml_de() {
438        let toml_str = "[invalid toml";
439        let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
440        let err: Error = toml_err.into();
441        assert!(matches!(err, Error::TomlDe(_)));
442    }
443
444    #[test]
445    fn test_result_type_alias() {
446        fn _returns_error() -> Result<i32> {
447            Err(Error::InvalidConfig("test error".to_string()))
448        }
449
450        let result: Result<i32> = Ok(42);
451        assert!(result.is_ok());
452        if let Ok(value) = result {
453            assert_eq!(value, 42);
454        }
455    }
456
457    #[test]
458    fn test_error_source_chain() {
459        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
460        let err = Error::ServerSpawnFailed {
461            command: "rust-analyzer".to_string(),
462            source: io_err,
463        };
464
465        let source = std::error::Error::source(&err);
466        assert!(source.is_some());
467    }
468
469    #[test]
470    fn test_server_spawn_failure_display() {
471        let failure = ServerSpawnFailure {
472            server_id: ServerId::from("rust"),
473            language_id: "rust".to_string(),
474            command: "rust-analyzer".to_string(),
475            message: "No such file or directory".to_string(),
476        };
477        assert_eq!(
478            failure.to_string(),
479            "rust [rust] (rust-analyzer): No such file or directory"
480        );
481    }
482
483    #[test]
484    fn test_server_spawn_failure_debug() {
485        let failure = ServerSpawnFailure {
486            server_id: ServerId::from("python"),
487            language_id: "python".to_string(),
488            command: "pyright".to_string(),
489            message: "command not found".to_string(),
490        };
491        let debug_str = format!("{failure:?}");
492        assert!(debug_str.contains("python"));
493        assert!(debug_str.contains("pyright"));
494        assert!(debug_str.contains("command not found"));
495    }
496
497    #[test]
498    fn test_server_spawn_failure_clone() {
499        let failure = ServerSpawnFailure {
500            server_id: ServerId::from("typescript"),
501            language_id: "typescript".to_string(),
502            command: "tsserver".to_string(),
503            message: "failed to start".to_string(),
504        };
505        let cloned = failure.clone();
506        assert_eq!(failure.language_id, cloned.language_id);
507        assert_eq!(failure.command, cloned.command);
508        assert_eq!(failure.message, cloned.message);
509    }
510
511    #[test]
512    fn test_error_display_all_servers_failed_to_init() {
513        let err = Error::AllServersFailedToInit {
514            count: 2,
515            failures: vec![],
516        };
517        assert_eq!(
518            err.to_string(),
519            "all LSP servers failed to initialize (2 configured)"
520        );
521    }
522
523    #[test]
524    fn test_error_all_servers_failed_with_failures() {
525        let failures = vec![
526            ServerSpawnFailure {
527                server_id: ServerId::from("rust"),
528                language_id: "rust".to_string(),
529                command: "rust-analyzer".to_string(),
530                message: "not found".to_string(),
531            },
532            ServerSpawnFailure {
533                server_id: ServerId::from("python"),
534                language_id: "python".to_string(),
535                command: "pyright".to_string(),
536                message: "permission denied".to_string(),
537            },
538        ];
539
540        let err = Error::AllServersFailedToInit { count: 2, failures };
541
542        assert!(err.to_string().contains("all LSP servers failed"));
543        assert!(err.to_string().contains("2 configured"));
544    }
545
546    #[test]
547    fn test_error_display_no_servers_available() {
548        let err =
549            Error::NoServersAvailable("none configured or all failed to initialize".to_string());
550        assert_eq!(
551            err.to_string(),
552            "none configured or all failed to initialize"
553        );
554    }
555
556    #[test]
557    fn test_error_no_servers_available_with_custom_message() {
558        let custom_msg = "none configured or all failed to initialize";
559        let err = Error::NoServersAvailable(custom_msg.to_string());
560        assert_eq!(err.to_string(), custom_msg);
561    }
562
563    #[test]
564    fn test_error_display_capability_not_supported() {
565        let err = Error::CapabilityNotSupported {
566            server_id: ServerId::from("rust"),
567            capability: "renameProvider",
568        };
569        assert_eq!(
570            err.to_string(),
571            "server 'rust' does not support capability 'renameProvider'"
572        );
573    }
574}