use std::path::PathBuf;
use crate::config::{ServerId, ToolKind};
const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
fn sanitize_lsp_server_message(message: &str) -> String {
if message.contains(INVALID_OFFSET_MARKER) {
"position out of range for this document".to_string()
} else {
message.to_string()
}
}
#[derive(Debug, Clone)]
pub struct ServerSpawnFailure {
pub server_id: ServerId,
pub language_id: String,
pub command: String,
pub message: String,
}
impl std::fmt::Display for ServerSpawnFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} [{}] ({}): {}",
self.server_id, self.language_id, self.command, self.message
)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("LSP server initialization failed: {message}")]
LspInitFailed {
message: String,
},
#[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
LspServerError {
code: i32,
message: String,
data: Option<serde_json::Value>,
},
#[error("MCP server error: {0}")]
McpServer(String),
#[error("document not found: {0}")]
DocumentNotFound(PathBuf),
#[error("no LSP server configured for language: {0}")]
NoServerForLanguage(String),
#[error("no server handles tool '{tool}' for language '{language_id}'")]
NoServerForTool {
language_id: String,
tool: ToolKind,
},
#[error(
"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)"
)]
ServerInitializing {
server_id: ServerId,
},
#[error(
"LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
)]
WorkspaceServersInitializing,
#[error("no LSP server configured")]
NoServerConfigured,
#[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
NoServerForWorkspaceTool {
tool: ToolKind,
},
#[error("configuration file not found: {0}")]
ConfigNotFound(PathBuf),
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("TOML parsing error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("TOML serialization error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("transport error: {0}")]
Transport(String),
#[error("request timed out after {0} seconds")]
Timeout(u64),
#[error("failed to spawn LSP server '{command}': {source}")]
ServerSpawnFailed {
command: String,
#[source]
source: std::io::Error,
},
#[error("LSP protocol error: {0}")]
LspProtocolError(String),
#[error("invalid URI: {0}")]
InvalidUri(String),
#[error("LSP server process terminated unexpectedly")]
ServerTerminated,
#[error("LSP server '{server_id}' is unavailable: {reason}")]
ServerUnavailable {
server_id: ServerId,
reason: String,
},
#[error("invalid tool parameters: {0}")]
InvalidToolParams(String),
#[error("file I/O error for {path:?}: {source}")]
FileIo {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("path outside workspace: {0}")]
PathOutsideWorkspace(PathBuf),
#[error("no workspace roots configured: refusing access to {0}")]
NoWorkspaceRoots(PathBuf),
#[error(
"document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
)]
DocumentLimitExceeded {
current: usize,
max: usize,
},
#[error("subscription limit of {max} reached")]
SubscriptionLimitReached {
max: usize,
},
#[error(
"file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)"
)]
FileSizeLimitExceeded {
size: u64,
max: u64,
},
#[error("not a regular file: {0}")]
NotARegularFile(PathBuf),
#[error("all LSP servers failed to initialize ({count} configured)")]
AllServersFailedToInit {
count: usize,
failures: Vec<ServerSpawnFailure>,
},
#[error("{0}")]
NoServersAvailable(String),
#[error("server '{server_id}' does not support capability '{capability}'")]
CapabilityNotSupported {
server_id: ServerId,
capability: &'static str,
},
#[error(
"LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
)]
WorkspaceIndexing {
server_id: ServerId,
elapsed_secs: u64,
},
}
pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpErrorKind {
InvalidParams,
Retryable {
code: i32,
data: serde_json::Value,
},
Internal,
}
impl Error {
#[must_use]
pub fn mcp_error_kind(&self) -> McpErrorKind {
match self {
Self::InvalidToolParams(_)
| Self::PathOutsideWorkspace(_)
| Self::NotARegularFile(_)
| Self::InvalidUri(_)
| Self::DocumentNotFound(_)
| Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
Self::FileIo { source, .. } => {
if source.kind() == std::io::ErrorKind::NotFound {
McpErrorKind::InvalidParams
} else {
McpErrorKind::Internal
}
}
Self::WorkspaceIndexing {
server_id,
elapsed_secs,
} => McpErrorKind::Retryable {
code: WORKSPACE_INDEXING_ERROR_CODE,
data: serde_json::json!({
"serverId": server_id.as_str(),
"elapsedSecs": elapsed_secs,
}),
},
Self::ServerInitializing { server_id } => McpErrorKind::Retryable {
code: SERVER_INITIALIZING_ERROR_CODE,
data: serde_json::json!({
"serverId": server_id.as_str(),
}),
},
Self::WorkspaceServersInitializing => McpErrorKind::Retryable {
code: SERVER_INITIALIZING_ERROR_CODE,
data: serde_json::json!({}),
},
Self::LspServerError { message, .. } if message.contains(INVALID_OFFSET_MARKER) => {
McpErrorKind::InvalidParams
}
Self::LspInitFailed { .. }
| Self::LspServerError { .. }
| Self::McpServer(_)
| Self::NoServerForLanguage(_)
| Self::NoServerForTool { .. }
| Self::NoServerConfigured
| Self::NoServerForWorkspaceTool { .. }
| Self::ConfigNotFound(_)
| Self::InvalidConfig(_)
| Self::Io(_)
| Self::Json(_)
| Self::TomlDe(_)
| Self::TomlSer(_)
| Self::Transport(_)
| Self::Timeout(_)
| Self::ServerSpawnFailed { .. }
| Self::LspProtocolError(_)
| Self::ServerTerminated
| Self::ServerUnavailable { .. }
| Self::NoWorkspaceRoots(_)
| Self::DocumentLimitExceeded { .. }
| Self::SubscriptionLimitReached { .. }
| Self::AllServersFailedToInit { .. }
| Self::NoServersAvailable(_)
| Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display_lsp_init_failed() {
let err = Error::LspInitFailed {
message: "server not found".to_string(),
};
assert_eq!(
err.to_string(),
"LSP server initialization failed: server not found"
);
}
#[test]
fn test_error_display_lsp_server_error() {
let err = Error::LspServerError {
code: -32600,
message: "Invalid request".to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32600 - Invalid request"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
let err = Error::LspServerError {
code: -32603,
message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
.to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32603 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
let err = Error::LspServerError {
code: -32803,
message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
(line index length: 3)"
.to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32803 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
let err = Error::LspServerError {
code: -32602,
message: "Invalid params: expected object".to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32602 - Invalid params: expected object"
);
}
#[test]
fn test_error_display_document_not_found() {
let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
assert!(err.to_string().contains("document not found"));
assert!(err.to_string().contains("file.rs"));
}
#[test]
fn test_error_display_no_server_for_language() {
let err = Error::NoServerForLanguage("rust".to_string());
assert_eq!(
err.to_string(),
"no LSP server configured for language: rust"
);
}
#[test]
fn test_error_display_workspace_servers_initializing() {
let err = Error::WorkspaceServersInitializing;
assert!(err.to_string().contains("still initializing"));
}
#[test]
fn test_error_display_no_server_for_workspace_tool() {
let err = Error::NoServerForWorkspaceTool {
tool: crate::config::ToolKind::WorkspaceSymbols,
};
assert!(err.to_string().contains("workspace_symbols"));
assert!(err.to_string().contains("no server's `handles` list"));
}
#[test]
fn test_error_display_timeout() {
let err = Error::Timeout(30);
assert_eq!(err.to_string(), "request timed out after 30 seconds");
}
#[test]
fn test_error_display_document_limit() {
let err = Error::DocumentLimitExceeded {
current: 150,
max: 100,
};
assert_eq!(
err.to_string(),
"document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
);
}
#[test]
fn test_error_display_file_size_limit() {
let err = Error::FileSizeLimitExceeded {
size: 20_000_000,
max: 10_000_000,
};
assert_eq!(
err.to_string(),
"file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
);
}
#[test]
fn test_error_display_not_a_regular_file() {
let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
}
#[test]
fn test_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: Error = io_err.into();
assert!(matches!(err, Error::Io(_)));
}
#[test]
#[allow(clippy::unwrap_used)]
fn test_error_from_json() {
let json_str = "{invalid json}";
let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
let err: Error = json_err.into();
assert!(matches!(err, Error::Json(_)));
}
#[test]
#[allow(clippy::unwrap_used)]
fn test_error_from_toml_de() {
let toml_str = "[invalid toml";
let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
let err: Error = toml_err.into();
assert!(matches!(err, Error::TomlDe(_)));
}
#[test]
fn test_result_type_alias() {
fn _returns_error() -> Result<i32> {
Err(Error::InvalidConfig("test error".to_string()))
}
let result: Result<i32> = Ok(42);
assert!(result.is_ok());
if let Ok(value) = result {
assert_eq!(value, 42);
}
}
#[test]
fn test_error_source_chain() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = Error::ServerSpawnFailed {
command: "rust-analyzer".to_string(),
source: io_err,
};
let source = std::error::Error::source(&err);
assert!(source.is_some());
}
#[test]
fn test_server_spawn_failure_display() {
let failure = ServerSpawnFailure {
server_id: ServerId::from("rust"),
language_id: "rust".to_string(),
command: "rust-analyzer".to_string(),
message: "No such file or directory".to_string(),
};
assert_eq!(
failure.to_string(),
"rust [rust] (rust-analyzer): No such file or directory"
);
}
#[test]
fn test_server_spawn_failure_debug() {
let failure = ServerSpawnFailure {
server_id: ServerId::from("python"),
language_id: "python".to_string(),
command: "pyright".to_string(),
message: "command not found".to_string(),
};
let debug_str = format!("{failure:?}");
assert!(debug_str.contains("python"));
assert!(debug_str.contains("pyright"));
assert!(debug_str.contains("command not found"));
}
#[test]
fn test_server_spawn_failure_clone() {
let failure = ServerSpawnFailure {
server_id: ServerId::from("typescript"),
language_id: "typescript".to_string(),
command: "tsserver".to_string(),
message: "failed to start".to_string(),
};
let cloned = failure.clone();
assert_eq!(failure.language_id, cloned.language_id);
assert_eq!(failure.command, cloned.command);
assert_eq!(failure.message, cloned.message);
}
#[test]
fn test_error_display_all_servers_failed_to_init() {
let err = Error::AllServersFailedToInit {
count: 2,
failures: vec![],
};
assert_eq!(
err.to_string(),
"all LSP servers failed to initialize (2 configured)"
);
}
#[test]
fn test_error_all_servers_failed_with_failures() {
let failures = vec![
ServerSpawnFailure {
server_id: ServerId::from("rust"),
language_id: "rust".to_string(),
command: "rust-analyzer".to_string(),
message: "not found".to_string(),
},
ServerSpawnFailure {
server_id: ServerId::from("python"),
language_id: "python".to_string(),
command: "pyright".to_string(),
message: "permission denied".to_string(),
},
];
let err = Error::AllServersFailedToInit { count: 2, failures };
assert!(err.to_string().contains("all LSP servers failed"));
assert!(err.to_string().contains("2 configured"));
}
#[test]
fn test_error_display_no_servers_available() {
let err =
Error::NoServersAvailable("none configured or all failed to initialize".to_string());
assert_eq!(
err.to_string(),
"none configured or all failed to initialize"
);
}
#[test]
fn test_error_no_servers_available_with_custom_message() {
let custom_msg = "none configured or all failed to initialize";
let err = Error::NoServersAvailable(custom_msg.to_string());
assert_eq!(err.to_string(), custom_msg);
}
#[test]
fn test_error_display_capability_not_supported() {
let err = Error::CapabilityNotSupported {
server_id: ServerId::from("rust"),
capability: "renameProvider",
};
assert_eq!(
err.to_string(),
"server 'rust' does not support capability 'renameProvider'"
);
}
#[test]
fn test_error_display_workspace_indexing() {
let err = Error::WorkspaceIndexing {
server_id: ServerId::from("rust"),
elapsed_secs: 30,
};
assert_eq!(
err.to_string(),
"LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
);
}
#[test]
fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
let caller_fault_errors = vec![
Error::InvalidToolParams("bad params".to_string()),
Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
Error::NotARegularFile(PathBuf::from("/dev/null")),
Error::InvalidUri("not a uri".to_string()),
Error::DocumentNotFound(PathBuf::from("/missing.rs")),
Error::FileSizeLimitExceeded { size: 100, max: 10 },
];
for err in caller_fault_errors {
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::InvalidParams,
"expected {err:?} to classify as InvalidParams"
);
}
}
#[test]
fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
let err = Error::WorkspaceIndexing {
server_id: ServerId::from("rust"),
elapsed_secs: 30,
};
let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
panic!("expected WorkspaceIndexing to classify as Retryable");
};
assert_eq!(code, WORKSPACE_INDEXING_ERROR_CODE);
assert_eq!(data["serverId"], "rust");
assert_eq!(data["elapsedSecs"], 30);
}
#[test]
fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
let err = Error::ServerInitializing {
server_id: ServerId::from("python"),
};
let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
panic!("expected ServerInitializing to classify as Retryable");
};
assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
assert_eq!(data["serverId"], "python");
assert_ne!(
code, WORKSPACE_INDEXING_ERROR_CODE,
"ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
);
}
#[test]
fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
let err = Error::WorkspaceServersInitializing;
let McpErrorKind::Retryable { code, .. } = err.mcp_error_kind() else {
panic!("expected WorkspaceServersInitializing to classify as Retryable");
};
assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
}
#[test]
fn test_mcp_error_kind_unretained_variants_stay_internal() {
let internal_errors = vec![
Error::NoServerForLanguage("python".to_string()),
Error::NoServerForTool {
language_id: "rust".to_string(),
tool: crate::config::ToolKind::Hover,
},
Error::CapabilityNotSupported {
server_id: ServerId::from("rust"),
capability: "renameProvider",
},
Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
Error::DocumentLimitExceeded {
current: 150,
max: 100,
},
Error::SubscriptionLimitReached { max: 1000 },
];
for err in internal_errors {
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::Internal,
"expected {err:?} to classify as Internal"
);
}
}
#[test]
fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
let err = Error::FileIo {
path: PathBuf::from("/no/such/file.rs"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
}
#[test]
fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
let err = Error::FileIo {
path: PathBuf::from("/root/secret.rs"),
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
let err = Error::LspServerError {
code: -32603,
message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
.to_string(),
data: None,
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
}
#[test]
fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
let err = Error::LspServerError {
code: -32603,
message: "internal error".to_string(),
data: None,
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
let err = Error::SubscriptionLimitReached { max: 1000 };
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
}