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};
17use crate::config::{
18 BuiltinServer, DuplicateEnvKey, EntrySummary, EnvKey, EnvTooLarge, FileKey, FilePattern,
19 InvalidAutoSelection, InvalidEnvValue, InvalidServerArgs, LanguageId, ServerCommand, ServerId,
20 ToolKind, UnsupportedFilePattern,
21};
22use crate::lsp::JsonRpcError;
23pub use crate::redaction::RedactedText;
24use crate::redaction::Redactions;
25pub use crate::util::SizeExceeded;
26use crate::util::{
27 MAX_ECHOED_NAME_BYTES, MAX_ECHOED_PATH_BYTES, MAX_ERROR_MESSAGE_CALLER_BYTES,
28 TRUNCATION_MARKER, escape_bounded, escape_control, truncate_str,
29};
30
31fn no_server_detail(language: &LanguageId, file: &FileKey, patterns: &[FilePattern]) -> String {
35 if *language != LanguageId::PLAINTEXT {
36 return String::new();
37 }
38 let (subject, remedy) = match file {
39 FileKey::Extension(ext) => (
40 format!("file extension '{ext}' is not mapped to any language"),
41 "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
42 ),
43 FileKey::Name(name) => (
44 format!("file name '{name}' is not mapped to any language"),
45 format!("a `**/{name}` file_patterns entry"),
46 ),
47 FileKey::Unmappable => (
48 "the file has no usable extension or name".to_owned(),
49 "a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
50 ),
51 };
52 let configured = if patterns.is_empty() {
53 "no file_patterns are configured".to_owned()
54 } else {
55 let list: Vec<&str> = patterns.iter().map(FilePattern::as_str).collect();
56 format!("configured file_patterns: {}", list.join(", "))
57 };
58 format!(" ({subject}; {configured}; map it with {remedy})")
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum Platform {
64 Windows,
65 Other,
66}
67
68impl Platform {
69 const CURRENT: Self = if cfg!(windows) {
70 Self::Windows
71 } else {
72 Self::Other
73 };
74}
75
76struct NotFoundGuidance<'a>(&'a str, Platform);
78
79impl fmt::Display for NotFoundGuidance<'_> {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 let Self(command, platform) = *self;
82 if Path::new(command).parent().is_some_and(|p| !p.is_empty()) {
83 return f.write_str("; check that the configured path exists");
84 }
85 write!(
86 f,
87 "; '{command}' is not on the PATH mcpls runs with -- if it is installed, add its \
88 directory to the MCP client's PATH or set `command` to an absolute path"
89 )?;
90 let Some(builtin) = BuiltinServer::from_command(command) else {
91 return Ok(());
92 };
93 if platform == Platform::Windows && builtin.is_npm_package() {
94 write!(
95 f,
96 " (npm-installed servers need the `.cmd` name, e.g. `{command}.cmd`)"
97 )?;
98 }
99 write!(f, "; otherwise install it: {}", builtin.install_hint())
100 }
101}
102
103const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
109
110pub(crate) fn is_invalid_offset(response: &JsonRpcError) -> bool {
113 response.message.contains(INVALID_OFFSET_MARKER)
114}
115
116fn sanitize_lsp_server_message(message: &str) -> String {
139 if message.contains(INVALID_OFFSET_MARKER) {
140 "position out of range for this document".to_string()
141 } else {
142 escape_control(message).into_owned()
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
167pub struct RewrittenServerError {
168 code: i32,
169 raw_message: String,
170}
171
172impl RewrittenServerError {
173 #[must_use]
176 pub fn new(code: i32, raw_message: &str) -> Self {
177 Self {
178 code,
179 raw_message: truncate_str(raw_message, MAX_ERROR_MESSAGE_CALLER_BYTES),
180 }
181 }
182
183 #[must_use]
185 pub const fn code(&self) -> i32 {
186 self.code
187 }
188
189 #[must_use]
191 pub fn raw_message(&self) -> &str {
192 &self.raw_message
193 }
194}
195
196#[derive(Debug, Clone)]
206#[non_exhaustive]
207pub enum StartupFailure {
208 Spawn(Arc<Error>),
210 InitTaskPanicked,
213 RefusedUntrustedWorkspace(UntrustedRefusal),
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219#[non_exhaustive]
220pub enum HomeVariable {
221 Home,
223 UserProfile,
225}
226
227impl HomeVariable {
228 pub const ALL: [Self; 2] = [Self::Home, Self::UserProfile];
230
231 #[must_use]
233 pub const fn name(self) -> &'static str {
234 match self {
235 Self::Home => "HOME",
236 Self::UserProfile => "USERPROFILE",
237 }
238 }
239}
240
241impl fmt::Display for HomeVariable {
242 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243 f.write_str(self.name())
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249#[non_exhaustive]
250pub enum ResolvedItem {
251 Executable,
253 SearchPath,
255}
256
257impl fmt::Display for ResolvedItem {
258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259 f.write_str(match self {
260 Self::Executable => "executable",
261 Self::SearchPath => "PATH",
262 })
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct EchoedArgument(String);
284
285impl EchoedArgument {
286 #[must_use]
291 pub fn name(arg: &str) -> Self {
292 let name = arg
293 .split_once(|c: char| c == '=' || c.is_whitespace())
294 .map_or(arg, |(name, _)| name);
295 Self(escape_bounded(name, MAX_ECHOED_NAME_BYTES))
296 }
297
298 #[must_use]
300 pub fn as_str(&self) -> &str {
301 &self.0
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct EchoedPath(String);
324
325impl EchoedPath {
326 #[must_use]
328 pub fn new(path: &Path) -> Self {
329 Self(escape_bounded(
330 &path.to_string_lossy(),
331 MAX_ECHOED_PATH_BYTES,
332 ))
333 }
334
335 #[must_use]
340 pub fn program(arg: &str) -> Self {
341 let assignment = arg
342 .split_once('=')
343 .is_some_and(|(name, _)| !name.contains(std::path::is_separator));
344 if assignment {
345 Self(EchoedArgument::name(arg).0)
346 } else {
347 Self::new(Path::new(arg))
348 }
349 }
350
351 #[must_use]
353 pub fn as_str(&self) -> &str {
354 &self.0
355 }
356}
357
358struct BoundedDebugPath<'a>(&'a Path);
372
373struct CutWriter {
375 text: String,
376 left: usize,
377 cut: bool,
378}
379
380impl fmt::Write for CutWriter {
381 fn write_str(&mut self, piece: &str) -> fmt::Result {
382 if self.cut {
383 return Err(fmt::Error);
384 }
385 if piece.len() <= self.left {
386 self.text.push_str(piece);
387 self.left = self.left.saturating_sub(piece.len());
388 return Ok(());
389 }
390 let keep = piece.floor_char_boundary(self.left);
391 self.text.push_str(piece.get(..keep).unwrap_or_default());
392 self.cut = true;
393 Err(fmt::Error)
394 }
395}
396
397fn complete_escapes_len(text: &str) -> usize {
400 let mut at = 0;
401 while let Some(offset) = text.get(at..).and_then(|tail| tail.find('\\')) {
402 let start = at.saturating_add(offset);
403 let after = start.saturating_add(1);
404 let rest = text.get(after..).unwrap_or_default();
405 let escape_len = match rest.chars().next() {
406 Some('u') => rest.find('}').map(|end| end.saturating_add(1)),
407 Some('x') => (rest.len() >= 3).then_some(3),
408 Some(other) => Some(other.len_utf8()),
409 None => None,
410 };
411 let Some(escape_len) = escape_len else {
412 return start;
413 };
414 at = after.saturating_add(escape_len);
415 }
416 text.len()
417}
418
419impl fmt::Display for BoundedDebugPath<'_> {
420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421 use fmt::Write as _;
422
423 let mut writer = CutWriter {
424 text: String::new(),
425 left: MAX_ECHOED_PATH_BYTES,
426 cut: false,
427 };
428 #[expect(
430 clippy::unnecessary_debug_formatting,
431 reason = "the quoted, escaped debug form is the message format"
432 )]
433 let formatted = write!(writer, "{:?}", self.0);
434 debug_assert!(formatted.is_ok() || writer.cut);
435 if !writer.cut {
436 return f.write_str(&writer.text);
437 }
438 let whole = complete_escapes_len(&writer.text);
439 f.write_str(writer.text.get(..whole).unwrap_or_default())?;
440 f.write_str(TRUNCATION_MARKER)
441 }
442}
443
444impl fmt::Display for EchoedPath {
445 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446 f.write_str(&self.0)
447 }
448}
449
450impl fmt::Display for EchoedArgument {
451 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
452 f.write_str(&self.0)
453 }
454}
455
456macro_rules! spelled_enum {
459 (
460 $(#[$meta:meta])*
461 $name:ident {
462 $($(#[$variant_meta:meta])* $variant:ident => $spelling:expr),+ $(,)?
463 }
464 ) => {
465 $(#[$meta])*
466 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
467 #[non_exhaustive]
468 pub enum $name {
469 $($(#[$variant_meta])* $variant),+
470 }
471
472 impl $name {
473 pub const ALL: [Self; [$(Self::$variant),+].len()] = [$(Self::$variant),+];
475
476 #[must_use]
478 pub const fn as_str(self) -> &'static str {
479 match self {
480 $(Self::$variant => $spelling),+
481 }
482 }
483 }
484
485 impl ::std::fmt::Display for $name {
486 fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
487 f.write_str(self.as_str())
488 }
489 }
490 };
491}
492
493pub(crate) use spelled_enum;
494
495spelled_enum! {
496 RunnerSubcommand {
498 Run => "run",
500 X => "x",
502 Task => "task",
504 Eval => "eval",
506 Repl => "repl",
508 Tool => "tool",
510 Exec => "exec",
512 V1Run => "v1-run",
514 V2Run => "v2-run",
516 NewRun => "new-run",
518 V1Exec => "v1-exec",
520 V2Exec => "v2-exec",
522 NewExec => "new-exec",
524 Runghc => "runghc",
526 Runhaskell => "runhaskell",
528 Script => "script",
530 R => "r",
532 T => "t",
534 Test => "test",
536 Bench => "bench",
538 Ghci => "ghci",
540 V1Repl => "v1-repl",
542 V2Repl => "v2-repl",
544 NewRepl => "new-repl",
546 }
547}
548
549spelled_enum! {
550 LongCommandName {
552 Command => "--command",
554 Commands => "--commands",
556 InitCommand => "--init-command",
558 Execute => "--execute",
560 }
561}
562
563spelled_enum! {
564 PowerShellParameter {
566 Command => "-Command",
568 CommandWithArgs => "-CommandWithArgs",
570 EncodedCommand => "-EncodedCommand",
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578#[non_exhaustive]
579pub enum ShellFlag {
580 DashC,
582 PlusC,
584 DashCapitalC,
586 DashE,
588 LongCommand(LongCommandName),
590 PowerShell(PowerShellParameter),
592}
593
594enum FlagSpelling {
596 Short(char, char),
598 Word(&'static str),
600}
601
602impl ShellFlag {
603 const fn spelling(self) -> FlagSpelling {
604 match self {
605 Self::DashC => FlagSpelling::Short('-', 'c'),
606 Self::PlusC => FlagSpelling::Short('+', 'c'),
607 Self::DashCapitalC => FlagSpelling::Short('-', 'C'),
608 Self::DashE => FlagSpelling::Short('-', 'e'),
609 Self::LongCommand(name) => FlagSpelling::Word(name.as_str()),
610 Self::PowerShell(parameter) => FlagSpelling::Word(parameter.as_str()),
611 }
612 }
613
614 #[must_use]
620 pub(crate) const fn short_spelling(self) -> Option<(char, char)> {
621 match self.spelling() {
622 FlagSpelling::Short(prefix, letter) => Some((prefix, letter)),
623 FlagSpelling::Word(_) => None,
624 }
625 }
626}
627
628impl fmt::Display for ShellFlag {
629 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
630 match self.spelling() {
631 FlagSpelling::Short(prefix, letter) => write!(f, "{prefix}{letter}"),
632 FlagSpelling::Word(word) => f.write_str(word),
633 }
634 }
635}
636
637spelled_enum! {
638 LongInlineFlag {
640 Eval => "--eval",
642 Print => "--print",
644 Import => "--import",
646 Loader => "--loader",
648 ExperimentalLoader => "--experimental-loader",
650 Run => "--run",
652 ProcessBegin => "--process-begin",
654 ProcessCode => "--process-code",
656 ProcessEnd => "--process-end",
658 ProcessFile => "--process-file",
660 }
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665#[non_exhaustive]
666pub enum InlineFlag {
667 Short(char),
669 Long(LongInlineFlag),
671}
672
673impl fmt::Display for InlineFlag {
674 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
675 match self {
676 Self::Short(letter) => write!(f, "-{letter}"),
677 Self::Long(flag) => f.write_str(flag.as_str()),
678 }
679 }
680}
681
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
684#[non_exhaustive]
685pub enum OperandKind {
686 Duration,
688}
689
690impl fmt::Display for OperandKind {
691 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
692 f.write_str(match self {
693 Self::Duration => "DURATION",
694 })
695 }
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
701#[non_exhaustive]
702pub enum StdinSource {
703 NoProgram,
705 DashS,
707 DashOperand,
709 DeviceOperand,
712 ParentTraversal,
714 SpecialFile,
717}
718
719impl fmt::Display for StdinSource {
720 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
721 f.write_str(match self {
722 Self::NoProgram => "no script is given, so it reads its program from standard input",
723 Self::DashS => "`-s` makes it read its program from standard input",
724 Self::DashOperand => "a `-` operand makes it read its program from standard input",
725 Self::DeviceOperand => {
726 "its script is under /dev or /proc, where a path can name standard input"
727 }
728 Self::ParentTraversal => "its script path has a `..` component, which can reach /dev",
729 Self::SpecialFile => {
730 "its script is a device, FIFO or socket, which may be an alias of standard input"
731 }
732 })
733 }
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq)]
740#[non_exhaustive]
741pub enum LaunchTrigger {
742 Always,
745 Subcommand(RunnerSubcommand),
747 CommandString(ShellFlag),
749 ImplicitCommandString,
751 InlineProgram(InlineFlag),
753 NpmSpecifier,
755 ProgramFromStdin(StdinSource),
757}
758
759impl fmt::Display for LaunchTrigger {
760 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
761 match self {
762 Self::Always => f.write_str("every use of it does"),
763 Self::Subcommand(name) => write!(f, "its `{name}` subcommand does"),
764 Self::CommandString(flag) => {
765 write!(
766 f,
767 "`{flag}` gives it a command string, which cannot be analyzed"
768 )
769 }
770 Self::ImplicitCommandString => f.write_str(
771 "it reads a bare argument as a command string, which cannot be analyzed",
772 ),
773 Self::InlineProgram(flag) => write!(f, "`{flag}` gives it a program to run"),
774 Self::NpmSpecifier => f.write_str("an `npm:` argument names a package to run"),
775 Self::ProgramFromStdin(source) => write!(f, "{source}"),
776 }
777 }
778}
779
780#[derive(Debug, Clone, PartialEq, Eq)]
783#[non_exhaustive]
784pub enum UnanalyzableLaunch {
785 UnknownOption(EchoedArgument),
787 MissingValue(EchoedArgument),
789 MissingCommand,
791 MalformedOperand(OperandKind),
793 PathAssignment,
795 SplitString,
797 RelativeProgramAfterChdir,
799 RelativeOperandAfterChdir,
801 BlankProgram,
803}
804
805impl fmt::Display for UnanalyzableLaunch {
806 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
807 match self {
808 Self::UnknownOption(option) => write!(f, "unknown option '{option}'"),
809 Self::MissingValue(option) => write!(f, "option '{option}' has no value"),
810 Self::MissingCommand => f.write_str("no command follows its options"),
811 Self::MalformedOperand(name) => write!(f, "its {name} operand is malformed"),
812 Self::PathAssignment => f.write_str("it assigns PATH"),
813 Self::SplitString => f.write_str("it splits a string into arguments"),
814 Self::RelativeProgramAfterChdir => {
815 f.write_str("it starts a relative program after changing directory")
816 }
817 Self::RelativeOperandAfterChdir => {
818 f.write_str("it reads a relative script after changing directory")
819 }
820 Self::BlankProgram => f.write_str("the program name is empty"),
821 }
822 }
823}
824
825#[derive(Debug, Clone, PartialEq, Eq)]
827#[non_exhaustive]
828pub enum LauncherRefusal {
829 SelectsWorkspaceCode {
831 program: EchoedPath,
833 trigger: LaunchTrigger,
835 },
836 Unanalyzable {
838 program: EchoedPath,
840 reason: UnanalyzableLaunch,
842 },
843 TooDeep,
845}
846
847impl fmt::Display for LauncherRefusal {
848 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
849 match self {
850 Self::SelectsWorkspaceCode { program, trigger } => write!(
851 f,
852 "its launcher '{program}' chooses the server from files in the workspace \
853 ({trigger}), which untrusted mode never runs; install the server globally \
854 and give its absolute path as `command`"
855 ),
856 Self::Unanalyzable { program, reason } => write!(
857 f,
858 "its launcher '{program}' cannot be analyzed ({reason}), so untrusted mode \
859 cannot tell which program it starts; give the absolute path of the server \
860 as `command`"
861 ),
862 Self::TooDeep => f.write_str(
863 "its launchers are nested too deeply to analyze, so untrusted mode cannot \
864 tell which program they start; give the absolute path of the server as \
865 `command`",
866 ),
867 }
868 }
869}
870
871#[derive(Debug, Clone, PartialEq, Eq)]
885#[non_exhaustive]
886pub enum UntrustedRefusal {
887 NotAllowed {
889 builtin: Option<BuiltinServer>,
892 },
893 WorkspaceExecutable {
896 executable: PathBuf,
898 },
899 UnresolvedExecutable {
902 command: ServerCommand,
904 },
905 AutoSelectionTarget {
909 executable: ServerCommand,
911 cause: InvalidAutoSelection,
913 },
914 WorkspaceHome {
918 variable: HomeVariable,
920 home: PathBuf,
922 },
923 EmptyHome {
926 variable: HomeVariable,
928 },
929 UnknownHome,
933 WorkspaceTsserver {
936 tsserver: PathBuf,
938 },
939 ProjectLauncher {
943 command: ServerCommand,
945 cause: LauncherRefusal,
947 },
948 WorkspaceExecutableArgument {
951 index: usize,
953 executable: PathBuf,
955 },
956 UnreadableWorkspaceArgument {
960 index: usize,
962 path: PathBuf,
964 },
965 UnresolvedWrappedProgram {
969 program: EchoedPath,
971 },
972 WrappedProgramPathContainsEquals {
975 program: EchoedPath,
977 },
978 UnpinnedTypescriptLauncher {
981 command: ServerCommand,
983 },
984 NonUtf8Path {
987 what: ResolvedItem,
989 path: PathBuf,
991 },
992 BlankResolvedPath {
994 what: ResolvedItem,
996 },
997 NulInResolvedPath {
1000 what: ResolvedItem,
1002 },
1003 NoSafeWorkingDirectory,
1006 EnvTooLarge {
1009 error: EnvTooLarge,
1011 },
1012}
1013
1014impl fmt::Display for UntrustedRefusal {
1015 #[allow(
1016 clippy::too_many_lines,
1017 reason = "one message per refusal, and the refusals are the table"
1018 )]
1019 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1020 match self {
1021 Self::NotAllowed {
1022 builtin: Some(builtin),
1023 } => write!(
1024 f,
1025 "the workspace is untrusted and it may run workspace code ({})",
1026 builtin.workspace_code()
1027 ),
1028 Self::NotAllowed { builtin: None } => f.write_str(
1029 "the workspace is untrusted and it is not a built-in server, so the workspace \
1030 code it may run is unknown",
1031 ),
1032 Self::WorkspaceExecutable { executable } => write!(
1033 f,
1034 "its executable {} lies inside the workspace, which untrusted mode never runs",
1035 EchoedPath::new(executable)
1036 ),
1037 Self::WorkspaceExecutableArgument { index, executable } => write!(
1038 f,
1039 "its argument at index {index}, {}, is an executable inside the workspace, \
1040 which untrusted mode never runs",
1041 EchoedPath::new(executable)
1042 ),
1043 Self::UnreadableWorkspaceArgument { index, path } => write!(
1044 f,
1045 "its argument at index {index}, {}, lies inside the workspace and cannot be \
1046 read, so untrusted mode cannot tell whether it is an executable",
1047 EchoedPath::new(path)
1048 ),
1049 Self::UnresolvedExecutable { command } => write!(
1050 f,
1051 "its executable '{command}' was not found on a PATH outside the workspace, \
1052 which untrusted mode requires"
1053 ),
1054 Self::AutoSelectionTarget { executable, cause } => write!(
1055 f,
1056 "its executable resolved to '{executable}' ({cause}), so untrusted mode \
1057 cannot pin it; give the absolute path of the server as `command`"
1058 ),
1059 Self::UnknownHome => f.write_str(
1060 "HOME is not set and the login home directory is unknown, so untrusted mode \
1061 cannot give it a safe one",
1062 ),
1063 Self::EmptyHome { variable } => write!(
1064 f,
1065 "its {variable} is empty, which resolves inside the workspace, and the login \
1066 home directory is unknown, so untrusted mode cannot give it a safe one"
1067 ),
1068 Self::WorkspaceHome { variable, home } => write!(
1069 f,
1070 "its {variable}, {}, lies inside the workspace and the login home directory is \
1071 unknown, so untrusted mode cannot give it a safe one",
1072 EchoedPath::new(home)
1073 ),
1074 Self::WorkspaceTsserver { tsserver } => write!(
1075 f,
1076 "the tsserver it would use, {}, lies inside the workspace, which untrusted \
1077 mode never runs",
1078 EchoedPath::new(tsserver)
1079 ),
1080 Self::ProjectLauncher { cause, .. } => cause.fmt(f),
1081 Self::UnresolvedWrappedProgram { program } => write!(
1082 f,
1083 "the program '{program}' its launcher starts was not found where the child \
1084 would look it up, which untrusted mode requires"
1085 ),
1086 Self::WrappedProgramPathContainsEquals { program } => write!(
1087 f,
1088 "the program '{program}' its launcher starts resolves to a path containing \
1089 '=', which env reads as an assignment, so untrusted mode cannot pin it; give \
1090 a program whose path has no '='"
1091 ),
1092 Self::UnpinnedTypescriptLauncher { command } => write!(
1093 f,
1094 "its launcher '{command}' starts the TypeScript server in a way untrusted \
1095 mode cannot pin to a binary outside the workspace; install the server \
1096 globally and give its absolute path as `command`"
1097 ),
1098 Self::NonUtf8Path { what, path } => write!(
1099 f,
1100 "its {what}, {}, is not valid UTF-8, so untrusted mode cannot pass it on",
1101 EchoedPath::new(path)
1102 ),
1103 Self::BlankResolvedPath { what } => write!(
1104 f,
1105 "its {what} resolved to a blank path, which cannot be started"
1106 ),
1107 Self::NulInResolvedPath { what } => {
1108 write!(
1109 f,
1110 "its {what} resolved to a path containing NUL, which cannot be passed on"
1111 )
1112 }
1113 Self::NoSafeWorkingDirectory => f.write_str(
1114 "no directory outside the workspace is available to start it in, which \
1115 untrusted mode requires",
1116 ),
1117 Self::EnvTooLarge { error } => write!(
1118 f,
1119 "the variables untrusted mode sets do not fit in its environment: {error}"
1120 ),
1121 }
1122 }
1123}
1124
1125impl fmt::Display for StartupFailure {
1126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1127 match self {
1128 Self::Spawn(error) => ErrorChain(error.as_ref()).fmt(f),
1129 Self::InitTaskPanicked => {
1130 f.write_str("the initialization task panicked (see the mcpls log)")
1131 }
1132 Self::RefusedUntrustedWorkspace(refusal) => refusal.fmt(f),
1133 }
1134 }
1135}
1136
1137#[derive(Debug, Clone)]
1139pub struct ServerSpawnFailure {
1140 pub server_id: ServerId,
1142 pub language_id: LanguageId,
1144 pub command: ServerCommand,
1146 pub reason: StartupFailure,
1148}
1149
1150impl fmt::Display for ServerSpawnFailure {
1151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1152 write!(
1153 f,
1154 "{} [{}] ({}): {}",
1155 self.server_id, self.language_id, self.command, self.reason
1156 )
1157 }
1158}
1159
1160impl ServerSpawnFailure {
1161 pub(crate) const fn failed_to_start(&self) -> FailedToStart<'_> {
1165 FailedToStart(self)
1166 }
1167}
1168
1169pub(crate) struct FailedToStart<'a>(&'a ServerSpawnFailure);
1171
1172impl fmt::Display for FailedToStart<'_> {
1173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1174 let failure = self.0;
1175 let (id, language) = (&failure.server_id, &failure.language_id);
1176 match &failure.reason {
1177 StartupFailure::RefusedUntrustedWorkspace(refusal) => {
1178 write!(
1179 f,
1180 "LSP server '{id}' for language '{language}' was not started: {refusal}"
1181 )?;
1182 match refusal {
1183 UntrustedRefusal::NotAllowed { .. } => {
1184 write!(f, "; restart mcpls with `--allow-server {id}` to start it")
1185 }
1186 UntrustedRefusal::WorkspaceExecutable { .. }
1187 | UntrustedRefusal::WorkspaceExecutableArgument { .. }
1188 | UntrustedRefusal::UnreadableWorkspaceArgument { .. }
1189 | UntrustedRefusal::UnresolvedExecutable { .. }
1190 | UntrustedRefusal::AutoSelectionTarget { .. }
1191 | UntrustedRefusal::WorkspaceHome { .. }
1192 | UntrustedRefusal::EmptyHome { .. }
1193 | UntrustedRefusal::UnknownHome
1194 | UntrustedRefusal::WorkspaceTsserver { .. }
1195 | UntrustedRefusal::ProjectLauncher { .. }
1196 | UntrustedRefusal::UnresolvedWrappedProgram { .. }
1197 | UntrustedRefusal::WrappedProgramPathContainsEquals { .. }
1198 | UntrustedRefusal::UnpinnedTypescriptLauncher { .. }
1199 | UntrustedRefusal::NonUtf8Path { .. }
1200 | UntrustedRefusal::BlankResolvedPath { .. }
1201 | UntrustedRefusal::NulInResolvedPath { .. }
1202 | UntrustedRefusal::NoSafeWorkingDirectory
1203 | UntrustedRefusal::EnvTooLarge { .. } => Ok(()),
1204 }
1205 }
1206 reason @ (StartupFailure::Spawn(_) | StartupFailure::InitTaskPanicked) => write!(
1207 f,
1208 "LSP server '{id}' for language '{language}' failed to start: {reason}; restart \
1209 mcpls after fixing it (startup failures are not retried)"
1210 ),
1211 }
1212 }
1213}
1214
1215struct FailureList<'a>(&'a [ServerSpawnFailure]);
1217
1218impl fmt::Display for FailureList<'_> {
1219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1220 for (index, failure) in self.0.iter().enumerate() {
1221 if index > 0 {
1222 f.write_str("; ")?;
1223 }
1224 failure.fmt(f)?;
1225 }
1226 Ok(())
1227 }
1228}
1229
1230const MAX_DISPLAYED_CANDIDATES: usize = 10;
1233
1234#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1253pub struct SymbolCandidate {
1254 pub name: String,
1256 pub kind: u32,
1258 pub kind_name: String,
1260 pub container: Option<String>,
1262 pub line: u32,
1264 pub character: u32,
1266}
1267
1268impl fmt::Display for SymbolCandidate {
1269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270 write!(f, "{} `{}`", self.kind_name, self.name)?;
1271 if let Some(container) = &self.container {
1272 write!(f, " in `{container}`")?;
1273 }
1274 write!(f, " at {}:{}", self.line, self.character)
1275 }
1276}
1277
1278#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1295#[serde(tag = "resolution", rename_all = "snake_case")]
1296pub enum SymbolResolutionData {
1297 Ambiguous {
1299 name: String,
1301 candidates: Vec<SymbolCandidate>,
1303 truncated: bool,
1305 },
1306 NotFound {
1308 name: String,
1310 excluded_by_filters: usize,
1312 },
1313 NotDefinedInFile {
1316 name: String,
1318 },
1319 PositionUnverified {
1322 name: String,
1324 candidate: SymbolCandidate,
1326 },
1327}
1328
1329impl fmt::Display for SymbolResolutionData {
1330 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1331 match self {
1332 Self::Ambiguous {
1333 name,
1334 candidates,
1335 truncated,
1336 } => {
1337 write!(
1338 f,
1339 "symbol `{name}` is ambiguous ({} candidates{}): ",
1340 candidates.len(),
1341 if *truncated { ", more not listed" } else { "" }
1342 )?;
1343 for (index, candidate) in
1344 candidates.iter().take(MAX_DISPLAYED_CANDIDATES).enumerate()
1345 {
1346 if index > 0 {
1347 f.write_str("; ")?;
1348 }
1349 candidate.fmt(f)?;
1350 }
1351 if candidates.len() > MAX_DISPLAYED_CANDIDATES {
1352 f.write_str("; ...")?;
1353 }
1354 f.write_str(
1355 ". Narrow it with `symbol_kind` or `container`, or address it by line and character",
1356 )
1357 }
1358 Self::NotFound {
1359 name,
1360 excluded_by_filters,
1361 } => {
1362 write!(f, "no symbol named `{name}` is defined in this file")?;
1363 if *excluded_by_filters > 0 {
1364 write!(
1365 f,
1366 " ({excluded_by_filters} with that name excluded by `symbol_kind`/`container`)"
1367 )?;
1368 }
1369 f.write_str("; try `workspace_symbol_search` or address it by line and character")
1370 }
1371 Self::NotDefinedInFile { name } => write!(
1372 f,
1373 "`{name}` is not defined in this file (it only occurs as a reference or import); \
1374 find its definition with `workspace_symbol_search` or address it by line and character"
1375 ),
1376 Self::PositionUnverified { name, candidate } => write!(
1377 f,
1378 "could not verify where the identifier `{name}` is ({candidate}), so nothing was \
1379 queried; address it by line and character"
1380 ),
1381 }
1382 }
1383}
1384
1385struct IdList<'a>(&'a [ServerId]);
1387
1388impl fmt::Display for IdList<'_> {
1389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390 for (index, id) in self.0.iter().enumerate() {
1391 if index > 0 {
1392 f.write_str(", ")?;
1393 }
1394 write!(f, "'{id}'")?;
1395 }
1396 Ok(())
1397 }
1398}
1399
1400#[derive(Debug, Clone, PartialEq, Eq)]
1460pub struct StderrExcerpt {
1461 body: ExcerptBody,
1462}
1463
1464#[derive(Debug, Clone, PartialEq, Eq)]
1465enum ExcerptBody {
1466 Complete(Box<str>),
1467 Elided { head: Box<str>, tail: Box<str> },
1468}
1469
1470impl StderrExcerpt {
1471 pub(crate) fn complete(bytes: &[u8], redactions: &Redactions) -> Option<Self> {
1474 let text = clean_stderr(&String::from_utf8_lossy(bytes), redactions);
1475 (!text.is_empty()).then(|| Self {
1476 body: ExcerptBody::Complete(text.into()),
1477 })
1478 }
1479
1480 pub(crate) fn elided(head: &[u8], tail: &[u8], redactions: &Redactions) -> Option<Self> {
1485 let head = clean_stderr(
1486 &redactions.mask_cut_head(&decode_cut_head(head)),
1487 redactions,
1488 );
1489 let tail_start = tail
1490 .iter()
1491 .position(|byte| byte & 0xC0 != 0x80)
1492 .unwrap_or(tail.len());
1493 let tail = clean_stderr(
1494 &redactions.mask_cut_tail(&String::from_utf8_lossy(
1495 tail.get(tail_start..).unwrap_or_default(),
1496 )),
1497 redactions,
1498 );
1499 match (head.is_empty(), tail.is_empty()) {
1500 (true, true) => None,
1501 (false, true) => Some(Self {
1502 body: ExcerptBody::Complete(head.into()),
1503 }),
1504 (true, false) => Some(Self {
1505 body: ExcerptBody::Complete(tail.into()),
1506 }),
1507 (false, false) => Some(Self {
1508 body: ExcerptBody::Elided {
1509 head: head.into(),
1510 tail: tail.into(),
1511 },
1512 }),
1513 }
1514 }
1515
1516 #[must_use]
1518 pub fn head(&self) -> &str {
1519 match &self.body {
1520 ExcerptBody::Complete(text) => text,
1521 ExcerptBody::Elided { head, .. } => head,
1522 }
1523 }
1524
1525 #[must_use]
1527 pub fn tail(&self) -> Option<&str> {
1528 match &self.body {
1529 ExcerptBody::Complete(_) => None,
1530 ExcerptBody::Elided { tail, .. } => Some(tail),
1531 }
1532 }
1533
1534 #[must_use]
1536 pub const fn is_elided(&self) -> bool {
1537 matches!(self.body, ExcerptBody::Elided { .. })
1538 }
1539}
1540
1541impl fmt::Display for StderrExcerpt {
1542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1543 fn visible_lines(text: &str) -> impl Iterator<Item = &str> {
1544 text.lines().map(str::trim).filter(|line| !line.is_empty())
1545 }
1546
1547 let mut parts: Vec<&str> = visible_lines(self.head()).collect();
1548 if let Some(tail) = self.tail() {
1549 parts.push("...");
1550 parts.extend(visible_lines(tail));
1551 }
1552 f.write_str(&parts.join(" | "))
1553 }
1554}
1555
1556fn decode_cut_head(head: &[u8]) -> String {
1558 match std::str::from_utf8(head) {
1559 Ok(text) => text.to_owned(),
1560 Err(e) if e.error_len().is_none() => {
1561 String::from_utf8_lossy(head.get(..e.valid_up_to()).unwrap_or_default()).into_owned()
1562 }
1563 Err(_) => String::from_utf8_lossy(head).into_owned(),
1564 }
1565}
1566
1567fn clean_stderr(text: &str, redactions: &Redactions) -> String {
1572 let filtered: String = redactions
1573 .apply(text)
1574 .chars()
1575 .filter(|c| {
1576 (!c.is_control() || matches!(c, '\n' | '\t'))
1577 && !crate::util::is_deceptive_format_char(*c)
1578 })
1579 .collect();
1580 redactions.apply(&filtered).trim().to_owned()
1581}
1582
1583struct StderrSuffix<'a>(&'a Option<StderrExcerpt>);
1585
1586impl fmt::Display for StderrSuffix<'_> {
1587 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1588 self.0
1589 .as_ref()
1590 .map_or(Ok(()), |excerpt| write!(f, "; stderr: {excerpt}"))
1591 }
1592}
1593
1594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1605#[non_exhaustive]
1606pub enum InitFailureHint {
1607 NativeTypescriptOnly,
1610}
1611
1612impl fmt::Display for InitFailureHint {
1613 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1614 match self {
1615 Self::NativeTypescriptOnly => write!(
1616 f,
1617 "typescript-language-server found only TypeScript 7 or later, which ships no \
1618 tsserver; install the TypeScript version the server supports next to it (for a \
1619 global install: `{}`), or change the `command` of your existing `typescript` \
1620 server entry to the absolute path of the `tsc` of a TypeScript 7 install \
1621 outside the workspace, with `args = [\"--lsp\", \"--stdio\"]` (a `tsc` from the \
1622 workspace, or a bare `tsc` that PATH may resolve into the workspace, is \
1623 workspace-supplied code)",
1624 BuiltinServer::TypescriptLanguageServer.install_hint()
1625 ),
1626 }
1627 }
1628}
1629
1630struct HintSuffix<'a>(&'a Option<InitFailureHint>);
1632
1633impl fmt::Display for HintSuffix<'_> {
1634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1635 self.0.map_or(Ok(()), |hint| write!(f, "; {hint}"))
1636 }
1637}
1638
1639struct EarlyExitDetail<'a>(&'a str, Option<i32>);
1642
1643impl fmt::Display for EarlyExitDetail<'_> {
1644 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1645 let Self(command, exit_code) = *self;
1646 match exit_code {
1647 Some(code) => write!(f, " with exit code {code}")?,
1648 None => f.write_str(" (terminated by a signal)")?,
1649 }
1650 BuiltinServer::from_command(command)
1651 .and_then(BuiltinServer::early_exit_hint)
1652 .map_or(Ok(()), |hint| write!(f, "; {hint}"))
1653 }
1654}
1655
1656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1659#[non_exhaustive]
1660pub enum BackgroundTask {
1661 McpService,
1663 LspReceiver,
1665 ListenResolution,
1667 PathValidation,
1669 FileOpen,
1671 ServerPlanning,
1673 IdleSessionReaper,
1675}
1676
1677impl fmt::Display for BackgroundTask {
1678 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1679 f.write_str(match self {
1680 Self::McpService => "MCP service",
1681 Self::LspReceiver => "LSP receiver",
1682 Self::ListenResolution => "listen URI resolution",
1683 Self::PathValidation => "path validation",
1684 Self::FileOpen => "file open",
1685 Self::ServerPlanning => "server start planning",
1686 Self::IdleSessionReaper => "idle HTTP session reaper",
1687 })
1688 }
1689}
1690
1691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1693#[non_exhaustive]
1694pub enum StdioStream {
1695 Stdin,
1697 Stdout,
1699 Stderr,
1701}
1702
1703impl fmt::Display for StdioStream {
1704 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1705 f.write_str(match self {
1706 Self::Stdin => "stdin",
1707 Self::Stdout => "stdout",
1708 Self::Stderr => "stderr",
1709 })
1710 }
1711}
1712
1713#[derive(Debug, thiserror::Error)]
1719#[non_exhaustive]
1720pub enum ConfigError {
1721 #[error("lsp_servers entry '{server}'")]
1723 UnsupportedFilePattern {
1724 server: ServerId,
1726 #[source]
1728 pattern: UnsupportedFilePattern,
1729 },
1730
1731 #[error("config file is not valid UTF-8")]
1733 NotUtf8(#[source] std::string::FromUtf8Error),
1734
1735 #[error("config file is {} bytes, over the fixed {} byte limit", .0.size, .0.max)]
1737 FileTooLarge(SizeExceeded),
1738
1739 #[error("lsp_servers entry '{server}'")]
1742 DuplicateEnvKey {
1743 server: ServerId,
1745 #[source]
1747 error: DuplicateEnvKey,
1748 },
1749
1750 #[error("lsp_servers entry '{server}': environment variable '{key}'")]
1752 InvalidEnvValue {
1753 server: ServerId,
1755 key: EnvKey,
1757 #[source]
1759 error: InvalidEnvValue,
1760 },
1761
1762 #[error("lsp_servers entry '{server}'")]
1764 EnvTooLarge {
1765 server: ServerId,
1767 #[source]
1769 error: EnvTooLarge,
1770 },
1771
1772 #[error("lsp_servers entry '{server}'")]
1775 ArgContainsNul {
1776 server: ServerId,
1778 #[source]
1780 error: InvalidServerArgs,
1781 },
1782
1783 #[error("configuration path has no parent directory: {}", .path.display())]
1785 NoParentDirectory {
1786 path: PathBuf,
1788 },
1789
1790 #[error(
1792 "selection = \"auto\" is only valid for typescript-language-server entries (language \
1793 '{language}'); remove `selection` to use `command` as written"
1794 )]
1795 SelectionAutoOnNonTypescript {
1796 language: LanguageId,
1798 },
1799
1800 #[error(
1802 "allowed server '{server}' is not a configured server (configured: {})",
1803 IdList(configured)
1804 )]
1805 UnknownAllowedServer {
1806 server: ServerId,
1808 configured: Vec<ServerId>,
1810 },
1811
1812 #[error(
1814 "duplicate server id '{id}' in this workspace (used by both an entry with {first} and \
1815 one with {second}); add a unique `name` to each `[[lsp_servers]]` entry"
1816 )]
1817 DuplicateServerId {
1818 id: ServerId,
1820 first: Box<EntrySummary>,
1822 second: Box<EntrySummary>,
1824 },
1825
1826 #[error(
1828 "language '{language}' has two catch-all servers ('{existing}' and '{id}'); at most one \
1829 server per language may omit `handles`"
1830 )]
1831 TwoCatchAllServers {
1832 language: LanguageId,
1834 existing: ServerId,
1836 id: ServerId,
1838 },
1839
1840 #[error("tool '{tool}' for language '{language}' is claimed by both '{existing}' and '{id}'")]
1842 ToolClaimedTwice {
1843 tool: ToolKind,
1845 language: LanguageId,
1847 existing: ServerId,
1849 id: ServerId,
1851 },
1852
1853 #[error(
1855 "workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized",
1856 .written.display(), .base_dir.display(), .probe.display()
1857 )]
1858 UnresolvableWorkspaceRoot {
1859 written: PathBuf,
1861 base_dir: PathBuf,
1863 probe: PathBuf,
1865 #[source]
1867 source: std::io::Error,
1868 },
1869}
1870
1871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1873#[non_exhaustive]
1874pub enum InitPhase {
1875 Initialize,
1877 Initialized,
1879 DidChangeConfiguration,
1881}
1882
1883impl fmt::Display for InitPhase {
1884 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1885 f.write_str(match self {
1886 Self::Initialize => "Initialize request failed",
1887 Self::Initialized => "initialized notification failed",
1888 Self::DidChangeConfiguration => "workspace/didChangeConfiguration notification failed",
1889 })
1890 }
1891}
1892
1893#[derive(Debug, thiserror::Error)]
1899#[non_exhaustive]
1900pub enum Error {
1901 #[error("LSP server initialization failed: {phase}: {}{}{}", ErrorChain(.cause.as_ref()), HintSuffix(.hint), StderrSuffix(.stderr))]
1903 LspInitFailed {
1904 phase: InitPhase,
1906 cause: Box<Self>,
1911 hint: Option<InitFailureHint>,
1913 stderr: Option<StderrExcerpt>,
1915 },
1916
1917 #[error("LSP server error: {} - {}", .0.code, sanitize_lsp_server_message(&.0.message))]
1924 LspServerError(JsonRpcError),
1925
1926 #[error("failed to start MCP server")]
1928 McpServerStart(#[source] Box<dyn std::error::Error + Send + Sync>),
1929
1930 #[error("{task} task failed")]
1932 TaskFailed {
1933 task: BackgroundTask,
1935 #[source]
1937 source: tokio::task::JoinError,
1938 },
1939
1940 #[error("failed to capture LSP server {0}")]
1942 StdioCapture(StdioStream),
1943
1944 #[error("failed to bind HTTP listener on {addr}")]
1946 HttpBind {
1947 addr: std::net::SocketAddr,
1949 #[source]
1951 source: std::io::Error,
1952 },
1953
1954 #[error("document not found: {}", EchoedPath::new(.0))]
1956 DocumentNotFound(PathBuf),
1957
1958 #[error(
1960 "no LSP server configured for language: {language}{}",
1961 no_server_detail(.language, .file, .patterns)
1962 )]
1963 NoServerForLanguage {
1964 language: LanguageId,
1966 file: FileKey,
1969 patterns: Arc<[FilePattern]>,
1972 },
1973
1974 #[error("no server handles tool '{tool}' for language '{language_id}'")]
1979 NoServerForTool {
1980 language_id: LanguageId,
1982 tool: ToolKind,
1984 },
1985
1986 #[error("{}", .0.failed_to_start())]
1992 ServerFailedToStart(Box<ServerSpawnFailure>),
1993
1994 #[error(
1996 "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)"
1997 )]
1998 ServerInitializing {
1999 server_id: ServerId,
2001 },
2002
2003 #[error(
2006 "LSP server '{server_id}' was restarted while this request was in flight; retry the request"
2007 )]
2008 ServerRestarted {
2009 server_id: ServerId,
2011 },
2012
2013 #[error("{0}")]
2017 SymbolResolution(Box<SymbolResolutionData>),
2018
2019 #[error(
2021 "unknown LSP server(s) {}; configured servers: {}",
2022 IdList(unknown),
2023 IdList(configured)
2024 )]
2025 UnknownServers {
2026 unknown: Vec<ServerId>,
2028 configured: Vec<ServerId>,
2030 },
2031
2032 #[error(
2038 "LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
2039 )]
2040 WorkspaceServersInitializing,
2041
2042 #[error("no LSP server configured")]
2044 NoServerConfigured,
2045
2046 #[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
2051 NoServerForWorkspaceTool {
2052 tool: ToolKind,
2054 },
2055
2056 #[error("configuration file not found: {0}")]
2058 ConfigNotFound(PathBuf),
2059
2060 #[error(
2064 "config file {} lies inside the workspace or the current directory, which untrusted mode does not trust; move it elsewhere",
2065 .path.display()
2066 )]
2067 ConfigInsideWorkspace {
2068 path: PathBuf,
2070 },
2071
2072 #[error(transparent)]
2074 Config(#[from] ConfigError),
2075
2076 #[error(transparent)]
2078 Io(#[from] std::io::Error),
2079
2080 #[error("JSON error")]
2082 Json(#[from] serde_json::Error),
2083
2084 #[error("{0}")]
2091 TomlDe(TomlParseError),
2092
2093 #[error(transparent)]
2095 TomlSer(#[from] toml::ser::Error),
2096
2097 #[error("request timed out after {0:?}")]
2099 Timeout(Duration),
2100
2101 #[error("failed to spawn LSP server '{command}'")]
2103 ServerSpawnFailed {
2104 command: ServerCommand,
2106 #[source]
2108 source: std::io::Error,
2109 },
2110
2111 #[error("failed to spawn LSP server '{command}': {io}{}", NotFoundGuidance(.command.as_str(), Platform::CURRENT))]
2116 ServerNotFound {
2117 command: ServerCommand,
2119 io: std::io::Error,
2122 },
2123
2124 #[error("LSP protocol error: {}", escape_control(.0.as_str()))]
2126 LspProtocolError(RedactedText),
2127
2128 #[error("none of the requested resource URIs resolve inside the workspace")]
2131 NoResolvableListenUris,
2132
2133 #[error(transparent)]
2135 InvalidPositionInput(#[from] InvalidPosition),
2136
2137 #[error(transparent)]
2139 InvalidRangeInput(#[from] InvalidRange),
2140
2141 #[error(transparent)]
2143 InvalidHierarchyItemInput(#[from] InvalidHierarchyItem),
2144
2145 #[error(
2151 "line {line} is beyond the end of the document (the document ends at line {last_line})"
2152 )]
2153 PositionBeyondDocument {
2154 line: std::num::NonZeroU32,
2156 last_line: std::num::NonZeroU32,
2158 },
2159
2160 #[error(transparent)]
2162 ResourceUri(#[from] ResourceUriError),
2163
2164 #[error("cannot convert path to URI: {}", EchoedPath::new(.0))]
2166 PathToUri(PathBuf),
2167
2168 #[error("LSP server process terminated unexpectedly")]
2170 ServerTerminated,
2171
2172 #[error("LSP server shutdown did not complete before its deadline")]
2174 ShutdownTimeout,
2175
2176 #[error("LSP server '{command}' exited during initialization{}{}{}", EarlyExitDetail(.command.as_str(), *.exit_code), HintSuffix(.hint), StderrSuffix(.stderr))]
2179 ServerExitedDuringInit {
2180 command: ServerCommand,
2182 exit_code: Option<i32>,
2184 hint: Option<InitFailureHint>,
2186 stderr: Option<StderrExcerpt>,
2188 },
2189
2190 #[error(
2197 "LSP server '{server_id}' is unavailable: crash-looping, retry in {:.1}s",
2198 .retry_in.as_secs_f64()
2199 )]
2200 ServerUnavailable {
2201 server_id: ServerId,
2203 retry_in: Duration,
2205 },
2206
2207 #[error("invalid tool parameters: {0}")]
2209 InvalidToolParams(String),
2210
2211 #[error(transparent)]
2213 InvalidClientPath(#[from] InvalidClientPath),
2214
2215 #[error("malformed file path {}", BoundedDebugPath(.path))]
2223 MalformedPath {
2224 path: PathBuf,
2226 #[source]
2228 source: std::io::Error,
2229 },
2230
2231 #[error("file I/O error for {}", BoundedDebugPath(.path))]
2239 FileIo {
2240 path: PathBuf,
2242 #[source]
2244 source: std::io::Error,
2245 },
2246
2247 #[error("path outside workspace: {}", EchoedPath::new(.0))]
2249 PathOutsideWorkspace(PathBuf),
2250
2251 #[error("no workspace roots configured: refusing access to {}", EchoedPath::new(.0))]
2254 NoWorkspaceRoots(PathBuf),
2255
2256 #[error(
2258 "document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
2259 )]
2260 DocumentLimitExceeded {
2261 current: usize,
2263 max: std::num::NonZeroUsize,
2265 },
2266
2267 #[error("subscription limit of {max} reached")]
2274 SubscriptionLimitReached {
2275 max: usize,
2277 },
2278
2279 #[error("listen stream limit of {max} reached; retry once another stream closes")]
2284 ListenStreamsExhausted {
2285 max: usize,
2287 },
2288
2289 #[error(
2292 "subscriptions/listen request exceeds the limit of {max} resource URIs or their total size budget"
2293 )]
2294 ListenFilterTooLarge {
2295 max: usize,
2297 },
2298
2299 #[error(
2301 "file size limit exceeded: {} bytes, max {} bytes (raise workspace.max_file_size in config to increase this)",
2302 .0.size,
2303 .0.max
2304 )]
2305 FileSizeLimitExceeded(SizeExceeded),
2306
2307 #[error("not a regular file: {}", EchoedPath::new(.0))]
2318 NotARegularFile(PathBuf),
2319
2320 #[error("all LSP servers failed to initialize: {}", FailureList(failures))]
2322 AllServersFailedToInit {
2323 failures: Vec<ServerSpawnFailure>,
2325 },
2326
2327 #[error("server '{server_id}' does not support capability '{capability}'")]
2331 CapabilityNotSupported {
2332 server_id: ServerId,
2334 capability: Capability,
2337 },
2338
2339 #[error(
2346 "LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
2347 )]
2348 WorkspaceIndexing {
2349 server_id: ServerId,
2351 elapsed_secs: u64,
2353 },
2354}
2355
2356pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
2368
2369pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
2384
2385pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
2404
2405pub const LISTEN_STREAMS_EXHAUSTED_ERROR_CODE: i32 = -32053;
2421
2422pub const SERVER_RESTARTED_ERROR_CODE: i32 = -32054;
2436
2437#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
2460#[serde(untagged)]
2461pub enum RetryableErrorData {
2462 WorkspaceIndexing {
2464 server_id: ServerId,
2466 elapsed_secs: u64,
2468 },
2469 ServerInitializing {
2471 server_id: ServerId,
2473 },
2474 WorkspaceServersInitializing {},
2478 ListenStreamsExhausted {
2480 max_listen_streams: usize,
2482 },
2483 ServerRestarted {
2485 server_id: ServerId,
2487 },
2488}
2489
2490impl RetryableErrorData {
2491 #[must_use]
2493 pub const fn code(&self) -> i32 {
2494 match self {
2495 Self::WorkspaceIndexing { .. } => WORKSPACE_INDEXING_ERROR_CODE,
2496 Self::ServerInitializing { .. } | Self::WorkspaceServersInitializing {} => {
2497 SERVER_INITIALIZING_ERROR_CODE
2498 }
2499 Self::ListenStreamsExhausted { .. } => LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
2500 Self::ServerRestarted { .. } => SERVER_RESTARTED_ERROR_CODE,
2501 }
2502 }
2503}
2504
2505#[derive(Debug, Clone, PartialEq, Eq)]
2523#[non_exhaustive]
2524pub enum McpErrorKind {
2525 InvalidParams,
2528 InvalidPosition(RewrittenServerError),
2532 SymbolResolution(SymbolResolutionData),
2535 Retryable(RetryableErrorData),
2540 Internal,
2543}
2544
2545impl Error {
2546 #[must_use]
2568 pub fn mcp_error_kind(&self) -> McpErrorKind {
2569 match self {
2570 Self::InvalidToolParams(_)
2571 | Self::UnknownServers { .. }
2572 | Self::InvalidClientPath(_)
2573 | Self::MalformedPath { .. }
2574 | Self::PathOutsideWorkspace(_)
2575 | Self::NotARegularFile(_)
2576 | Self::NoResolvableListenUris
2577 | Self::ResourceUri(_)
2578 | Self::InvalidPositionInput(_)
2579 | Self::InvalidRangeInput(_)
2580 | Self::InvalidHierarchyItemInput(_)
2581 | Self::PositionBeyondDocument { .. }
2582 | Self::ListenFilterTooLarge { .. }
2583 | Self::DocumentNotFound(_)
2584 | Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
2585
2586 Self::ListenStreamsExhausted { max } => {
2588 McpErrorKind::Retryable(RetryableErrorData::ListenStreamsExhausted {
2589 max_listen_streams: *max,
2590 })
2591 }
2592
2593 Self::FileIo { source, .. } => {
2604 if source.kind() == std::io::ErrorKind::NotFound {
2605 McpErrorKind::InvalidParams
2606 } else {
2607 McpErrorKind::Internal
2608 }
2609 }
2610
2611 Self::WorkspaceIndexing {
2612 server_id,
2613 elapsed_secs,
2614 } => McpErrorKind::Retryable(RetryableErrorData::WorkspaceIndexing {
2615 server_id: server_id.clone(),
2616 elapsed_secs: *elapsed_secs,
2617 }),
2618 Self::ServerInitializing { server_id } => {
2619 McpErrorKind::Retryable(RetryableErrorData::ServerInitializing {
2620 server_id: server_id.clone(),
2621 })
2622 }
2623 Self::SymbolResolution(data) => McpErrorKind::SymbolResolution((**data).clone()),
2624 Self::ServerRestarted { server_id } => {
2625 McpErrorKind::Retryable(RetryableErrorData::ServerRestarted {
2626 server_id: server_id.clone(),
2627 })
2628 }
2629 Self::WorkspaceServersInitializing => {
2634 McpErrorKind::Retryable(RetryableErrorData::WorkspaceServersInitializing {})
2635 }
2636
2637 Self::LspServerError(response) if is_invalid_offset(response) => {
2643 McpErrorKind::InvalidPosition(RewrittenServerError::new(
2644 response.code,
2645 &response.message,
2646 ))
2647 }
2648
2649 Self::LspInitFailed { .. }
2650 | Self::LspServerError(..)
2651 | Self::McpServerStart(_)
2652 | Self::TaskFailed { .. }
2653 | Self::StdioCapture(_)
2654 | Self::PathToUri(_)
2655 | Self::HttpBind { .. }
2656 | Self::NoServerForLanguage { .. }
2657 | Self::NoServerForTool { .. }
2658 | Self::NoServerConfigured
2659 | Self::NoServerForWorkspaceTool { .. }
2660 | Self::ConfigNotFound(_)
2661 | Self::ConfigInsideWorkspace { .. }
2662 | Self::Config(_)
2663 | Self::Io(_)
2664 | Self::Json(_)
2665 | Self::TomlDe(_)
2666 | Self::TomlSer(_)
2667 | Self::Timeout(_)
2668 | Self::ServerSpawnFailed { .. }
2669 | Self::ServerNotFound { .. }
2670 | Self::LspProtocolError(_)
2671 | Self::ServerTerminated
2672 | Self::ShutdownTimeout
2673 | Self::ServerUnavailable { .. }
2674 | Self::ServerFailedToStart(_)
2675 | Self::ServerExitedDuringInit { .. }
2676 | Self::NoWorkspaceRoots(_)
2677 | Self::DocumentLimitExceeded { .. }
2682 | Self::SubscriptionLimitReached { .. }
2685 | Self::AllServersFailedToInit { .. }
2686 | Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
2687 }
2688 }
2689
2690 pub(crate) const fn is_unresolvable_resource(&self) -> bool {
2694 match self {
2695 Self::MalformedPath { .. }
2696 | Self::FileIo { .. }
2697 | Self::PathOutsideWorkspace(..)
2698 | Self::NoWorkspaceRoots(..) => true,
2699 Self::LspInitFailed { .. }
2700 | Self::LspServerError(..)
2701 | Self::McpServerStart(..)
2702 | Self::TaskFailed { .. }
2703 | Self::StdioCapture(..)
2704 | Self::HttpBind { .. }
2705 | Self::DocumentNotFound(..)
2706 | Self::NoServerForLanguage { .. }
2707 | Self::NoServerForTool { .. }
2708 | Self::ServerFailedToStart(..)
2709 | Self::ServerInitializing { .. }
2710 | Self::ServerRestarted { .. }
2711 | Self::SymbolResolution(..)
2712 | Self::UnknownServers { .. }
2713 | Self::WorkspaceServersInitializing
2714 | Self::NoServerConfigured
2715 | Self::NoServerForWorkspaceTool { .. }
2716 | Self::ConfigNotFound(..)
2717 | Self::ConfigInsideWorkspace { .. }
2718 | Self::Config(..)
2719 | Self::Io(..)
2720 | Self::Json(..)
2721 | Self::TomlDe(..)
2722 | Self::TomlSer(..)
2723 | Self::Timeout(..)
2724 | Self::ServerSpawnFailed { .. }
2725 | Self::ServerNotFound { .. }
2726 | Self::LspProtocolError(..)
2727 | Self::NoResolvableListenUris
2728 | Self::InvalidPositionInput(..)
2729 | Self::InvalidRangeInput(..)
2730 | Self::InvalidHierarchyItemInput(..)
2731 | Self::PositionBeyondDocument { .. }
2732 | Self::ResourceUri(..)
2733 | Self::PathToUri(..)
2734 | Self::ServerTerminated
2735 | Self::ShutdownTimeout
2736 | Self::ServerExitedDuringInit { .. }
2737 | Self::ServerUnavailable { .. }
2738 | Self::InvalidToolParams(..)
2739 | Self::InvalidClientPath(..)
2740 | Self::DocumentLimitExceeded { .. }
2741 | Self::SubscriptionLimitReached { .. }
2742 | Self::ListenStreamsExhausted { .. }
2743 | Self::ListenFilterTooLarge { .. }
2744 | Self::FileSizeLimitExceeded { .. }
2745 | Self::NotARegularFile(..)
2746 | Self::AllServersFailedToInit { .. }
2747 | Self::CapabilityNotSupported { .. }
2748 | Self::WorkspaceIndexing { .. } => false,
2749 }
2750 }
2751}
2752
2753#[derive(Debug, Clone)]
2760pub struct TomlParseError {
2761 error: toml::de::Error,
2762 line_column: Option<(usize, usize)>,
2763}
2764
2765impl TomlParseError {
2766 #[must_use]
2780 pub fn new(error: toml::de::Error, source: &str) -> Self {
2781 let line_column = error
2782 .span()
2783 .and_then(|span| source.get(..span.start))
2784 .map(|before| {
2785 let line = before.matches('\n').count().saturating_add(1);
2786 let column = before
2787 .rsplit('\n')
2788 .next()
2789 .map_or_default(|last| last.chars().count())
2790 .saturating_add(1);
2791 (line, column)
2792 });
2793 Self { error, line_column }
2794 }
2795
2796 #[must_use]
2798 pub fn message(&self) -> &str {
2799 self.error.message()
2800 }
2801}
2802
2803impl fmt::Display for TomlParseError {
2804 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2805 f.write_str(self.message())?;
2806 if let Some((line, column)) = self.line_column {
2807 write!(f, " (line {line}, column {column})")
2808 } else if let Some(span) = self.error.span() {
2809 write!(f, " (at bytes {}..{})", span.start, span.end)
2810 } else {
2811 Ok(())
2812 }
2813 }
2814}
2815
2816pub type Result<T> = std::result::Result<T, Error>;
2818
2819#[derive(Debug, Clone, Copy)]
2842pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static));
2843
2844impl fmt::Display for ErrorChain<'_> {
2845 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2846 write!(f, "{}", self.0)?;
2847 let mut cause = self.0.source();
2848 while let Some(error) = cause {
2849 write!(f, ": {error}")?;
2850 cause = error.source();
2851 }
2852 Ok(())
2853 }
2854}
2855
2856#[derive(Debug)]
2860pub(crate) struct ChainedError<E>(pub(crate) E);
2861
2862impl<E> From<E> for ChainedError<E> {
2863 fn from(error: E) -> Self {
2864 Self(error)
2865 }
2866}
2867
2868impl<E: std::error::Error + 'static> fmt::Display for ChainedError<E> {
2869 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2870 ErrorChain(&self.0).fmt(f)
2871 }
2872}
2873
2874#[cfg(test)]
2875mod tests {
2876 use std::assert_matches;
2877
2878 use super::*;
2879 use crate::config::{FileExtension, FileName};
2880 use crate::util::TRUNCATION_MARKER;
2881
2882 #[test]
2883 fn test_all_servers_failed_to_init_error() {
2884 let failures = vec![
2885 ServerSpawnFailure {
2886 server_id: ServerId::from_static("rust"),
2887 language_id: LanguageId::from_static("rust"),
2888 command: ServerCommand::from_static("rust-analyzer"),
2889 reason: StartupFailure::InitTaskPanicked,
2890 },
2891 ServerSpawnFailure {
2892 server_id: ServerId::from_static("python"),
2893 language_id: LanguageId::from_static("python"),
2894 command: ServerCommand::from_static("pyright"),
2895 reason: StartupFailure::InitTaskPanicked,
2896 },
2897 ];
2898
2899 let err = Error::AllServersFailedToInit { failures };
2900
2901 assert!(err.to_string().contains("all LSP servers failed"));
2902
2903 if let Error::AllServersFailedToInit { failures: f } = err {
2905 assert_eq!(f.len(), 2);
2906 assert_eq!(f[0].language_id, "rust");
2907 assert_eq!(f[1].language_id, "python");
2908 } else {
2909 panic!("Expected AllServersFailedToInit error");
2910 }
2911 }
2912
2913 #[test]
2914 fn test_server_spawn_failure_display_names_init_panic() {
2915 let failure = ServerSpawnFailure {
2916 server_id: ServerId::from_static("typescript"),
2917 language_id: LanguageId::from_static("typescript"),
2918 command: ServerCommand::from_static("tsserver"),
2919 reason: StartupFailure::InitTaskPanicked,
2920 };
2921
2922 let display = failure.to_string();
2923 assert!(display.contains("typescript"));
2924 assert!(display.contains("tsserver"));
2925 assert!(display.contains("panicked"));
2926 }
2927
2928 #[test]
2929 fn test_ambiguity_display_lists_at_most_ten_candidates_and_the_hint() {
2930 let candidates: Vec<SymbolCandidate> = (1..=12)
2931 .map(|line| SymbolCandidate {
2932 name: "new".to_string(),
2933 kind: 6,
2934 kind_name: "Method".to_string(),
2935 container: Some("Foo".to_string()),
2936 line,
2937 character: 1,
2938 })
2939 .collect();
2940 let text = SymbolResolutionData::Ambiguous {
2941 name: "new".to_string(),
2942 candidates,
2943 truncated: false,
2944 }
2945 .to_string();
2946
2947 assert!(text.contains("12 candidates"), "{text}");
2948 assert!(
2949 text.contains("at 10:1") && !text.contains("at 11:1"),
2950 "{text}"
2951 );
2952 assert!(text.contains("; ..."), "{text}");
2953 assert!(text.contains("`symbol_kind` or `container`"), "{text}");
2954 }
2955
2956 #[test]
2957 fn test_error_display_lsp_init_failed() {
2958 let err = Error::LspInitFailed {
2959 phase: InitPhase::Initialize,
2960 cause: Box::new(Error::Io(std::io::Error::other("server not found"))),
2961 hint: None,
2962 stderr: None,
2963 };
2964 assert_eq!(
2965 err.to_string(),
2966 "LSP server initialization failed: Initialize request failed: server not found"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_stderr_excerpt_none_when_nothing_visible() {
2972 assert_eq!(
2973 StderrExcerpt::complete(b" \n\x1b\r\n ", &Redactions::default()),
2974 None
2975 );
2976 assert_eq!(StderrExcerpt::complete(b"", &Redactions::default()), None);
2977 }
2978
2979 #[test]
2980 fn test_stderr_excerpt_strips_control_characters_but_keeps_newline_and_tab() {
2981 let excerpt =
2982 StderrExcerpt::complete(b"a\x1b[31mb\x00\r\nc\td", &Redactions::default()).unwrap();
2983 assert_eq!(excerpt.head(), "a[31mb\nc\td");
2984 }
2985
2986 #[test]
2987 fn test_stderr_excerpt_display_is_single_line() {
2988 let excerpt =
2989 StderrExcerpt::complete(b"first\n\n second \nthird\n", &Redactions::default())
2990 .unwrap();
2991 assert_eq!(excerpt.to_string(), "first | second | third");
2992 }
2993
2994 #[test]
2995 fn test_stderr_excerpt_elided_display_marks_the_gap() {
2996 let excerpt =
2997 StderrExcerpt::elided(b"start\nbanner", b"last\nerror", &Redactions::default())
2998 .unwrap();
2999 assert!(excerpt.is_elided());
3000 assert_eq!(excerpt.tail(), Some("last\nerror"));
3001 assert_eq!(excerpt.to_string(), "start | banner | ... | last | error");
3002 }
3003
3004 #[test]
3005 fn test_stderr_excerpt_elided_drops_utf8_sequences_cut_by_the_split() {
3006 let text = "é".as_bytes();
3007 let head = [b"ok ".as_slice(), &text[..1]].concat();
3008 let tail = [&text[1..], b" end".as_slice()].concat();
3009
3010 let excerpt = StderrExcerpt::elided(&head, &tail, &Redactions::default()).unwrap();
3011
3012 assert_eq!(excerpt.head(), "ok");
3013 assert_eq!(excerpt.tail(), Some("end"));
3014 }
3015
3016 fn secrets(pairs: &[(&str, &str)]) -> Redactions {
3017 Redactions::new(
3018 pairs
3019 .iter()
3020 .map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
3021 )
3022 }
3023
3024 #[test]
3025 fn test_stderr_excerpt_redacts_secrets_with_their_label() {
3026 let excerpt = StderrExcerpt::complete(
3027 b"token=hunter2hunter2 id=abc",
3028 &secrets(&[("API_TOKEN", "hunter2hunter2"), ("SHORT_KEY", "abc")]),
3029 )
3030 .unwrap();
3031 assert_eq!(excerpt.head(), "token=[redacted:API_TOKEN] id=abc");
3032 }
3033
3034 #[test]
3035 fn test_stderr_excerpt_redacts_longest_overlapping_secret_whole() {
3036 let excerpt = StderrExcerpt::complete(
3037 b"v=abcdefghXYZ12345",
3038 &secrets(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]),
3039 )
3040 .unwrap();
3041 assert_eq!(excerpt.head(), "v=[redacted:B_KEY]");
3042 }
3043
3044 #[test]
3045 fn test_stderr_excerpt_keeps_non_secret_values_visible() {
3046 let excerpt = StderrExcerpt::complete(
3047 b"toolchain 'nightly-2024-01-01' is not installed",
3048 &secrets(&[("API_TOKEN", "hunter2hunter2")]),
3049 )
3050 .unwrap();
3051 assert_eq!(
3052 excerpt.head(),
3053 "toolchain 'nightly-2024-01-01' is not installed"
3054 );
3055 }
3056
3057 #[test]
3058 fn test_stderr_excerpt_redacts_a_secret_split_by_a_control_character() {
3059 let excerpt = StderrExcerpt::complete(
3060 "tok=hunter2\u{200B}hunter2".as_bytes(),
3061 &secrets(&[("API_TOKEN", "hunter2hunter2")]),
3062 )
3063 .unwrap();
3064 assert_eq!(excerpt.head(), "tok=[redacted:API_TOKEN]");
3065 }
3066
3067 #[test]
3070 fn test_stderr_excerpt_masks_a_secret_split_across_the_elision_boundary() {
3071 let redactions = secrets(&[("API_TOKEN", "supersecretvalue")]);
3072
3073 let excerpt =
3074 StderrExcerpt::elided(b"token=supersec", b"retvalue done", &redactions).unwrap();
3075
3076 let shown = excerpt.to_string();
3077 assert!(!shown.contains("supersec"), "{shown}");
3078 assert!(!shown.contains("retvalue"), "{shown}");
3079 assert!(shown.contains("[redacted:API_TOKEN]"), "{shown}");
3080 }
3081
3082 #[test]
3083 fn test_stderr_excerpt_strips_separators_bidi_and_zero_width_characters() {
3084 let text = "a\u{2028}b\u{2029}c\u{202E}d\u{2066}e\u{200B}f\u{FEFF}g\u{200F}h";
3085
3086 let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
3087
3088 assert_eq!(excerpt.head(), "abcdefgh");
3089 }
3090
3091 #[test]
3092 fn test_escape_control_and_stderr_agree_on_deceptive_characters() {
3093 let deceptive = [
3094 '\u{061C}',
3095 '\u{200B}',
3096 '\u{200C}',
3097 '\u{200D}',
3098 '\u{200E}',
3099 '\u{200F}',
3100 '\u{2028}',
3101 '\u{2029}',
3102 '\u{202A}',
3103 '\u{202B}',
3104 '\u{202C}',
3105 '\u{202D}',
3106 '\u{202E}',
3107 '\u{2060}',
3108 '\u{2066}',
3109 '\u{2067}',
3110 '\u{2068}',
3111 '\u{2069}',
3112 '\u{FEFF}',
3113 '\u{00AD}',
3114 '\u{180E}',
3115 '\u{2061}',
3116 '\u{2064}',
3117 '\u{206A}',
3118 '\u{206F}',
3119 '\u{FFF9}',
3120 '\u{FFFB}',
3121 '\u{E0000}',
3122 '\u{E0041}',
3123 '\u{E007F}',
3124 ];
3125 for c in deceptive {
3126 let text = format!("a{c}b");
3127 let escaped = crate::util::escape_control(&text);
3128 assert!(
3129 !escaped.contains(c),
3130 "escape_control kept U+{:04X}",
3131 c as u32
3132 );
3133 let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
3134 assert_eq!(excerpt.head(), "ab", "stderr kept U+{:04X}", c as u32);
3135 }
3136 }
3137
3138 #[test]
3139 fn test_init_errors_append_stderr_to_display() {
3140 let stderr = StderrExcerpt::complete(b"fatal: bad config", &Redactions::default());
3141 let failed = Error::LspInitFailed {
3142 phase: InitPhase::Initialize,
3143 cause: Box::new(Error::Io(std::io::Error::other("boom"))),
3144 hint: None,
3145 stderr: stderr.clone(),
3146 };
3147 assert_eq!(
3148 failed.to_string(),
3149 "LSP server initialization failed: Initialize request failed: boom; stderr: fatal: bad config"
3150 );
3151 let exited = Error::ServerExitedDuringInit {
3152 command: crate::config::ServerCommand::from_static("gopls"),
3153 exit_code: Some(2),
3154 hint: None,
3155 stderr,
3156 };
3157 assert_eq!(
3158 exited.to_string(),
3159 "LSP server 'gopls' exited during initialization with exit code 2; stderr: fatal: bad config"
3160 );
3161 }
3162
3163 #[test]
3164 fn test_error_display_lsp_server_error() {
3165 let err = Error::LspServerError(JsonRpcError {
3166 code: -32600,
3167 message: "Invalid request".to_string(),
3168 data: None,
3169 });
3170 assert_eq!(
3171 err.to_string(),
3172 "LSP server error: -32600 - Invalid request"
3173 );
3174 }
3175
3176 #[test]
3177 fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
3178 let err = Error::LspServerError(JsonRpcError {
3179 code: -32603,
3180 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
3181 .to_string(),
3182 data: None,
3183 });
3184 assert_eq!(
3185 err.to_string(),
3186 "LSP server error: -32603 - position out of range for this document"
3187 );
3188 }
3189
3190 #[test]
3191 fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
3192 let err = Error::LspServerError(JsonRpcError {
3196 code: -32803,
3197 message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
3198 (line index length: 3)"
3199 .to_string(),
3200 data: None,
3201 });
3202 assert_eq!(
3203 err.to_string(),
3204 "LSP server error: -32803 - position out of range for this document"
3205 );
3206 }
3207
3208 #[test]
3209 fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
3210 let err = Error::LspServerError(JsonRpcError {
3211 code: -32602,
3212 message: "Invalid params: expected object".to_string(),
3213 data: None,
3214 });
3215 assert_eq!(
3216 err.to_string(),
3217 "LSP server error: -32602 - Invalid params: expected object"
3218 );
3219 }
3220
3221 #[test]
3222 fn test_error_display_document_not_found() {
3223 let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
3224 assert!(err.to_string().contains("document not found"));
3225 assert!(err.to_string().contains("file.rs"));
3226 }
3227
3228 #[test]
3229 fn test_error_display_no_server_for_language() {
3230 let err = no_server_for_language("rust");
3231 assert_eq!(
3232 err.to_string(),
3233 "no LSP server configured for language: rust"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_error_display_workspace_servers_initializing() {
3239 let err = Error::WorkspaceServersInitializing;
3240 assert!(err.to_string().contains("still initializing"));
3241 }
3242
3243 #[test]
3244 fn test_error_display_no_server_for_workspace_tool() {
3245 let err = Error::NoServerForWorkspaceTool {
3246 tool: crate::config::ToolKind::WorkspaceSymbols,
3247 };
3248 assert!(err.to_string().contains("workspace_symbols"));
3249 assert!(err.to_string().contains("no server's `handles` list"));
3250 }
3251
3252 #[test]
3253 fn test_error_display_timeout() {
3254 assert_eq!(
3255 Error::Timeout(Duration::from_secs(30)).to_string(),
3256 "request timed out after 30s"
3257 );
3258 assert_eq!(
3259 Error::Timeout(Duration::from_millis(500)).to_string(),
3260 "request timed out after 500ms"
3261 );
3262 }
3263
3264 #[test]
3265 fn test_error_display_document_limit() {
3266 let err = Error::DocumentLimitExceeded {
3267 current: 150,
3268 max: std::num::NonZeroUsize::new(100).unwrap(),
3269 };
3270 assert_eq!(
3271 err.to_string(),
3272 "document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
3273 );
3274 }
3275
3276 #[test]
3277 fn test_error_display_file_size_limit() {
3278 let err = Error::FileSizeLimitExceeded(SizeExceeded {
3279 size: 20_000_000,
3280 max: std::num::NonZeroU64::new(10_000_000).unwrap(),
3281 });
3282 assert_eq!(
3283 err.to_string(),
3284 "file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
3285 );
3286 }
3287
3288 #[test]
3289 fn test_error_display_not_a_regular_file() {
3290 let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
3291 assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
3292 }
3293
3294 #[test]
3295 fn test_error_from_io() {
3296 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
3297 let err: Error = io_err.into();
3298 assert_matches!(err, Error::Io(_));
3299 }
3300
3301 #[test]
3302 fn test_error_from_json() {
3303 let json_str = "{invalid json}";
3304 let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
3305 let err: Error = json_err.into();
3306 assert_matches!(err, Error::Json(_));
3307 }
3308
3309 fn assert_rendered_once(err: &(dyn std::error::Error + 'static), marker: &str) {
3310 let text = ErrorChain(err).to_string();
3311 assert_eq!(text.matches(marker).count(), 1, "{text}");
3312 let segments: Vec<&str> = text.split(": ").collect();
3313 let distinct: std::collections::HashSet<&str> = segments.iter().copied().collect();
3314 assert_eq!(distinct.len(), segments.len(), "repeated segment in {text}");
3315 }
3316
3317 fn io_sourced_cases() -> Vec<(Error, String)> {
3318 let io = |marker: &str| std::io::Error::other(marker.to_owned());
3319 let command = ServerCommand::from_static("rust-analyzer");
3320 vec![
3321 (
3322 Error::HttpBind {
3323 addr: std::net::SocketAddr::from(([127, 0, 0, 1], 1)),
3324 source: io("bind-marker"),
3325 },
3326 "bind-marker".to_owned(),
3327 ),
3328 (
3329 Error::ServerSpawnFailed {
3330 command: command.clone(),
3331 source: io("spawn-marker"),
3332 },
3333 "spawn-marker".to_owned(),
3334 ),
3335 (
3336 Error::ServerNotFound {
3337 command: command.clone(),
3338 io: io("missing-marker"),
3339 },
3340 "missing-marker".to_owned(),
3341 ),
3342 (
3343 Error::LspInitFailed {
3344 phase: InitPhase::Initialize,
3345 cause: Box::new(Error::ServerSpawnFailed {
3346 command,
3347 source: io("init-marker"),
3348 }),
3349 hint: None,
3350 stderr: None,
3351 },
3352 "init-marker".to_owned(),
3353 ),
3354 (
3355 Error::from(ConfigError::UnresolvableWorkspaceRoot {
3356 written: PathBuf::from("root"),
3357 base_dir: PathBuf::from("base"),
3358 probe: PathBuf::from("base/root"),
3359 source: io("root-marker"),
3360 }),
3361 "root-marker".to_owned(),
3362 ),
3363 (Error::from(io("io-marker")), "io-marker".to_owned()),
3364 (
3365 Error::FileIo {
3366 path: PathBuf::from("a.rs"),
3367 source: io("file-marker"),
3368 },
3369 "file-marker".to_owned(),
3370 ),
3371 (
3372 Error::MalformedPath {
3373 path: PathBuf::from("b.rs"),
3374 source: io("path-marker"),
3375 },
3376 "path-marker".to_owned(),
3377 ),
3378 ]
3379 }
3380
3381 #[tokio::test]
3383 async fn test_error_chain_renders_each_cause_exactly_once() {
3384 let toml_text = "[invalid toml";
3385 let toml_err = toml::from_str::<toml::Value>(toml_text).unwrap_err();
3386 let json_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
3387 let json_text = json_err.to_string();
3388 let join_error = tokio::spawn(async { panic!("task-marker") })
3389 .await
3390 .unwrap_err();
3391 let url_err = url::Url::parse("http://[").unwrap_err();
3392 let non_utf8 = String::from_utf8(vec![0xff]).unwrap_err();
3393
3394 let mut cases = io_sourced_cases();
3395 cases.extend([
3396 (
3397 Error::TomlDe(TomlParseError::new(toml_err.clone(), toml_text)),
3398 toml_err.message().to_owned(),
3399 ),
3400 (Error::from(json_err), json_text),
3401 (
3402 Error::McpServerStart("mcp-marker".into()),
3403 "mcp-marker".to_owned(),
3404 ),
3405 (
3406 Error::TaskFailed {
3407 task: BackgroundTask::McpService,
3408 source: join_error,
3409 },
3410 "task-marker".to_owned(),
3411 ),
3412 (
3413 Error::from(ConfigError::UnsupportedFilePattern {
3414 server: ServerId::from_static("rust"),
3415 pattern: FilePattern::parse("zz/yy").unwrap_err(),
3416 }),
3417 "'zz/yy'".to_owned(),
3418 ),
3419 (
3420 Error::from(ConfigError::NotUtf8(non_utf8.clone())),
3421 non_utf8.to_string(),
3422 ),
3423 (
3424 Error::from(ResourceUriError::UrlParse(url_err)),
3425 url_err.to_string(),
3426 ),
3427 (
3428 Error::from(ResourceUriError::ClientPath(InvalidClientPath::Empty)),
3429 InvalidClientPath::Empty.to_string(),
3430 ),
3431 ]);
3432 for (err, marker) in &cases {
3433 assert_rendered_once(err, marker);
3434 }
3435 }
3436
3437 #[test]
3438 fn test_startup_failure_spawn_renders_the_cause_once() {
3439 let failure = StartupFailure::Spawn(Arc::new(Error::ServerSpawnFailed {
3440 command: ServerCommand::from_static("rust-analyzer"),
3441 source: std::io::Error::other("spawn-marker"),
3442 }));
3443 let text = failure.to_string();
3444 assert_eq!(text.matches("spawn-marker").count(), 1, "{text}");
3445 }
3446
3447 #[test]
3450 fn test_echo_bound_docs_state_the_constants() {
3451 let source = include_str!("error.rs");
3452 for (bytes, doc_marker) in [
3453 (MAX_ECHOED_NAME_BYTES, "pub struct EchoedArgument"),
3454 (MAX_ECHOED_PATH_BYTES, "pub struct EchoedPath"),
3455 ] {
3456 let before = source.split(doc_marker).next().unwrap();
3457 let doc = before.rsplit("\n\n").next().unwrap();
3458 assert!(
3459 doc.contains(&format!("{bytes} bytes")),
3460 "{doc_marker}: {doc}"
3461 );
3462 }
3463 let debug_doc = source
3464 .split("struct BoundedDebugPath")
3465 .next()
3466 .and_then(|before| before.rsplit("\n\n").next())
3467 .unwrap();
3468 assert!(
3469 debug_doc.contains(&format!("{MAX_ECHOED_PATH_BYTES} bytes")),
3470 "{debug_doc}"
3471 );
3472 }
3473
3474 fn debug_of(path: &str) -> String {
3475 BoundedDebugPath(Path::new(path)).to_string()
3476 }
3477
3478 #[test]
3481 fn test_bounded_debug_path_cuts_at_the_bound() {
3482 let at_bound = "a".repeat(MAX_ECHOED_PATH_BYTES - 2);
3483 assert_eq!(debug_of(&at_bound), format!("{:?}", Path::new(&at_bound)));
3484 assert!(!debug_of(&at_bound).ends_with(TRUNCATION_MARKER));
3485
3486 let over = "a".repeat(MAX_ECHOED_PATH_BYTES - 1);
3487 let cut = debug_of(&over);
3488 assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
3489 assert_eq!(cut.len(), MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
3490
3491 let straddling = "é".repeat(MAX_ECHOED_PATH_BYTES);
3492 let cut = debug_of(&straddling);
3493 assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
3494 assert!(cut.len() <= MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
3495
3496 let honest = debug_of("/ws/quote\"d\\ name.rs");
3497 assert_eq!(honest, format!("{:?}", Path::new("/ws/quote\"d\\ name.rs")));
3498 }
3499
3500 #[test]
3502 fn test_bounded_debug_path_never_splits_an_escape() {
3503 for prefix in 0..8 {
3504 let text = format!(
3505 "{}{}",
3506 "a".repeat(prefix),
3507 "\u{1b}".repeat(MAX_ECHOED_PATH_BYTES)
3508 );
3509 let cut = debug_of(&text);
3510 let body = cut.strip_suffix(TRUNCATION_MARKER).expect("cut");
3511 let tail = body.rsplit('\\').next().unwrap_or_default();
3512 assert!(
3513 tail.is_empty() || (tail.starts_with("u{1b}") && tail == "u{1b}"),
3514 "prefix {prefix}: ends with {tail:?}"
3515 );
3516 assert!(body.len() <= MAX_ECHOED_PATH_BYTES, "{}", body.len());
3517 }
3518 }
3519
3520 #[test]
3523 fn test_client_text_echoes_stay_bounded() {
3524 let huge = "Z".repeat(1 << 20);
3525 let path = PathBuf::from(format!("/{huge}"));
3526 let io = || std::io::Error::other("cause");
3527 let errors = [
3528 Error::PathOutsideWorkspace(path.clone()),
3529 Error::NoWorkspaceRoots(path.clone()),
3530 Error::NotARegularFile(path.clone()),
3531 Error::DocumentNotFound(path.clone()),
3532 Error::PathToUri(path.clone()),
3533 Error::FileIo {
3534 path: path.clone(),
3535 source: io(),
3536 },
3537 Error::MalformedPath {
3538 path: path.clone(),
3539 source: io(),
3540 },
3541 Error::from(ResourceUriError::InvalidPath(huge.clone())),
3542 Error::from(ResourceUriError::InvalidScheme(huge.clone())),
3543 Error::from(ResourceUriError::NotAFilePath(huge)),
3544 ];
3545 for error in &errors {
3546 let text = ErrorChain(error).to_string();
3547 let len = text.len();
3548 assert!(len <= 2 * MAX_ECHOED_PATH_BYTES, "{len} bytes: {text:.80}");
3549 assert!(text.contains(TRUNCATION_MARKER), "{text:.80}");
3550 }
3551 assert_matches!(&errors[0], Error::PathOutsideWorkspace(stored) if *stored == path);
3552 }
3553
3554 #[test]
3556 fn test_path_echo_variants_cut_one_byte_over_the_bound() {
3557 let at_bound = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES));
3558 let text = Error::PathOutsideWorkspace(at_bound).to_string();
3559 assert!(!text.contains(TRUNCATION_MARKER), "{text:.80}");
3560
3561 let over = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES + 1));
3562 let text = Error::NotARegularFile(over).to_string();
3563 assert!(text.ends_with(TRUNCATION_MARKER), "{text:.80}");
3564
3565 let controls = PathBuf::from("\u{1b}".repeat(MAX_ECHOED_PATH_BYTES));
3566 let text = Error::NoWorkspaceRoots(controls).to_string();
3567 assert!(text.len() <= 2 * MAX_ECHOED_PATH_BYTES, "{}", text.len());
3568 }
3569
3570 #[test]
3571 fn test_document_not_found_and_path_to_uri_escape_control_characters() {
3572 let path = || PathBuf::from("/a\nERROR forged\u{1b}[31m");
3573
3574 for error in [Error::DocumentNotFound(path()), Error::PathToUri(path())] {
3575 let text = error.to_string();
3576 assert!(!text.contains('\n') && !text.contains('\u{1b}'), "{text}");
3577 }
3578 }
3579
3580 #[test]
3581 fn test_error_chain_joins_causes_once() {
3582 let not_found = || std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
3583 let file_io = Error::FileIo {
3584 path: PathBuf::from("a.rs"),
3585 source: not_found(),
3586 };
3587 let spawn = Error::ServerSpawnFailed {
3588 command: crate::config::ServerCommand::new("x").unwrap(),
3589 source: not_found(),
3590 };
3591
3592 assert_eq!(
3593 ErrorChain(&file_io).to_string(),
3594 "file I/O error for \"a.rs\": gone"
3595 );
3596 assert_eq!(
3597 ErrorChain(&spawn).to_string(),
3598 "failed to spawn LSP server 'x': gone"
3599 );
3600 }
3601
3602 #[test]
3603 fn test_toml_parse_error_reports_line_and_column_without_the_line() {
3604 let text = "a = 1\nsecret = \"sk-SECRET\" bad\n";
3605 let error = toml::from_str::<toml::Value>(text).unwrap_err();
3606
3607 let rendered = TomlParseError::new(error, text).to_string();
3608
3609 assert!(rendered.contains("(line 2, column "), "{rendered}");
3610 assert!(!rendered.contains("sk-SECRET"), "{rendered}");
3611 }
3612
3613 #[test]
3616 fn test_toml_error_never_quotes_an_env_secret() {
3617 let text = "lsp_servers = [{ language_id = \"rust\", command = \"ra\", \
3618 env = { API_TOKEN = \"sk-SECRET\\u0000\" } }]";
3619 let parse_error = toml::from_str::<crate::config::ServerConfig>(text).unwrap_err();
3620 let err = Error::TomlDe(TomlParseError::new(parse_error, text));
3621
3622 let rendered = ErrorChain(&err).to_string();
3623 assert!(!rendered.contains("sk-SECRET"), "{rendered}");
3624 assert!(
3625 rendered.contains("environment variable 'API_TOKEN'"),
3626 "{rendered}"
3627 );
3628 assert!(rendered.contains("cannot contain NUL"), "{rendered}");
3629 }
3630
3631 #[test]
3632 fn test_result_type_alias() {
3633 fn _returns_error() -> Result<i32> {
3634 Err(Error::Config(ConfigError::NoParentDirectory {
3635 path: PathBuf::new(),
3636 }))
3637 }
3638
3639 let result: Result<i32> = Ok(42);
3640 assert!(result.is_ok());
3641 if let Ok(value) = result {
3642 assert_eq!(value, 42);
3643 }
3644 }
3645
3646 fn not_found(command: &str) -> Error {
3647 Error::ServerNotFound {
3648 command: crate::config::ServerCommand::new(command).unwrap(),
3649 io: std::io::Error::from(std::io::ErrorKind::NotFound),
3650 }
3651 }
3652
3653 #[test]
3654 fn test_server_not_found_bare_builtin_has_path_and_install_hint() {
3655 let msg = not_found("rust-analyzer").to_string();
3656 assert!(msg.contains("not on the PATH"), "{msg}");
3657 assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3658 }
3659
3660 #[test]
3661 fn test_server_not_found_bare_unknown_has_no_install_hint() {
3662 let msg = not_found("my-custom-lsp").to_string();
3663 assert!(msg.contains("not on the PATH"), "{msg}");
3664 assert!(!msg.contains("install it"), "{msg}");
3665 assert!(!msg.contains(".cmd"), "{msg}");
3666 }
3667
3668 #[test]
3669 fn test_server_not_found_path_command_has_no_path_text() {
3670 let msg = not_found("/nonexistent/rust-analyzer").to_string();
3671 assert!(msg.contains("configured path exists"), "{msg}");
3672 assert!(!msg.contains("PATH"), "{msg}");
3673 assert!(!msg.contains("install it"), "{msg}");
3674 }
3675
3676 #[test]
3677 fn test_lifecycle_errors_map_to_internal() {
3678 for err in [Error::ShutdownTimeout, not_found("x")] {
3679 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3680 }
3681 }
3682
3683 #[test]
3684 fn test_not_found_guidance_cmd_note_only_for_npm_builtins_on_windows() {
3685 let cases = [
3686 ("pyright-langserver", Platform::Windows, true),
3687 ("typescript-language-server", Platform::Windows, true),
3688 ("pyright-langserver", Platform::Other, false),
3689 ("rust-analyzer", Platform::Windows, false),
3690 ("gopls", Platform::Windows, false),
3691 ("my-custom-lsp", Platform::Windows, false),
3692 ];
3693 for (command, platform, expects_cmd_note) in cases {
3694 let msg = NotFoundGuidance(command, platform).to_string();
3695 assert_eq!(
3696 msg.contains(".cmd"),
3697 expects_cmd_note,
3698 "{command} {platform:?}: {msg}"
3699 );
3700 }
3701 }
3702
3703 #[test]
3704 fn test_error_source_chain() {
3705 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
3706 let err = Error::ServerSpawnFailed {
3707 command: crate::config::ServerCommand::from_static("rust-analyzer"),
3708 source: io_err,
3709 };
3710
3711 let source = std::error::Error::source(&err);
3712 assert!(source.is_some());
3713 }
3714
3715 fn spawn_failure(id: &str, command: &str, error: Error) -> ServerSpawnFailure {
3716 ServerSpawnFailure {
3717 server_id: ServerId::new(id).unwrap(),
3718 language_id: LanguageId::new(id).unwrap(),
3719 command: ServerCommand::new(command).unwrap(),
3720 reason: StartupFailure::Spawn(Arc::new(error)),
3721 }
3722 }
3723
3724 #[test]
3725 fn test_server_spawn_failure_display() {
3726 let failure = spawn_failure(
3727 "rust",
3728 "rust-analyzer",
3729 Error::LspInitFailed {
3730 phase: InitPhase::Initialize,
3731 cause: Box::new(Error::Io(std::io::Error::other("boom"))),
3732 hint: None,
3733 stderr: None,
3734 },
3735 );
3736 assert_eq!(
3737 failure.to_string(),
3738 "rust [rust] (rust-analyzer): LSP server initialization failed: Initialize request failed: boom"
3739 );
3740 }
3741
3742 #[test]
3743 fn test_server_spawn_failure_clone_shares_reason() {
3744 let failure = spawn_failure("python", "pyright", not_found("pyright"));
3745 let cloned = failure.clone();
3746 assert_eq!(failure.language_id, cloned.language_id);
3747 assert_eq!(failure.to_string(), cloned.to_string());
3748 }
3749
3750 #[test]
3751 fn test_server_failed_to_start_display_carries_not_found_guidance() {
3752 let err = Error::ServerFailedToStart(Box::new(spawn_failure(
3753 "rust",
3754 "rust-analyzer",
3755 not_found("rust-analyzer"),
3756 )));
3757 let msg = err.to_string();
3758 assert!(
3759 msg.contains("'rust' for language 'rust' failed to start"),
3760 "{msg}"
3761 );
3762 assert!(msg.contains("not on the PATH"), "{msg}");
3763 assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
3764 assert!(msg.contains("restart mcpls"), "{msg}");
3765 }
3766
3767 #[test]
3768 fn test_server_failed_to_start_init_task_panicked_display() {
3769 let err = Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
3770 server_id: ServerId::from_static("rust"),
3771 language_id: LanguageId::from_static("rust"),
3772 command: ServerCommand::from_static("rust-analyzer"),
3773 reason: StartupFailure::InitTaskPanicked,
3774 }));
3775 assert!(err.to_string().contains("initialization task panicked"));
3776 }
3777
3778 #[test]
3779 fn test_startup_errors_map_to_internal() {
3780 let failure = spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer"));
3781 for err in [
3782 Error::ServerFailedToStart(Box::new(failure.clone())),
3783 Error::AllServersFailedToInit {
3784 failures: vec![failure],
3785 },
3786 Error::ServerExitedDuringInit {
3787 command: crate::config::ServerCommand::from_static("x"),
3788 exit_code: Some(1),
3789 hint: None,
3790 stderr: None,
3791 },
3792 Error::ServerUnavailable {
3793 server_id: ServerId::from_static("rust"),
3794 retry_in: Duration::from_secs(1),
3795 },
3796 ] {
3797 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
3798 }
3799 }
3800
3801 #[test]
3802 fn test_init_failure_hint_renders_between_detail_and_stderr() {
3803 let stderr = StderrExcerpt::complete(b"boom", &Redactions::default());
3804 let hint = Some(InitFailureHint::NativeTypescriptOnly);
3805 let failed = Error::LspInitFailed {
3806 phase: InitPhase::Initialize,
3807 cause: Box::new(Error::Io(std::io::Error::other("x"))),
3808 hint,
3809 stderr: stderr.clone(),
3810 }
3811 .to_string();
3812 let exited = Error::ServerExitedDuringInit {
3813 command: crate::config::ServerCommand::from_static("typescript-language-server"),
3814 exit_code: Some(1),
3815 hint,
3816 stderr,
3817 }
3818 .to_string();
3819 for text in [failed, exited] {
3820 let hint_at = text.find("TypeScript 7").unwrap();
3821 assert!(hint_at < text.find("; stderr: boom").unwrap(), "{text}");
3822 assert!(
3823 text.contains(BuiltinServer::TypescriptLanguageServer.install_hint()),
3824 "{text}"
3825 );
3826 }
3827 }
3828
3829 #[test]
3830 fn test_server_exited_during_init_display_hints_only_for_rust_analyzer() {
3831 let hinted = Error::ServerExitedDuringInit {
3832 command: crate::config::ServerCommand::from_static("rust-analyzer"),
3833 exit_code: Some(1),
3834 hint: None,
3835 stderr: None,
3836 }
3837 .to_string();
3838 assert!(hinted.contains("exit code 1"), "{hinted}");
3839 assert!(
3840 hinted.contains("rustup component add rust-analyzer"),
3841 "{hinted}"
3842 );
3843
3844 let plain = Error::ServerExitedDuringInit {
3845 command: crate::config::ServerCommand::from_static("gopls"),
3846 exit_code: None,
3847 hint: None,
3848 stderr: None,
3849 }
3850 .to_string();
3851 assert!(plain.contains("terminated by a signal"), "{plain}");
3852 assert!(!plain.contains("rustup"), "{plain}");
3853 }
3854
3855 #[test]
3856 fn test_server_unavailable_display_names_retry_delay() {
3857 let err = Error::ServerUnavailable {
3858 server_id: ServerId::from_static("rust"),
3859 retry_in: Duration::from_secs(2),
3860 };
3861 assert_eq!(
3862 err.to_string(),
3863 "LSP server 'rust' is unavailable: crash-looping, retry in 2.0s"
3864 );
3865 }
3866
3867 #[test]
3868 fn test_error_display_all_servers_failed_lists_each_failure() {
3869 let err = Error::AllServersFailedToInit {
3870 failures: vec![
3871 spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer")),
3872 spawn_failure(
3873 "python",
3874 "pyright",
3875 Error::LspInitFailed {
3876 phase: InitPhase::Initialize,
3877 cause: Box::new(Error::Io(std::io::Error::other("denied"))),
3878 hint: None,
3879 stderr: None,
3880 },
3881 ),
3882 ],
3883 };
3884 let msg = err.to_string();
3885 assert!(
3886 msg.starts_with("all LSP servers failed to initialize: "),
3887 "{msg}"
3888 );
3889 assert!(msg.contains("rust [rust] (rust-analyzer)"), "{msg}");
3890 assert!(msg.contains("python [python] (pyright)"), "{msg}");
3891 }
3892
3893 #[test]
3894 fn test_untrusted_refusal_paths_are_escaped_and_bounded() {
3895 let hostile = PathBuf::from(format!("/ws/evil{}", "\n\u{202e}".repeat(3000)));
3896 for refusal in [
3897 UntrustedRefusal::WorkspaceExecutable {
3898 executable: hostile.clone(),
3899 },
3900 UntrustedRefusal::WorkspaceTsserver {
3901 tsserver: hostile.clone(),
3902 },
3903 UntrustedRefusal::WorkspaceHome {
3904 variable: HomeVariable::Home,
3905 home: hostile.clone(),
3906 },
3907 UntrustedRefusal::NonUtf8Path {
3908 what: ResolvedItem::Executable,
3909 path: hostile,
3910 },
3911 ] {
3912 let text = refusal.to_string();
3913 assert!(!text.contains('\n'), "{text}");
3914 assert!(!text.contains('\u{202e}'), "{text}");
3915 assert!(text.len() < MAX_ECHOED_PATH_BYTES + 512, "{}", text.len());
3916 }
3917 let name = EchoedArgument::name(&"\u{202e}".repeat(5000));
3918 assert!(name.as_str().len() < MAX_ECHOED_NAME_BYTES + 64);
3919 }
3920
3921 #[test]
3922 fn test_untrusted_refusal_new_variants_name_their_cause() {
3923 let launcher = UntrustedRefusal::ProjectLauncher {
3924 command: ServerCommand::from_static("npx"),
3925 cause: LauncherRefusal::SelectsWorkspaceCode {
3926 program: EchoedPath::program("npx"),
3927 trigger: LaunchTrigger::Always,
3928 },
3929 };
3930 assert!(launcher.to_string().contains("'npx'"), "{launcher}");
3931 let unpinned = UntrustedRefusal::UnpinnedTypescriptLauncher {
3932 command: ServerCommand::from_static("pnpm"),
3933 };
3934 assert!(unpinned.to_string().contains("'pnpm'"), "{unpinned}");
3935 let non_utf8 = UntrustedRefusal::NonUtf8Path {
3936 what: ResolvedItem::SearchPath,
3937 path: PathBuf::from("/odd"),
3938 };
3939 assert_eq!(
3940 non_utf8.to_string(),
3941 "its PATH, /odd, is not valid UTF-8, so untrusted mode cannot pass it on"
3942 );
3943 assert!(
3944 UntrustedRefusal::NoSafeWorkingDirectory
3945 .to_string()
3946 .contains("outside the workspace")
3947 );
3948 }
3949
3950 fn no_server_for_language(language: &'static str) -> Error {
3951 Error::NoServerForLanguage {
3952 language: LanguageId::from_static(language),
3953 file: FileKey::Unmappable,
3954 patterns: Arc::default(),
3955 }
3956 }
3957
3958 #[test]
3959 fn test_no_server_for_plaintext_names_extension_and_patterns() {
3960 let err = Error::NoServerForLanguage {
3961 language: LanguageId::PLAINTEXT,
3962 file: FileKey::Extension(FileExtension::from_static("cpp")),
3963 patterns: Arc::from([
3964 FilePattern::from_static("**/*.rs"),
3965 FilePattern::from_static("**/*.h"),
3966 ]),
3967 };
3968 let message = err.to_string();
3969 assert!(message.starts_with("no LSP server configured for language: plaintext ("));
3970 assert!(
3971 message.contains("file extension 'cpp' is not mapped"),
3972 "{message}"
3973 );
3974 assert!(
3975 message.contains("configured file_patterns: **/*.rs, **/*.h"),
3976 "{message}"
3977 );
3978 }
3979
3980 #[test]
3981 fn test_no_server_for_plaintext_without_patterns_or_extension() {
3982 let err = Error::NoServerForLanguage {
3983 language: LanguageId::PLAINTEXT,
3984 file: FileKey::Unmappable,
3985 patterns: Arc::default(),
3986 };
3987 let message = err.to_string();
3988 assert!(
3989 message.contains("the file has no usable extension or name"),
3990 "{message}"
3991 );
3992 assert!(
3993 message.contains("no file_patterns are configured"),
3994 "{message}"
3995 );
3996 }
3997
3998 #[test]
3999 fn test_no_server_for_other_language_text_is_unchanged() {
4000 let err = Error::NoServerForLanguage {
4001 language: LanguageId::from_static("nushell"),
4002 file: FileKey::Extension(FileExtension::from_static("nu")),
4003 patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
4004 };
4005 assert_eq!(
4006 err.to_string(),
4007 "no LSP server configured for language: nushell"
4008 );
4009 }
4010
4011 #[test]
4012 fn test_no_server_for_plaintext_name_suggests_a_name_pattern() {
4013 let err = Error::NoServerForLanguage {
4014 language: LanguageId::PLAINTEXT,
4015 file: FileKey::Name(FileName::from_static("Makefile")),
4016 patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
4017 };
4018 let message = err.to_string();
4019 assert!(
4020 message.contains("file name 'Makefile' is not mapped"),
4021 "{message}"
4022 );
4023 assert!(
4024 message.contains("a `**/Makefile` file_patterns entry"),
4025 "{message}"
4026 );
4027 assert!(!message.contains("*.EXT"), "{message}");
4028 }
4029
4030 #[test]
4031 fn test_error_display_capability_not_supported() {
4032 let err = Error::CapabilityNotSupported {
4033 server_id: ServerId::from_static("rust"),
4034 capability: Capability::Rename,
4035 };
4036 assert_eq!(
4037 err.to_string(),
4038 "server 'rust' does not support capability 'renameProvider'"
4039 );
4040 }
4041
4042 #[test]
4043 fn test_error_display_workspace_indexing() {
4044 let err = Error::WorkspaceIndexing {
4045 server_id: ServerId::from_static("rust"),
4046 elapsed_secs: 30,
4047 };
4048 assert_eq!(
4049 err.to_string(),
4050 "LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
4051 );
4052 }
4053
4054 #[test]
4057 fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
4058 let caller_fault_errors = vec![
4059 Error::InvalidToolParams("bad params".to_string()),
4060 Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
4061 Error::NotARegularFile(PathBuf::from("/dev/null")),
4062 Error::NoResolvableListenUris,
4063 Error::ResourceUri(ResourceUriError::InvalidScheme("x".to_string())),
4064 Error::DocumentNotFound(PathBuf::from("/missing.rs")),
4065 Error::FileSizeLimitExceeded(SizeExceeded {
4066 size: 100,
4067 max: std::num::NonZeroU64::new(10).unwrap(),
4068 }),
4069 Error::ListenFilterTooLarge { max: 1000 },
4070 Error::InvalidClientPath(InvalidClientPath::ContainsNul),
4071 ];
4072
4073 for err in caller_fault_errors {
4074 assert_eq!(
4075 err.mcp_error_kind(),
4076 McpErrorKind::InvalidParams,
4077 "expected {err:?} to classify as InvalidParams"
4078 );
4079 }
4080 }
4081
4082 #[tokio::test]
4083 async fn test_mcp_error_kind_internal_failures_are_internal() {
4084 let join_error = tokio::spawn(async { panic!("boom") }).await.unwrap_err();
4085 let internal_errors = vec![
4086 Error::TaskFailed {
4087 task: BackgroundTask::McpService,
4088 source: join_error,
4089 },
4090 Error::StdioCapture(StdioStream::Stdin),
4091 Error::PathToUri(PathBuf::from("/x")),
4092 Error::McpServerStart("handshake".into()),
4093 ];
4094
4095 for err in internal_errors {
4096 assert_eq!(
4097 err.mcp_error_kind(),
4098 McpErrorKind::Internal,
4099 "expected {err:?} to classify as Internal"
4100 );
4101 }
4102 }
4103
4104 #[tokio::test]
4105 async fn test_task_failed_keeps_the_panic_payload_as_source() {
4106 let join_error = tokio::spawn(async { panic!("secret boom") })
4107 .await
4108 .unwrap_err();
4109 let err = Error::TaskFailed {
4110 task: BackgroundTask::LspReceiver,
4111 source: join_error,
4112 };
4113 let source = std::error::Error::source(&err).unwrap();
4114 assert!(source.to_string().contains("secret boom"), "{source}");
4115 assert!(err.to_string().starts_with("LSP receiver task failed"));
4116 }
4117
4118 #[test]
4119 fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
4120 let err = Error::WorkspaceIndexing {
4121 server_id: ServerId::from_static("rust"),
4122 elapsed_secs: 30,
4123 };
4124 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4125 panic!("expected WorkspaceIndexing to classify as Retryable");
4126 };
4127 assert_eq!(data.code(), WORKSPACE_INDEXING_ERROR_CODE);
4128 assert_eq!(
4129 serde_json::to_value(&data).unwrap(),
4130 serde_json::json!({"server_id": "rust", "elapsed_secs": 30})
4131 );
4132 }
4133
4134 #[test]
4135 fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
4136 let err = Error::ServerInitializing {
4137 server_id: ServerId::from_static("python"),
4138 };
4139 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4140 panic!("expected ServerInitializing to classify as Retryable");
4141 };
4142 assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
4143 assert_eq!(
4144 serde_json::to_value(&data).unwrap(),
4145 serde_json::json!({"server_id": "python"})
4146 );
4147 assert_ne!(
4148 data.code(),
4149 WORKSPACE_INDEXING_ERROR_CODE,
4150 "ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
4151 );
4152 }
4153
4154 #[test]
4160 fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
4161 let err = Error::WorkspaceServersInitializing;
4162 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4163 panic!("expected WorkspaceServersInitializing to classify as Retryable");
4164 };
4165 assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
4166 assert_eq!(
4167 serde_json::to_value(&data).unwrap(),
4168 serde_json::json!({}),
4169 "the empty struct variant must serialize as `{{}}`, not `null`"
4170 );
4171 }
4172
4173 #[test]
4174 fn test_retryable_error_data_codes_and_snake_case_keys() {
4175 let cases = [
4176 (
4177 RetryableErrorData::WorkspaceIndexing {
4178 server_id: ServerId::from_static("rust"),
4179 elapsed_secs: 7,
4180 },
4181 WORKSPACE_INDEXING_ERROR_CODE,
4182 serde_json::json!({"server_id": "rust", "elapsed_secs": 7}),
4183 ),
4184 (
4185 RetryableErrorData::ServerInitializing {
4186 server_id: ServerId::from_static("python"),
4187 },
4188 SERVER_INITIALIZING_ERROR_CODE,
4189 serde_json::json!({"server_id": "python"}),
4190 ),
4191 (
4192 RetryableErrorData::WorkspaceServersInitializing {},
4193 SERVER_INITIALIZING_ERROR_CODE,
4194 serde_json::json!({}),
4195 ),
4196 (
4197 RetryableErrorData::ListenStreamsExhausted {
4198 max_listen_streams: 100,
4199 },
4200 LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
4201 serde_json::json!({"max_listen_streams": 100}),
4202 ),
4203 ];
4204 for (data, code, wire) in cases {
4205 assert_eq!(data.code(), code);
4206 assert_eq!(serde_json::to_value(&data).unwrap(), wire);
4207 }
4208 }
4209
4210 #[test]
4211 fn test_mcp_error_kind_listen_streams_exhausted_is_retryable() {
4212 let err = Error::ListenStreamsExhausted { max: 100 };
4213 let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
4214 panic!("expected ListenStreamsExhausted to classify as Retryable");
4215 };
4216 assert_eq!(data.code(), LISTEN_STREAMS_EXHAUSTED_ERROR_CODE);
4217 assert_eq!(
4218 data,
4219 RetryableErrorData::ListenStreamsExhausted {
4220 max_listen_streams: 100
4221 }
4222 );
4223 }
4224
4225 #[test]
4226 fn test_mcp_error_kind_unretained_variants_stay_internal() {
4227 let internal_errors = vec![
4228 no_server_for_language("python"),
4229 Error::NoServerForTool {
4230 language_id: LanguageId::from_static("rust"),
4231 tool: crate::config::ToolKind::Hover,
4232 },
4233 Error::CapabilityNotSupported {
4234 server_id: ServerId::from_static("rust"),
4235 capability: Capability::Rename,
4236 },
4237 Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
4238 Error::DocumentLimitExceeded {
4239 current: 150,
4240 max: std::num::NonZeroUsize::new(100).unwrap(),
4241 },
4242 Error::SubscriptionLimitReached { max: 1000 },
4243 ];
4244
4245 for err in internal_errors {
4246 assert_eq!(
4247 err.mcp_error_kind(),
4248 McpErrorKind::Internal,
4249 "expected {err:?} to classify as Internal"
4250 );
4251 }
4252 }
4253
4254 #[test]
4258 fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
4259 let err = Error::FileIo {
4260 path: PathBuf::from("/no/such/file.rs"),
4261 source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
4262 };
4263 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
4264 }
4265
4266 #[test]
4267 fn test_position_beyond_document_is_invalid_params_naming_the_line() {
4268 let err = Error::PositionBeyondDocument {
4269 line: std::num::NonZeroU32::new(7).unwrap(),
4270 last_line: std::num::NonZeroU32::new(3).unwrap(),
4271 };
4272 assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
4273 assert_eq!(
4274 err.to_string(),
4275 "line 7 is beyond the end of the document (the document ends at line 3)"
4276 );
4277 }
4278
4279 #[test]
4282 fn test_mcp_error_kind_malformed_path_is_invalid_params() {
4283 use std::io::ErrorKind;
4284
4285 for kind in [
4286 ErrorKind::NotADirectory,
4287 ErrorKind::InvalidFilename,
4288 ErrorKind::InvalidInput,
4289 ] {
4290 let err = Error::MalformedPath {
4291 path: PathBuf::from("/ws/main.rs/x"),
4292 source: std::io::Error::new(kind, "bad path"),
4293 };
4294 assert_eq!(
4295 err.mcp_error_kind(),
4296 McpErrorKind::InvalidParams,
4297 "{kind:?}"
4298 );
4299 }
4300 }
4301
4302 #[test]
4305 fn test_mcp_error_kind_post_validation_file_io_invalid_input_stays_internal() {
4306 let err = Error::FileIo {
4307 path: PathBuf::from("/ws/main.rs"),
4308 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad input"),
4309 };
4310 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4311 }
4312
4313 #[test]
4317 fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
4318 let err = Error::FileIo {
4319 path: PathBuf::from("/root/secret.rs"),
4320 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
4321 };
4322 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4323 }
4324
4325 #[test]
4329 fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
4330 let err = Error::LspServerError(JsonRpcError {
4331 code: -32603,
4332 message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
4333 .to_string(),
4334 data: None,
4335 });
4336 assert_eq!(
4337 err.mcp_error_kind(),
4338 McpErrorKind::InvalidPosition(RewrittenServerError::new(
4339 -32603,
4340 "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
4341 ))
4342 );
4343 }
4344
4345 #[test]
4348 fn test_display_escapes_control_characters_from_the_server() {
4349 let server_error = Error::LspServerError(JsonRpcError {
4350 code: -32603,
4351 message: "boom\nERROR forged\x1b[31m".to_string(),
4352 data: None,
4353 });
4354 assert_eq!(
4355 server_error.to_string(),
4356 "LSP server error: -32603 - boom\\nERROR forged\\u{1b}[31m"
4357 );
4358
4359 let protocol_error = Error::LspProtocolError(RedactedText::fixed("bad\nline"));
4360 assert_eq!(protocol_error.to_string(), "LSP protocol error: bad\\nline");
4361 }
4362
4363 #[test]
4365 fn test_rewritten_server_error_bounds_raw_message() {
4366 let raw =
4367 RewrittenServerError::new(-32603, &"x".repeat(MAX_ERROR_MESSAGE_CALLER_BYTES * 2));
4368 assert!(raw.raw_message().len() < MAX_ERROR_MESSAGE_CALLER_BYTES * 2);
4369 assert!(raw.raw_message().ends_with("(truncated)"));
4370 }
4371
4372 #[test]
4376 fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
4377 let err = Error::LspServerError(JsonRpcError {
4378 code: -32603,
4379 message: "internal error".to_string(),
4380 data: None,
4381 });
4382 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4383 }
4384
4385 #[test]
4389 fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
4390 let err = Error::SubscriptionLimitReached { max: 1000 };
4391 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4392 }
4393
4394 fn refusal_failure(refusal: UntrustedRefusal) -> ServerSpawnFailure {
4395 ServerSpawnFailure {
4396 server_id: ServerId::from_static("rust"),
4397 language_id: LanguageId::new("rust").unwrap(),
4398 command: ServerCommand::from_static("rust-analyzer"),
4399 reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
4400 }
4401 }
4402
4403 #[test]
4404 fn test_not_allowed_refusal_names_the_server_class_and_the_remedy() {
4405 let err =
4406 Error::ServerFailedToStart(Box::new(refusal_failure(UntrustedRefusal::NotAllowed {
4407 builtin: Some(BuiltinServer::RustAnalyzer),
4408 })));
4409 assert_eq!(
4410 err.to_string(),
4411 "LSP server 'rust' for language 'rust' was not started: the workspace is untrusted \
4412 and it may run workspace code (Cargo build scripts and procedural macros); restart \
4413 mcpls with `--allow-server rust` to start it"
4414 );
4415 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4416 }
4417
4418 #[test]
4419 fn test_workspace_executable_refusal_offers_no_allow_remedy() {
4420 let err = Error::ServerFailedToStart(Box::new(refusal_failure(
4421 UntrustedRefusal::WorkspaceExecutable {
4422 executable: PathBuf::from("/ws/bin/rust-analyzer"),
4423 },
4424 )));
4425 let text = err.to_string();
4426 assert!(
4427 text.contains("its executable /ws/bin/rust-analyzer lies inside the workspace"),
4428 "{text}"
4429 );
4430 assert!(!text.contains("--allow-server"), "{text}");
4431 assert!(!text.contains("not retried"), "{text}");
4432 }
4433
4434 #[test]
4435 fn test_custom_server_refusal_says_its_workspace_code_is_unknown() {
4436 let text = UntrustedRefusal::NotAllowed { builtin: None }.to_string();
4437 assert!(text.contains("not a built-in server"), "{text}");
4438 }
4439
4440 #[test]
4441 fn test_config_inside_workspace_is_a_startup_error_of_kind_internal() {
4442 let err = Error::ConfigInsideWorkspace {
4443 path: PathBuf::from("/ws/mcpls.toml"),
4444 };
4445 assert!(err.to_string().contains("/ws/mcpls.toml"));
4446 assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
4447 }
4448
4449 #[test]
4450 fn echoed_path_keeps_spaces_and_equals() {
4451 let path = EchoedPath::new(Path::new("/tmp/out/no such dir/a=b/srv"));
4452 assert_eq!(path.as_str(), "/tmp/out/no such dir/a=b/srv");
4453 }
4454
4455 #[test]
4456 fn echoed_path_never_cuts_an_escape_in_half() {
4457 let escape = "\\u{202e}";
4458 let hostile = "\u{202e}".repeat(MAX_ECHOED_PATH_BYTES);
4459 let echoed = EchoedPath::new(Path::new(&hostile));
4460 let kept = echoed
4461 .as_str()
4462 .strip_suffix(TRUNCATION_MARKER)
4463 .expect("an over-long path is marked as truncated");
4464 assert!(kept.len() <= MAX_ECHOED_PATH_BYTES);
4465 assert!(
4466 !kept.is_empty() && kept.len().is_multiple_of(escape.len()),
4467 "{kept}"
4468 );
4469 assert_eq!(kept, escape.repeat(kept.len() / escape.len()));
4470 }
4471
4472 #[test]
4473 fn echoed_program_cuts_an_assignment_but_keeps_a_path() {
4474 assert_eq!(EchoedPath::program("API_TOKEN=abc").as_str(), "API_TOKEN");
4475 assert_eq!(EchoedPath::program("X=/tmp/secret").as_str(), "X");
4476 assert_eq!(
4477 EchoedPath::program("/tmp/out/a=b/srv").as_str(),
4478 "/tmp/out/a=b/srv"
4479 );
4480 assert_eq!(EchoedPath::program("no such dir").as_str(), "no such dir");
4481 }
4482
4483 #[test]
4484 fn shell_flags_display_the_prefix_they_are_spelled_with() {
4485 assert_eq!(ShellFlag::PlusC.short_spelling(), Some(('+', 'c')));
4486 assert_eq!(ShellFlag::PlusC.to_string(), "+c");
4487 assert_eq!(ShellFlag::DashC.to_string(), "-c");
4488 assert_eq!(ShellFlag::DashCapitalC.short_spelling(), Some(('-', 'C')));
4489 let long = ShellFlag::LongCommand(LongCommandName::Command);
4490 assert_eq!(long.short_spelling(), None);
4491 assert_eq!(long.to_string(), "--command");
4492 }
4493
4494 #[test]
4495 fn echoed_path_within_the_bound_is_not_marked() {
4496 let path = "a".repeat(MAX_ECHOED_PATH_BYTES);
4497 assert_eq!(EchoedPath::new(Path::new(&path)).as_str(), path);
4498 }
4499
4500 #[test]
4501 fn wrapped_program_refusals_name_the_program_its_launcher_starts() {
4502 let program = EchoedPath::new(Path::new("/tmp/out/a=b"));
4503 let text = UntrustedRefusal::WrappedProgramPathContainsEquals { program }.to_string();
4504 assert!(text.contains("'/tmp/out/a=b'"), "{text}");
4505 assert!(text.contains("the program '"), "{text}");
4506 assert!(!text.contains("its launcher '/tmp"), "{text}");
4507 }
4508}