1use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::Duration;
10
11use serde::Serialize;
12
13use crate::bridge::resources::ResourceUriError;
14use crate::bridge::{
15 Capability, InvalidClientPath, InvalidHierarchyItem, InvalidPosition, InvalidRange,
16 MAX_SYMBOL_NAME_BYTES,
17};
18use crate::config::{
19 BuiltinServer, DuplicateEnvKey, EntrySummary, FileKey, FilePattern, InvalidAutoSelection,
20 LanguageId, ServerCommand, ServerId, ToolKind, UnsupportedFilePattern,
21};
22pub use crate::redaction::RedactedText;
23use crate::redaction::Redactions;
24pub use crate::util::SizeExceeded;
25use crate::util::{MAX_ERROR_MESSAGE_CALLER_BYTES, escape_bounded, escape_control, truncate_str};
26
27fn no_server_detail(language: &LanguageId, file: &FileKey, patterns: &[FilePattern]) -> String {
31 if *language != LanguageId::PLAINTEXT {
32 return String::new();
33 }
34 let (subject, remedy) = match file {
35 FileKey::Extension(ext) => (
36 format!("file extension '{ext}' is not mapped to any language"),
37 "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
38 ),
39 FileKey::Name(name) => (
40 format!("file name '{name}' is not mapped to any language"),
41 format!("a `**/{name}` file_patterns entry"),
42 ),
43 FileKey::Unmappable => (
44 "the file has no usable extension or name".to_owned(),
45 "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
46 ),
47 };
48 let configured = if patterns.is_empty() {
49 "no file_patterns are configured".to_owned()
50 } else {
51 let list: Vec<&str> = patterns.iter().map(FilePattern::as_str).collect();
52 format!("configured file_patterns: {}", list.join(", "))
53 };
54 format!(" ({subject}; {configured}; map it with {remedy})")
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Platform {
60 Windows,
61 Other,
62}
63
64impl Platform {
65 const CURRENT: Self = if cfg!(windows) {
66 Self::Windows
67 } else {
68 Self::Other
69 };
70}
71
72struct NotFoundGuidance<'a>(&'a str, Platform);
74
75impl fmt::Display for NotFoundGuidance<'_> {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 let Self(command, platform) = *self;
78 if Path::new(command).parent().is_some_and(|p| !p.is_empty()) {
79 return f.write_str("; check that the configured path exists");
80 }
81 write!(
82 f,
83 "; '{command}' is not on the PATH mcpls runs with -- if it is installed, add its \
84 directory to the MCP client's PATH or set `command` to an absolute path"
85 )?;
86 let Some(builtin) = BuiltinServer::from_command(command) else {
87 return Ok(());
88 };
89 if platform == Platform::Windows && builtin.is_npm_package() {
90 write!(
91 f,
92 " (npm-installed servers need the `.cmd` name, e.g. `{command}.cmd`)"
93 )?;
94 }
95 write!(f, "; otherwise install it: {}", builtin.install_hint())
96 }
97}
98
99const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
105
106fn sanitize_lsp_server_message(message: &str) -> String {
129 if message.contains(INVALID_OFFSET_MARKER) {
130 "position out of range for this document".to_string()
131 } else {
132 escape_control(message).into_owned()
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
157pub struct RewrittenServerError {
158 code: i32,
159 raw_message: String,
160}
161
162impl RewrittenServerError {
163 #[must_use]
166 pub fn new(code: i32, raw_message: &str) -> Self {
167 Self {
168 code,
169 raw_message: truncate_str(raw_message, MAX_ERROR_MESSAGE_CALLER_BYTES),
170 }
171 }
172
173 #[must_use]
175 pub const fn code(&self) -> i32 {
176 self.code
177 }
178
179 #[must_use]
181 pub fn raw_message(&self) -> &str {
182 &self.raw_message
183 }
184}
185
186#[derive(Debug, Clone)]
196#[non_exhaustive]
197pub enum StartupFailure {
198 Spawn(Arc<Error>),
200 InitTaskPanicked,
203 RefusedUntrustedWorkspace(UntrustedRefusal),
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum HomeVariable {
210 Home,
212 UserProfile,
214}
215
216impl HomeVariable {
217 pub const ALL: [Self; 2] = [Self::Home, Self::UserProfile];
219
220 #[must_use]
222 pub const fn name(self) -> &'static str {
223 match self {
224 Self::Home => "HOME",
225 Self::UserProfile => "USERPROFILE",
226 }
227 }
228}
229
230impl fmt::Display for HomeVariable {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 f.write_str(self.name())
233 }
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum ResolvedItem {
239 Executable,
241 SearchPath,
243}
244
245impl fmt::Display for ResolvedItem {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.write_str(match self {
248 Self::Executable => "executable",
249 Self::SearchPath => "PATH",
250 })
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct EchoedArgument(String);
272
273impl EchoedArgument {
274 #[must_use]
279 pub fn name(arg: &str) -> Self {
280 let name = arg
281 .split_once(|c: char| c == '=' || c.is_whitespace())
282 .map_or(arg, |(name, _)| name);
283 Self(escape_bounded(name, MAX_SYMBOL_NAME_BYTES))
284 }
285
286 #[must_use]
288 pub fn as_str(&self) -> &str {
289 &self.0
290 }
291}
292
293const MAX_ECHOED_PATH_BYTES: usize = 1024;
295
296#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct EchoedPath(String);
315
316impl EchoedPath {
317 #[must_use]
319 pub fn new(path: &Path) -> Self {
320 Self(escape_bounded(
321 &path.to_string_lossy(),
322 MAX_ECHOED_PATH_BYTES,
323 ))
324 }
325
326 #[must_use]
331 pub fn program(arg: &str) -> Self {
332 let assignment = arg
333 .split_once('=')
334 .is_some_and(|(name, _)| !name.contains(std::path::is_separator));
335 if assignment {
336 Self(EchoedArgument::name(arg).0)
337 } else {
338 Self::new(Path::new(arg))
339 }
340 }
341
342 #[must_use]
344 pub fn as_str(&self) -> &str {
345 &self.0
346 }
347}
348
349impl fmt::Display for EchoedPath {
350 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351 f.write_str(&self.0)
352 }
353}
354
355impl fmt::Display for EchoedArgument {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 f.write_str(&self.0)
358 }
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum RunnerSubcommand {
364 Run,
366 X,
368 Task,
370 Eval,
372 Repl,
374 Tool,
376 Exec,
378}
379
380impl RunnerSubcommand {
381 #[must_use]
383 pub const fn as_str(self) -> &'static str {
384 match self {
385 Self::Run => "run",
386 Self::X => "x",
387 Self::Task => "task",
388 Self::Eval => "eval",
389 Self::Repl => "repl",
390 Self::Tool => "tool",
391 Self::Exec => "exec",
392 }
393 }
394}
395
396impl fmt::Display for RunnerSubcommand {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 f.write_str(self.as_str())
399 }
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum LongCommandName {
405 Command,
407 Commands,
409 InitCommand,
411 Execute,
413}
414
415impl LongCommandName {
416 #[must_use]
418 pub const fn as_str(self) -> &'static str {
419 match self {
420 Self::Command => "--command",
421 Self::Commands => "--commands",
422 Self::InitCommand => "--init-command",
423 Self::Execute => "--execute",
424 }
425 }
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum CmdSwitch {
431 C,
433 K,
435 R,
437}
438
439impl CmdSwitch {
440 #[must_use]
442 pub const fn as_str(self) -> &'static str {
443 match self {
444 Self::C => "/c",
445 Self::K => "/k",
446 Self::R => "/r",
447 }
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
453pub enum PowerShellParameter {
454 Command,
456 CommandWithArgs,
458 EncodedCommand,
460}
461
462impl PowerShellParameter {
463 #[must_use]
465 pub const fn as_str(self) -> &'static str {
466 match self {
467 Self::Command => "-Command",
468 Self::CommandWithArgs => "-CommandWithArgs",
469 Self::EncodedCommand => "-EncodedCommand",
470 }
471 }
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
477pub enum ShellFlag {
478 DashC,
480 DashCapitalC,
482 DashE,
484 LongCommand(LongCommandName),
486 SlashC(CmdSwitch),
488 PowerShell(PowerShellParameter),
490}
491
492impl fmt::Display for ShellFlag {
493 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494 f.write_str(match self {
495 Self::DashC => "-c",
496 Self::DashCapitalC => "-C",
497 Self::DashE => "-e",
498 Self::LongCommand(name) => name.as_str(),
499 Self::SlashC(switch) => switch.as_str(),
500 Self::PowerShell(parameter) => parameter.as_str(),
501 })
502 }
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum InlineFlag {
508 Short(char),
510 Eval,
512 Print,
514 Import,
516 Loader,
518 ExperimentalLoader,
520}
521
522impl InlineFlag {
523 #[must_use]
525 pub const fn long_name(self) -> Option<&'static str> {
526 match self {
527 Self::Short(_) => None,
528 Self::Eval => Some("--eval"),
529 Self::Print => Some("--print"),
530 Self::Import => Some("--import"),
531 Self::Loader => Some("--loader"),
532 Self::ExperimentalLoader => Some("--experimental-loader"),
533 }
534 }
535}
536
537impl fmt::Display for InlineFlag {
538 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
539 match self {
540 Self::Short(letter) => write!(f, "-{letter}"),
541 Self::Eval | Self::Print | Self::Import | Self::Loader | Self::ExperimentalLoader => {
542 f.write_str(self.long_name().unwrap_or_default())
543 }
544 }
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum OperandKind {
551 Duration,
553}
554
555impl fmt::Display for OperandKind {
556 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557 f.write_str(match self {
558 Self::Duration => "DURATION",
559 })
560 }
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
567#[non_exhaustive]
568pub enum LaunchTrigger {
569 Always,
572 Subcommand(RunnerSubcommand),
574 CommandString(ShellFlag),
576 InlineProgram(InlineFlag),
578 NpmSpecifier,
580}
581
582impl fmt::Display for LaunchTrigger {
583 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
584 match self {
585 Self::Always => f.write_str("every use of it does"),
586 Self::Subcommand(name) => write!(f, "its `{name}` subcommand does"),
587 Self::CommandString(flag) => {
588 write!(
589 f,
590 "`{flag}` gives it a command string, which cannot be analyzed"
591 )
592 }
593 Self::InlineProgram(flag) => write!(f, "`{flag}` gives it a program to run"),
594 Self::NpmSpecifier => f.write_str("an `npm:` argument names a package to run"),
595 }
596 }
597}
598
599#[derive(Debug, Clone, PartialEq, Eq)]
602#[non_exhaustive]
603pub enum UnanalyzableLaunch {
604 UnknownOption(EchoedArgument),
606 MissingValue(EchoedArgument),
608 MissingCommand,
610 MalformedOperand(OperandKind),
612 PathAssignment,
614 SplitString,
616 RelativeProgramAfterChdir,
618 BlankProgram,
620}
621
622impl fmt::Display for UnanalyzableLaunch {
623 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624 match self {
625 Self::UnknownOption(option) => write!(f, "unknown option '{option}'"),
626 Self::MissingValue(option) => write!(f, "option '{option}' has no value"),
627 Self::MissingCommand => f.write_str("no command follows its options"),
628 Self::MalformedOperand(name) => write!(f, "its {name} operand is malformed"),
629 Self::PathAssignment => f.write_str("it assigns PATH"),
630 Self::SplitString => f.write_str("it splits a string into arguments"),
631 Self::RelativeProgramAfterChdir => {
632 f.write_str("it starts a relative program after changing directory")
633 }
634 Self::BlankProgram => f.write_str("the program name is empty"),
635 }
636 }
637}
638
639#[derive(Debug, Clone, PartialEq, Eq)]
641#[non_exhaustive]
642pub enum LauncherRefusal {
643 SelectsWorkspaceCode {
645 program: EchoedPath,
647 trigger: LaunchTrigger,
649 },
650 Unanalyzable {
652 program: EchoedPath,
654 reason: UnanalyzableLaunch,
656 },
657 TooDeep,
659}
660
661impl fmt::Display for LauncherRefusal {
662 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663 match self {
664 Self::SelectsWorkspaceCode { program, trigger } => write!(
665 f,
666 "its launcher '{program}' chooses the server from files in the workspace \
667 ({trigger}), which untrusted mode never runs; install the server globally \
668 and give its absolute path as `command`"
669 ),
670 Self::Unanalyzable { program, reason } => write!(
671 f,
672 "its launcher '{program}' cannot be analyzed ({reason}), so untrusted mode \
673 cannot tell which program it starts; give the absolute path of the server \
674 as `command`"
675 ),
676 Self::TooDeep => f.write_str(
677 "its launchers are nested too deeply to analyze, so untrusted mode cannot \
678 tell which program they start; give the absolute path of the server as \
679 `command`",
680 ),
681 }
682 }
683}
684
685#[derive(Debug, Clone, PartialEq, Eq)]
699#[non_exhaustive]
700pub enum UntrustedRefusal {
701 NotAllowed {
703 builtin: Option<BuiltinServer>,
706 },
707 WorkspaceExecutable {
710 executable: PathBuf,
712 },
713 UnresolvedExecutable {
716 command: ServerCommand,
718 },
719 AutoSelectionTarget {
723 executable: ServerCommand,
725 cause: InvalidAutoSelection,
727 },
728 WorkspaceHome {
732 variable: HomeVariable,
734 home: PathBuf,
736 },
737 EmptyHome {
740 variable: HomeVariable,
742 },
743 UnknownHome,
747 WorkspaceTsserver {
750 tsserver: PathBuf,
752 },
753 ProjectLauncher {
757 command: ServerCommand,
759 cause: LauncherRefusal,
761 },
762 WorkspaceExecutableArgument {
765 index: usize,
767 executable: PathBuf,
769 },
770 UnreadableWorkspaceArgument {
774 index: usize,
776 path: PathBuf,
778 },
779 UnresolvedWrappedProgram {
783 program: EchoedPath,
785 },
786 WrappedProgramPathContainsEquals {
789 program: EchoedPath,
791 },
792 UnpinnedTypescriptLauncher {
795 command: ServerCommand,
797 },
798 NonUtf8Path {
801 what: ResolvedItem,
803 path: PathBuf,
805 },
806 NoSafeWorkingDirectory,
809}
810
811impl fmt::Display for UntrustedRefusal {
812 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
813 match self {
814 Self::NotAllowed {
815 builtin: Some(builtin),
816 } => write!(
817 f,
818 "the workspace is untrusted and it may run workspace code ({})",
819 builtin.workspace_code()
820 ),
821 Self::NotAllowed { builtin: None } => f.write_str(
822 "the workspace is untrusted and it is not a built-in server, so the workspace \
823 code it may run is unknown",
824 ),
825 Self::WorkspaceExecutable { executable } => write!(
826 f,
827 "its executable {} lies inside the workspace, which untrusted mode never runs",
828 EchoedPath::new(executable)
829 ),
830 Self::WorkspaceExecutableArgument { index, executable } => write!(
831 f,
832 "its argument at index {index}, {}, is an executable inside the workspace, \
833 which untrusted mode never runs",
834 EchoedPath::new(executable)
835 ),
836 Self::UnreadableWorkspaceArgument { index, path } => write!(
837 f,
838 "its argument at index {index}, {}, lies inside the workspace and cannot be \
839 read, so untrusted mode cannot tell whether it is an executable",
840 EchoedPath::new(path)
841 ),
842 Self::UnresolvedExecutable { command } => write!(
843 f,
844 "its executable '{command}' was not found on a PATH outside the workspace, \
845 which untrusted mode requires"
846 ),
847 Self::AutoSelectionTarget { executable, cause } => write!(
848 f,
849 "its executable resolved to '{executable}' ({cause}), so untrusted mode \
850 cannot pin it; give the absolute path of the server as `command`"
851 ),
852 Self::UnknownHome => f.write_str(
853 "HOME is not set and the login home directory is unknown, so untrusted mode \
854 cannot give it a safe one",
855 ),
856 Self::EmptyHome { variable } => write!(
857 f,
858 "its {variable} is empty, which resolves inside the workspace, and the login \
859 home directory is unknown, so untrusted mode cannot give it a safe one"
860 ),
861 Self::WorkspaceHome { variable, home } => write!(
862 f,
863 "its {variable}, {}, lies inside the workspace and the login home directory is \
864 unknown, so untrusted mode cannot give it a safe one",
865 EchoedPath::new(home)
866 ),
867 Self::WorkspaceTsserver { tsserver } => write!(
868 f,
869 "the tsserver it would use, {}, lies inside the workspace, which untrusted \
870 mode never runs",
871 EchoedPath::new(tsserver)
872 ),
873 Self::ProjectLauncher { cause, .. } => cause.fmt(f),
874 Self::UnresolvedWrappedProgram { program } => write!(
875 f,
876 "the program '{program}' its launcher starts was not found where the child \
877 would look it up, which untrusted mode requires"
878 ),
879 Self::WrappedProgramPathContainsEquals { program } => write!(
880 f,
881 "the program '{program}' its launcher starts resolves to a path containing \
882 '=', which env reads as an assignment, so untrusted mode cannot pin it; give \
883 a program whose path has no '='"
884 ),
885 Self::UnpinnedTypescriptLauncher { command } => write!(
886 f,
887 "its launcher '{command}' starts the TypeScript server in a way untrusted \
888 mode cannot pin to a binary outside the workspace; install the server \
889 globally and give its absolute path as `command`"
890 ),
891 Self::NonUtf8Path { what, path } => write!(
892 f,
893 "its {what}, {}, is not valid UTF-8, so untrusted mode cannot pass it on",
894 EchoedPath::new(path)
895 ),
896 Self::NoSafeWorkingDirectory => f.write_str(
897 "no directory outside the workspace is available to start it in, which \
898 untrusted mode requires",
899 ),
900 }
901 }
902}
903
904impl fmt::Display for StartupFailure {
905 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
906 match self {
907 Self::Spawn(error) => error.fmt(f),
908 Self::InitTaskPanicked => {
909 f.write_str("the initialization task panicked (see the mcpls log)")
910 }
911 Self::RefusedUntrustedWorkspace(refusal) => refusal.fmt(f),
912 }
913 }
914}
915
916#[derive(Debug, Clone)]
918pub struct ServerSpawnFailure {
919 pub server_id: ServerId,
921 pub language_id: LanguageId,
923 pub command: ServerCommand,
925 pub reason: StartupFailure,
927}
928
929impl fmt::Display for ServerSpawnFailure {
930 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
931 write!(
932 f,
933 "{} [{}] ({}): {}",
934 self.server_id, self.language_id, self.command, self.reason
935 )
936 }
937}
938
939impl ServerSpawnFailure {
940 pub(crate) const fn failed_to_start(&self) -> FailedToStart<'_> {
944 FailedToStart(self)
945 }
946}
947
948pub(crate) struct FailedToStart<'a>(&'a ServerSpawnFailure);
950
951impl fmt::Display for FailedToStart<'_> {
952 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
953 let failure = self.0;
954 let (id, language) = (&failure.server_id, &failure.language_id);
955 match &failure.reason {
956 StartupFailure::RefusedUntrustedWorkspace(refusal) => {
957 write!(
958 f,
959 "LSP server '{id}' for language '{language}' was not started: {refusal}"
960 )?;
961 match refusal {
962 UntrustedRefusal::NotAllowed { .. } => {
963 write!(f, "; restart mcpls with `--allow-server {id}` to start it")
964 }
965 UntrustedRefusal::WorkspaceExecutable { .. }
966 | UntrustedRefusal::WorkspaceExecutableArgument { .. }
967 | UntrustedRefusal::UnreadableWorkspaceArgument { .. }
968 | UntrustedRefusal::UnresolvedExecutable { .. }
969 | UntrustedRefusal::AutoSelectionTarget { .. }
970 | UntrustedRefusal::WorkspaceHome { .. }
971 | UntrustedRefusal::EmptyHome { .. }
972 | UntrustedRefusal::UnknownHome
973 | UntrustedRefusal::WorkspaceTsserver { .. }
974 | UntrustedRefusal::ProjectLauncher { .. }
975 | UntrustedRefusal::UnresolvedWrappedProgram { .. }
976 | UntrustedRefusal::WrappedProgramPathContainsEquals { .. }
977 | UntrustedRefusal::UnpinnedTypescriptLauncher { .. }
978 | UntrustedRefusal::NonUtf8Path { .. }
979 | UntrustedRefusal::NoSafeWorkingDirectory => Ok(()),
980 }
981 }
982 reason @ (StartupFailure::Spawn(_) | StartupFailure::InitTaskPanicked) => write!(
983 f,
984 "LSP server '{id}' for language '{language}' failed to start: {reason}; restart \
985 mcpls after fixing it (startup failures are not retried)"
986 ),
987 }
988 }
989}
990
991struct FailureList<'a>(&'a [ServerSpawnFailure]);
993
994impl fmt::Display for FailureList<'_> {
995 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
996 for (index, failure) in self.0.iter().enumerate() {
997 if index > 0 {
998 f.write_str("; ")?;
999 }
1000 failure.fmt(f)?;
1001 }
1002 Ok(())
1003 }
1004}
1005
1006const MAX_DISPLAYED_CANDIDATES: usize = 10;
1009
1010#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1029pub struct SymbolCandidate {
1030 pub name: String,
1032 pub kind: u32,
1034 pub kind_name: String,
1036 pub container: Option<String>,
1038 pub line: u32,
1040 pub character: u32,
1042}
1043
1044impl fmt::Display for SymbolCandidate {
1045 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1046 write!(f, "{} `{}`", self.kind_name, self.name)?;
1047 if let Some(container) = &self.container {
1048 write!(f, " in `{container}`")?;
1049 }
1050 write!(f, " at {}:{}", self.line, self.character)
1051 }
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1071#[serde(tag = "resolution", rename_all = "snake_case")]
1072pub enum SymbolResolutionData {
1073 Ambiguous {
1075 name: String,
1077 candidates: Vec<SymbolCandidate>,
1079 truncated: bool,
1081 },
1082 NotFound {
1084 name: String,
1086 excluded_by_filters: usize,
1088 },
1089 NotDefinedInFile {
1092 name: String,
1094 },
1095 PositionUnverified {
1098 name: String,
1100 candidate: SymbolCandidate,
1102 },
1103}
1104
1105impl fmt::Display for SymbolResolutionData {
1106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1107 match self {
1108 Self::Ambiguous {
1109 name,
1110 candidates,
1111 truncated,
1112 } => {
1113 write!(
1114 f,
1115 "symbol `{name}` is ambiguous ({} candidates{}): ",
1116 candidates.len(),
1117 if *truncated { ", more not listed" } else { "" }
1118 )?;
1119 for (index, candidate) in
1120 candidates.iter().take(MAX_DISPLAYED_CANDIDATES).enumerate()
1121 {
1122 if index > 0 {
1123 f.write_str("; ")?;
1124 }
1125 candidate.fmt(f)?;
1126 }
1127 if candidates.len() > MAX_DISPLAYED_CANDIDATES {
1128 f.write_str("; ...")?;
1129 }
1130 f.write_str(
1131 ". Narrow it with `symbol_kind` or `container`, or address it by line and character",
1132 )
1133 }
1134 Self::NotFound {
1135 name,
1136 excluded_by_filters,
1137 } => {
1138 write!(f, "no symbol named `{name}` is defined in this file")?;
1139 if *excluded_by_filters > 0 {
1140 write!(
1141 f,
1142 " ({excluded_by_filters} with that name excluded by `symbol_kind`/`container`)"
1143 )?;
1144 }
1145 f.write_str("; try `workspace_symbol_search` or address it by line and character")
1146 }
1147 Self::NotDefinedInFile { name } => write!(
1148 f,
1149 "`{name}` is not defined in this file (it only occurs as a reference or import); \
1150 find its definition with `workspace_symbol_search` or address it by line and character"
1151 ),
1152 Self::PositionUnverified { name, candidate } => write!(
1153 f,
1154 "could not verify where the identifier `{name}` is ({candidate}), so nothing was \
1155 queried; address it by line and character"
1156 ),
1157 }
1158 }
1159}
1160
1161struct IdList<'a>(&'a [ServerId]);
1163
1164impl fmt::Display for IdList<'_> {
1165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1166 for (index, id) in self.0.iter().enumerate() {
1167 if index > 0 {
1168 f.write_str(", ")?;
1169 }
1170 write!(f, "'{id}'")?;
1171 }
1172 Ok(())
1173 }
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Eq)]
1236pub struct StderrExcerpt {
1237 body: ExcerptBody,
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Eq)]
1241enum ExcerptBody {
1242 Complete(Box<str>),
1243 Elided { head: Box<str>, tail: Box<str> },
1244}
1245
1246impl StderrExcerpt {
1247 pub(crate) fn complete(bytes: &[u8], redactions: &Redactions) -> Option<Self> {
1250 let text = clean_stderr(&String::from_utf8_lossy(bytes), redactions);
1251 (!text.is_empty()).then(|| Self {
1252 body: ExcerptBody::Complete(text.into()),
1253 })
1254 }
1255
1256 pub(crate) fn elided(head: &[u8], tail: &[u8], redactions: &Redactions) -> Option<Self> {
1261 let head = clean_stderr(
1262 &redactions.mask_cut_head(&decode_cut_head(head)),
1263 redactions,
1264 );
1265 let tail_start = tail
1266 .iter()
1267 .position(|byte| byte & 0xC0 != 0x80)
1268 .unwrap_or(tail.len());
1269 let tail = clean_stderr(
1270 &redactions.mask_cut_tail(&String::from_utf8_lossy(
1271 tail.get(tail_start..).unwrap_or_default(),
1272 )),
1273 redactions,
1274 );
1275 match (head.is_empty(), tail.is_empty()) {
1276 (true, true) => None,
1277 (false, true) => Some(Self {
1278 body: ExcerptBody::Complete(head.into()),
1279 }),
1280 (true, false) => Some(Self {
1281 body: ExcerptBody::Complete(tail.into()),
1282 }),
1283 (false, false) => Some(Self {
1284 body: ExcerptBody::Elided {
1285 head: head.into(),
1286 tail: tail.into(),
1287 },
1288 }),
1289 }
1290 }
1291
1292 #[must_use]
1294 pub fn head(&self) -> &str {
1295 match &self.body {
1296 ExcerptBody::Complete(text) => text,
1297 ExcerptBody::Elided { head, .. } => head,
1298 }
1299 }
1300
1301 #[must_use]
1303 pub fn tail(&self) -> Option<&str> {
1304 match &self.body {
1305 ExcerptBody::Complete(_) => None,
1306 ExcerptBody::Elided { tail, .. } => Some(tail),
1307 }
1308 }
1309
1310 #[must_use]
1312 pub const fn is_elided(&self) -> bool {
1313 matches!(self.body, ExcerptBody::Elided { .. })
1314 }
1315}
1316
1317impl fmt::Display for StderrExcerpt {
1318 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1319 fn visible_lines(text: &str) -> impl Iterator<Item = &str> {
1320 text.lines().map(str::trim).filter(|line| !line.is_empty())
1321 }
1322
1323 let mut parts: Vec<&str> = visible_lines(self.head()).collect();
1324 if let Some(tail) = self.tail() {
1325 parts.push("...");
1326 parts.extend(visible_lines(tail));
1327 }
1328 f.write_str(&parts.join(" | "))
1329 }
1330}
1331
1332fn decode_cut_head(head: &[u8]) -> String {
1334 match std::str::from_utf8(head) {
1335 Ok(text) => text.to_owned(),
1336 Err(e) if e.error_len().is_none() => {
1337 String::from_utf8_lossy(head.get(..e.valid_up_to()).unwrap_or_default()).into_owned()
1338 }
1339 Err(_) => String::from_utf8_lossy(head).into_owned(),
1340 }
1341}
1342
1343fn clean_stderr(text: &str, redactions: &Redactions) -> String {
1348 let filtered: String = redactions
1349 .apply(text)
1350 .chars()
1351 .filter(|c| {
1352 (!c.is_control() || matches!(c, '\n' | '\t'))
1353 && !crate::util::is_deceptive_format_char(*c)
1354 })
1355 .collect();
1356 redactions.apply(&filtered).trim().to_owned()
1357}
1358
1359struct StderrSuffix<'a>(&'a Option<StderrExcerpt>);
1361
1362impl fmt::Display for StderrSuffix<'_> {
1363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1364 self.0
1365 .as_ref()
1366 .map_or(Ok(()), |excerpt| write!(f, "; stderr: {excerpt}"))
1367 }
1368}
1369
1370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1381#[non_exhaustive]
1382pub enum InitFailureHint {
1383 NativeTypescriptOnly,
1386}
1387
1388impl fmt::Display for InitFailureHint {
1389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390 match self {
1391 Self::NativeTypescriptOnly => write!(
1392 f,
1393 "typescript-language-server found only TypeScript 7 or later, which ships no \
1394 tsserver; install the TypeScript version the server supports next to it (for a \
1395 global install: `{}`), or change the `command` of your existing `typescript` \
1396 server entry to the absolute path of the `tsc` of a TypeScript 7 install \
1397 outside the workspace, with `args = [\"--lsp\", \"--stdio\"]` (a `tsc` from the \
1398 workspace, or a bare `tsc` that PATH may resolve into the workspace, is \
1399 workspace-supplied code)",
1400 BuiltinServer::TypescriptLanguageServer.install_hint()
1401 ),
1402 }
1403 }
1404}
1405
1406struct HintSuffix<'a>(&'a Option<InitFailureHint>);
1408
1409impl fmt::Display for HintSuffix<'_> {
1410 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1411 self.0.map_or(Ok(()), |hint| write!(f, "; {hint}"))
1412 }
1413}
1414
1415struct EarlyExitDetail<'a>(&'a str, Option<i32>);
1418
1419impl fmt::Display for EarlyExitDetail<'_> {
1420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1421 let Self(command, exit_code) = *self;
1422 match exit_code {
1423 Some(code) => write!(f, " with exit code {code}")?,
1424 None => f.write_str(" (terminated by a signal)")?,
1425 }
1426 BuiltinServer::from_command(command)
1427 .and_then(BuiltinServer::early_exit_hint)
1428 .map_or(Ok(()), |hint| write!(f, "; {hint}"))
1429 }
1430}
1431
1432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1435#[non_exhaustive]
1436pub enum BackgroundTask {
1437 McpService,
1439 LspReceiver,
1441 ListenResolution,
1443 PathValidation,
1445 FileOpen,
1447 ServerPlanning,
1449 IdleSessionReaper,
1451}
1452
1453impl fmt::Display for BackgroundTask {
1454 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1455 f.write_str(match self {
1456 Self::McpService => "MCP service",
1457 Self::LspReceiver => "LSP receiver",
1458 Self::ListenResolution => "listen URI resolution",
1459 Self::PathValidation => "path validation",
1460 Self::FileOpen => "file open",
1461 Self::ServerPlanning => "server start planning",
1462 Self::IdleSessionReaper => "idle HTTP session reaper",
1463 })
1464 }
1465}
1466
1467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1469#[non_exhaustive]
1470pub enum StdioStream {
1471 Stdin,
1473 Stdout,
1475 Stderr,
1477}
1478
1479impl fmt::Display for StdioStream {
1480 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1481 f.write_str(match self {
1482 Self::Stdin => "stdin",
1483 Self::Stdout => "stdout",
1484 Self::Stderr => "stderr",
1485 })
1486 }
1487}
1488
1489#[derive(Debug, thiserror::Error)]
1495#[non_exhaustive]
1496pub enum ConfigError {
1497 #[error("lsp_servers entry '{server}': {pattern}")]
1499 UnsupportedFilePattern {
1500 server: ServerId,
1502 #[source]
1504 pattern: UnsupportedFilePattern,
1505 },
1506
1507 #[error("config file is not valid UTF-8")]
1509 NotUtf8(#[source] std::string::FromUtf8Error),
1510
1511 #[error("config file is {} bytes, over the fixed {} byte limit", .0.size, .0.max)]
1513 FileTooLarge(SizeExceeded),
1514
1515 #[error("lsp_servers entry '{server}': {error}")]
1518 DuplicateEnvKey {
1519 server: ServerId,
1521 error: DuplicateEnvKey,
1523 },
1524
1525 #[error("configuration path has no parent directory: {}", .path.display())]
1527 NoParentDirectory {
1528 path: PathBuf,
1530 },
1531
1532 #[error(
1534 "selection = \"auto\" is only valid for typescript-language-server entries (language \
1535 '{language}'); remove `selection` to use `command` as written"
1536 )]
1537 SelectionAutoOnNonTypescript {
1538 language: LanguageId,
1540 },
1541
1542 #[error(
1544 "allowed server '{server}' is not a configured server (configured: {})",
1545 IdList(configured)
1546 )]
1547 UnknownAllowedServer {
1548 server: ServerId,
1550 configured: Vec<ServerId>,
1552 },
1553
1554 #[error(
1556 "duplicate server id '{id}' in this workspace (used by both an entry with {first} and \
1557 one with {second}); add a unique `name` to each `[[lsp_servers]]` entry"
1558 )]
1559 DuplicateServerId {
1560 id: ServerId,
1562 first: Box<EntrySummary>,
1564 second: Box<EntrySummary>,
1566 },
1567
1568 #[error(
1570 "language '{language}' has two catch-all servers ('{existing}' and '{id}'); at most one \
1571 server per language may omit `handles`"
1572 )]
1573 TwoCatchAllServers {
1574 language: LanguageId,
1576 existing: ServerId,
1578 id: ServerId,
1580 },
1581
1582 #[error("tool '{tool}' for language '{language}' is claimed by both '{existing}' and '{id}'")]
1584 ToolClaimedTwice {
1585 tool: ToolKind,
1587 language: LanguageId,
1589 existing: ServerId,
1591 id: ServerId,
1593 },
1594
1595 #[error(
1597 "workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized",
1598 .written.display(), .base_dir.display(), .probe.display()
1599 )]
1600 UnresolvableWorkspaceRoot {
1601 written: PathBuf,
1603 base_dir: PathBuf,
1605 probe: PathBuf,
1607 #[source]
1609 source: std::io::Error,
1610 },
1611}
1612
1613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1615pub enum InitPhase {
1616 Initialize,
1618 Initialized,
1620 DidChangeConfiguration,
1622}
1623
1624impl fmt::Display for InitPhase {
1625 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1626 f.write_str(match self {
1627 Self::Initialize => "Initialize request failed",
1628 Self::Initialized => "initialized notification failed",
1629 Self::DidChangeConfiguration => "workspace/didChangeConfiguration notification failed",
1630 })
1631 }
1632}
1633
1634#[derive(Debug, thiserror::Error)]
1640#[non_exhaustive]
1641pub enum Error {
1642 #[error("LSP server initialization failed: {phase}: {cause}{}{}", HintSuffix(.hint), StderrSuffix(.stderr))]
1644 LspInitFailed {
1645 phase: InitPhase,
1647 cause: Box<Self>,
1651 hint: Option<InitFailureHint>,
1653 stderr: Option<StderrExcerpt>,
1655 },
1656
1657 #[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
1659 LspServerError {
1660 code: i32,
1662 message: String,
1667 data: Option<serde_json::Value>,
1669 },
1670
1671 #[error("failed to start MCP server: {0}")]
1673 McpServerStart(#[source] Box<dyn std::error::Error + Send + Sync>),
1674
1675 #[error("{task} task failed: {source}")]
1677 TaskFailed {
1678 task: BackgroundTask,
1680 #[source]
1682 source: tokio::task::JoinError,
1683 },
1684
1685 #[error("failed to capture LSP server {0}")]
1687 StdioCapture(StdioStream),
1688
1689 #[error("failed to bind HTTP listener on {addr}: {source}")]
1691 HttpBind {
1692 addr: std::net::SocketAddr,
1694 #[source]
1696 source: std::io::Error,
1697 },
1698
1699 #[error("document not found: {0}")]
1701 DocumentNotFound(PathBuf),
1702
1703 #[error(
1705 "no LSP server configured for language: {language}{}",
1706 no_server_detail(.language, .file, .patterns)
1707 )]
1708 NoServerForLanguage {
1709 language: LanguageId,
1711 file: FileKey,
1714 patterns: Arc<[FilePattern]>,
1717 },
1718
1719 #[error("no server handles tool '{tool}' for language '{language_id}'")]
1724 NoServerForTool {
1725 language_id: LanguageId,
1727 tool: ToolKind,
1729 },
1730
1731 #[error("{}", .0.failed_to_start())]
1737 ServerFailedToStart(Box<ServerSpawnFailure>),
1738
1739 #[error(
1741 "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)"
1742 )]
1743 ServerInitializing {
1744 server_id: ServerId,
1746 },
1747
1748 #[error(
1751 "LSP server '{server_id}' was restarted while this request was in flight; retry the request"
1752 )]
1753 ServerRestarted {
1754 server_id: ServerId,
1756 },
1757
1758 #[error("{0}")]
1762 SymbolResolution(Box<SymbolResolutionData>),
1763
1764 #[error(
1766 "unknown LSP server(s) {}; configured servers: {}",
1767 IdList(unknown),
1768 IdList(configured)
1769 )]
1770 UnknownServers {
1771 unknown: Vec<ServerId>,
1773 configured: Vec<ServerId>,
1775 },
1776
1777 #[error(
1783 "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
1784 )]
1785 WorkspaceServersInitializing,
1786
1787 #[error("no LSP server configured")]
1789 NoServerConfigured,
1790
1791 #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
1796 NoServerForWorkspaceTool {
1797 tool: ToolKind,
1799 },
1800
1801 #[error("configuration file not found: {0}")]
1803 ConfigNotFound(PathBuf),
1804
1805 #[error(
1809 "config file {} lies inside the workspace or the current directory, which untrusted mode does not trust; move it elsewhere",
1810 .path.display()
1811 )]
1812 ConfigInsideWorkspace {
1813 path: PathBuf,
1815 },
1816
1817 #[error(transparent)]
1819 Config(#[from] ConfigError),
1820
1821 #[error(transparent)]
1823 Io(#[from] std::io::Error),
1824
1825 #[error("JSON error: {0}")]
1827 Json(#[from] serde_json::Error),
1828
1829 #[error(transparent)]
1831 TomlDe(#[from] toml::de::Error),
1832
1833 #[error(transparent)]
1835 TomlSer(#[from] toml::ser::Error),
1836
1837 #[error("request timed out after {0:?}")]
1839 Timeout(Duration),
1840
1841 #[error("failed to spawn LSP server '{command}': {source}")]
1843 ServerSpawnFailed {
1844 command: ServerCommand,
1846 #[source]
1848 source: std::io::Error,
1849 },
1850
1851 #[error("failed to spawn LSP server '{command}': {source}{}", NotFoundGuidance(.command.as_str(), Platform::CURRENT))]
1856 ServerNotFound {
1857 command: ServerCommand,
1859 #[source]
1861 source: std::io::Error,
1862 },
1863
1864 #[error("LSP protocol error: {}", escape_control(.0.as_str()))]
1866 LspProtocolError(RedactedText),
1867
1868 #[error("none of the requested resource URIs resolve inside the workspace")]
1871 NoResolvableListenUris,
1872
1873 #[error(transparent)]
1875 InvalidPositionInput(#[from] InvalidPosition),
1876
1877 #[error(transparent)]
1879 InvalidRangeInput(#[from] InvalidRange),
1880
1881 #[error(transparent)]
1883 InvalidHierarchyItemInput(#[from] InvalidHierarchyItem),
1884
1885 #[error(
1891 "line {line} is beyond the end of the document (the document ends at line {last_line})"
1892 )]
1893 PositionBeyondDocument {
1894 line: std::num::NonZeroU32,
1896 last_line: std::num::NonZeroU32,
1898 },
1899
1900 #[error(transparent)]
1902 ResourceUri(#[from] ResourceUriError),
1903
1904 #[error("cannot convert path to URI: {}", .0.display())]
1906 PathToUri(PathBuf),
1907
1908 #[error("LSP server process terminated unexpectedly")]
1910 ServerTerminated,
1911
1912 #[error("LSP server shutdown did not complete before its deadline")]
1914 ShutdownTimeout,
1915
1916 #[error("LSP server '{command}' exited during initialization{}{}{}", EarlyExitDetail(.command.as_str(), *.exit_code), HintSuffix(.hint), StderrSuffix(.stderr))]
1919 ServerExitedDuringInit {
1920 command: ServerCommand,
1922 exit_code: Option<i32>,
1924 hint: Option<InitFailureHint>,
1926 stderr: Option<StderrExcerpt>,
1928 },
1929
1930 #[error(
1937 "LSP server '{server_id}' is unavailable: crash-looping, retry in {:.1}s",
1938 .retry_in.as_secs_f64()
1939 )]
1940 ServerUnavailable {
1941 server_id: ServerId,
1943 retry_in: Duration,
1945 },
1946
1947 #[error("invalid tool parameters: {0}")]
1949 InvalidToolParams(String),
1950
1951 #[error(transparent)]
1953 InvalidClientPath(#[from] InvalidClientPath),
1954
1955 #[error("malformed file path {path:?}")]
1963 MalformedPath {
1964 path: PathBuf,
1966 #[source]
1968 source: std::io::Error,
1969 },
1970
1971 #[error("file I/O error for {path:?}")]
1979 FileIo {
1980 path: PathBuf,
1982 #[source]
1984 source: std::io::Error,
1985 },
1986
1987 #[error("path outside workspace: {0}")]
1989 PathOutsideWorkspace(PathBuf),
1990
1991 #[error("no workspace roots configured: refusing access to {0}")]
1994 NoWorkspaceRoots(PathBuf),
1995
1996 #[error(
1998 "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
1999 )]
2000 DocumentLimitExceeded {
2001 current: usize,
2003 max: std::num::NonZeroUsize,
2005 },
2006
2007 #[error("subscription limit of {max} reached")]
2014 SubscriptionLimitReached {
2015 max: usize,
2017 },
2018
2019 #[error("listen stream limit of {max} reached; retry once another stream closes")]
2024 ListenStreamsExhausted {
2025 max: usize,
2027 },
2028
2029 #[error(
2032 "subscriptions/listen request exceeds the limit of {max} resource URIs or their total size budget"
2033 )]
2034 ListenFilterTooLarge {
2035 max: usize,
2037 },
2038
2039 #[error(
2041 "file size limit exceeded: {} bytes, max {} bytes (raise workspace.max_file_size in config to increase this)",
2042 .0.size,
2043 .0.max
2044 )]
2045 FileSizeLimitExceeded(SizeExceeded),
2046
2047 #[error("not a regular file: {0}")]
2058 NotARegularFile(PathBuf),
2059
2060 #[error("all LSP servers failed to initialize: {}", FailureList(failures))]
2062 AllServersFailedToInit {
2063 failures: Vec<ServerSpawnFailure>,
2065 },
2066
2067 #[error("server '{server_id}' does not support capability '{capability}'")]
2071 CapabilityNotSupported {
2072 server_id: ServerId,
2074 capability: Capability,
2077 },
2078
2079 #[error(
2086 "LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
2087 )]
2088 WorkspaceIndexing {
2089 server_id: ServerId,
2091 elapsed_secs: u64,
2093 },
2094}
2095
2096pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
2108
2109pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
2124
2125pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
2144
2145pub const LISTEN_STREAMS_EXHAUSTED_ERROR_CODE: i32 = -32053;
2161
2162pub const SERVER_RESTARTED_ERROR_CODE: i32 = -32054;
2176
2177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2200#[serde(untagged)]
2201pub enum RetryableErrorData {
2202 WorkspaceIndexing {
2204 server_id: ServerId,
2206 elapsed_secs: u64,
2208 },
2209 ServerInitializing {
2211 server_id: ServerId,
2213 },
2214 WorkspaceServersInitializing {},
2218 ListenStreamsExhausted {
2220 max_listen_streams: usize,
2222 },
2223 ServerRestarted {
2225 server_id: ServerId,
2227 },
2228}
2229
2230impl RetryableErrorData {
2231 #[must_use]
2233 pub const fn code(&self) -> i32 {
2234 match self {
2235 Self::WorkspaceIndexing { .. } => WORKSPACE_INDEXING_ERROR_CODE,
2236 Self::ServerInitializing { .. } | Self::WorkspaceServersInitializing {} => {
2237 SERVER_INITIALIZING_ERROR_CODE
2238 }
2239 Self::ListenStreamsExhausted { .. } => LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
2240 Self::ServerRestarted { .. } => SERVER_RESTARTED_ERROR_CODE,
2241 }
2242 }
2243}
2244
2245#[derive(Debug, Clone, PartialEq, Eq)]
2263#[non_exhaustive]
2264pub enum McpErrorKind {
2265 InvalidParams,
2268 InvalidPosition(RewrittenServerError),
2272 SymbolResolution(SymbolResolutionData),
2275 Retryable(RetryableErrorData),
2280 Internal,
2283}
2284
2285impl Error {
2286 #[must_use]
2308 pub fn mcp_error_kind(&self) -> McpErrorKind {
2309 match self {
2310 Self::InvalidToolParams(_)
2311 | Self::UnknownServers { .. }
2312 | Self::InvalidClientPath(_)
2313 | Self::MalformedPath { .. }
2314 | Self::PathOutsideWorkspace(_)
2315 | Self::NotARegularFile(_)
2316 | Self::NoResolvableListenUris
2317 | Self::ResourceUri(_)
2318 | Self::InvalidPositionInput(_)
2319 | Self::InvalidRangeInput(_)
2320 | Self::InvalidHierarchyItemInput(_)
2321 | Self::PositionBeyondDocument { .. }
2322 | Self::ListenFilterTooLarge { .. }
2323 | Self::DocumentNotFound(_)
2324 | Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
2325
2326 Self::ListenStreamsExhausted { max } => {
2328 McpErrorKind::Retryable(RetryableErrorData::ListenStreamsExhausted {
2329 max_listen_streams: *max,
2330 })
2331 }
2332
2333 Self::FileIo { source, .. } => {
2344 if source.kind() == std::io::ErrorKind::NotFound {
2345 McpErrorKind::InvalidParams
2346 } else {
2347 McpErrorKind::Internal
2348 }
2349 }
2350
2351 Self::WorkspaceIndexing {
2352 server_id,
2353 elapsed_secs,
2354 } => McpErrorKind::Retryable(RetryableErrorData::WorkspaceIndexing {
2355 server_id: server_id.clone(),
2356 elapsed_secs: *elapsed_secs,
2357 }),
2358 Self::ServerInitializing { server_id } => {
2359 McpErrorKind::Retryable(RetryableErrorData::ServerInitializing {
2360 server_id: server_id.clone(),
2361 })
2362 }
2363 Self::SymbolResolution(data) => McpErrorKind::SymbolResolution((**data).clone()),
2364 Self::ServerRestarted { server_id } => {
2365 McpErrorKind::Retryable(RetryableErrorData::ServerRestarted {
2366 server_id: server_id.clone(),
2367 })
2368 }
2369 Self::WorkspaceServersInitializing => {
2374 McpErrorKind::Retryable(RetryableErrorData::WorkspaceServersInitializing {})
2375 }
2376
2377 Self::LspServerError { code, message, .. }
2383 if message.contains(INVALID_OFFSET_MARKER) =>
2384 {
2385 McpErrorKind::InvalidPosition(RewrittenServerError::new(*code, message))
2386 }
2387
2388 Self::LspInitFailed { .. }
2389 | Self::LspServerError { .. }
2390 | Self::McpServerStart(_)
2391 | Self::TaskFailed { .. }
2392 | Self::StdioCapture(_)
2393 | Self::PathToUri(_)
2394 | Self::HttpBind { .. }
2395 | Self::NoServerForLanguage { .. }
2396 | Self::NoServerForTool { .. }
2397 | Self::NoServerConfigured
2398 | Self::NoServerForWorkspaceTool { .. }
2399 | Self::ConfigNotFound(_)
2400 | Self::ConfigInsideWorkspace { .. }
2401 | Self::Config(_)
2402 | Self::Io(_)
2403 | Self::Json(_)
2404 | Self::TomlDe(_)
2405 | Self::TomlSer(_)
2406 | Self::Timeout(_)
2407 | Self::ServerSpawnFailed { .. }
2408 | Self::ServerNotFound { .. }
2409 | Self::LspProtocolError(_)
2410 | Self::ServerTerminated
2411 | Self::ShutdownTimeout
2412 | Self::ServerUnavailable { .. }
2413 | Self::ServerFailedToStart(_)
2414 | Self::ServerExitedDuringInit { .. }
2415 | Self::NoWorkspaceRoots(_)
2416 | Self::DocumentLimitExceeded { .. }
2421 | Self::SubscriptionLimitReached { .. }
2424 | Self::AllServersFailedToInit { .. }
2425 | Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
2426 }
2427 }
2428
2429 pub(crate) const fn is_unresolvable_resource(&self) -> bool {
2433 match self {
2434 Self::MalformedPath { .. }
2435 | Self::FileIo { .. }
2436 | Self::PathOutsideWorkspace(..)
2437 | Self::NoWorkspaceRoots(..) => true,
2438 Self::LspInitFailed { .. }
2439 | Self::LspServerError { .. }
2440 | Self::McpServerStart(..)
2441 | Self::TaskFailed { .. }
2442 | Self::StdioCapture(..)
2443 | Self::HttpBind { .. }
2444 | Self::DocumentNotFound(..)
2445 | Self::NoServerForLanguage { .. }
2446 | Self::NoServerForTool { .. }
2447 | Self::ServerFailedToStart(..)
2448 | Self::ServerInitializing { .. }
2449 | Self::ServerRestarted { .. }
2450 | Self::SymbolResolution(..)
2451 | Self::UnknownServers { .. }
2452 | Self::WorkspaceServersInitializing
2453 | Self::NoServerConfigured
2454 | Self::NoServerForWorkspaceTool { .. }
2455 | Self::ConfigNotFound(..)
2456 | Self::ConfigInsideWorkspace { .. }
2457 | Self::Config(..)
2458 | Self::Io(..)
2459 | Self::Json(..)
2460 | Self::TomlDe(..)
2461 | Self::TomlSer(..)
2462 | Self::Timeout(..)
2463 | Self::ServerSpawnFailed { .. }
2464 | Self::ServerNotFound { .. }
2465 | Self::LspProtocolError(..)
2466 | Self::NoResolvableListenUris
2467 | Self::InvalidPositionInput(..)
2468 | Self::InvalidRangeInput(..)
2469 | Self::InvalidHierarchyItemInput(..)
2470 | Self::PositionBeyondDocument { .. }
2471 | Self::ResourceUri(..)
2472 | Self::PathToUri(..)
2473 | Self::ServerTerminated
2474 | Self::ShutdownTimeout
2475 | Self::ServerExitedDuringInit { .. }
2476 | Self::ServerUnavailable { .. }
2477 | Self::InvalidToolParams(..)
2478 | Self::InvalidClientPath(..)
2479 | Self::DocumentLimitExceeded { .. }
2480 | Self::SubscriptionLimitReached { .. }
2481 | Self::ListenStreamsExhausted { .. }
2482 | Self::ListenFilterTooLarge { .. }
2483 | Self::FileSizeLimitExceeded { .. }
2484 | Self::NotARegularFile(..)
2485 | Self::AllServersFailedToInit { .. }
2486 | Self::CapabilityNotSupported { .. }
2487 | Self::WorkspaceIndexing { .. } => false,
2488 }
2489 }
2490}
2491
2492pub type Result<T> = std::result::Result<T, Error>;
2494
2495#[derive(Debug, Clone, Copy)]
2517pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static));
2518
2519impl fmt::Display for ErrorChain<'_> {
2520 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2521 let mut text = self.0.to_string();
2522 let mut cause = self.0.source();
2523 while let Some(error) = cause {
2524 let part = error.to_string();
2525 if !text.contains(&part) {
2526 text.push_str(": ");
2527 text.push_str(&part);
2528 }
2529 cause = error.source();
2530 }
2531 f.write_str(&text)
2532 }
2533}
2534
2535#[cfg(test)]
2536mod tests {
2537 use std::assert_matches;
2538
2539 use super::*;
2540 use crate::config::{FileExtension, FileName};
2541 use crate::util::TRUNCATION_MARKER;
2542
2543 #[test]
2544 fn test_all_servers_failed_to_init_error() {
2545 let failures = vec![
2546 ServerSpawnFailure {
2547 server_id: ServerId::from_static("rust"),
2548 language_id: LanguageId::from_static("rust"),
2549 command: ServerCommand::from_static("rust-analyzer"),
2550 reason: StartupFailure::InitTaskPanicked,
2551 },
2552 ServerSpawnFailure {
2553 server_id: ServerId::from_static("python"),
2554 language_id: LanguageId::from_static("python"),
2555 command: ServerCommand::from_static("pyright"),
2556 reason: StartupFailure::InitTaskPanicked,
2557 },
2558 ];
2559
2560 let err = Error::AllServersFailedToInit { failures };
2561
2562 assert!(err.to_string().contains("all LSP servers failed"));
2563
2564 if let Error::AllServersFailedToInit { failures: f } = err {
2566 assert_eq!(f.len(), 2);
2567 assert_eq!(f[0].language_id, "rust");
2568 assert_eq!(f[1].language_id, "python");
2569 } else {
2570 panic!("Expected AllServersFailedToInit error");
2571 }
2572 }
2573
2574 #[test]
2575 fn test_server_spawn_failure_display_names_init_panic() {
2576 let failure = ServerSpawnFailure {
2577 server_id: ServerId::from_static("typescript"),
2578 language_id: LanguageId::from_static("typescript"),
2579 command: ServerCommand::from_static("tsserver"),
2580 reason: StartupFailure::InitTaskPanicked,
2581 };
2582
2583 let display = failure.to_string();
2584 assert!(display.contains("typescript"));
2585 assert!(display.contains("tsserver"));
2586 assert!(display.contains("panicked"));
2587 }
2588
2589 #[test]
2590 fn test_ambiguity_display_lists_at_most_ten_candidates_and_the_hint() {
2591 let candidates: Vec<SymbolCandidate> = (1..=12)
2592 .map(|line| SymbolCandidate {
2593 name: "new".to_string(),
2594 kind: 6,
2595 kind_name: "Method".to_string(),
2596 container: Some("Foo".to_string()),
2597 line,
2598 character: 1,
2599 })
2600 .collect();
2601 let text = SymbolResolutionData::Ambiguous {
2602 name: "new".to_string(),
2603 candidates,
2604 truncated: false,
2605 }
2606 .to_string();
2607
2608 assert!(text.contains("12 candidates"), "{text}");
2609 assert!(
2610 text.contains("at 10:1") && !text.contains("at 11:1"),
2611 "{text}"
2612 );
2613 assert!(text.contains("; ..."), "{text}");
2614 assert!(text.contains("`symbol_kind` or `container`"), "{text}");
2615 }
2616
2617 #[test]
2618 fn test_error_display_lsp_init_failed() {
2619 let err = Error::LspInitFailed {
2620 phase: InitPhase::Initialize,
2621 cause: Box::new(Error::Io(std::io::Error::other("server not found"))),
2622 hint: None,
2623 stderr: None,
2624 };
2625 assert_eq!(
2626 err.to_string(),
2627 "LSP server initialization failed: Initialize request failed: server not found"
2628 );
2629 }
2630
2631 #[test]
2632 fn test_stderr_excerpt_none_when_nothing_visible() {
2633 assert_eq!(
2634 StderrExcerpt::complete(b" \n\x1b\r\n ", &Redactions::default()),
2635 None
2636 );
2637 assert_eq!(StderrExcerpt::complete(b"", &Redactions::default()), None);
2638 }
2639
2640 #[test]
2641 fn test_stderr_excerpt_strips_control_characters_but_keeps_newline_and_tab() {
2642 let excerpt =
2643 StderrExcerpt::complete(b"a\x1b[31mb\x00\r\nc\td", &Redactions::default()).unwrap();
2644 assert_eq!(excerpt.head(), "a[31mb\nc\td");
2645 }
2646
2647 #[test]
2648 fn test_stderr_excerpt_display_is_single_line() {
2649 let excerpt =
2650 StderrExcerpt::complete(b"first\n\n second \nthird\n", &Redactions::default())
2651 .unwrap();
2652 assert_eq!(excerpt.to_string(), "first | second | third");
2653 }
2654
2655 #[test]
2656 fn test_stderr_excerpt_elided_display_marks_the_gap() {
2657 let excerpt =
2658 StderrExcerpt::elided(b"start\nbanner", b"last\nerror", &Redactions::default())
2659 .unwrap();
2660 assert!(excerpt.is_elided());
2661 assert_eq!(excerpt.tail(), Some("last\nerror"));
2662 assert_eq!(excerpt.to_string(), "start | banner | ... | last | error");
2663 }
2664
2665 #[test]
2666 fn test_stderr_excerpt_elided_drops_utf8_sequences_cut_by_the_split() {
2667 let text = "é".as_bytes();
2668 let head = [b"ok ".as_slice(), &text[..1]].concat();
2669 let tail = [&text[1..], b" end".as_slice()].concat();
2670
2671 let excerpt = StderrExcerpt::elided(&head, &tail, &Redactions::default()).unwrap();
2672
2673 assert_eq!(excerpt.head(), "ok");
2674 assert_eq!(excerpt.tail(), Some("end"));
2675 }
2676
2677 fn secrets(pairs: &[(&str, &str)]) -> Redactions {
2678 Redactions::new(
2679 pairs
2680 .iter()
2681 .map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
2682 )
2683 }
2684
2685 #[test]
2686 fn test_stderr_excerpt_redacts_secrets_with_their_label() {
2687 let excerpt = StderrExcerpt::complete(
2688 b"token=hunter2hunter2 id=abc",
2689 &secrets(&[("API_TOKEN", "hunter2hunter2"), ("SHORT_KEY", "abc")]),
2690 )
2691 .unwrap();
2692 assert_eq!(excerpt.head(), "token=[redacted:API_TOKEN] id=abc");
2693 }
2694
2695 #[test]
2696 fn test_stderr_excerpt_redacts_longest_overlapping_secret_whole() {
2697 let excerpt = StderrExcerpt::complete(
2698 b"v=abcdefghXYZ12345",
2699 &secrets(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]),
2700 )
2701 .unwrap();
2702 assert_eq!(excerpt.head(), "v=[redacted:B_KEY]");
2703 }
2704
2705 #[test]
2706 fn test_stderr_excerpt_keeps_non_secret_values_visible() {
2707 let excerpt = StderrExcerpt::complete(
2708 b"toolchain 'nightly-2024-01-01' is not installed",
2709 &secrets(&[("API_TOKEN", "hunter2hunter2")]),
2710 )
2711 .unwrap();
2712 assert_eq!(
2713 excerpt.head(),
2714 "toolchain 'nightly-2024-01-01' is not installed"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_stderr_excerpt_redacts_a_secret_split_by_a_control_character() {
2720 let excerpt = StderrExcerpt::complete(
2721 "tok=hunter2\u{200B}hunter2".as_bytes(),
2722 &secrets(&[("API_TOKEN", "hunter2hunter2")]),
2723 )
2724 .unwrap();
2725 assert_eq!(excerpt.head(), "tok=[redacted:API_TOKEN]");
2726 }
2727
2728 #[test]
2731 fn test_stderr_excerpt_masks_a_secret_split_across_the_elision_boundary() {
2732 let redactions = secrets(&[("API_TOKEN", "supersecretvalue")]);
2733
2734 let excerpt =
2735 StderrExcerpt::elided(b"token=supersec", b"retvalue done", &redactions).unwrap();
2736
2737 let shown = excerpt.to_string();
2738 assert!(!shown.contains("supersec"), "{shown}");
2739 assert!(!shown.contains("retvalue"), "{shown}");
2740 assert!(shown.contains("[redacted:API_TOKEN]"), "{shown}");
2741 }
2742
2743 #[test]
2744 fn test_stderr_excerpt_strips_separators_bidi_and_zero_width_characters() {
2745 let text = "a\u{2028}b\u{2029}c\u{202E}d\u{2066}e\u{200B}f\u{FEFF}g\u{200F}h";
2746
2747 let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
2748
2749 assert_eq!(excerpt.head(), "abcdefgh");
2750 }
2751
2752 #[test]
2753 fn test_escape_control_and_stderr_agree_on_deceptive_characters() {
2754 let deceptive = [
2755 '\u{061C}',
2756 '\u{200B}',
2757 '\u{200C}',
2758 '\u{200D}',
2759 '\u{200E}',
2760 '\u{200F}',
2761 '\u{2028}',
2762 '\u{2029}',
2763 '\u{202A}',
2764 '\u{202B}',
2765 '\u{202C}',
2766 '\u{202D}',
2767 '\u{202E}',
2768 '\u{2060}',
2769 '\u{2066}',
2770 '\u{2067}',
2771 '\u{2068}',
2772 '\u{2069}',
2773 '\u{FEFF}',
2774 '\u{00AD}',
2775 '\u{180E}',
2776 '\u{2061}',
2777 '\u{2064}',
2778 '\u{206A}',
2779 '\u{206F}',
2780 '\u{FFF9}',
2781 '\u{FFFB}',
2782 '\u{E0000}',
2783 '\u{E0041}',
2784 '\u{E007F}',
2785 ];
2786 for c in deceptive {
2787 let text = format!("a{c}b");
2788 let escaped = crate::util::escape_control(&text);
2789 assert!(
2790 !escaped.contains(c),
2791 "escape_control kept U+{:04X}",
2792 c as u32
2793 );
2794 let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
2795 assert_eq!(excerpt.head(), "ab", "stderr kept U+{:04X}", c as u32);
2796 }
2797 }
2798
2799 #[test]
2800 fn test_init_errors_append_stderr_to_display() {
2801 let stderr = StderrExcerpt::complete(b"fatal: bad config", &Redactions::default());
2802 let failed = Error::LspInitFailed {
2803 phase: InitPhase::Initialize,
2804 cause: Box::new(Error::Io(std::io::Error::other("boom"))),
2805 hint: None,
2806 stderr: stderr.clone(),
2807 };
2808 assert_eq!(
2809 failed.to_string(),
2810 "LSP server initialization failed: Initialize request failed: boom; stderr: fatal: bad config"
2811 );
2812 let exited = Error::ServerExitedDuringInit {
2813 command: crate::config::ServerCommand::from_static("gopls"),
2814 exit_code: Some(2),
2815 hint: None,
2816 stderr,
2817 };
2818 assert_eq!(
2819 exited.to_string(),
2820 "LSP server 'gopls' exited during initialization with exit code 2; stderr: fatal: bad config"
2821 );
2822 }
2823
2824 #[test]
2825 fn test_error_display_lsp_server_error() {
2826 let err = Error::LspServerError {
2827 code: -32600,
2828 message: "Invalid request".to_string(),
2829 data: None,
2830 };
2831 assert_eq!(
2832 err.to_string(),
2833 "LSP server error: -32600 - Invalid request"
2834 );
2835 }
2836
2837 #[test]
2838 fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
2839 let err = Error::LspServerError {
2840 code: -32603,
2841 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
2842 .to_string(),
2843 data: None,
2844 };
2845 assert_eq!(
2846 err.to_string(),
2847 "LSP server error: -32603 - position out of range for this document"
2848 );
2849 }
2850
2851 #[test]
2852 fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
2853 let err = Error::LspServerError {
2857 code: -32803,
2858 message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
2859 (line index length: 3)"
2860 .to_string(),
2861 data: None,
2862 };
2863 assert_eq!(
2864 err.to_string(),
2865 "LSP server error: -32803 - position out of range for this document"
2866 );
2867 }
2868
2869 #[test]
2870 fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
2871 let err = Error::LspServerError {
2872 code: -32602,
2873 message: "Invalid params: expected object".to_string(),
2874 data: None,
2875 };
2876 assert_eq!(
2877 err.to_string(),
2878 "LSP server error: -32602 - Invalid params: expected object"
2879 );
2880 }
2881
2882 #[test]
2883 fn test_error_display_document_not_found() {
2884 let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
2885 assert!(err.to_string().contains("document not found"));
2886 assert!(err.to_string().contains("file.rs"));
2887 }
2888
2889 #[test]
2890 fn test_error_display_no_server_for_language() {
2891 let err = no_server_for_language("rust");
2892 assert_eq!(
2893 err.to_string(),
2894 "no LSP server configured for language: rust"
2895 );
2896 }
2897
2898 #[test]
2899 fn test_error_display_workspace_servers_initializing() {
2900 let err = Error::WorkspaceServersInitializing;
2901 assert!(err.to_string().contains("still initializing"));
2902 }
2903
2904 #[test]
2905 fn test_error_display_no_server_for_workspace_tool() {
2906 let err = Error::NoServerForWorkspaceTool {
2907 tool: crate::config::ToolKind::WorkspaceSymbols,
2908 };
2909 assert!(err.to_string().contains("workspace_symbols"));
2910 assert!(err.to_string().contains("no server's `handles` list"));
2911 }
2912
2913 #[test]
2914 fn test_error_display_timeout() {
2915 assert_eq!(
2916 Error::Timeout(Duration::from_secs(30)).to_string(),
2917 "request timed out after 30s"
2918 );
2919 assert_eq!(
2920 Error::Timeout(Duration::from_millis(500)).to_string(),
2921 "request timed out after 500ms"
2922 );
2923 }
2924
2925 #[test]
2926 fn test_error_display_document_limit() {
2927 let err = Error::DocumentLimitExceeded {
2928 current: 150,
2929 max: std::num::NonZeroUsize::new(100).unwrap(),
2930 };
2931 assert_eq!(
2932 err.to_string(),
2933 "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_error_display_file_size_limit() {
2939 let err = Error::FileSizeLimitExceeded(SizeExceeded {
2940 size: 20_000_000,
2941 max: std::num::NonZeroU64::new(10_000_000).unwrap(),
2942 });
2943 assert_eq!(
2944 err.to_string(),
2945 "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
2946 );
2947 }
2948
2949 #[test]
2950 fn test_error_display_not_a_regular_file() {
2951 let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
2952 assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
2953 }
2954
2955 #[test]
2956 fn test_error_from_io() {
2957 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
2958 let err: Error = io_err.into();
2959 assert_matches!(err, Error::Io(_));
2960 }
2961
2962 #[test]
2963 fn test_error_from_json() {
2964 let json_str = "{invalid json}";
2965 let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
2966 let err: Error = json_err.into();
2967 assert_matches!(err, Error::Json(_));
2968 }
2969
2970 #[test]
2973 fn test_config_error_chain_does_not_repeat_source_text() {
2974 let toml_err = toml::from_str::<toml::Value>("[invalid toml").unwrap_err();
2975 let non_utf8 = String::from_utf8(vec![0xff]).unwrap_err();
2976 let errors = [
2977 Error::from(toml_err),
2978 Error::from(ConfigError::NotUtf8(non_utf8)),
2979 Error::from(ConfigError::UnresolvableWorkspaceRoot {
2980 written: PathBuf::from("root"),
2981 base_dir: PathBuf::from("base"),
2982 probe: PathBuf::from("base/root"),
2983 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such directory"),
2984 }),
2985 Error::from(std::io::Error::new(
2986 std::io::ErrorKind::PermissionDenied,
2987 "access refused",
2988 )),
2989 Error::FileIo {
2990 path: PathBuf::from("a.rs"),
2991 source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
2992 },
2993 Error::MalformedPath {
2994 path: PathBuf::from("b.rs"),
2995 source: std::io::Error::new(std::io::ErrorKind::NotADirectory, "not a dir"),
2996 },
2997 ];
2998 for err in errors {
2999 let mut chain = vec![err.to_string()];
3000 let mut source = std::error::Error::source(&err);
3001 while let Some(cause) = source {
3002 chain.push(cause.to_string());
3003 source = cause.source();
3004 }
3005 for [parent, cause] in chain.array_windows() {
3006 assert!(
3007 !parent.contains(cause),
3008 "'{parent}' repeats its source '{cause}'"
3009 );
3010 }
3011 }
3012 }
3013
3014 #[test]
3015 fn test_error_chain_joins_causes_once() {
3016 let not_found = || std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
3017 let file_io = Error::FileIo {
3018 path: PathBuf::from("a.rs"),
3019 source: not_found(),
3020 };
3021 let spawn = Error::ServerSpawnFailed {
3022 command: crate::config::ServerCommand::new("x").unwrap(),
3023 source: not_found(),
3024 };
3025
3026 assert_eq!(
3027 ErrorChain(&file_io).to_string(),
3028 "file I/O error for \"a.rs\": gone"
3029 );
3030 assert_eq!(
3031 ErrorChain(&spawn).to_string(),
3032 "failed to spawn LSP server 'x': gone"
3033 );
3034 }
3035
3036 #[test]
3037 fn test_error_from_toml_de() {
3038 let toml_str = "[invalid toml";
3039 let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
3040 let err: Error = toml_err.into();
3041 assert_matches!(err, Error::TomlDe(_));
3042 }
3043
3044 #[test]
3045 fn test_result_type_alias() {
3046 fn _returns_error() -> Result<i32> {
3047 Err(Error::Config(ConfigError::NoParentDirectory {
3048 path: PathBuf::new(),
3049 }))
3050 }
3051
3052 let result: Result<i32> = Ok(42);
3053 assert!(result.is_ok());
3054 if let Ok(value) = result {
3055 assert_eq!(value, 42);
3056 }
3057 }
3058
3059 fn not_found(command: &str) -> Error {
3060 Error::ServerNotFound {
3061 command: crate::config::ServerCommand::new(command).unwrap(),
3062 source: std::io::Error::from(std::io::ErrorKind::NotFound),
3063 }
3064 }
3065
3066 #[test]
3067 fn test_server_not_found_bare_builtin_has_path_and_install_hint() {
3068 let msg = not_found("rust-analyzer").to_string();
3069 assert!(msg.contains("not on the PATH"), "{msg}");
3070 assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3071 }
3072
3073 #[test]
3074 fn test_server_not_found_bare_unknown_has_no_install_hint() {
3075 let msg = not_found("my-custom-lsp").to_string();
3076 assert!(msg.contains("not on the PATH"), "{msg}");
3077 assert!(!msg.contains("install it"), "{msg}");
3078 assert!(!msg.contains(".cmd"), "{msg}");
3079 }
3080
3081 #[test]
3082 fn test_server_not_found_path_command_has_no_path_text() {
3083 let msg = not_found("/nonexistent/rust-analyzer").to_string();
3084 assert!(msg.contains("configured path exists"), "{msg}");
3085 assert!(!msg.contains("PATH"), "{msg}");
3086 assert!(!msg.contains("install it"), "{msg}");
3087 }
3088
3089 #[test]
3090 fn test_lifecycle_errors_map_to_internal() {
3091 for err in [Error::ShutdownTimeout, not_found("x")] {
3092 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3093 }
3094 }
3095
3096 #[test]
3097 fn test_not_found_guidance_cmd_note_only_for_npm_builtins_on_windows() {
3098 let cases = [
3099 ("pyright-langserver", Platform::Windows, true),
3100 ("typescript-language-server", Platform::Windows, true),
3101 ("pyright-langserver", Platform::Other, false),
3102 ("rust-analyzer", Platform::Windows, false),
3103 ("gopls", Platform::Windows, false),
3104 ("my-custom-lsp", Platform::Windows, false),
3105 ];
3106 for (command, platform, expects_cmd_note) in cases {
3107 let msg = NotFoundGuidance(command, platform).to_string();
3108 assert_eq!(
3109 msg.contains(".cmd"),
3110 expects_cmd_note,
3111 "{command} {platform:?}: {msg}"
3112 );
3113 }
3114 }
3115
3116 #[test]
3117 fn test_error_source_chain() {
3118 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
3119 let err = Error::ServerSpawnFailed {
3120 command: crate::config::ServerCommand::from_static("rust-analyzer"),
3121 source: io_err,
3122 };
3123
3124 let source = std::error::Error::source(&err);
3125 assert!(source.is_some());
3126 }
3127
3128 fn spawn_failure(id: &str, command: &str, error: Error) -> ServerSpawnFailure {
3129 ServerSpawnFailure {
3130 server_id: ServerId::new(id).unwrap(),
3131 language_id: LanguageId::new(id).unwrap(),
3132 command: ServerCommand::new(command).unwrap(),
3133 reason: StartupFailure::Spawn(Arc::new(error)),
3134 }
3135 }
3136
3137 #[test]
3138 fn test_server_spawn_failure_display() {
3139 let failure = spawn_failure(
3140 "rust",
3141 "rust-analyzer",
3142 Error::LspInitFailed {
3143 phase: InitPhase::Initialize,
3144 cause: Box::new(Error::Io(std::io::Error::other("boom"))),
3145 hint: None,
3146 stderr: None,
3147 },
3148 );
3149 assert_eq!(
3150 failure.to_string(),
3151 "rust [rust] (rust-analyzer): LSP server initialization failed: Initialize request failed: boom"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_server_spawn_failure_clone_shares_reason() {
3157 let failure = spawn_failure("python", "pyright", not_found("pyright"));
3158 let cloned = failure.clone();
3159 assert_eq!(failure.language_id, cloned.language_id);
3160 assert_eq!(failure.to_string(), cloned.to_string());
3161 }
3162
3163 #[test]
3164 fn test_server_failed_to_start_display_carries_not_found_guidance() {
3165 let err = Error::ServerFailedToStart(Box::new(spawn_failure(
3166 "rust",
3167 "rust-analyzer",
3168 not_found("rust-analyzer"),
3169 )));
3170 let msg = err.to_string();
3171 assert!(
3172 msg.contains("'rust' for language 'rust' failed to start"),
3173 "{msg}"
3174 );
3175 assert!(msg.contains("not on the PATH"), "{msg}");
3176 assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3177 assert!(msg.contains("restart mcpls"), "{msg}");
3178 }
3179
3180 #[test]
3181 fn test_server_failed_to_start_init_task_panicked_display() {
3182 let err = Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
3183 server_id: ServerId::from_static("rust"),
3184 language_id: LanguageId::from_static("rust"),
3185 command: ServerCommand::from_static("rust-analyzer"),
3186 reason: StartupFailure::InitTaskPanicked,
3187 }));
3188 assert!(err.to_string().contains("initialization task panicked"));
3189 }
3190
3191 #[test]
3192 fn test_startup_errors_map_to_internal() {
3193 let failure = spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer"));
3194 for err in [
3195 Error::ServerFailedToStart(Box::new(failure.clone())),
3196 Error::AllServersFailedToInit {
3197 failures: vec![failure],
3198 },
3199 Error::ServerExitedDuringInit {
3200 command: crate::config::ServerCommand::from_static("x"),
3201 exit_code: Some(1),
3202 hint: None,
3203 stderr: None,
3204 },
3205 Error::ServerUnavailable {
3206 server_id: ServerId::from_static("rust"),
3207 retry_in: Duration::from_secs(1),
3208 },
3209 ] {
3210 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3211 }
3212 }
3213
3214 #[test]
3215 fn test_init_failure_hint_renders_between_detail_and_stderr() {
3216 let stderr = StderrExcerpt::complete(b"boom", &Redactions::default());
3217 let hint = Some(InitFailureHint::NativeTypescriptOnly);
3218 let failed = Error::LspInitFailed {
3219 phase: InitPhase::Initialize,
3220 cause: Box::new(Error::Io(std::io::Error::other("x"))),
3221 hint,
3222 stderr: stderr.clone(),
3223 }
3224 .to_string();
3225 let exited = Error::ServerExitedDuringInit {
3226 command: crate::config::ServerCommand::from_static("typescript-language-server"),
3227 exit_code: Some(1),
3228 hint,
3229 stderr,
3230 }
3231 .to_string();
3232 for text in [failed, exited] {
3233 let hint_at = text.find("TypeScript 7").unwrap();
3234 assert!(hint_at < text.find("; stderr: boom").unwrap(), "{text}");
3235 assert!(
3236 text.contains(BuiltinServer::TypescriptLanguageServer.install_hint()),
3237 "{text}"
3238 );
3239 }
3240 }
3241
3242 #[test]
3243 fn test_server_exited_during_init_display_hints_only_for_rust_analyzer() {
3244 let hinted = Error::ServerExitedDuringInit {
3245 command: crate::config::ServerCommand::from_static("rust-analyzer"),
3246 exit_code: Some(1),
3247 hint: None,
3248 stderr: None,
3249 }
3250 .to_string();
3251 assert!(hinted.contains("exit code 1"), "{hinted}");
3252 assert!(
3253 hinted.contains("rustup component add rust-analyzer"),
3254 "{hinted}"
3255 );
3256
3257 let plain = Error::ServerExitedDuringInit {
3258 command: crate::config::ServerCommand::from_static("gopls"),
3259 exit_code: None,
3260 hint: None,
3261 stderr: None,
3262 }
3263 .to_string();
3264 assert!(plain.contains("terminated by a signal"), "{plain}");
3265 assert!(!plain.contains("rustup"), "{plain}");
3266 }
3267
3268 #[test]
3269 fn test_server_unavailable_display_names_retry_delay() {
3270 let err = Error::ServerUnavailable {
3271 server_id: ServerId::from_static("rust"),
3272 retry_in: Duration::from_secs(2),
3273 };
3274 assert_eq!(
3275 err.to_string(),
3276 "LSP server 'rust' is unavailable: crash-looping, retry in 2.0s"
3277 );
3278 }
3279
3280 #[test]
3281 fn test_error_display_all_servers_failed_lists_each_failure() {
3282 let err = Error::AllServersFailedToInit {
3283 failures: vec![
3284 spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer")),
3285 spawn_failure(
3286 "python",
3287 "pyright",
3288 Error::LspInitFailed {
3289 phase: InitPhase::Initialize,
3290 cause: Box::new(Error::Io(std::io::Error::other("denied"))),
3291 hint: None,
3292 stderr: None,
3293 },
3294 ),
3295 ],
3296 };
3297 let msg = err.to_string();
3298 assert!(
3299 msg.starts_with("all LSP servers failed to initialize: "),
3300 "{msg}"
3301 );
3302 assert!(msg.contains("rust [rust] (rust-analyzer)"), "{msg}");
3303 assert!(msg.contains("python [python] (pyright)"), "{msg}");
3304 }
3305
3306 #[test]
3307 fn test_untrusted_refusal_paths_are_escaped_and_bounded() {
3308 let hostile = PathBuf::from(format!("/ws/evil{}", "\n\u{202e}".repeat(3000)));
3309 for refusal in [
3310 UntrustedRefusal::WorkspaceExecutable {
3311 executable: hostile.clone(),
3312 },
3313 UntrustedRefusal::WorkspaceTsserver {
3314 tsserver: hostile.clone(),
3315 },
3316 UntrustedRefusal::WorkspaceHome {
3317 variable: HomeVariable::Home,
3318 home: hostile.clone(),
3319 },
3320 UntrustedRefusal::NonUtf8Path {
3321 what: ResolvedItem::Executable,
3322 path: hostile,
3323 },
3324 ] {
3325 let text = refusal.to_string();
3326 assert!(!text.contains('\n'), "{text}");
3327 assert!(!text.contains('\u{202e}'), "{text}");
3328 assert!(text.len() < MAX_ECHOED_PATH_BYTES + 512, "{}", text.len());
3329 }
3330 let name = EchoedArgument::name(&"\u{202e}".repeat(5000));
3331 assert!(name.as_str().len() < MAX_SYMBOL_NAME_BYTES + 64);
3332 }
3333
3334 #[test]
3335 fn test_untrusted_refusal_new_variants_name_their_cause() {
3336 let launcher = UntrustedRefusal::ProjectLauncher {
3337 command: ServerCommand::from_static("npx"),
3338 cause: LauncherRefusal::SelectsWorkspaceCode {
3339 program: EchoedPath::program("npx"),
3340 trigger: LaunchTrigger::Always,
3341 },
3342 };
3343 assert!(launcher.to_string().contains("'npx'"), "{launcher}");
3344 let unpinned = UntrustedRefusal::UnpinnedTypescriptLauncher {
3345 command: ServerCommand::from_static("pnpm"),
3346 };
3347 assert!(unpinned.to_string().contains("'pnpm'"), "{unpinned}");
3348 let non_utf8 = UntrustedRefusal::NonUtf8Path {
3349 what: ResolvedItem::SearchPath,
3350 path: PathBuf::from("/odd"),
3351 };
3352 assert_eq!(
3353 non_utf8.to_string(),
3354 "its PATH, /odd, is not valid UTF-8, so untrusted mode cannot pass it on"
3355 );
3356 assert!(
3357 UntrustedRefusal::NoSafeWorkingDirectory
3358 .to_string()
3359 .contains("outside the workspace")
3360 );
3361 }
3362
3363 fn no_server_for_language(language: &'static str) -> Error {
3364 Error::NoServerForLanguage {
3365 language: LanguageId::from_static(language),
3366 file: FileKey::Unmappable,
3367 patterns: Arc::default(),
3368 }
3369 }
3370
3371 #[test]
3372 fn test_no_server_for_plaintext_names_extension_and_patterns() {
3373 let err = Error::NoServerForLanguage {
3374 language: LanguageId::PLAINTEXT,
3375 file: FileKey::Extension(FileExtension::from_static("cpp")),
3376 patterns: Arc::from([
3377 FilePattern::from_static("**/*.rs"),
3378 FilePattern::from_static("**/*.h"),
3379 ]),
3380 };
3381 let message = err.to_string();
3382 assert!(message.starts_with("no LSP server configured for language: plaintext ("));
3383 assert!(
3384 message.contains("file extension 'cpp' is not mapped"),
3385 "{message}"
3386 );
3387 assert!(
3388 message.contains("configured file_patterns: **/*.rs, **/*.h"),
3389 "{message}"
3390 );
3391 }
3392
3393 #[test]
3394 fn test_no_server_for_plaintext_without_patterns_or_extension() {
3395 let err = Error::NoServerForLanguage {
3396 language: LanguageId::PLAINTEXT,
3397 file: FileKey::Unmappable,
3398 patterns: Arc::default(),
3399 };
3400 let message = err.to_string();
3401 assert!(
3402 message.contains("the file has no usable extension or name"),
3403 "{message}"
3404 );
3405 assert!(
3406 message.contains("no file_patterns are configured"),
3407 "{message}"
3408 );
3409 }
3410
3411 #[test]
3412 fn test_no_server_for_other_language_text_is_unchanged() {
3413 let err = Error::NoServerForLanguage {
3414 language: LanguageId::from_static("nushell"),
3415 file: FileKey::Extension(FileExtension::from_static("nu")),
3416 patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
3417 };
3418 assert_eq!(
3419 err.to_string(),
3420 "no LSP server configured for language: nushell"
3421 );
3422 }
3423
3424 #[test]
3425 fn test_no_server_for_plaintext_name_suggests_a_name_pattern() {
3426 let err = Error::NoServerForLanguage {
3427 language: LanguageId::PLAINTEXT,
3428 file: FileKey::Name(FileName::from_static("Makefile")),
3429 patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
3430 };
3431 let message = err.to_string();
3432 assert!(
3433 message.contains("file name 'Makefile' is not mapped"),
3434 "{message}"
3435 );
3436 assert!(
3437 message.contains("a `**/Makefile` file_patterns entry"),
3438 "{message}"
3439 );
3440 assert!(!message.contains("*.EXT"), "{message}");
3441 }
3442
3443 #[test]
3444 fn test_error_display_capability_not_supported() {
3445 let err = Error::CapabilityNotSupported {
3446 server_id: ServerId::from_static("rust"),
3447 capability: Capability::Rename,
3448 };
3449 assert_eq!(
3450 err.to_string(),
3451 "server 'rust' does not support capability 'renameProvider'"
3452 );
3453 }
3454
3455 #[test]
3456 fn test_error_display_workspace_indexing() {
3457 let err = Error::WorkspaceIndexing {
3458 server_id: ServerId::from_static("rust"),
3459 elapsed_secs: 30,
3460 };
3461 assert_eq!(
3462 err.to_string(),
3463 "LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
3464 );
3465 }
3466
3467 #[test]
3470 fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
3471 let caller_fault_errors = vec![
3472 Error::InvalidToolParams("bad params".to_string()),
3473 Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
3474 Error::NotARegularFile(PathBuf::from("/dev/null")),
3475 Error::NoResolvableListenUris,
3476 Error::ResourceUri(ResourceUriError::InvalidScheme("x".to_string())),
3477 Error::DocumentNotFound(PathBuf::from("/missing.rs")),
3478 Error::FileSizeLimitExceeded(SizeExceeded {
3479 size: 100,
3480 max: std::num::NonZeroU64::new(10).unwrap(),
3481 }),
3482 Error::ListenFilterTooLarge { max: 1000 },
3483 Error::InvalidClientPath(InvalidClientPath::ContainsNul),
3484 ];
3485
3486 for err in caller_fault_errors {
3487 assert_eq!(
3488 err.mcp_error_kind(),
3489 McpErrorKind::InvalidParams,
3490 "expected {err:?} to classify as InvalidParams"
3491 );
3492 }
3493 }
3494
3495 #[tokio::test]
3496 async fn test_mcp_error_kind_internal_failures_are_internal() {
3497 let join_error = tokio::spawn(async { panic!("boom") }).await.unwrap_err();
3498 let internal_errors = vec![
3499 Error::TaskFailed {
3500 task: BackgroundTask::McpService,
3501 source: join_error,
3502 },
3503 Error::StdioCapture(StdioStream::Stdin),
3504 Error::PathToUri(PathBuf::from("/x")),
3505 Error::McpServerStart("handshake".into()),
3506 ];
3507
3508 for err in internal_errors {
3509 assert_eq!(
3510 err.mcp_error_kind(),
3511 McpErrorKind::Internal,
3512 "expected {err:?} to classify as Internal"
3513 );
3514 }
3515 }
3516
3517 #[tokio::test]
3518 async fn test_task_failed_keeps_the_panic_payload_as_source() {
3519 let join_error = tokio::spawn(async { panic!("secret boom") })
3520 .await
3521 .unwrap_err();
3522 let err = Error::TaskFailed {
3523 task: BackgroundTask::LspReceiver,
3524 source: join_error,
3525 };
3526 let source = std::error::Error::source(&err).unwrap();
3527 assert!(source.to_string().contains("secret boom"), "{source}");
3528 assert!(err.to_string().starts_with("LSP receiver task failed"));
3529 }
3530
3531 #[test]
3532 fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
3533 let err = Error::WorkspaceIndexing {
3534 server_id: ServerId::from_static("rust"),
3535 elapsed_secs: 30,
3536 };
3537 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
3538 panic!("expected WorkspaceIndexing to classify as Retryable");
3539 };
3540 assert_eq!(data.code(), WORKSPACE_INDEXING_ERROR_CODE);
3541 assert_eq!(
3542 serde_json::to_value(&data).unwrap(),
3543 serde_json::json!({"server_id": "rust", "elapsed_secs": 30})
3544 );
3545 }
3546
3547 #[test]
3548 fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
3549 let err = Error::ServerInitializing {
3550 server_id: ServerId::from_static("python"),
3551 };
3552 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
3553 panic!("expected ServerInitializing to classify as Retryable");
3554 };
3555 assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
3556 assert_eq!(
3557 serde_json::to_value(&data).unwrap(),
3558 serde_json::json!({"server_id": "python"})
3559 );
3560 assert_ne!(
3561 data.code(),
3562 WORKSPACE_INDEXING_ERROR_CODE,
3563 "ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
3564 );
3565 }
3566
3567 #[test]
3573 fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
3574 let err = Error::WorkspaceServersInitializing;
3575 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
3576 panic!("expected WorkspaceServersInitializing to classify as Retryable");
3577 };
3578 assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
3579 assert_eq!(
3580 serde_json::to_value(&data).unwrap(),
3581 serde_json::json!({}),
3582 "the empty struct variant must serialize as `{{}}`, not `null`"
3583 );
3584 }
3585
3586 #[test]
3587 fn test_retryable_error_data_codes_and_snake_case_keys() {
3588 let cases = [
3589 (
3590 RetryableErrorData::WorkspaceIndexing {
3591 server_id: ServerId::from_static("rust"),
3592 elapsed_secs: 7,
3593 },
3594 WORKSPACE_INDEXING_ERROR_CODE,
3595 serde_json::json!({"server_id": "rust", "elapsed_secs": 7}),
3596 ),
3597 (
3598 RetryableErrorData::ServerInitializing {
3599 server_id: ServerId::from_static("python"),
3600 },
3601 SERVER_INITIALIZING_ERROR_CODE,
3602 serde_json::json!({"server_id": "python"}),
3603 ),
3604 (
3605 RetryableErrorData::WorkspaceServersInitializing {},
3606 SERVER_INITIALIZING_ERROR_CODE,
3607 serde_json::json!({}),
3608 ),
3609 (
3610 RetryableErrorData::ListenStreamsExhausted {
3611 max_listen_streams: 100,
3612 },
3613 LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
3614 serde_json::json!({"max_listen_streams": 100}),
3615 ),
3616 ];
3617 for (data, code, wire) in cases {
3618 assert_eq!(data.code(), code);
3619 assert_eq!(serde_json::to_value(&data).unwrap(), wire);
3620 }
3621 }
3622
3623 #[test]
3624 fn test_mcp_error_kind_listen_streams_exhausted_is_retryable() {
3625 let err = Error::ListenStreamsExhausted { max: 100 };
3626 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
3627 panic!("expected ListenStreamsExhausted to classify as Retryable");
3628 };
3629 assert_eq!(data.code(), LISTEN_STREAMS_EXHAUSTED_ERROR_CODE);
3630 assert_eq!(
3631 data,
3632 RetryableErrorData::ListenStreamsExhausted {
3633 max_listen_streams: 100
3634 }
3635 );
3636 }
3637
3638 #[test]
3639 fn test_mcp_error_kind_unretained_variants_stay_internal() {
3640 let internal_errors = vec![
3641 no_server_for_language("python"),
3642 Error::NoServerForTool {
3643 language_id: LanguageId::from_static("rust"),
3644 tool: crate::config::ToolKind::Hover,
3645 },
3646 Error::CapabilityNotSupported {
3647 server_id: ServerId::from_static("rust"),
3648 capability: Capability::Rename,
3649 },
3650 Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
3651 Error::DocumentLimitExceeded {
3652 current: 150,
3653 max: std::num::NonZeroUsize::new(100).unwrap(),
3654 },
3655 Error::SubscriptionLimitReached { max: 1000 },
3656 ];
3657
3658 for err in internal_errors {
3659 assert_eq!(
3660 err.mcp_error_kind(),
3661 McpErrorKind::Internal,
3662 "expected {err:?} to classify as Internal"
3663 );
3664 }
3665 }
3666
3667 #[test]
3671 fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
3672 let err = Error::FileIo {
3673 path: PathBuf::from("/no/such/file.rs"),
3674 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
3675 };
3676 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
3677 }
3678
3679 #[test]
3680 fn test_position_beyond_document_is_invalid_params_naming_the_line() {
3681 let err = Error::PositionBeyondDocument {
3682 line: std::num::NonZeroU32::new(7).unwrap(),
3683 last_line: std::num::NonZeroU32::new(3).unwrap(),
3684 };
3685 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
3686 assert_eq!(
3687 err.to_string(),
3688 "line 7 is beyond the end of the document (the document ends at line 3)"
3689 );
3690 }
3691
3692 #[test]
3695 fn test_mcp_error_kind_malformed_path_is_invalid_params() {
3696 use std::io::ErrorKind;
3697
3698 for kind in [
3699 ErrorKind::NotADirectory,
3700 ErrorKind::InvalidFilename,
3701 ErrorKind::InvalidInput,
3702 ] {
3703 let err = Error::MalformedPath {
3704 path: PathBuf::from("/ws/main.rs/x"),
3705 source: std::io::Error::new(kind, "bad path"),
3706 };
3707 assert_eq!(
3708 err.mcp_error_kind(),
3709 McpErrorKind::InvalidParams,
3710 "{kind:?}"
3711 );
3712 }
3713 }
3714
3715 #[test]
3718 fn test_mcp_error_kind_post_validation_file_io_invalid_input_stays_internal() {
3719 let err = Error::FileIo {
3720 path: PathBuf::from("/ws/main.rs"),
3721 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad input"),
3722 };
3723 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3724 }
3725
3726 #[test]
3730 fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
3731 let err = Error::FileIo {
3732 path: PathBuf::from("/root/secret.rs"),
3733 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
3734 };
3735 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3736 }
3737
3738 #[test]
3742 fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
3743 let err = Error::LspServerError {
3744 code: -32603,
3745 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
3746 .to_string(),
3747 data: None,
3748 };
3749 assert_eq!(
3750 err.mcp_error_kind(),
3751 McpErrorKind::InvalidPosition(RewrittenServerError::new(
3752 -32603,
3753 "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
3754 ))
3755 );
3756 }
3757
3758 #[test]
3761 fn test_display_escapes_control_characters_from_the_server() {
3762 let server_error = Error::LspServerError {
3763 code: -32603,
3764 message: "boom\nERROR forged\x1b[31m".to_string(),
3765 data: None,
3766 };
3767 assert_eq!(
3768 server_error.to_string(),
3769 "LSP server error: -32603 - boom\\nERROR forged\\u{1b}[31m"
3770 );
3771
3772 let protocol_error = Error::LspProtocolError(RedactedText::fixed("bad\nline"));
3773 assert_eq!(protocol_error.to_string(), "LSP protocol error: bad\\nline");
3774 }
3775
3776 #[test]
3778 fn test_rewritten_server_error_bounds_raw_message() {
3779 let raw =
3780 RewrittenServerError::new(-32603, &"x".repeat(MAX_ERROR_MESSAGE_CALLER_BYTES * 2));
3781 assert!(raw.raw_message().len() < MAX_ERROR_MESSAGE_CALLER_BYTES * 2);
3782 assert!(raw.raw_message().ends_with("(truncated)"));
3783 }
3784
3785 #[test]
3789 fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
3790 let err = Error::LspServerError {
3791 code: -32603,
3792 message: "internal error".to_string(),
3793 data: None,
3794 };
3795 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3796 }
3797
3798 #[test]
3802 fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
3803 let err = Error::SubscriptionLimitReached { max: 1000 };
3804 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3805 }
3806
3807 fn refusal_failure(refusal: UntrustedRefusal) -> ServerSpawnFailure {
3808 ServerSpawnFailure {
3809 server_id: ServerId::from_static("rust"),
3810 language_id: LanguageId::new("rust").unwrap(),
3811 command: ServerCommand::from_static("rust-analyzer"),
3812 reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
3813 }
3814 }
3815
3816 #[test]
3817 fn test_not_allowed_refusal_names_the_server_class_and_the_remedy() {
3818 let err =
3819 Error::ServerFailedToStart(Box::new(refusal_failure(UntrustedRefusal::NotAllowed {
3820 builtin: Some(BuiltinServer::RustAnalyzer),
3821 })));
3822 assert_eq!(
3823 err.to_string(),
3824 "LSP server 'rust' for language 'rust' was not started: the workspace is untrusted \
3825 and it may run workspace code (Cargo build scripts and procedural macros); restart \
3826 mcpls with `--allow-server rust` to start it"
3827 );
3828 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3829 }
3830
3831 #[test]
3832 fn test_workspace_executable_refusal_offers_no_allow_remedy() {
3833 let err = Error::ServerFailedToStart(Box::new(refusal_failure(
3834 UntrustedRefusal::WorkspaceExecutable {
3835 executable: PathBuf::from("/ws/bin/rust-analyzer"),
3836 },
3837 )));
3838 let text = err.to_string();
3839 assert!(
3840 text.contains("its executable /ws/bin/rust-analyzer lies inside the workspace"),
3841 "{text}"
3842 );
3843 assert!(!text.contains("--allow-server"), "{text}");
3844 assert!(!text.contains("not retried"), "{text}");
3845 }
3846
3847 #[test]
3848 fn test_custom_server_refusal_says_its_workspace_code_is_unknown() {
3849 let text = UntrustedRefusal::NotAllowed { builtin: None }.to_string();
3850 assert!(text.contains("not a built-in server"), "{text}");
3851 }
3852
3853 #[test]
3854 fn test_config_inside_workspace_is_a_startup_error_of_kind_internal() {
3855 let err = Error::ConfigInsideWorkspace {
3856 path: PathBuf::from("/ws/mcpls.toml"),
3857 };
3858 assert!(err.to_string().contains("/ws/mcpls.toml"));
3859 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
3860 }
3861
3862 #[test]
3863 fn echoed_path_keeps_spaces_and_equals() {
3864 let path = EchoedPath::new(Path::new("/tmp/out/no such dir/a=b/srv"));
3865 assert_eq!(path.as_str(), "/tmp/out/no such dir/a=b/srv");
3866 }
3867
3868 #[test]
3869 fn echoed_path_never_cuts_an_escape_in_half() {
3870 let escape = "\\u{202e}";
3871 let hostile = "\u{202e}".repeat(MAX_ECHOED_PATH_BYTES);
3872 let echoed = EchoedPath::new(Path::new(&hostile));
3873 let kept = echoed
3874 .as_str()
3875 .strip_suffix(TRUNCATION_MARKER)
3876 .expect("an over-long path is marked as truncated");
3877 assert!(kept.len() <= MAX_ECHOED_PATH_BYTES);
3878 assert!(
3879 !kept.is_empty() && kept.len().is_multiple_of(escape.len()),
3880 "{kept}"
3881 );
3882 assert_eq!(kept, escape.repeat(kept.len() / escape.len()));
3883 }
3884
3885 #[test]
3886 fn echoed_program_cuts_an_assignment_but_keeps_a_path() {
3887 assert_eq!(EchoedPath::program("API_TOKEN=abc").as_str(), "API_TOKEN");
3888 assert_eq!(EchoedPath::program("X=/tmp/secret").as_str(), "X");
3889 assert_eq!(
3890 EchoedPath::program("/tmp/out/a=b/srv").as_str(),
3891 "/tmp/out/a=b/srv"
3892 );
3893 assert_eq!(EchoedPath::program("no such dir").as_str(), "no such dir");
3894 }
3895
3896 #[test]
3897 fn echoed_path_within_the_bound_is_not_marked() {
3898 let path = "a".repeat(MAX_ECHOED_PATH_BYTES);
3899 assert_eq!(EchoedPath::new(Path::new(&path)).as_str(), path);
3900 }
3901
3902 #[test]
3903 fn wrapped_program_refusals_name_the_program_its_launcher_starts() {
3904 let program = EchoedPath::new(Path::new("/tmp/out/a=b"));
3905 let text = UntrustedRefusal::WrappedProgramPathContainsEquals { program }.to_string();
3906 assert!(text.contains("'/tmp/out/a=b'"), "{text}");
3907 assert!(text.contains("the program '"), "{text}");
3908 assert!(!text.contains("its launcher '/tmp"), "{text}");
3909 }
3910}