1use std::path::PathBuf;
7
8use crate::config::{ServerId, ToolKind};
9
10const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
16
17fn sanitize_lsp_server_message(message: &str) -> String {
40 if message.contains(INVALID_OFFSET_MARKER) {
41 "position out of range for this document".to_string()
42 } else {
43 message.to_string()
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct ServerSpawnFailure {
50 pub server_id: ServerId,
52 pub language_id: String,
54 pub command: String,
56 pub message: String,
58}
59
60impl std::fmt::Display for ServerSpawnFailure {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 write!(
63 f,
64 "{} [{}] ({}): {}",
65 self.server_id, self.language_id, self.command, self.message
66 )
67 }
68}
69
70#[derive(Debug, thiserror::Error)]
76#[non_exhaustive]
77pub enum Error {
78 #[error("LSP server initialization failed: {message}")]
80 LspInitFailed {
81 message: String,
83 },
84
85 #[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
87 LspServerError {
88 code: i32,
90 message: String,
95 data: Option<serde_json::Value>,
97 },
98
99 #[error("MCP server error: {0}")]
101 McpServer(String),
102
103 #[error("document not found: {0}")]
105 DocumentNotFound(PathBuf),
106
107 #[error("no LSP server configured for language: {0}")]
109 NoServerForLanguage(String),
110
111 #[error("no server handles tool '{tool}' for language '{language_id}'")]
116 NoServerForTool {
117 language_id: String,
119 tool: ToolKind,
121 },
122
123 #[error(
125 "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)"
126 )]
127 ServerInitializing {
128 server_id: ServerId,
130 },
131
132 #[error(
138 "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
139 )]
140 WorkspaceServersInitializing,
141
142 #[error("no LSP server configured")]
144 NoServerConfigured,
145
146 #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
151 NoServerForWorkspaceTool {
152 tool: ToolKind,
154 },
155
156 #[error("configuration file not found: {0}")]
158 ConfigNotFound(PathBuf),
159
160 #[error("invalid configuration: {0}")]
162 InvalidConfig(String),
163
164 #[error("I/O error: {0}")]
166 Io(#[from] std::io::Error),
167
168 #[error("JSON error: {0}")]
170 Json(#[from] serde_json::Error),
171
172 #[error("TOML parsing error: {0}")]
174 TomlDe(#[from] toml::de::Error),
175
176 #[error("TOML serialization error: {0}")]
178 TomlSer(#[from] toml::ser::Error),
179
180 #[error("transport error: {0}")]
182 Transport(String),
183
184 #[error("request timed out after {0} seconds")]
186 Timeout(u64),
187
188 #[error("failed to spawn LSP server '{command}': {source}")]
190 ServerSpawnFailed {
191 command: String,
193 #[source]
195 source: std::io::Error,
196 },
197
198 #[error("LSP protocol error: {0}")]
200 LspProtocolError(String),
201
202 #[error("invalid URI: {0}")]
204 InvalidUri(String),
205
206 #[error("LSP server process terminated unexpectedly")]
208 ServerTerminated,
209
210 #[error("LSP server '{server_id}' is unavailable: {reason}")]
217 ServerUnavailable {
218 server_id: ServerId,
220 reason: String,
222 },
223
224 #[error("invalid tool parameters: {0}")]
226 InvalidToolParams(String),
227
228 #[error("file I/O error for {path:?}: {source}")]
236 FileIo {
237 path: PathBuf,
239 #[source]
241 source: std::io::Error,
242 },
243
244 #[error("path outside workspace: {0}")]
246 PathOutsideWorkspace(PathBuf),
247
248 #[error("no workspace roots configured: refusing access to {0}")]
251 NoWorkspaceRoots(PathBuf),
252
253 #[error(
255 "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
256 )]
257 DocumentLimitExceeded {
258 current: usize,
260 max: usize,
262 },
263
264 #[error("subscription limit of {max} reached")]
271 SubscriptionLimitReached {
272 max: usize,
274 },
275
276 #[error(
278 "file size limit exceeded: {size} bytes, max {max} bytes (raise workspace.max_file_size in config to increase this)"
279 )]
280 FileSizeLimitExceeded {
281 size: u64,
283 max: u64,
285 },
286
287 #[error("not a regular file: {0}")]
297 NotARegularFile(PathBuf),
298
299 #[error("all LSP servers failed to initialize ({count} configured)")]
301 AllServersFailedToInit {
302 count: usize,
304 failures: Vec<ServerSpawnFailure>,
306 },
307
308 #[error("{0}")]
310 NoServersAvailable(String),
311
312 #[error("server '{server_id}' does not support capability '{capability}'")]
316 CapabilityNotSupported {
317 server_id: ServerId,
319 capability: &'static str,
322 },
323
324 #[error(
331 "LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
332 )]
333 WorkspaceIndexing {
334 server_id: ServerId,
336 elapsed_secs: u64,
338 },
339}
340
341pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
353
354pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
369
370pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
389
390#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum McpErrorKind {
407 InvalidParams,
410 Retryable {
415 code: i32,
417 data: serde_json::Value,
419 },
420 Internal,
423}
424
425impl Error {
426 #[must_use]
448 pub fn mcp_error_kind(&self) -> McpErrorKind {
449 match self {
450 Self::InvalidToolParams(_)
451 | Self::PathOutsideWorkspace(_)
452 | Self::NotARegularFile(_)
453 | Self::InvalidUri(_)
454 | Self::DocumentNotFound(_)
455 | Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
456
457 Self::FileIo { source, .. } => {
467 if source.kind() == std::io::ErrorKind::NotFound {
468 McpErrorKind::InvalidParams
469 } else {
470 McpErrorKind::Internal
471 }
472 }
473
474 Self::WorkspaceIndexing {
475 server_id,
476 elapsed_secs,
477 } => McpErrorKind::Retryable {
478 code: WORKSPACE_INDEXING_ERROR_CODE,
479 data: serde_json::json!({
480 "serverId": server_id.as_str(),
481 "elapsedSecs": elapsed_secs,
482 }),
483 },
484 Self::ServerInitializing { server_id } => McpErrorKind::Retryable {
485 code: SERVER_INITIALIZING_ERROR_CODE,
486 data: serde_json::json!({
487 "serverId": server_id.as_str(),
488 }),
489 },
490 Self::WorkspaceServersInitializing => McpErrorKind::Retryable {
495 code: SERVER_INITIALIZING_ERROR_CODE,
496 data: serde_json::json!({}),
497 },
498
499 Self::LspServerError { message, .. } if message.contains(INVALID_OFFSET_MARKER) => {
505 McpErrorKind::InvalidParams
506 }
507
508 Self::LspInitFailed { .. }
509 | Self::LspServerError { .. }
510 | Self::McpServer(_)
511 | Self::NoServerForLanguage(_)
512 | Self::NoServerForTool { .. }
513 | Self::NoServerConfigured
514 | Self::NoServerForWorkspaceTool { .. }
515 | Self::ConfigNotFound(_)
516 | Self::InvalidConfig(_)
517 | Self::Io(_)
518 | Self::Json(_)
519 | Self::TomlDe(_)
520 | Self::TomlSer(_)
521 | Self::Transport(_)
522 | Self::Timeout(_)
523 | Self::ServerSpawnFailed { .. }
524 | Self::LspProtocolError(_)
525 | Self::ServerTerminated
526 | Self::ServerUnavailable { .. }
527 | Self::NoWorkspaceRoots(_)
528 | Self::DocumentLimitExceeded { .. }
533 | Self::SubscriptionLimitReached { .. }
536 | Self::AllServersFailedToInit { .. }
537 | Self::NoServersAvailable(_)
538 | Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
539 }
540 }
541}
542
543pub type Result<T> = std::result::Result<T, Error>;
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn test_error_display_lsp_init_failed() {
552 let err = Error::LspInitFailed {
553 message: "server not found".to_string(),
554 };
555 assert_eq!(
556 err.to_string(),
557 "LSP server initialization failed: server not found"
558 );
559 }
560
561 #[test]
562 fn test_error_display_lsp_server_error() {
563 let err = Error::LspServerError {
564 code: -32600,
565 message: "Invalid request".to_string(),
566 data: None,
567 };
568 assert_eq!(
569 err.to_string(),
570 "LSP server error: -32600 - Invalid request"
571 );
572 }
573
574 #[test]
575 fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
576 let err = Error::LspServerError {
577 code: -32603,
578 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
579 .to_string(),
580 data: None,
581 };
582 assert_eq!(
583 err.to_string(),
584 "LSP server error: -32603 - position out of range for this document"
585 );
586 }
587
588 #[test]
589 fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
590 let err = Error::LspServerError {
594 code: -32803,
595 message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
596 (line index length: 3)"
597 .to_string(),
598 data: None,
599 };
600 assert_eq!(
601 err.to_string(),
602 "LSP server error: -32803 - position out of range for this document"
603 );
604 }
605
606 #[test]
607 fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
608 let err = Error::LspServerError {
609 code: -32602,
610 message: "Invalid params: expected object".to_string(),
611 data: None,
612 };
613 assert_eq!(
614 err.to_string(),
615 "LSP server error: -32602 - Invalid params: expected object"
616 );
617 }
618
619 #[test]
620 fn test_error_display_document_not_found() {
621 let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
622 assert!(err.to_string().contains("document not found"));
623 assert!(err.to_string().contains("file.rs"));
624 }
625
626 #[test]
627 fn test_error_display_no_server_for_language() {
628 let err = Error::NoServerForLanguage("rust".to_string());
629 assert_eq!(
630 err.to_string(),
631 "no LSP server configured for language: rust"
632 );
633 }
634
635 #[test]
636 fn test_error_display_workspace_servers_initializing() {
637 let err = Error::WorkspaceServersInitializing;
638 assert!(err.to_string().contains("still initializing"));
639 }
640
641 #[test]
642 fn test_error_display_no_server_for_workspace_tool() {
643 let err = Error::NoServerForWorkspaceTool {
644 tool: crate::config::ToolKind::WorkspaceSymbols,
645 };
646 assert!(err.to_string().contains("workspace_symbols"));
647 assert!(err.to_string().contains("no server's `handles` list"));
648 }
649
650 #[test]
651 fn test_error_display_timeout() {
652 let err = Error::Timeout(30);
653 assert_eq!(err.to_string(), "request timed out after 30 seconds");
654 }
655
656 #[test]
657 fn test_error_display_document_limit() {
658 let err = Error::DocumentLimitExceeded {
659 current: 150,
660 max: 100,
661 };
662 assert_eq!(
663 err.to_string(),
664 "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
665 );
666 }
667
668 #[test]
669 fn test_error_display_file_size_limit() {
670 let err = Error::FileSizeLimitExceeded {
671 size: 20_000_000,
672 max: 10_000_000,
673 };
674 assert_eq!(
675 err.to_string(),
676 "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
677 );
678 }
679
680 #[test]
681 fn test_error_display_not_a_regular_file() {
682 let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
683 assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
684 }
685
686 #[test]
687 fn test_error_from_io() {
688 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
689 let err: Error = io_err.into();
690 assert!(matches!(err, Error::Io(_)));
691 }
692
693 #[test]
694 #[allow(clippy::unwrap_used)]
695 fn test_error_from_json() {
696 let json_str = "{invalid json}";
697 let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
698 let err: Error = json_err.into();
699 assert!(matches!(err, Error::Json(_)));
700 }
701
702 #[test]
703 #[allow(clippy::unwrap_used)]
704 fn test_error_from_toml_de() {
705 let toml_str = "[invalid toml";
706 let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
707 let err: Error = toml_err.into();
708 assert!(matches!(err, Error::TomlDe(_)));
709 }
710
711 #[test]
712 fn test_result_type_alias() {
713 fn _returns_error() -> Result<i32> {
714 Err(Error::InvalidConfig("test error".to_string()))
715 }
716
717 let result: Result<i32> = Ok(42);
718 assert!(result.is_ok());
719 if let Ok(value) = result {
720 assert_eq!(value, 42);
721 }
722 }
723
724 #[test]
725 fn test_error_source_chain() {
726 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
727 let err = Error::ServerSpawnFailed {
728 command: "rust-analyzer".to_string(),
729 source: io_err,
730 };
731
732 let source = std::error::Error::source(&err);
733 assert!(source.is_some());
734 }
735
736 #[test]
737 fn test_server_spawn_failure_display() {
738 let failure = ServerSpawnFailure {
739 server_id: ServerId::from("rust"),
740 language_id: "rust".to_string(),
741 command: "rust-analyzer".to_string(),
742 message: "No such file or directory".to_string(),
743 };
744 assert_eq!(
745 failure.to_string(),
746 "rust [rust] (rust-analyzer): No such file or directory"
747 );
748 }
749
750 #[test]
751 fn test_server_spawn_failure_debug() {
752 let failure = ServerSpawnFailure {
753 server_id: ServerId::from("python"),
754 language_id: "python".to_string(),
755 command: "pyright".to_string(),
756 message: "command not found".to_string(),
757 };
758 let debug_str = format!("{failure:?}");
759 assert!(debug_str.contains("python"));
760 assert!(debug_str.contains("pyright"));
761 assert!(debug_str.contains("command not found"));
762 }
763
764 #[test]
765 fn test_server_spawn_failure_clone() {
766 let failure = ServerSpawnFailure {
767 server_id: ServerId::from("typescript"),
768 language_id: "typescript".to_string(),
769 command: "tsserver".to_string(),
770 message: "failed to start".to_string(),
771 };
772 let cloned = failure.clone();
773 assert_eq!(failure.language_id, cloned.language_id);
774 assert_eq!(failure.command, cloned.command);
775 assert_eq!(failure.message, cloned.message);
776 }
777
778 #[test]
779 fn test_error_display_all_servers_failed_to_init() {
780 let err = Error::AllServersFailedToInit {
781 count: 2,
782 failures: vec![],
783 };
784 assert_eq!(
785 err.to_string(),
786 "all LSP servers failed to initialize (2 configured)"
787 );
788 }
789
790 #[test]
791 fn test_error_all_servers_failed_with_failures() {
792 let failures = vec![
793 ServerSpawnFailure {
794 server_id: ServerId::from("rust"),
795 language_id: "rust".to_string(),
796 command: "rust-analyzer".to_string(),
797 message: "not found".to_string(),
798 },
799 ServerSpawnFailure {
800 server_id: ServerId::from("python"),
801 language_id: "python".to_string(),
802 command: "pyright".to_string(),
803 message: "permission denied".to_string(),
804 },
805 ];
806
807 let err = Error::AllServersFailedToInit { count: 2, failures };
808
809 assert!(err.to_string().contains("all LSP servers failed"));
810 assert!(err.to_string().contains("2 configured"));
811 }
812
813 #[test]
814 fn test_error_display_no_servers_available() {
815 let err =
816 Error::NoServersAvailable("none configured or all failed to initialize".to_string());
817 assert_eq!(
818 err.to_string(),
819 "none configured or all failed to initialize"
820 );
821 }
822
823 #[test]
824 fn test_error_no_servers_available_with_custom_message() {
825 let custom_msg = "none configured or all failed to initialize";
826 let err = Error::NoServersAvailable(custom_msg.to_string());
827 assert_eq!(err.to_string(), custom_msg);
828 }
829
830 #[test]
831 fn test_error_display_capability_not_supported() {
832 let err = Error::CapabilityNotSupported {
833 server_id: ServerId::from("rust"),
834 capability: "renameProvider",
835 };
836 assert_eq!(
837 err.to_string(),
838 "server 'rust' does not support capability 'renameProvider'"
839 );
840 }
841
842 #[test]
843 fn test_error_display_workspace_indexing() {
844 let err = Error::WorkspaceIndexing {
845 server_id: ServerId::from("rust"),
846 elapsed_secs: 30,
847 };
848 assert_eq!(
849 err.to_string(),
850 "LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
851 );
852 }
853
854 #[test]
857 fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
858 let caller_fault_errors = vec![
859 Error::InvalidToolParams("bad params".to_string()),
860 Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
861 Error::NotARegularFile(PathBuf::from("/dev/null")),
862 Error::InvalidUri("not a uri".to_string()),
863 Error::DocumentNotFound(PathBuf::from("/missing.rs")),
864 Error::FileSizeLimitExceeded { size: 100, max: 10 },
865 ];
866
867 for err in caller_fault_errors {
868 assert_eq!(
869 err.mcp_error_kind(),
870 McpErrorKind::InvalidParams,
871 "expected {err:?} to classify as InvalidParams"
872 );
873 }
874 }
875
876 #[test]
877 fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
878 let err = Error::WorkspaceIndexing {
879 server_id: ServerId::from("rust"),
880 elapsed_secs: 30,
881 };
882 let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
883 panic!("expected WorkspaceIndexing to classify as Retryable");
884 };
885 assert_eq!(code, WORKSPACE_INDEXING_ERROR_CODE);
886 assert_eq!(data["serverId"], "rust");
887 assert_eq!(data["elapsedSecs"], 30);
888 }
889
890 #[test]
891 fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
892 let err = Error::ServerInitializing {
893 server_id: ServerId::from("python"),
894 };
895 let McpErrorKind::Retryable { code, data } = err.mcp_error_kind() else {
896 panic!("expected ServerInitializing to classify as Retryable");
897 };
898 assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
899 assert_eq!(data["serverId"], "python");
900 assert_ne!(
901 code, WORKSPACE_INDEXING_ERROR_CODE,
902 "ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
903 );
904 }
905
906 #[test]
912 fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
913 let err = Error::WorkspaceServersInitializing;
914 let McpErrorKind::Retryable { code, .. } = err.mcp_error_kind() else {
915 panic!("expected WorkspaceServersInitializing to classify as Retryable");
916 };
917 assert_eq!(code, SERVER_INITIALIZING_ERROR_CODE);
918 }
919
920 #[test]
921 fn test_mcp_error_kind_unretained_variants_stay_internal() {
922 let internal_errors = vec![
923 Error::NoServerForLanguage("python".to_string()),
924 Error::NoServerForTool {
925 language_id: "rust".to_string(),
926 tool: crate::config::ToolKind::Hover,
927 },
928 Error::CapabilityNotSupported {
929 server_id: ServerId::from("rust"),
930 capability: "renameProvider",
931 },
932 Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
933 Error::DocumentLimitExceeded {
934 current: 150,
935 max: 100,
936 },
937 Error::SubscriptionLimitReached { max: 1000 },
938 ];
939
940 for err in internal_errors {
941 assert_eq!(
942 err.mcp_error_kind(),
943 McpErrorKind::Internal,
944 "expected {err:?} to classify as Internal"
945 );
946 }
947 }
948
949 #[test]
953 fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
954 let err = Error::FileIo {
955 path: PathBuf::from("/no/such/file.rs"),
956 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
957 };
958 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
959 }
960
961 #[test]
965 fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
966 let err = Error::FileIo {
967 path: PathBuf::from("/root/secret.rs"),
968 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
969 };
970 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
971 }
972
973 #[test]
977 fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
978 let err = Error::LspServerError {
979 code: -32603,
980 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
981 .to_string(),
982 data: None,
983 };
984 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
985 }
986
987 #[test]
991 fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
992 let err = Error::LspServerError {
993 code: -32603,
994 message: "internal error".to_string(),
995 data: None,
996 };
997 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
998 }
999
1000 #[test]
1004 fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
1005 let err = Error::SubscriptionLimitReached { max: 1000 };
1006 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
1007 }
1008}