Skip to main content

pathfinder_lsp/
error.rs

1//! Error types for LSP operations.
2
3use thiserror::Error;
4
5#[cfg(test)]
6mod tests {
7    use super::*;
8    use std::io;
9
10    #[test]
11    #[allow(clippy::unwrap_used)]
12    fn test_lsp_error_no_lsp_available_recovery_hint() {
13        let err = LspError::NoLspAvailable;
14        let hint = err.recovery_hint();
15
16        assert!(hint.is_some());
17        let hint_str = hint.unwrap();
18        assert!(hint_str.contains("No LSP available"));
19        assert!(hint_str.contains("lsp_health"));
20    }
21
22    #[test]
23    #[allow(clippy::unwrap_used)]
24    fn test_lsp_error_timeout_recovery_hint() {
25        let err = LspError::Timeout {
26            operation: "textDocument/definition".to_string(),
27            timeout_ms: 10000,
28        };
29        let hint = err.recovery_hint();
30
31        assert!(hint.is_some());
32        let hint_str = hint.unwrap();
33        assert!(hint_str.contains("textDocument/definition"));
34        assert!(hint_str.contains("10000ms"));
35        assert!(hint_str.contains("lsp_health"));
36        assert!(hint_str.contains("retry in 30s"));
37    }
38
39    #[test]
40    #[allow(clippy::unwrap_used)]
41    fn test_lsp_error_protocol_recovery_hint() {
42        let err = LspError::Protocol("malformed JSON-RPC".to_string());
43        let hint = err.recovery_hint();
44
45        assert!(hint.is_some());
46        let hint_str = hint.unwrap();
47        assert!(hint_str.contains("malformed JSON-RPC"));
48        assert!(hint_str.contains("lsp_health"));
49        assert!(hint_str.contains("search_codebase"));
50    }
51
52    #[test]
53    #[allow(clippy::unwrap_used)]
54    fn test_lsp_error_connection_lost_recovery_hint() {
55        let err = LspError::ConnectionLost;
56        let hint = err.recovery_hint();
57
58        assert!(hint.is_some());
59        let hint_str = hint.unwrap();
60        assert!(hint_str.contains("connection lost"));
61        assert!(hint_str.contains("lsp_health"));
62        assert!(hint_str.contains("restart"));
63        assert!(hint_str.contains("read_source_file"));
64    }
65
66    #[test]
67    #[allow(clippy::unwrap_used)]
68    fn test_lsp_error_unsupported_capability_recovery_hint() {
69        let err = LspError::UnsupportedCapability {
70            capability: "diagnosticProvider".to_string(),
71        };
72        let hint = err.recovery_hint();
73
74        assert!(hint.is_some());
75        let hint_str = hint.unwrap();
76        assert!(hint_str.contains("diagnosticProvider"));
77        assert!(hint_str.contains("does not support"));
78        assert!(hint_str.contains("search_codebase"));
79    }
80
81    #[test]
82    #[allow(clippy::unwrap_used)]
83    fn test_lsp_error_io_not_found_recovery_hint() {
84        let io_err = io::Error::new(io::ErrorKind::NotFound, "binary not found");
85        let err = LspError::Io(io_err);
86        let hint = err.recovery_hint();
87
88        assert!(hint.is_some());
89        let hint_str = hint.unwrap();
90        assert!(hint_str.contains("binary not found"));
91        assert!(hint_str.contains("installed"));
92        assert!(hint_str.contains("PATH"));
93        assert!(hint_str.contains("lsp_health"));
94    }
95
96    #[test]
97    #[allow(clippy::unwrap_used)]
98    fn test_lsp_error_io_permission_denied_recovery_hint() {
99        let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
100        let err = LspError::Io(io_err);
101        let hint = err.recovery_hint();
102
103        assert!(hint.is_some());
104        let hint_str = hint.unwrap();
105        assert!(hint_str.contains("I/O error"));
106        assert!(hint_str.contains("access denied"));
107        assert!(hint_str.contains("lsp_health"));
108    }
109
110    #[test]
111    #[allow(clippy::unwrap_used)]
112    fn test_lsp_error_io_broken_pipe_recovery_hint() {
113        let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "pipe broken");
114        let err = LspError::Io(io_err);
115        let hint = err.recovery_hint();
116
117        assert!(hint.is_some());
118        let hint_str = hint.unwrap();
119        assert!(hint_str.contains("I/O error"));
120        assert!(hint_str.contains("pipe broken"));
121        assert!(hint_str.contains("lsp_health"));
122    }
123
124    #[test]
125    fn test_lsp_error_display_no_lsp_available() {
126        let err = LspError::NoLspAvailable;
127        let display = format!("{err}");
128        assert_eq!(display, "no LSP available for this file type");
129    }
130
131    #[test]
132    fn test_lsp_error_display_timeout() {
133        let err = LspError::Timeout {
134            operation: "initialize".to_string(),
135            timeout_ms: 30000,
136        };
137        let display = format!("{err}");
138        assert!(display.contains("initialize"));
139        assert!(display.contains("30000ms"));
140    }
141
142    #[test]
143    fn test_lsp_error_display_protocol() {
144        let err = LspError::Protocol("invalid response".to_string());
145        let display = format!("{err}");
146        assert_eq!(display, "LSP protocol error: invalid response");
147    }
148
149    #[test]
150    fn test_lsp_error_display_connection_lost() {
151        let err = LspError::ConnectionLost;
152        let display = format!("{err}");
153        assert_eq!(display, "LSP connection lost");
154    }
155
156    #[test]
157    fn test_lsp_error_display_unsupported_capability() {
158        let err = LspError::UnsupportedCapability {
159            capability: "callHierarchy".to_string(),
160        };
161        let display = format!("{err}");
162        assert!(display.contains("callHierarchy"));
163    }
164
165    #[test]
166    fn test_lsp_error_display_io() {
167        let io_err = io::Error::new(io::ErrorKind::NotFound, "test error");
168        let err = LspError::Io(io_err);
169        let display = format!("{err}");
170        assert!(display.contains("I/O error"));
171        assert!(display.contains("test error"));
172    }
173
174    #[test]
175    fn test_all_error_variants_have_recovery_hints() {
176        // NoLspAvailable
177        assert!(LspError::NoLspAvailable.recovery_hint().is_some());
178
179        // Timeout
180        assert!(LspError::Timeout {
181            operation: "test".to_string(),
182            timeout_ms: 5000,
183        }
184        .recovery_hint()
185        .is_some());
186
187        // Protocol
188        assert!(LspError::Protocol("test".to_string())
189            .recovery_hint()
190            .is_some());
191
192        // ConnectionLost
193        assert!(LspError::ConnectionLost.recovery_hint().is_some());
194
195        // UnsupportedCapability
196        assert!(LspError::UnsupportedCapability {
197            capability: "test".to_string(),
198        }
199        .recovery_hint()
200        .is_some());
201
202        // Io - NotFound
203        assert!(
204            LspError::Io(io::Error::new(io::ErrorKind::NotFound, "test"))
205                .recovery_hint()
206                .is_some()
207        );
208
209        // Io - PermissionDenied
210        assert!(
211            LspError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "test"))
212                .recovery_hint()
213                .is_some()
214        );
215
216        // Io - Other
217        assert!(LspError::Io(std::io::Error::other("test"))
218            .recovery_hint()
219            .is_some());
220    }
221}
222
223/// Errors that the LSP engine can produce.
224#[derive(Debug, Error)]
225pub enum LspError {
226    /// No language server is configured or available for this file type.
227    ///
228    /// This is the expected error when Pathfinder is running without LSP
229    /// support (i.e., in degraded mode). The calling tool handler should
230    /// return a gracefully degraded response rather than propagating this error.
231    #[error("no LSP available for this file type")]
232    NoLspAvailable,
233
234    /// The LSP server did not respond within the timeout window.
235    ///
236    /// For initialization this is 30 seconds. For individual requests,
237    /// it is configurable (default: 10s). Includes the operation name and
238    /// timeout duration for structured logging.
239    #[error("LSP timed out on '{operation}' after {timeout_ms}ms")]
240    Timeout {
241        /// The JSON-RPC method that timed out.
242        operation: String,
243        /// The timeout that elapsed, in milliseconds.
244        timeout_ms: u64,
245    },
246
247    /// The LSP server returned a JSON-RPC error response or sent malformed data.
248    ///
249    /// The contained string is a human-readable description of the error,
250    /// suitable for logging and agent-facing messages.
251    #[error("LSP protocol error: {0}")]
252    Protocol(String),
253
254    /// The LSP server process crashed or the connection was broken.
255    ///
256    /// Triggers crash-recovery logic (exponential backoff, max 3 retries).
257    #[error("LSP connection lost")]
258    ConnectionLost,
259
260    /// The LSP server is running but does not advertise the requested capability.
261    ///
262    /// For example, a server that doesn't implement Pull Diagnostics (LSP 3.17)
263    /// will trigger this error for diagnostic queries. The tool handler should
264    /// degrade gracefully and report the limitation to the caller.
265    #[error("LSP does not support capability: {capability}")]
266    UnsupportedCapability {
267        /// The LSP capability name (e.g., `"diagnosticProvider"`).
268        capability: String,
269    },
270
271    /// I/O error communicating with the LSP process.
272    #[error("LSP I/O error: {0}")]
273    Io(#[source] std::io::Error),
274}
275
276impl LspError {
277    /// Returns an actionable recovery hint for the agent.
278    ///
279    /// The hint tells agents *what to do next* — not just what went wrong.
280    /// All variants return `Some`; agents should surface these in tool
281    /// responses when validation or navigation degrades.
282    ///
283    /// This is the LSP-layer equivalent of `PathfinderError::hint()`.
284    #[must_use]
285    pub fn recovery_hint(&self) -> Option<String> {
286        match self {
287            Self::NoLspAvailable => Some(
288                "No LSP available for this file type. \
289                 Call lsp_health to see install instructions and check which languages are configured."
290                    .to_owned(),
291            ),
292            Self::Timeout { operation, timeout_ms } => Some(format!(
293                "LSP request '{operation}' timed out after {timeout_ms}ms. \
294                 The language server may still be indexing or under memory pressure. \
295                 (1) Call lsp_health to check server status and indexing progress. \
296                 (2) If status is 'warming_up', retry in 30s. \
297                 (3) Use search_codebase + read_symbol_scope as tree-sitter fallbacks."
298            )),
299            Self::ConnectionLost => Some(
300                "LSP connection lost — the language server may have crashed. \
301                 Call lsp_health(action='restart') to recover the server. \
302                 Use read_source_file and search_codebase as fallbacks while it restarts."
303                    .to_owned(),
304            ),
305            Self::Protocol(msg) => Some(format!(
306                "LSP protocol error: {msg}. \
307                 Call lsp_health to check server health. \
308                 Use search_codebase for text-based navigation in the meantime."
309            )),
310            Self::UnsupportedCapability { capability } => Some(format!(
311                "The LSP server does not support '{capability}'. \
312                 This is a server limitation — validation is skipped for this file type. \
313                 Use search_codebase + read_symbol_scope for navigation."
314            )),
315            Self::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => Some(
316                "LSP binary not found. \
317                 Ensure the language server is installed and available in PATH. \
318                 Call lsp_health for install instructions."
319                    .to_owned(),
320            ),
321            Self::Io(io_err) => Some(format!(
322                "LSP I/O error: {io_err}. \
323                 The language server process failed to start. \
324                 Call lsp_health to check server status."
325            )),
326        }
327    }
328}