use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use crate::bridge::resources::ResourceUriError;
use crate::bridge::{
Capability, InvalidClientPath, InvalidHierarchyItem, InvalidPosition, InvalidRange,
};
use crate::config::{
BuiltinServer, DuplicateEnvKey, EntrySummary, EnvKey, EnvTooLarge, FileKey, FilePattern,
InvalidAutoSelection, InvalidEnvValue, InvalidServerArgs, LanguageId, ServerCommand, ServerId,
ToolKind, UnsupportedFilePattern,
};
use crate::lsp::JsonRpcError;
pub use crate::redaction::RedactedText;
use crate::redaction::Redactions;
pub use crate::util::SizeExceeded;
use crate::util::{
MAX_ECHOED_NAME_BYTES, MAX_ECHOED_PATH_BYTES, MAX_ERROR_MESSAGE_CALLER_BYTES,
TRUNCATION_MARKER, escape_bounded, escape_control, truncate_str,
};
fn no_server_detail(language: &LanguageId, file: &FileKey, patterns: &[FilePattern]) -> String {
if *language != LanguageId::PLAINTEXT {
return String::new();
}
let (subject, remedy) = match file {
FileKey::Extension(ext) => (
format!("file extension '{ext}' is not mapped to any language"),
"a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
),
FileKey::Name(name) => (
format!("file name '{name}' is not mapped to any language"),
format!("a `**/{name}` file_patterns entry"),
),
FileKey::Unmappable => (
"the file has no usable extension or name".to_owned(),
"a `*.EXT` file_patterns entry or workspace.language_extensions".to_owned(),
),
};
let configured = if patterns.is_empty() {
"no file_patterns are configured".to_owned()
} else {
let list: Vec<&str> = patterns.iter().map(FilePattern::as_str).collect();
format!("configured file_patterns: {}", list.join(", "))
};
format!(" ({subject}; {configured}; map it with {remedy})")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Platform {
Windows,
Other,
}
impl Platform {
const CURRENT: Self = if cfg!(windows) {
Self::Windows
} else {
Self::Other
};
}
struct NotFoundGuidance<'a>(&'a str, Platform);
impl fmt::Display for NotFoundGuidance<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self(command, platform) = *self;
if Path::new(command).parent().is_some_and(|p| !p.is_empty()) {
return f.write_str("; check that the configured path exists");
}
write!(
f,
"; '{command}' is not on the PATH mcpls runs with -- if it is installed, add its \
directory to the MCP client's PATH or set `command` to an absolute path"
)?;
let Some(builtin) = BuiltinServer::from_command(command) else {
return Ok(());
};
if platform == Platform::Windows && builtin.is_npm_package() {
write!(
f,
" (npm-installed servers need the `.cmd` name, e.g. `{command}.cmd`)"
)?;
}
write!(f, "; otherwise install it: {}", builtin.install_hint())
}
}
const INVALID_OFFSET_MARKER: &str = "Invalid offset LineCol";
pub(crate) fn is_invalid_offset(response: &JsonRpcError) -> bool {
response.message.contains(INVALID_OFFSET_MARKER)
}
fn sanitize_lsp_server_message(message: &str) -> String {
if message.contains(INVALID_OFFSET_MARKER) {
"position out of range for this document".to_string()
} else {
escape_control(message).into_owned()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RewrittenServerError {
code: i32,
raw_message: String,
}
impl RewrittenServerError {
#[must_use]
pub fn new(code: i32, raw_message: &str) -> Self {
Self {
code,
raw_message: truncate_str(raw_message, MAX_ERROR_MESSAGE_CALLER_BYTES),
}
}
#[must_use]
pub const fn code(&self) -> i32 {
self.code
}
#[must_use]
pub fn raw_message(&self) -> &str {
&self.raw_message
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum StartupFailure {
Spawn(Arc<Error>),
InitTaskPanicked,
RefusedUntrustedWorkspace(UntrustedRefusal),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum HomeVariable {
Home,
UserProfile,
}
impl HomeVariable {
pub const ALL: [Self; 2] = [Self::Home, Self::UserProfile];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Home => "HOME",
Self::UserProfile => "USERPROFILE",
}
}
}
impl fmt::Display for HomeVariable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ResolvedItem {
Executable,
SearchPath,
}
impl fmt::Display for ResolvedItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Executable => "executable",
Self::SearchPath => "PATH",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EchoedArgument(String);
impl EchoedArgument {
#[must_use]
pub fn name(arg: &str) -> Self {
let name = arg
.split_once(|c: char| c == '=' || c.is_whitespace())
.map_or(arg, |(name, _)| name);
Self(escape_bounded(name, MAX_ECHOED_NAME_BYTES))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EchoedPath(String);
impl EchoedPath {
#[must_use]
pub fn new(path: &Path) -> Self {
Self(escape_bounded(
&path.to_string_lossy(),
MAX_ECHOED_PATH_BYTES,
))
}
#[must_use]
pub fn program(arg: &str) -> Self {
let assignment = arg
.split_once('=')
.is_some_and(|(name, _)| !name.contains(std::path::is_separator));
if assignment {
Self(EchoedArgument::name(arg).0)
} else {
Self::new(Path::new(arg))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
struct BoundedDebugPath<'a>(&'a Path);
struct CutWriter {
text: String,
left: usize,
cut: bool,
}
impl fmt::Write for CutWriter {
fn write_str(&mut self, piece: &str) -> fmt::Result {
if self.cut {
return Err(fmt::Error);
}
if piece.len() <= self.left {
self.text.push_str(piece);
self.left = self.left.saturating_sub(piece.len());
return Ok(());
}
let keep = piece.floor_char_boundary(self.left);
self.text.push_str(piece.get(..keep).unwrap_or_default());
self.cut = true;
Err(fmt::Error)
}
}
fn complete_escapes_len(text: &str) -> usize {
let mut at = 0;
while let Some(offset) = text.get(at..).and_then(|tail| tail.find('\\')) {
let start = at.saturating_add(offset);
let after = start.saturating_add(1);
let rest = text.get(after..).unwrap_or_default();
let escape_len = match rest.chars().next() {
Some('u') => rest.find('}').map(|end| end.saturating_add(1)),
Some('x') => (rest.len() >= 3).then_some(3),
Some(other) => Some(other.len_utf8()),
None => None,
};
let Some(escape_len) = escape_len else {
return start;
};
at = after.saturating_add(escape_len);
}
text.len()
}
impl fmt::Display for BoundedDebugPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use fmt::Write as _;
let mut writer = CutWriter {
text: String::new(),
left: MAX_ECHOED_PATH_BYTES,
cut: false,
};
#[expect(
clippy::unnecessary_debug_formatting,
reason = "the quoted, escaped debug form is the message format"
)]
let formatted = write!(writer, "{:?}", self.0);
debug_assert!(formatted.is_ok() || writer.cut);
if !writer.cut {
return f.write_str(&writer.text);
}
let whole = complete_escapes_len(&writer.text);
f.write_str(writer.text.get(..whole).unwrap_or_default())?;
f.write_str(TRUNCATION_MARKER)
}
}
impl fmt::Display for EchoedPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for EchoedArgument {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
macro_rules! spelled_enum {
(
$(#[$meta:meta])*
$name:ident {
$($(#[$variant_meta:meta])* $variant:ident => $spelling:expr),+ $(,)?
}
) => {
$(#[$meta])*
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum $name {
$($(#[$variant_meta])* $variant),+
}
impl $name {
pub const ALL: [Self; [$(Self::$variant),+].len()] = [$(Self::$variant),+];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $spelling),+
}
}
}
impl ::std::fmt::Display for $name {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
f.write_str(self.as_str())
}
}
};
}
pub(crate) use spelled_enum;
spelled_enum! {
RunnerSubcommand {
Run => "run",
X => "x",
Task => "task",
Eval => "eval",
Repl => "repl",
Tool => "tool",
Exec => "exec",
V1Run => "v1-run",
V2Run => "v2-run",
NewRun => "new-run",
V1Exec => "v1-exec",
V2Exec => "v2-exec",
NewExec => "new-exec",
Runghc => "runghc",
Runhaskell => "runhaskell",
Script => "script",
R => "r",
T => "t",
Test => "test",
Bench => "bench",
Ghci => "ghci",
V1Repl => "v1-repl",
V2Repl => "v2-repl",
NewRepl => "new-repl",
}
}
spelled_enum! {
LongCommandName {
Command => "--command",
Commands => "--commands",
InitCommand => "--init-command",
Execute => "--execute",
}
}
spelled_enum! {
PowerShellParameter {
Command => "-Command",
CommandWithArgs => "-CommandWithArgs",
EncodedCommand => "-EncodedCommand",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ShellFlag {
DashC,
PlusC,
DashCapitalC,
DashE,
LongCommand(LongCommandName),
PowerShell(PowerShellParameter),
}
enum FlagSpelling {
Short(char, char),
Word(&'static str),
}
impl ShellFlag {
const fn spelling(self) -> FlagSpelling {
match self {
Self::DashC => FlagSpelling::Short('-', 'c'),
Self::PlusC => FlagSpelling::Short('+', 'c'),
Self::DashCapitalC => FlagSpelling::Short('-', 'C'),
Self::DashE => FlagSpelling::Short('-', 'e'),
Self::LongCommand(name) => FlagSpelling::Word(name.as_str()),
Self::PowerShell(parameter) => FlagSpelling::Word(parameter.as_str()),
}
}
#[must_use]
pub(crate) const fn short_spelling(self) -> Option<(char, char)> {
match self.spelling() {
FlagSpelling::Short(prefix, letter) => Some((prefix, letter)),
FlagSpelling::Word(_) => None,
}
}
}
impl fmt::Display for ShellFlag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.spelling() {
FlagSpelling::Short(prefix, letter) => write!(f, "{prefix}{letter}"),
FlagSpelling::Word(word) => f.write_str(word),
}
}
}
spelled_enum! {
LongInlineFlag {
Eval => "--eval",
Print => "--print",
Import => "--import",
Loader => "--loader",
ExperimentalLoader => "--experimental-loader",
Run => "--run",
ProcessBegin => "--process-begin",
ProcessCode => "--process-code",
ProcessEnd => "--process-end",
ProcessFile => "--process-file",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InlineFlag {
Short(char),
Long(LongInlineFlag),
}
impl fmt::Display for InlineFlag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Short(letter) => write!(f, "-{letter}"),
Self::Long(flag) => f.write_str(flag.as_str()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum OperandKind {
Duration,
}
impl fmt::Display for OperandKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Duration => "DURATION",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StdinSource {
NoProgram,
DashS,
DashOperand,
DeviceOperand,
ParentTraversal,
SpecialFile,
}
impl fmt::Display for StdinSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::NoProgram => "no script is given, so it reads its program from standard input",
Self::DashS => "`-s` makes it read its program from standard input",
Self::DashOperand => "a `-` operand makes it read its program from standard input",
Self::DeviceOperand => {
"its script is under /dev or /proc, where a path can name standard input"
}
Self::ParentTraversal => "its script path has a `..` component, which can reach /dev",
Self::SpecialFile => {
"its script is a device, FIFO or socket, which may be an alias of standard input"
}
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LaunchTrigger {
Always,
Subcommand(RunnerSubcommand),
CommandString(ShellFlag),
ImplicitCommandString,
InlineProgram(InlineFlag),
NpmSpecifier,
ProgramFromStdin(StdinSource),
}
impl fmt::Display for LaunchTrigger {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Always => f.write_str("every use of it does"),
Self::Subcommand(name) => write!(f, "its `{name}` subcommand does"),
Self::CommandString(flag) => {
write!(
f,
"`{flag}` gives it a command string, which cannot be analyzed"
)
}
Self::ImplicitCommandString => f.write_str(
"it reads a bare argument as a command string, which cannot be analyzed",
),
Self::InlineProgram(flag) => write!(f, "`{flag}` gives it a program to run"),
Self::NpmSpecifier => f.write_str("an `npm:` argument names a package to run"),
Self::ProgramFromStdin(source) => write!(f, "{source}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnanalyzableLaunch {
UnknownOption(EchoedArgument),
MissingValue(EchoedArgument),
MissingCommand,
MalformedOperand(OperandKind),
PathAssignment,
SplitString,
RelativeProgramAfterChdir,
RelativeOperandAfterChdir,
BlankProgram,
}
impl fmt::Display for UnanalyzableLaunch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownOption(option) => write!(f, "unknown option '{option}'"),
Self::MissingValue(option) => write!(f, "option '{option}' has no value"),
Self::MissingCommand => f.write_str("no command follows its options"),
Self::MalformedOperand(name) => write!(f, "its {name} operand is malformed"),
Self::PathAssignment => f.write_str("it assigns PATH"),
Self::SplitString => f.write_str("it splits a string into arguments"),
Self::RelativeProgramAfterChdir => {
f.write_str("it starts a relative program after changing directory")
}
Self::RelativeOperandAfterChdir => {
f.write_str("it reads a relative script after changing directory")
}
Self::BlankProgram => f.write_str("the program name is empty"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LauncherRefusal {
SelectsWorkspaceCode {
program: EchoedPath,
trigger: LaunchTrigger,
},
Unanalyzable {
program: EchoedPath,
reason: UnanalyzableLaunch,
},
TooDeep,
}
impl fmt::Display for LauncherRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SelectsWorkspaceCode { program, trigger } => write!(
f,
"its launcher '{program}' chooses the server from files in the workspace \
({trigger}), which untrusted mode never runs; install the server globally \
and give its absolute path as `command`"
),
Self::Unanalyzable { program, reason } => write!(
f,
"its launcher '{program}' cannot be analyzed ({reason}), so untrusted mode \
cannot tell which program it starts; give the absolute path of the server \
as `command`"
),
Self::TooDeep => f.write_str(
"its launchers are nested too deeply to analyze, so untrusted mode cannot \
tell which program they start; give the absolute path of the server as \
`command`",
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UntrustedRefusal {
NotAllowed {
builtin: Option<BuiltinServer>,
},
WorkspaceExecutable {
executable: PathBuf,
},
UnresolvedExecutable {
command: ServerCommand,
},
AutoSelectionTarget {
executable: ServerCommand,
cause: InvalidAutoSelection,
},
WorkspaceHome {
variable: HomeVariable,
home: PathBuf,
},
EmptyHome {
variable: HomeVariable,
},
UnknownHome,
WorkspaceTsserver {
tsserver: PathBuf,
},
ProjectLauncher {
command: ServerCommand,
cause: LauncherRefusal,
},
WorkspaceExecutableArgument {
index: usize,
executable: PathBuf,
},
UnreadableWorkspaceArgument {
index: usize,
path: PathBuf,
},
UnresolvedWrappedProgram {
program: EchoedPath,
},
WrappedProgramPathContainsEquals {
program: EchoedPath,
},
UnpinnedTypescriptLauncher {
command: ServerCommand,
},
NonUtf8Path {
what: ResolvedItem,
path: PathBuf,
},
BlankResolvedPath {
what: ResolvedItem,
},
NulInResolvedPath {
what: ResolvedItem,
},
NoSafeWorkingDirectory,
EnvTooLarge {
error: EnvTooLarge,
},
}
impl fmt::Display for UntrustedRefusal {
#[allow(
clippy::too_many_lines,
reason = "one message per refusal, and the refusals are the table"
)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotAllowed {
builtin: Some(builtin),
} => write!(
f,
"the workspace is untrusted and it may run workspace code ({})",
builtin.workspace_code()
),
Self::NotAllowed { builtin: None } => f.write_str(
"the workspace is untrusted and it is not a built-in server, so the workspace \
code it may run is unknown",
),
Self::WorkspaceExecutable { executable } => write!(
f,
"its executable {} lies inside the workspace, which untrusted mode never runs",
EchoedPath::new(executable)
),
Self::WorkspaceExecutableArgument { index, executable } => write!(
f,
"its argument at index {index}, {}, is an executable inside the workspace, \
which untrusted mode never runs",
EchoedPath::new(executable)
),
Self::UnreadableWorkspaceArgument { index, path } => write!(
f,
"its argument at index {index}, {}, lies inside the workspace and cannot be \
read, so untrusted mode cannot tell whether it is an executable",
EchoedPath::new(path)
),
Self::UnresolvedExecutable { command } => write!(
f,
"its executable '{command}' was not found on a PATH outside the workspace, \
which untrusted mode requires"
),
Self::AutoSelectionTarget { executable, cause } => write!(
f,
"its executable resolved to '{executable}' ({cause}), so untrusted mode \
cannot pin it; give the absolute path of the server as `command`"
),
Self::UnknownHome => f.write_str(
"HOME is not set and the login home directory is unknown, so untrusted mode \
cannot give it a safe one",
),
Self::EmptyHome { variable } => write!(
f,
"its {variable} is empty, which resolves inside the workspace, and the login \
home directory is unknown, so untrusted mode cannot give it a safe one"
),
Self::WorkspaceHome { variable, home } => write!(
f,
"its {variable}, {}, lies inside the workspace and the login home directory is \
unknown, so untrusted mode cannot give it a safe one",
EchoedPath::new(home)
),
Self::WorkspaceTsserver { tsserver } => write!(
f,
"the tsserver it would use, {}, lies inside the workspace, which untrusted \
mode never runs",
EchoedPath::new(tsserver)
),
Self::ProjectLauncher { cause, .. } => cause.fmt(f),
Self::UnresolvedWrappedProgram { program } => write!(
f,
"the program '{program}' its launcher starts was not found where the child \
would look it up, which untrusted mode requires"
),
Self::WrappedProgramPathContainsEquals { program } => write!(
f,
"the program '{program}' its launcher starts resolves to a path containing \
'=', which env reads as an assignment, so untrusted mode cannot pin it; give \
a program whose path has no '='"
),
Self::UnpinnedTypescriptLauncher { command } => write!(
f,
"its launcher '{command}' starts the TypeScript server in a way untrusted \
mode cannot pin to a binary outside the workspace; install the server \
globally and give its absolute path as `command`"
),
Self::NonUtf8Path { what, path } => write!(
f,
"its {what}, {}, is not valid UTF-8, so untrusted mode cannot pass it on",
EchoedPath::new(path)
),
Self::BlankResolvedPath { what } => write!(
f,
"its {what} resolved to a blank path, which cannot be started"
),
Self::NulInResolvedPath { what } => {
write!(
f,
"its {what} resolved to a path containing NUL, which cannot be passed on"
)
}
Self::NoSafeWorkingDirectory => f.write_str(
"no directory outside the workspace is available to start it in, which \
untrusted mode requires",
),
Self::EnvTooLarge { error } => write!(
f,
"the variables untrusted mode sets do not fit in its environment: {error}"
),
}
}
}
impl fmt::Display for StartupFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spawn(error) => ErrorChain(error.as_ref()).fmt(f),
Self::InitTaskPanicked => {
f.write_str("the initialization task panicked (see the mcpls log)")
}
Self::RefusedUntrustedWorkspace(refusal) => refusal.fmt(f),
}
}
}
#[derive(Debug, Clone)]
pub struct ServerSpawnFailure {
pub server_id: ServerId,
pub language_id: LanguageId,
pub command: ServerCommand,
pub reason: StartupFailure,
}
impl fmt::Display for ServerSpawnFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} [{}] ({}): {}",
self.server_id, self.language_id, self.command, self.reason
)
}
}
impl ServerSpawnFailure {
pub(crate) const fn failed_to_start(&self) -> FailedToStart<'_> {
FailedToStart(self)
}
}
pub(crate) struct FailedToStart<'a>(&'a ServerSpawnFailure);
impl fmt::Display for FailedToStart<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let failure = self.0;
let (id, language) = (&failure.server_id, &failure.language_id);
match &failure.reason {
StartupFailure::RefusedUntrustedWorkspace(refusal) => {
write!(
f,
"LSP server '{id}' for language '{language}' was not started: {refusal}"
)?;
match refusal {
UntrustedRefusal::NotAllowed { .. } => {
write!(f, "; restart mcpls with `--allow-server {id}` to start it")
}
UntrustedRefusal::WorkspaceExecutable { .. }
| UntrustedRefusal::WorkspaceExecutableArgument { .. }
| UntrustedRefusal::UnreadableWorkspaceArgument { .. }
| UntrustedRefusal::UnresolvedExecutable { .. }
| UntrustedRefusal::AutoSelectionTarget { .. }
| UntrustedRefusal::WorkspaceHome { .. }
| UntrustedRefusal::EmptyHome { .. }
| UntrustedRefusal::UnknownHome
| UntrustedRefusal::WorkspaceTsserver { .. }
| UntrustedRefusal::ProjectLauncher { .. }
| UntrustedRefusal::UnresolvedWrappedProgram { .. }
| UntrustedRefusal::WrappedProgramPathContainsEquals { .. }
| UntrustedRefusal::UnpinnedTypescriptLauncher { .. }
| UntrustedRefusal::NonUtf8Path { .. }
| UntrustedRefusal::BlankResolvedPath { .. }
| UntrustedRefusal::NulInResolvedPath { .. }
| UntrustedRefusal::NoSafeWorkingDirectory
| UntrustedRefusal::EnvTooLarge { .. } => Ok(()),
}
}
reason @ (StartupFailure::Spawn(_) | StartupFailure::InitTaskPanicked) => write!(
f,
"LSP server '{id}' for language '{language}' failed to start: {reason}; restart \
mcpls after fixing it (startup failures are not retried)"
),
}
}
}
struct FailureList<'a>(&'a [ServerSpawnFailure]);
impl fmt::Display for FailureList<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, failure) in self.0.iter().enumerate() {
if index > 0 {
f.write_str("; ")?;
}
failure.fmt(f)?;
}
Ok(())
}
}
const MAX_DISPLAYED_CANDIDATES: usize = 10;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SymbolCandidate {
pub name: String,
pub kind: u32,
pub kind_name: String,
pub container: Option<String>,
pub line: u32,
pub character: u32,
}
impl fmt::Display for SymbolCandidate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} `{}`", self.kind_name, self.name)?;
if let Some(container) = &self.container {
write!(f, " in `{container}`")?;
}
write!(f, " at {}:{}", self.line, self.character)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "resolution", rename_all = "snake_case")]
pub enum SymbolResolutionData {
Ambiguous {
name: String,
candidates: Vec<SymbolCandidate>,
truncated: bool,
},
NotFound {
name: String,
excluded_by_filters: usize,
},
NotDefinedInFile {
name: String,
},
PositionUnverified {
name: String,
candidate: SymbolCandidate,
},
}
impl fmt::Display for SymbolResolutionData {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ambiguous {
name,
candidates,
truncated,
} => {
write!(
f,
"symbol `{name}` is ambiguous ({} candidates{}): ",
candidates.len(),
if *truncated { ", more not listed" } else { "" }
)?;
for (index, candidate) in
candidates.iter().take(MAX_DISPLAYED_CANDIDATES).enumerate()
{
if index > 0 {
f.write_str("; ")?;
}
candidate.fmt(f)?;
}
if candidates.len() > MAX_DISPLAYED_CANDIDATES {
f.write_str("; ...")?;
}
f.write_str(
". Narrow it with `symbol_kind` or `container`, or address it by line and character",
)
}
Self::NotFound {
name,
excluded_by_filters,
} => {
write!(f, "no symbol named `{name}` is defined in this file")?;
if *excluded_by_filters > 0 {
write!(
f,
" ({excluded_by_filters} with that name excluded by `symbol_kind`/`container`)"
)?;
}
f.write_str("; try `workspace_symbol_search` or address it by line and character")
}
Self::NotDefinedInFile { name } => write!(
f,
"`{name}` is not defined in this file (it only occurs as a reference or import); \
find its definition with `workspace_symbol_search` or address it by line and character"
),
Self::PositionUnverified { name, candidate } => write!(
f,
"could not verify where the identifier `{name}` is ({candidate}), so nothing was \
queried; address it by line and character"
),
}
}
}
struct IdList<'a>(&'a [ServerId]);
impl fmt::Display for IdList<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, id) in self.0.iter().enumerate() {
if index > 0 {
f.write_str(", ")?;
}
write!(f, "'{id}'")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StderrExcerpt {
body: ExcerptBody,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ExcerptBody {
Complete(Box<str>),
Elided { head: Box<str>, tail: Box<str> },
}
impl StderrExcerpt {
pub(crate) fn complete(bytes: &[u8], redactions: &Redactions) -> Option<Self> {
let text = clean_stderr(&String::from_utf8_lossy(bytes), redactions);
(!text.is_empty()).then(|| Self {
body: ExcerptBody::Complete(text.into()),
})
}
pub(crate) fn elided(head: &[u8], tail: &[u8], redactions: &Redactions) -> Option<Self> {
let head = clean_stderr(
&redactions.mask_cut_head(&decode_cut_head(head)),
redactions,
);
let tail_start = tail
.iter()
.position(|byte| byte & 0xC0 != 0x80)
.unwrap_or(tail.len());
let tail = clean_stderr(
&redactions.mask_cut_tail(&String::from_utf8_lossy(
tail.get(tail_start..).unwrap_or_default(),
)),
redactions,
);
match (head.is_empty(), tail.is_empty()) {
(true, true) => None,
(false, true) => Some(Self {
body: ExcerptBody::Complete(head.into()),
}),
(true, false) => Some(Self {
body: ExcerptBody::Complete(tail.into()),
}),
(false, false) => Some(Self {
body: ExcerptBody::Elided {
head: head.into(),
tail: tail.into(),
},
}),
}
}
#[must_use]
pub fn head(&self) -> &str {
match &self.body {
ExcerptBody::Complete(text) => text,
ExcerptBody::Elided { head, .. } => head,
}
}
#[must_use]
pub fn tail(&self) -> Option<&str> {
match &self.body {
ExcerptBody::Complete(_) => None,
ExcerptBody::Elided { tail, .. } => Some(tail),
}
}
#[must_use]
pub const fn is_elided(&self) -> bool {
matches!(self.body, ExcerptBody::Elided { .. })
}
}
impl fmt::Display for StderrExcerpt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fn visible_lines(text: &str) -> impl Iterator<Item = &str> {
text.lines().map(str::trim).filter(|line| !line.is_empty())
}
let mut parts: Vec<&str> = visible_lines(self.head()).collect();
if let Some(tail) = self.tail() {
parts.push("...");
parts.extend(visible_lines(tail));
}
f.write_str(&parts.join(" | "))
}
}
fn decode_cut_head(head: &[u8]) -> String {
match std::str::from_utf8(head) {
Ok(text) => text.to_owned(),
Err(e) if e.error_len().is_none() => {
String::from_utf8_lossy(head.get(..e.valid_up_to()).unwrap_or_default()).into_owned()
}
Err(_) => String::from_utf8_lossy(head).into_owned(),
}
}
fn clean_stderr(text: &str, redactions: &Redactions) -> String {
let filtered: String = redactions
.apply(text)
.chars()
.filter(|c| {
(!c.is_control() || matches!(c, '\n' | '\t'))
&& !crate::util::is_deceptive_format_char(*c)
})
.collect();
redactions.apply(&filtered).trim().to_owned()
}
struct StderrSuffix<'a>(&'a Option<StderrExcerpt>);
impl fmt::Display for StderrSuffix<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0
.as_ref()
.map_or(Ok(()), |excerpt| write!(f, "; stderr: {excerpt}"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InitFailureHint {
NativeTypescriptOnly,
}
impl fmt::Display for InitFailureHint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NativeTypescriptOnly => write!(
f,
"typescript-language-server found only TypeScript 7 or later, which ships no \
tsserver; install the TypeScript version the server supports next to it (for a \
global install: `{}`), or change the `command` of your existing `typescript` \
server entry to the absolute path of the `tsc` of a TypeScript 7 install \
outside the workspace, with `args = [\"--lsp\", \"--stdio\"]` (a `tsc` from the \
workspace, or a bare `tsc` that PATH may resolve into the workspace, is \
workspace-supplied code)",
BuiltinServer::TypescriptLanguageServer.install_hint()
),
}
}
}
struct HintSuffix<'a>(&'a Option<InitFailureHint>);
impl fmt::Display for HintSuffix<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.map_or(Ok(()), |hint| write!(f, "; {hint}"))
}
}
struct EarlyExitDetail<'a>(&'a str, Option<i32>);
impl fmt::Display for EarlyExitDetail<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self(command, exit_code) = *self;
match exit_code {
Some(code) => write!(f, " with exit code {code}")?,
None => f.write_str(" (terminated by a signal)")?,
}
BuiltinServer::from_command(command)
.and_then(BuiltinServer::early_exit_hint)
.map_or(Ok(()), |hint| write!(f, "; {hint}"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BackgroundTask {
McpService,
LspReceiver,
ListenResolution,
PathValidation,
FileOpen,
ServerPlanning,
IdleSessionReaper,
}
impl fmt::Display for BackgroundTask {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::McpService => "MCP service",
Self::LspReceiver => "LSP receiver",
Self::ListenResolution => "listen URI resolution",
Self::PathValidation => "path validation",
Self::FileOpen => "file open",
Self::ServerPlanning => "server start planning",
Self::IdleSessionReaper => "idle HTTP session reaper",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StdioStream {
Stdin,
Stdout,
Stderr,
}
impl fmt::Display for StdioStream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Stdin => "stdin",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("lsp_servers entry '{server}'")]
UnsupportedFilePattern {
server: ServerId,
#[source]
pattern: UnsupportedFilePattern,
},
#[error("config file is not valid UTF-8")]
NotUtf8(#[source] std::string::FromUtf8Error),
#[error("config file is {} bytes, over the fixed {} byte limit", .0.size, .0.max)]
FileTooLarge(SizeExceeded),
#[error("lsp_servers entry '{server}'")]
DuplicateEnvKey {
server: ServerId,
#[source]
error: DuplicateEnvKey,
},
#[error("lsp_servers entry '{server}': environment variable '{key}'")]
InvalidEnvValue {
server: ServerId,
key: EnvKey,
#[source]
error: InvalidEnvValue,
},
#[error("lsp_servers entry '{server}'")]
EnvTooLarge {
server: ServerId,
#[source]
error: EnvTooLarge,
},
#[error("lsp_servers entry '{server}'")]
ArgContainsNul {
server: ServerId,
#[source]
error: InvalidServerArgs,
},
#[error("configuration path has no parent directory: {}", .path.display())]
NoParentDirectory {
path: PathBuf,
},
#[error(
"selection = \"auto\" is only valid for typescript-language-server entries (language \
'{language}'); remove `selection` to use `command` as written"
)]
SelectionAutoOnNonTypescript {
language: LanguageId,
},
#[error(
"allowed server '{server}' is not a configured server (configured: {})",
IdList(configured)
)]
UnknownAllowedServer {
server: ServerId,
configured: Vec<ServerId>,
},
#[error(
"duplicate server id '{id}' in this workspace (used by both an entry with {first} and \
one with {second}); add a unique `name` to each `[[lsp_servers]]` entry"
)]
DuplicateServerId {
id: ServerId,
first: Box<EntrySummary>,
second: Box<EntrySummary>,
},
#[error(
"language '{language}' has two catch-all servers ('{existing}' and '{id}'); at most one \
server per language may omit `handles`"
)]
TwoCatchAllServers {
language: LanguageId,
existing: ServerId,
id: ServerId,
},
#[error("tool '{tool}' for language '{language}' is claimed by both '{existing}' and '{id}'")]
ToolClaimedTwice {
tool: ToolKind,
language: LanguageId,
existing: ServerId,
id: ServerId,
},
#[error(
"workspace root '{}' resolved relative to '{}' as '{}' could not be canonicalized",
.written.display(), .base_dir.display(), .probe.display()
)]
UnresolvableWorkspaceRoot {
written: PathBuf,
base_dir: PathBuf,
probe: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InitPhase {
Initialize,
Initialized,
DidChangeConfiguration,
}
impl fmt::Display for InitPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Initialize => "Initialize request failed",
Self::Initialized => "initialized notification failed",
Self::DidChangeConfiguration => "workspace/didChangeConfiguration notification failed",
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("LSP server initialization failed: {phase}: {}{}{}", ErrorChain(.cause.as_ref()), HintSuffix(.hint), StderrSuffix(.stderr))]
LspInitFailed {
phase: InitPhase,
cause: Box<Self>,
hint: Option<InitFailureHint>,
stderr: Option<StderrExcerpt>,
},
#[error("LSP server error: {} - {}", .0.code, sanitize_lsp_server_message(&.0.message))]
LspServerError(JsonRpcError),
#[error("failed to start MCP server")]
McpServerStart(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("{task} task failed")]
TaskFailed {
task: BackgroundTask,
#[source]
source: tokio::task::JoinError,
},
#[error("failed to capture LSP server {0}")]
StdioCapture(StdioStream),
#[error("failed to bind HTTP listener on {addr}")]
HttpBind {
addr: std::net::SocketAddr,
#[source]
source: std::io::Error,
},
#[error("document not found: {}", EchoedPath::new(.0))]
DocumentNotFound(PathBuf),
#[error(
"no LSP server configured for language: {language}{}",
no_server_detail(.language, .file, .patterns)
)]
NoServerForLanguage {
language: LanguageId,
file: FileKey,
patterns: Arc<[FilePattern]>,
},
#[error("no server handles tool '{tool}' for language '{language_id}'")]
NoServerForTool {
language_id: LanguageId,
tool: ToolKind,
},
#[error("{}", .0.failed_to_start())]
ServerFailedToStart(Box<ServerSpawnFailure>),
#[error(
"LSP server '{server_id}' is still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
)]
ServerInitializing {
server_id: ServerId,
},
#[error(
"LSP server '{server_id}' was restarted while this request was in flight; retry the request"
)]
ServerRestarted {
server_id: ServerId,
},
#[error("{0}")]
SymbolResolution(Box<SymbolResolutionData>),
#[error(
"unknown LSP server(s) {}; configured servers: {}",
IdList(unknown),
IdList(configured)
)]
UnknownServers {
unknown: Vec<ServerId>,
configured: Vec<ServerId>,
},
#[error(
"LSP servers are still initializing (large project load in progress); wait and retry the request (this may take a few minutes on large projects)"
)]
WorkspaceServersInitializing,
#[error("no LSP server configured")]
NoServerConfigured,
#[error("no server handles tool '{tool}' (no server's `handles` list or catch-all claims it)")]
NoServerForWorkspaceTool {
tool: ToolKind,
},
#[error("configuration file not found: {0}")]
ConfigNotFound(PathBuf),
#[error(
"config file {} lies inside the workspace or the current directory, which untrusted mode does not trust; move it elsewhere",
.path.display()
)]
ConfigInsideWorkspace {
path: PathBuf,
},
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("JSON error")]
Json(#[from] serde_json::Error),
#[error("{0}")]
TomlDe(TomlParseError),
#[error(transparent)]
TomlSer(#[from] toml::ser::Error),
#[error("request timed out after {0:?}")]
Timeout(Duration),
#[error("failed to spawn LSP server '{command}'")]
ServerSpawnFailed {
command: ServerCommand,
#[source]
source: std::io::Error,
},
#[error("failed to spawn LSP server '{command}': {io}{}", NotFoundGuidance(.command.as_str(), Platform::CURRENT))]
ServerNotFound {
command: ServerCommand,
io: std::io::Error,
},
#[error("LSP protocol error: {}", escape_control(.0.as_str()))]
LspProtocolError(RedactedText),
#[error("none of the requested resource URIs resolve inside the workspace")]
NoResolvableListenUris,
#[error(transparent)]
InvalidPositionInput(#[from] InvalidPosition),
#[error(transparent)]
InvalidRangeInput(#[from] InvalidRange),
#[error(transparent)]
InvalidHierarchyItemInput(#[from] InvalidHierarchyItem),
#[error(
"line {line} is beyond the end of the document (the document ends at line {last_line})"
)]
PositionBeyondDocument {
line: std::num::NonZeroU32,
last_line: std::num::NonZeroU32,
},
#[error(transparent)]
ResourceUri(#[from] ResourceUriError),
#[error("cannot convert path to URI: {}", EchoedPath::new(.0))]
PathToUri(PathBuf),
#[error("LSP server process terminated unexpectedly")]
ServerTerminated,
#[error("LSP server shutdown did not complete before its deadline")]
ShutdownTimeout,
#[error("LSP server '{command}' exited during initialization{}{}{}", EarlyExitDetail(.command.as_str(), *.exit_code), HintSuffix(.hint), StderrSuffix(.stderr))]
ServerExitedDuringInit {
command: ServerCommand,
exit_code: Option<i32>,
hint: Option<InitFailureHint>,
stderr: Option<StderrExcerpt>,
},
#[error(
"LSP server '{server_id}' is unavailable: crash-looping, retry in {:.1}s",
.retry_in.as_secs_f64()
)]
ServerUnavailable {
server_id: ServerId,
retry_in: Duration,
},
#[error("invalid tool parameters: {0}")]
InvalidToolParams(String),
#[error(transparent)]
InvalidClientPath(#[from] InvalidClientPath),
#[error("malformed file path {}", BoundedDebugPath(.path))]
MalformedPath {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("file I/O error for {}", BoundedDebugPath(.path))]
FileIo {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("path outside workspace: {}", EchoedPath::new(.0))]
PathOutsideWorkspace(PathBuf),
#[error("no workspace roots configured: refusing access to {}", EchoedPath::new(.0))]
NoWorkspaceRoots(PathBuf),
#[error(
"document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
)]
DocumentLimitExceeded {
current: usize,
max: std::num::NonZeroUsize,
},
#[error("subscription limit of {max} reached")]
SubscriptionLimitReached {
max: usize,
},
#[error("listen stream limit of {max} reached; retry once another stream closes")]
ListenStreamsExhausted {
max: usize,
},
#[error(
"subscriptions/listen request exceeds the limit of {max} resource URIs or their total size budget"
)]
ListenFilterTooLarge {
max: usize,
},
#[error(
"file size limit exceeded: {} bytes, max {} bytes (raise workspace.max_file_size in config to increase this)",
.0.size,
.0.max
)]
FileSizeLimitExceeded(SizeExceeded),
#[error("not a regular file: {}", EchoedPath::new(.0))]
NotARegularFile(PathBuf),
#[error("all LSP servers failed to initialize: {}", FailureList(failures))]
AllServersFailedToInit {
failures: Vec<ServerSpawnFailure>,
},
#[error("server '{server_id}' does not support capability '{capability}'")]
CapabilityNotSupported {
server_id: ServerId,
capability: Capability,
},
#[error(
"LSP server '{server_id}' is still indexing the workspace after {elapsed_secs}s; wait and retry the request"
)]
WorkspaceIndexing {
server_id: ServerId,
elapsed_secs: u64,
},
}
pub const WORKSPACE_INDEXING_ERROR_CODE: i32 = -32050;
pub const SERVER_INITIALIZING_ERROR_CODE: i32 = -32051;
pub const STATELESS_SUBSCRIPTION_ERROR_CODE: i32 = -32052;
pub const LISTEN_STREAMS_EXHAUSTED_ERROR_CODE: i32 = -32053;
pub const SERVER_RESTARTED_ERROR_CODE: i32 = -32054;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(untagged)]
pub enum RetryableErrorData {
WorkspaceIndexing {
server_id: ServerId,
elapsed_secs: u64,
},
ServerInitializing {
server_id: ServerId,
},
WorkspaceServersInitializing {},
ListenStreamsExhausted {
max_listen_streams: usize,
},
ServerRestarted {
server_id: ServerId,
},
}
impl RetryableErrorData {
#[must_use]
pub const fn code(&self) -> i32 {
match self {
Self::WorkspaceIndexing { .. } => WORKSPACE_INDEXING_ERROR_CODE,
Self::ServerInitializing { .. } | Self::WorkspaceServersInitializing {} => {
SERVER_INITIALIZING_ERROR_CODE
}
Self::ListenStreamsExhausted { .. } => LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
Self::ServerRestarted { .. } => SERVER_RESTARTED_ERROR_CODE,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum McpErrorKind {
InvalidParams,
InvalidPosition(RewrittenServerError),
SymbolResolution(SymbolResolutionData),
Retryable(RetryableErrorData),
Internal,
}
impl Error {
#[must_use]
pub fn mcp_error_kind(&self) -> McpErrorKind {
match self {
Self::InvalidToolParams(_)
| Self::UnknownServers { .. }
| Self::InvalidClientPath(_)
| Self::MalformedPath { .. }
| Self::PathOutsideWorkspace(_)
| Self::NotARegularFile(_)
| Self::NoResolvableListenUris
| Self::ResourceUri(_)
| Self::InvalidPositionInput(_)
| Self::InvalidRangeInput(_)
| Self::InvalidHierarchyItemInput(_)
| Self::PositionBeyondDocument { .. }
| Self::ListenFilterTooLarge { .. }
| Self::DocumentNotFound(_)
| Self::FileSizeLimitExceeded { .. } => McpErrorKind::InvalidParams,
Self::ListenStreamsExhausted { max } => {
McpErrorKind::Retryable(RetryableErrorData::ListenStreamsExhausted {
max_listen_streams: *max,
})
}
Self::FileIo { source, .. } => {
if source.kind() == std::io::ErrorKind::NotFound {
McpErrorKind::InvalidParams
} else {
McpErrorKind::Internal
}
}
Self::WorkspaceIndexing {
server_id,
elapsed_secs,
} => McpErrorKind::Retryable(RetryableErrorData::WorkspaceIndexing {
server_id: server_id.clone(),
elapsed_secs: *elapsed_secs,
}),
Self::ServerInitializing { server_id } => {
McpErrorKind::Retryable(RetryableErrorData::ServerInitializing {
server_id: server_id.clone(),
})
}
Self::SymbolResolution(data) => McpErrorKind::SymbolResolution((**data).clone()),
Self::ServerRestarted { server_id } => {
McpErrorKind::Retryable(RetryableErrorData::ServerRestarted {
server_id: server_id.clone(),
})
}
Self::WorkspaceServersInitializing => {
McpErrorKind::Retryable(RetryableErrorData::WorkspaceServersInitializing {})
}
Self::LspServerError(response) if is_invalid_offset(response) => {
McpErrorKind::InvalidPosition(RewrittenServerError::new(
response.code,
&response.message,
))
}
Self::LspInitFailed { .. }
| Self::LspServerError(..)
| Self::McpServerStart(_)
| Self::TaskFailed { .. }
| Self::StdioCapture(_)
| Self::PathToUri(_)
| Self::HttpBind { .. }
| Self::NoServerForLanguage { .. }
| Self::NoServerForTool { .. }
| Self::NoServerConfigured
| Self::NoServerForWorkspaceTool { .. }
| Self::ConfigNotFound(_)
| Self::ConfigInsideWorkspace { .. }
| Self::Config(_)
| Self::Io(_)
| Self::Json(_)
| Self::TomlDe(_)
| Self::TomlSer(_)
| Self::Timeout(_)
| Self::ServerSpawnFailed { .. }
| Self::ServerNotFound { .. }
| Self::LspProtocolError(_)
| Self::ServerTerminated
| Self::ShutdownTimeout
| Self::ServerUnavailable { .. }
| Self::ServerFailedToStart(_)
| Self::ServerExitedDuringInit { .. }
| Self::NoWorkspaceRoots(_)
| Self::DocumentLimitExceeded { .. }
| Self::SubscriptionLimitReached { .. }
| Self::AllServersFailedToInit { .. }
| Self::CapabilityNotSupported { .. } => McpErrorKind::Internal,
}
}
pub(crate) const fn is_unresolvable_resource(&self) -> bool {
match self {
Self::MalformedPath { .. }
| Self::FileIo { .. }
| Self::PathOutsideWorkspace(..)
| Self::NoWorkspaceRoots(..) => true,
Self::LspInitFailed { .. }
| Self::LspServerError(..)
| Self::McpServerStart(..)
| Self::TaskFailed { .. }
| Self::StdioCapture(..)
| Self::HttpBind { .. }
| Self::DocumentNotFound(..)
| Self::NoServerForLanguage { .. }
| Self::NoServerForTool { .. }
| Self::ServerFailedToStart(..)
| Self::ServerInitializing { .. }
| Self::ServerRestarted { .. }
| Self::SymbolResolution(..)
| Self::UnknownServers { .. }
| Self::WorkspaceServersInitializing
| Self::NoServerConfigured
| Self::NoServerForWorkspaceTool { .. }
| Self::ConfigNotFound(..)
| Self::ConfigInsideWorkspace { .. }
| Self::Config(..)
| Self::Io(..)
| Self::Json(..)
| Self::TomlDe(..)
| Self::TomlSer(..)
| Self::Timeout(..)
| Self::ServerSpawnFailed { .. }
| Self::ServerNotFound { .. }
| Self::LspProtocolError(..)
| Self::NoResolvableListenUris
| Self::InvalidPositionInput(..)
| Self::InvalidRangeInput(..)
| Self::InvalidHierarchyItemInput(..)
| Self::PositionBeyondDocument { .. }
| Self::ResourceUri(..)
| Self::PathToUri(..)
| Self::ServerTerminated
| Self::ShutdownTimeout
| Self::ServerExitedDuringInit { .. }
| Self::ServerUnavailable { .. }
| Self::InvalidToolParams(..)
| Self::InvalidClientPath(..)
| Self::DocumentLimitExceeded { .. }
| Self::SubscriptionLimitReached { .. }
| Self::ListenStreamsExhausted { .. }
| Self::ListenFilterTooLarge { .. }
| Self::FileSizeLimitExceeded { .. }
| Self::NotARegularFile(..)
| Self::AllServersFailedToInit { .. }
| Self::CapabilityNotSupported { .. }
| Self::WorkspaceIndexing { .. } => false,
}
}
}
#[derive(Debug, Clone)]
pub struct TomlParseError {
error: toml::de::Error,
line_column: Option<(usize, usize)>,
}
impl TomlParseError {
#[must_use]
pub fn new(error: toml::de::Error, source: &str) -> Self {
let line_column = error
.span()
.and_then(|span| source.get(..span.start))
.map(|before| {
let line = before.matches('\n').count().saturating_add(1);
let column = before
.rsplit('\n')
.next()
.map_or_default(|last| last.chars().count())
.saturating_add(1);
(line, column)
});
Self { error, line_column }
}
#[must_use]
pub fn message(&self) -> &str {
self.error.message()
}
}
impl fmt::Display for TomlParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.message())?;
if let Some((line, column)) = self.line_column {
write!(f, " (line {line}, column {column})")
} else if let Some(span) = self.error.span() {
write!(f, " (at bytes {}..{})", span.start, span.end)
} else {
Ok(())
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Copy)]
pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static));
impl fmt::Display for ErrorChain<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)?;
let mut cause = self.0.source();
while let Some(error) = cause {
write!(f, ": {error}")?;
cause = error.source();
}
Ok(())
}
}
#[derive(Debug)]
pub(crate) struct ChainedError<E>(pub(crate) E);
impl<E> From<E> for ChainedError<E> {
fn from(error: E) -> Self {
Self(error)
}
}
impl<E: std::error::Error + 'static> fmt::Display for ChainedError<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
ErrorChain(&self.0).fmt(f)
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
use crate::config::{FileExtension, FileName};
use crate::util::TRUNCATION_MARKER;
#[test]
fn test_all_servers_failed_to_init_error() {
let failures = vec![
ServerSpawnFailure {
server_id: ServerId::from_static("rust"),
language_id: LanguageId::from_static("rust"),
command: ServerCommand::from_static("rust-analyzer"),
reason: StartupFailure::InitTaskPanicked,
},
ServerSpawnFailure {
server_id: ServerId::from_static("python"),
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pyright"),
reason: StartupFailure::InitTaskPanicked,
},
];
let err = Error::AllServersFailedToInit { failures };
assert!(err.to_string().contains("all LSP servers failed"));
if let Error::AllServersFailedToInit { failures: f } = err {
assert_eq!(f.len(), 2);
assert_eq!(f[0].language_id, "rust");
assert_eq!(f[1].language_id, "python");
} else {
panic!("Expected AllServersFailedToInit error");
}
}
#[test]
fn test_server_spawn_failure_display_names_init_panic() {
let failure = ServerSpawnFailure {
server_id: ServerId::from_static("typescript"),
language_id: LanguageId::from_static("typescript"),
command: ServerCommand::from_static("tsserver"),
reason: StartupFailure::InitTaskPanicked,
};
let display = failure.to_string();
assert!(display.contains("typescript"));
assert!(display.contains("tsserver"));
assert!(display.contains("panicked"));
}
#[test]
fn test_ambiguity_display_lists_at_most_ten_candidates_and_the_hint() {
let candidates: Vec<SymbolCandidate> = (1..=12)
.map(|line| SymbolCandidate {
name: "new".to_string(),
kind: 6,
kind_name: "Method".to_string(),
container: Some("Foo".to_string()),
line,
character: 1,
})
.collect();
let text = SymbolResolutionData::Ambiguous {
name: "new".to_string(),
candidates,
truncated: false,
}
.to_string();
assert!(text.contains("12 candidates"), "{text}");
assert!(
text.contains("at 10:1") && !text.contains("at 11:1"),
"{text}"
);
assert!(text.contains("; ..."), "{text}");
assert!(text.contains("`symbol_kind` or `container`"), "{text}");
}
#[test]
fn test_error_display_lsp_init_failed() {
let err = Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::Io(std::io::Error::other("server not found"))),
hint: None,
stderr: None,
};
assert_eq!(
err.to_string(),
"LSP server initialization failed: Initialize request failed: server not found"
);
}
#[test]
fn test_stderr_excerpt_none_when_nothing_visible() {
assert_eq!(
StderrExcerpt::complete(b" \n\x1b\r\n ", &Redactions::default()),
None
);
assert_eq!(StderrExcerpt::complete(b"", &Redactions::default()), None);
}
#[test]
fn test_stderr_excerpt_strips_control_characters_but_keeps_newline_and_tab() {
let excerpt =
StderrExcerpt::complete(b"a\x1b[31mb\x00\r\nc\td", &Redactions::default()).unwrap();
assert_eq!(excerpt.head(), "a[31mb\nc\td");
}
#[test]
fn test_stderr_excerpt_display_is_single_line() {
let excerpt =
StderrExcerpt::complete(b"first\n\n second \nthird\n", &Redactions::default())
.unwrap();
assert_eq!(excerpt.to_string(), "first | second | third");
}
#[test]
fn test_stderr_excerpt_elided_display_marks_the_gap() {
let excerpt =
StderrExcerpt::elided(b"start\nbanner", b"last\nerror", &Redactions::default())
.unwrap();
assert!(excerpt.is_elided());
assert_eq!(excerpt.tail(), Some("last\nerror"));
assert_eq!(excerpt.to_string(), "start | banner | ... | last | error");
}
#[test]
fn test_stderr_excerpt_elided_drops_utf8_sequences_cut_by_the_split() {
let text = "é".as_bytes();
let head = [b"ok ".as_slice(), &text[..1]].concat();
let tail = [&text[1..], b" end".as_slice()].concat();
let excerpt = StderrExcerpt::elided(&head, &tail, &Redactions::default()).unwrap();
assert_eq!(excerpt.head(), "ok");
assert_eq!(excerpt.tail(), Some("end"));
}
fn secrets(pairs: &[(&str, &str)]) -> Redactions {
Redactions::new(
pairs
.iter()
.map(|(label, value)| ((*label).to_owned(), (*value).to_owned())),
)
}
#[test]
fn test_stderr_excerpt_redacts_secrets_with_their_label() {
let excerpt = StderrExcerpt::complete(
b"token=hunter2hunter2 id=abc",
&secrets(&[("API_TOKEN", "hunter2hunter2"), ("SHORT_KEY", "abc")]),
)
.unwrap();
assert_eq!(excerpt.head(), "token=[redacted:API_TOKEN] id=abc");
}
#[test]
fn test_stderr_excerpt_redacts_longest_overlapping_secret_whole() {
let excerpt = StderrExcerpt::complete(
b"v=abcdefghXYZ12345",
&secrets(&[("A_KEY", "abcdefgh"), ("B_KEY", "abcdefghXYZ12345")]),
)
.unwrap();
assert_eq!(excerpt.head(), "v=[redacted:B_KEY]");
}
#[test]
fn test_stderr_excerpt_keeps_non_secret_values_visible() {
let excerpt = StderrExcerpt::complete(
b"toolchain 'nightly-2024-01-01' is not installed",
&secrets(&[("API_TOKEN", "hunter2hunter2")]),
)
.unwrap();
assert_eq!(
excerpt.head(),
"toolchain 'nightly-2024-01-01' is not installed"
);
}
#[test]
fn test_stderr_excerpt_redacts_a_secret_split_by_a_control_character() {
let excerpt = StderrExcerpt::complete(
"tok=hunter2\u{200B}hunter2".as_bytes(),
&secrets(&[("API_TOKEN", "hunter2hunter2")]),
)
.unwrap();
assert_eq!(excerpt.head(), "tok=[redacted:API_TOKEN]");
}
#[test]
fn test_stderr_excerpt_masks_a_secret_split_across_the_elision_boundary() {
let redactions = secrets(&[("API_TOKEN", "supersecretvalue")]);
let excerpt =
StderrExcerpt::elided(b"token=supersec", b"retvalue done", &redactions).unwrap();
let shown = excerpt.to_string();
assert!(!shown.contains("supersec"), "{shown}");
assert!(!shown.contains("retvalue"), "{shown}");
assert!(shown.contains("[redacted:API_TOKEN]"), "{shown}");
}
#[test]
fn test_stderr_excerpt_strips_separators_bidi_and_zero_width_characters() {
let text = "a\u{2028}b\u{2029}c\u{202E}d\u{2066}e\u{200B}f\u{FEFF}g\u{200F}h";
let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
assert_eq!(excerpt.head(), "abcdefgh");
}
#[test]
fn test_escape_control_and_stderr_agree_on_deceptive_characters() {
let deceptive = [
'\u{061C}',
'\u{200B}',
'\u{200C}',
'\u{200D}',
'\u{200E}',
'\u{200F}',
'\u{2028}',
'\u{2029}',
'\u{202A}',
'\u{202B}',
'\u{202C}',
'\u{202D}',
'\u{202E}',
'\u{2060}',
'\u{2066}',
'\u{2067}',
'\u{2068}',
'\u{2069}',
'\u{FEFF}',
'\u{00AD}',
'\u{180E}',
'\u{2061}',
'\u{2064}',
'\u{206A}',
'\u{206F}',
'\u{FFF9}',
'\u{FFFB}',
'\u{E0000}',
'\u{E0041}',
'\u{E007F}',
];
for c in deceptive {
let text = format!("a{c}b");
let escaped = crate::util::escape_control(&text);
assert!(
!escaped.contains(c),
"escape_control kept U+{:04X}",
c as u32
);
let excerpt = StderrExcerpt::complete(text.as_bytes(), &Redactions::default()).unwrap();
assert_eq!(excerpt.head(), "ab", "stderr kept U+{:04X}", c as u32);
}
}
#[test]
fn test_init_errors_append_stderr_to_display() {
let stderr = StderrExcerpt::complete(b"fatal: bad config", &Redactions::default());
let failed = Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::Io(std::io::Error::other("boom"))),
hint: None,
stderr: stderr.clone(),
};
assert_eq!(
failed.to_string(),
"LSP server initialization failed: Initialize request failed: boom; stderr: fatal: bad config"
);
let exited = Error::ServerExitedDuringInit {
command: crate::config::ServerCommand::from_static("gopls"),
exit_code: Some(2),
hint: None,
stderr,
};
assert_eq!(
exited.to_string(),
"LSP server 'gopls' exited during initialization with exit code 2; stderr: fatal: bad config"
);
}
#[test]
fn test_error_display_lsp_server_error() {
let err = Error::LspServerError(JsonRpcError {
code: -32600,
message: "Invalid request".to_string(),
data: None,
});
assert_eq!(
err.to_string(),
"LSP server error: -32600 - Invalid request"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
let err = Error::LspServerError(JsonRpcError {
code: -32603,
message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
.to_string(),
data: None,
});
assert_eq!(
err.to_string(),
"LSP server error: -32603 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
let err = Error::LspServerError(JsonRpcError {
code: -32803,
message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
(line index length: 3)"
.to_string(),
data: None,
});
assert_eq!(
err.to_string(),
"LSP server error: -32803 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
let err = Error::LspServerError(JsonRpcError {
code: -32602,
message: "Invalid params: expected object".to_string(),
data: None,
});
assert_eq!(
err.to_string(),
"LSP server error: -32602 - Invalid params: expected object"
);
}
#[test]
fn test_error_display_document_not_found() {
let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
assert!(err.to_string().contains("document not found"));
assert!(err.to_string().contains("file.rs"));
}
#[test]
fn test_error_display_no_server_for_language() {
let err = no_server_for_language("rust");
assert_eq!(
err.to_string(),
"no LSP server configured for language: rust"
);
}
#[test]
fn test_error_display_workspace_servers_initializing() {
let err = Error::WorkspaceServersInitializing;
assert!(err.to_string().contains("still initializing"));
}
#[test]
fn test_error_display_no_server_for_workspace_tool() {
let err = Error::NoServerForWorkspaceTool {
tool: crate::config::ToolKind::WorkspaceSymbols,
};
assert!(err.to_string().contains("workspace_symbols"));
assert!(err.to_string().contains("no server's `handles` list"));
}
#[test]
fn test_error_display_timeout() {
assert_eq!(
Error::Timeout(Duration::from_secs(30)).to_string(),
"request timed out after 30s"
);
assert_eq!(
Error::Timeout(Duration::from_millis(500)).to_string(),
"request timed out after 500ms"
);
}
#[test]
fn test_error_display_document_limit() {
let err = Error::DocumentLimitExceeded {
current: 150,
max: std::num::NonZeroUsize::new(100).unwrap(),
};
assert_eq!(
err.to_string(),
"document limit exceeded: 150/100 (raise workspace.max_documents in config to increase this)"
);
}
#[test]
fn test_error_display_file_size_limit() {
let err = Error::FileSizeLimitExceeded(SizeExceeded {
size: 20_000_000,
max: std::num::NonZeroU64::new(10_000_000).unwrap(),
});
assert_eq!(
err.to_string(),
"file size limit exceeded: 20000000 bytes, max 10000000 bytes (raise workspace.max_file_size in config to increase this)"
);
}
#[test]
fn test_error_display_not_a_regular_file() {
let err = Error::NotARegularFile(PathBuf::from("/tmp/some.fifo"));
assert_eq!(err.to_string(), "not a regular file: /tmp/some.fifo");
}
#[test]
fn test_error_from_io() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err: Error = io_err.into();
assert_matches!(err, Error::Io(_));
}
#[test]
fn test_error_from_json() {
let json_str = "{invalid json}";
let json_err = serde_json::from_str::<serde_json::Value>(json_str).unwrap_err();
let err: Error = json_err.into();
assert_matches!(err, Error::Json(_));
}
fn assert_rendered_once(err: &(dyn std::error::Error + 'static), marker: &str) {
let text = ErrorChain(err).to_string();
assert_eq!(text.matches(marker).count(), 1, "{text}");
let segments: Vec<&str> = text.split(": ").collect();
let distinct: std::collections::HashSet<&str> = segments.iter().copied().collect();
assert_eq!(distinct.len(), segments.len(), "repeated segment in {text}");
}
fn io_sourced_cases() -> Vec<(Error, String)> {
let io = |marker: &str| std::io::Error::other(marker.to_owned());
let command = ServerCommand::from_static("rust-analyzer");
vec![
(
Error::HttpBind {
addr: std::net::SocketAddr::from(([127, 0, 0, 1], 1)),
source: io("bind-marker"),
},
"bind-marker".to_owned(),
),
(
Error::ServerSpawnFailed {
command: command.clone(),
source: io("spawn-marker"),
},
"spawn-marker".to_owned(),
),
(
Error::ServerNotFound {
command: command.clone(),
io: io("missing-marker"),
},
"missing-marker".to_owned(),
),
(
Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::ServerSpawnFailed {
command,
source: io("init-marker"),
}),
hint: None,
stderr: None,
},
"init-marker".to_owned(),
),
(
Error::from(ConfigError::UnresolvableWorkspaceRoot {
written: PathBuf::from("root"),
base_dir: PathBuf::from("base"),
probe: PathBuf::from("base/root"),
source: io("root-marker"),
}),
"root-marker".to_owned(),
),
(Error::from(io("io-marker")), "io-marker".to_owned()),
(
Error::FileIo {
path: PathBuf::from("a.rs"),
source: io("file-marker"),
},
"file-marker".to_owned(),
),
(
Error::MalformedPath {
path: PathBuf::from("b.rs"),
source: io("path-marker"),
},
"path-marker".to_owned(),
),
]
}
#[tokio::test]
async fn test_error_chain_renders_each_cause_exactly_once() {
let toml_text = "[invalid toml";
let toml_err = toml::from_str::<toml::Value>(toml_text).unwrap_err();
let json_err = serde_json::from_str::<serde_json::Value>("{bad").unwrap_err();
let json_text = json_err.to_string();
let join_error = tokio::spawn(async { panic!("task-marker") })
.await
.unwrap_err();
let url_err = url::Url::parse("http://[").unwrap_err();
let non_utf8 = String::from_utf8(vec![0xff]).unwrap_err();
let mut cases = io_sourced_cases();
cases.extend([
(
Error::TomlDe(TomlParseError::new(toml_err.clone(), toml_text)),
toml_err.message().to_owned(),
),
(Error::from(json_err), json_text),
(
Error::McpServerStart("mcp-marker".into()),
"mcp-marker".to_owned(),
),
(
Error::TaskFailed {
task: BackgroundTask::McpService,
source: join_error,
},
"task-marker".to_owned(),
),
(
Error::from(ConfigError::UnsupportedFilePattern {
server: ServerId::from_static("rust"),
pattern: FilePattern::parse("zz/yy").unwrap_err(),
}),
"'zz/yy'".to_owned(),
),
(
Error::from(ConfigError::NotUtf8(non_utf8.clone())),
non_utf8.to_string(),
),
(
Error::from(ResourceUriError::UrlParse(url_err)),
url_err.to_string(),
),
(
Error::from(ResourceUriError::ClientPath(InvalidClientPath::Empty)),
InvalidClientPath::Empty.to_string(),
),
]);
for (err, marker) in &cases {
assert_rendered_once(err, marker);
}
}
#[test]
fn test_startup_failure_spawn_renders_the_cause_once() {
let failure = StartupFailure::Spawn(Arc::new(Error::ServerSpawnFailed {
command: ServerCommand::from_static("rust-analyzer"),
source: std::io::Error::other("spawn-marker"),
}));
let text = failure.to_string();
assert_eq!(text.matches("spawn-marker").count(), 1, "{text}");
}
#[test]
fn test_echo_bound_docs_state_the_constants() {
let source = include_str!("error.rs");
for (bytes, doc_marker) in [
(MAX_ECHOED_NAME_BYTES, "pub struct EchoedArgument"),
(MAX_ECHOED_PATH_BYTES, "pub struct EchoedPath"),
] {
let before = source.split(doc_marker).next().unwrap();
let doc = before.rsplit("\n\n").next().unwrap();
assert!(
doc.contains(&format!("{bytes} bytes")),
"{doc_marker}: {doc}"
);
}
let debug_doc = source
.split("struct BoundedDebugPath")
.next()
.and_then(|before| before.rsplit("\n\n").next())
.unwrap();
assert!(
debug_doc.contains(&format!("{MAX_ECHOED_PATH_BYTES} bytes")),
"{debug_doc}"
);
}
fn debug_of(path: &str) -> String {
BoundedDebugPath(Path::new(path)).to_string()
}
#[test]
fn test_bounded_debug_path_cuts_at_the_bound() {
let at_bound = "a".repeat(MAX_ECHOED_PATH_BYTES - 2);
assert_eq!(debug_of(&at_bound), format!("{:?}", Path::new(&at_bound)));
assert!(!debug_of(&at_bound).ends_with(TRUNCATION_MARKER));
let over = "a".repeat(MAX_ECHOED_PATH_BYTES - 1);
let cut = debug_of(&over);
assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
assert_eq!(cut.len(), MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
let straddling = "é".repeat(MAX_ECHOED_PATH_BYTES);
let cut = debug_of(&straddling);
assert!(cut.ends_with(TRUNCATION_MARKER), "{cut}");
assert!(cut.len() <= MAX_ECHOED_PATH_BYTES + TRUNCATION_MARKER.len());
let honest = debug_of("/ws/quote\"d\\ name.rs");
assert_eq!(honest, format!("{:?}", Path::new("/ws/quote\"d\\ name.rs")));
}
#[test]
fn test_bounded_debug_path_never_splits_an_escape() {
for prefix in 0..8 {
let text = format!(
"{}{}",
"a".repeat(prefix),
"\u{1b}".repeat(MAX_ECHOED_PATH_BYTES)
);
let cut = debug_of(&text);
let body = cut.strip_suffix(TRUNCATION_MARKER).expect("cut");
let tail = body.rsplit('\\').next().unwrap_or_default();
assert!(
tail.is_empty() || (tail.starts_with("u{1b}") && tail == "u{1b}"),
"prefix {prefix}: ends with {tail:?}"
);
assert!(body.len() <= MAX_ECHOED_PATH_BYTES, "{}", body.len());
}
}
#[test]
fn test_client_text_echoes_stay_bounded() {
let huge = "Z".repeat(1 << 20);
let path = PathBuf::from(format!("/{huge}"));
let io = || std::io::Error::other("cause");
let errors = [
Error::PathOutsideWorkspace(path.clone()),
Error::NoWorkspaceRoots(path.clone()),
Error::NotARegularFile(path.clone()),
Error::DocumentNotFound(path.clone()),
Error::PathToUri(path.clone()),
Error::FileIo {
path: path.clone(),
source: io(),
},
Error::MalformedPath {
path: path.clone(),
source: io(),
},
Error::from(ResourceUriError::InvalidPath(huge.clone())),
Error::from(ResourceUriError::InvalidScheme(huge.clone())),
Error::from(ResourceUriError::NotAFilePath(huge)),
];
for error in &errors {
let text = ErrorChain(error).to_string();
let len = text.len();
assert!(len <= 2 * MAX_ECHOED_PATH_BYTES, "{len} bytes: {text:.80}");
assert!(text.contains(TRUNCATION_MARKER), "{text:.80}");
}
assert_matches!(&errors[0], Error::PathOutsideWorkspace(stored) if *stored == path);
}
#[test]
fn test_path_echo_variants_cut_one_byte_over_the_bound() {
let at_bound = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES));
let text = Error::PathOutsideWorkspace(at_bound).to_string();
assert!(!text.contains(TRUNCATION_MARKER), "{text:.80}");
let over = PathBuf::from("a".repeat(MAX_ECHOED_PATH_BYTES + 1));
let text = Error::NotARegularFile(over).to_string();
assert!(text.ends_with(TRUNCATION_MARKER), "{text:.80}");
let controls = PathBuf::from("\u{1b}".repeat(MAX_ECHOED_PATH_BYTES));
let text = Error::NoWorkspaceRoots(controls).to_string();
assert!(text.len() <= 2 * MAX_ECHOED_PATH_BYTES, "{}", text.len());
}
#[test]
fn test_document_not_found_and_path_to_uri_escape_control_characters() {
let path = || PathBuf::from("/a\nERROR forged\u{1b}[31m");
for error in [Error::DocumentNotFound(path()), Error::PathToUri(path())] {
let text = error.to_string();
assert!(!text.contains('\n') && !text.contains('\u{1b}'), "{text}");
}
}
#[test]
fn test_error_chain_joins_causes_once() {
let not_found = || std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let file_io = Error::FileIo {
path: PathBuf::from("a.rs"),
source: not_found(),
};
let spawn = Error::ServerSpawnFailed {
command: crate::config::ServerCommand::new("x").unwrap(),
source: not_found(),
};
assert_eq!(
ErrorChain(&file_io).to_string(),
"file I/O error for \"a.rs\": gone"
);
assert_eq!(
ErrorChain(&spawn).to_string(),
"failed to spawn LSP server 'x': gone"
);
}
#[test]
fn test_toml_parse_error_reports_line_and_column_without_the_line() {
let text = "a = 1\nsecret = \"sk-SECRET\" bad\n";
let error = toml::from_str::<toml::Value>(text).unwrap_err();
let rendered = TomlParseError::new(error, text).to_string();
assert!(rendered.contains("(line 2, column "), "{rendered}");
assert!(!rendered.contains("sk-SECRET"), "{rendered}");
}
#[test]
fn test_toml_error_never_quotes_an_env_secret() {
let text = "lsp_servers = [{ language_id = \"rust\", command = \"ra\", \
env = { API_TOKEN = \"sk-SECRET\\u0000\" } }]";
let parse_error = toml::from_str::<crate::config::ServerConfig>(text).unwrap_err();
let err = Error::TomlDe(TomlParseError::new(parse_error, text));
let rendered = ErrorChain(&err).to_string();
assert!(!rendered.contains("sk-SECRET"), "{rendered}");
assert!(
rendered.contains("environment variable 'API_TOKEN'"),
"{rendered}"
);
assert!(rendered.contains("cannot contain NUL"), "{rendered}");
}
#[test]
fn test_result_type_alias() {
fn _returns_error() -> Result<i32> {
Err(Error::Config(ConfigError::NoParentDirectory {
path: PathBuf::new(),
}))
}
let result: Result<i32> = Ok(42);
assert!(result.is_ok());
if let Ok(value) = result {
assert_eq!(value, 42);
}
}
fn not_found(command: &str) -> Error {
Error::ServerNotFound {
command: crate::config::ServerCommand::new(command).unwrap(),
io: std::io::Error::from(std::io::ErrorKind::NotFound),
}
}
#[test]
fn test_server_not_found_bare_builtin_has_path_and_install_hint() {
let msg = not_found("rust-analyzer").to_string();
assert!(msg.contains("not on the PATH"), "{msg}");
assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
}
#[test]
fn test_server_not_found_bare_unknown_has_no_install_hint() {
let msg = not_found("my-custom-lsp").to_string();
assert!(msg.contains("not on the PATH"), "{msg}");
assert!(!msg.contains("install it"), "{msg}");
assert!(!msg.contains(".cmd"), "{msg}");
}
#[test]
fn test_server_not_found_path_command_has_no_path_text() {
let msg = not_found("/nonexistent/rust-analyzer").to_string();
assert!(msg.contains("configured path exists"), "{msg}");
assert!(!msg.contains("PATH"), "{msg}");
assert!(!msg.contains("install it"), "{msg}");
}
#[test]
fn test_lifecycle_errors_map_to_internal() {
for err in [Error::ShutdownTimeout, not_found("x")] {
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
}
}
#[test]
fn test_not_found_guidance_cmd_note_only_for_npm_builtins_on_windows() {
let cases = [
("pyright-langserver", Platform::Windows, true),
("typescript-language-server", Platform::Windows, true),
("pyright-langserver", Platform::Other, false),
("rust-analyzer", Platform::Windows, false),
("gopls", Platform::Windows, false),
("my-custom-lsp", Platform::Windows, false),
];
for (command, platform, expects_cmd_note) in cases {
let msg = NotFoundGuidance(command, platform).to_string();
assert_eq!(
msg.contains(".cmd"),
expects_cmd_note,
"{command} {platform:?}: {msg}"
);
}
}
#[test]
fn test_error_source_chain() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let err = Error::ServerSpawnFailed {
command: crate::config::ServerCommand::from_static("rust-analyzer"),
source: io_err,
};
let source = std::error::Error::source(&err);
assert!(source.is_some());
}
fn spawn_failure(id: &str, command: &str, error: Error) -> ServerSpawnFailure {
ServerSpawnFailure {
server_id: ServerId::new(id).unwrap(),
language_id: LanguageId::new(id).unwrap(),
command: ServerCommand::new(command).unwrap(),
reason: StartupFailure::Spawn(Arc::new(error)),
}
}
#[test]
fn test_server_spawn_failure_display() {
let failure = spawn_failure(
"rust",
"rust-analyzer",
Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::Io(std::io::Error::other("boom"))),
hint: None,
stderr: None,
},
);
assert_eq!(
failure.to_string(),
"rust [rust] (rust-analyzer): LSP server initialization failed: Initialize request failed: boom"
);
}
#[test]
fn test_server_spawn_failure_clone_shares_reason() {
let failure = spawn_failure("python", "pyright", not_found("pyright"));
let cloned = failure.clone();
assert_eq!(failure.language_id, cloned.language_id);
assert_eq!(failure.to_string(), cloned.to_string());
}
#[test]
fn test_server_failed_to_start_display_carries_not_found_guidance() {
let err = Error::ServerFailedToStart(Box::new(spawn_failure(
"rust",
"rust-analyzer",
not_found("rust-analyzer"),
)));
let msg = err.to_string();
assert!(
msg.contains("'rust' for language 'rust' failed to start"),
"{msg}"
);
assert!(msg.contains("not on the PATH"), "{msg}");
assert!(msg.contains("rustup component add rust-analyzer"), "{msg}");
assert!(msg.contains("restart mcpls"), "{msg}");
}
#[test]
fn test_server_failed_to_start_init_task_panicked_display() {
let err = Error::ServerFailedToStart(Box::new(ServerSpawnFailure {
server_id: ServerId::from_static("rust"),
language_id: LanguageId::from_static("rust"),
command: ServerCommand::from_static("rust-analyzer"),
reason: StartupFailure::InitTaskPanicked,
}));
assert!(err.to_string().contains("initialization task panicked"));
}
#[test]
fn test_startup_errors_map_to_internal() {
let failure = spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer"));
for err in [
Error::ServerFailedToStart(Box::new(failure.clone())),
Error::AllServersFailedToInit {
failures: vec![failure],
},
Error::ServerExitedDuringInit {
command: crate::config::ServerCommand::from_static("x"),
exit_code: Some(1),
hint: None,
stderr: None,
},
Error::ServerUnavailable {
server_id: ServerId::from_static("rust"),
retry_in: Duration::from_secs(1),
},
] {
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal, "{err:?}");
}
}
#[test]
fn test_init_failure_hint_renders_between_detail_and_stderr() {
let stderr = StderrExcerpt::complete(b"boom", &Redactions::default());
let hint = Some(InitFailureHint::NativeTypescriptOnly);
let failed = Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::Io(std::io::Error::other("x"))),
hint,
stderr: stderr.clone(),
}
.to_string();
let exited = Error::ServerExitedDuringInit {
command: crate::config::ServerCommand::from_static("typescript-language-server"),
exit_code: Some(1),
hint,
stderr,
}
.to_string();
for text in [failed, exited] {
let hint_at = text.find("TypeScript 7").unwrap();
assert!(hint_at < text.find("; stderr: boom").unwrap(), "{text}");
assert!(
text.contains(BuiltinServer::TypescriptLanguageServer.install_hint()),
"{text}"
);
}
}
#[test]
fn test_server_exited_during_init_display_hints_only_for_rust_analyzer() {
let hinted = Error::ServerExitedDuringInit {
command: crate::config::ServerCommand::from_static("rust-analyzer"),
exit_code: Some(1),
hint: None,
stderr: None,
}
.to_string();
assert!(hinted.contains("exit code 1"), "{hinted}");
assert!(
hinted.contains("rustup component add rust-analyzer"),
"{hinted}"
);
let plain = Error::ServerExitedDuringInit {
command: crate::config::ServerCommand::from_static("gopls"),
exit_code: None,
hint: None,
stderr: None,
}
.to_string();
assert!(plain.contains("terminated by a signal"), "{plain}");
assert!(!plain.contains("rustup"), "{plain}");
}
#[test]
fn test_server_unavailable_display_names_retry_delay() {
let err = Error::ServerUnavailable {
server_id: ServerId::from_static("rust"),
retry_in: Duration::from_secs(2),
};
assert_eq!(
err.to_string(),
"LSP server 'rust' is unavailable: crash-looping, retry in 2.0s"
);
}
#[test]
fn test_error_display_all_servers_failed_lists_each_failure() {
let err = Error::AllServersFailedToInit {
failures: vec![
spawn_failure("rust", "rust-analyzer", not_found("rust-analyzer")),
spawn_failure(
"python",
"pyright",
Error::LspInitFailed {
phase: InitPhase::Initialize,
cause: Box::new(Error::Io(std::io::Error::other("denied"))),
hint: None,
stderr: None,
},
),
],
};
let msg = err.to_string();
assert!(
msg.starts_with("all LSP servers failed to initialize: "),
"{msg}"
);
assert!(msg.contains("rust [rust] (rust-analyzer)"), "{msg}");
assert!(msg.contains("python [python] (pyright)"), "{msg}");
}
#[test]
fn test_untrusted_refusal_paths_are_escaped_and_bounded() {
let hostile = PathBuf::from(format!("/ws/evil{}", "\n\u{202e}".repeat(3000)));
for refusal in [
UntrustedRefusal::WorkspaceExecutable {
executable: hostile.clone(),
},
UntrustedRefusal::WorkspaceTsserver {
tsserver: hostile.clone(),
},
UntrustedRefusal::WorkspaceHome {
variable: HomeVariable::Home,
home: hostile.clone(),
},
UntrustedRefusal::NonUtf8Path {
what: ResolvedItem::Executable,
path: hostile,
},
] {
let text = refusal.to_string();
assert!(!text.contains('\n'), "{text}");
assert!(!text.contains('\u{202e}'), "{text}");
assert!(text.len() < MAX_ECHOED_PATH_BYTES + 512, "{}", text.len());
}
let name = EchoedArgument::name(&"\u{202e}".repeat(5000));
assert!(name.as_str().len() < MAX_ECHOED_NAME_BYTES + 64);
}
#[test]
fn test_untrusted_refusal_new_variants_name_their_cause() {
let launcher = UntrustedRefusal::ProjectLauncher {
command: ServerCommand::from_static("npx"),
cause: LauncherRefusal::SelectsWorkspaceCode {
program: EchoedPath::program("npx"),
trigger: LaunchTrigger::Always,
},
};
assert!(launcher.to_string().contains("'npx'"), "{launcher}");
let unpinned = UntrustedRefusal::UnpinnedTypescriptLauncher {
command: ServerCommand::from_static("pnpm"),
};
assert!(unpinned.to_string().contains("'pnpm'"), "{unpinned}");
let non_utf8 = UntrustedRefusal::NonUtf8Path {
what: ResolvedItem::SearchPath,
path: PathBuf::from("/odd"),
};
assert_eq!(
non_utf8.to_string(),
"its PATH, /odd, is not valid UTF-8, so untrusted mode cannot pass it on"
);
assert!(
UntrustedRefusal::NoSafeWorkingDirectory
.to_string()
.contains("outside the workspace")
);
}
fn no_server_for_language(language: &'static str) -> Error {
Error::NoServerForLanguage {
language: LanguageId::from_static(language),
file: FileKey::Unmappable,
patterns: Arc::default(),
}
}
#[test]
fn test_no_server_for_plaintext_names_extension_and_patterns() {
let err = Error::NoServerForLanguage {
language: LanguageId::PLAINTEXT,
file: FileKey::Extension(FileExtension::from_static("cpp")),
patterns: Arc::from([
FilePattern::from_static("**/*.rs"),
FilePattern::from_static("**/*.h"),
]),
};
let message = err.to_string();
assert!(message.starts_with("no LSP server configured for language: plaintext ("));
assert!(
message.contains("file extension 'cpp' is not mapped"),
"{message}"
);
assert!(
message.contains("configured file_patterns: **/*.rs, **/*.h"),
"{message}"
);
}
#[test]
fn test_no_server_for_plaintext_without_patterns_or_extension() {
let err = Error::NoServerForLanguage {
language: LanguageId::PLAINTEXT,
file: FileKey::Unmappable,
patterns: Arc::default(),
};
let message = err.to_string();
assert!(
message.contains("the file has no usable extension or name"),
"{message}"
);
assert!(
message.contains("no file_patterns are configured"),
"{message}"
);
}
#[test]
fn test_no_server_for_other_language_text_is_unchanged() {
let err = Error::NoServerForLanguage {
language: LanguageId::from_static("nushell"),
file: FileKey::Extension(FileExtension::from_static("nu")),
patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
};
assert_eq!(
err.to_string(),
"no LSP server configured for language: nushell"
);
}
#[test]
fn test_no_server_for_plaintext_name_suggests_a_name_pattern() {
let err = Error::NoServerForLanguage {
language: LanguageId::PLAINTEXT,
file: FileKey::Name(FileName::from_static("Makefile")),
patterns: Arc::from([FilePattern::from_static("**/*.rs")]),
};
let message = err.to_string();
assert!(
message.contains("file name 'Makefile' is not mapped"),
"{message}"
);
assert!(
message.contains("a `**/Makefile` file_patterns entry"),
"{message}"
);
assert!(!message.contains("*.EXT"), "{message}");
}
#[test]
fn test_error_display_capability_not_supported() {
let err = Error::CapabilityNotSupported {
server_id: ServerId::from_static("rust"),
capability: Capability::Rename,
};
assert_eq!(
err.to_string(),
"server 'rust' does not support capability 'renameProvider'"
);
}
#[test]
fn test_error_display_workspace_indexing() {
let err = Error::WorkspaceIndexing {
server_id: ServerId::from_static("rust"),
elapsed_secs: 30,
};
assert_eq!(
err.to_string(),
"LSP server 'rust' is still indexing the workspace after 30s; wait and retry the request"
);
}
#[test]
fn test_mcp_error_kind_caller_fault_variants_are_invalid_params() {
let caller_fault_errors = vec![
Error::InvalidToolParams("bad params".to_string()),
Error::PathOutsideWorkspace(PathBuf::from("/etc/passwd")),
Error::NotARegularFile(PathBuf::from("/dev/null")),
Error::NoResolvableListenUris,
Error::ResourceUri(ResourceUriError::InvalidScheme("x".to_string())),
Error::DocumentNotFound(PathBuf::from("/missing.rs")),
Error::FileSizeLimitExceeded(SizeExceeded {
size: 100,
max: std::num::NonZeroU64::new(10).unwrap(),
}),
Error::ListenFilterTooLarge { max: 1000 },
Error::InvalidClientPath(InvalidClientPath::ContainsNul),
];
for err in caller_fault_errors {
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::InvalidParams,
"expected {err:?} to classify as InvalidParams"
);
}
}
#[tokio::test]
async fn test_mcp_error_kind_internal_failures_are_internal() {
let join_error = tokio::spawn(async { panic!("boom") }).await.unwrap_err();
let internal_errors = vec![
Error::TaskFailed {
task: BackgroundTask::McpService,
source: join_error,
},
Error::StdioCapture(StdioStream::Stdin),
Error::PathToUri(PathBuf::from("/x")),
Error::McpServerStart("handshake".into()),
];
for err in internal_errors {
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::Internal,
"expected {err:?} to classify as Internal"
);
}
}
#[tokio::test]
async fn test_task_failed_keeps_the_panic_payload_as_source() {
let join_error = tokio::spawn(async { panic!("secret boom") })
.await
.unwrap_err();
let err = Error::TaskFailed {
task: BackgroundTask::LspReceiver,
source: join_error,
};
let source = std::error::Error::source(&err).unwrap();
assert!(source.to_string().contains("secret boom"), "{source}");
assert!(err.to_string().starts_with("LSP receiver task failed"));
}
#[test]
fn test_mcp_error_kind_workspace_indexing_is_retryable_with_dedicated_code() {
let err = Error::WorkspaceIndexing {
server_id: ServerId::from_static("rust"),
elapsed_secs: 30,
};
let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
panic!("expected WorkspaceIndexing to classify as Retryable");
};
assert_eq!(data.code(), WORKSPACE_INDEXING_ERROR_CODE);
assert_eq!(
serde_json::to_value(&data).unwrap(),
serde_json::json!({"server_id": "rust", "elapsed_secs": 30})
);
}
#[test]
fn test_mcp_error_kind_server_initializing_is_retryable_with_dedicated_code() {
let err = Error::ServerInitializing {
server_id: ServerId::from_static("python"),
};
let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
panic!("expected ServerInitializing to classify as Retryable");
};
assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
assert_eq!(
serde_json::to_value(&data).unwrap(),
serde_json::json!({"server_id": "python"})
);
assert_ne!(
data.code(),
WORKSPACE_INDEXING_ERROR_CODE,
"ServerInitializing must be distinguishable on the wire from WorkspaceIndexing"
);
}
#[test]
fn test_mcp_error_kind_workspace_servers_initializing_is_retryable() {
let err = Error::WorkspaceServersInitializing;
let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
panic!("expected WorkspaceServersInitializing to classify as Retryable");
};
assert_eq!(data.code(), SERVER_INITIALIZING_ERROR_CODE);
assert_eq!(
serde_json::to_value(&data).unwrap(),
serde_json::json!({}),
"the empty struct variant must serialize as `{{}}`, not `null`"
);
}
#[test]
fn test_retryable_error_data_codes_and_snake_case_keys() {
let cases = [
(
RetryableErrorData::WorkspaceIndexing {
server_id: ServerId::from_static("rust"),
elapsed_secs: 7,
},
WORKSPACE_INDEXING_ERROR_CODE,
serde_json::json!({"server_id": "rust", "elapsed_secs": 7}),
),
(
RetryableErrorData::ServerInitializing {
server_id: ServerId::from_static("python"),
},
SERVER_INITIALIZING_ERROR_CODE,
serde_json::json!({"server_id": "python"}),
),
(
RetryableErrorData::WorkspaceServersInitializing {},
SERVER_INITIALIZING_ERROR_CODE,
serde_json::json!({}),
),
(
RetryableErrorData::ListenStreamsExhausted {
max_listen_streams: 100,
},
LISTEN_STREAMS_EXHAUSTED_ERROR_CODE,
serde_json::json!({"max_listen_streams": 100}),
),
];
for (data, code, wire) in cases {
assert_eq!(data.code(), code);
assert_eq!(serde_json::to_value(&data).unwrap(), wire);
}
}
#[test]
fn test_mcp_error_kind_listen_streams_exhausted_is_retryable() {
let err = Error::ListenStreamsExhausted { max: 100 };
let McpErrorKind::Retryable(data) = err.mcp_error_kind() else {
panic!("expected ListenStreamsExhausted to classify as Retryable");
};
assert_eq!(data.code(), LISTEN_STREAMS_EXHAUSTED_ERROR_CODE);
assert_eq!(
data,
RetryableErrorData::ListenStreamsExhausted {
max_listen_streams: 100
}
);
}
#[test]
fn test_mcp_error_kind_unretained_variants_stay_internal() {
let internal_errors = vec![
no_server_for_language("python"),
Error::NoServerForTool {
language_id: LanguageId::from_static("rust"),
tool: crate::config::ToolKind::Hover,
},
Error::CapabilityNotSupported {
server_id: ServerId::from_static("rust"),
capability: Capability::Rename,
},
Error::NoWorkspaceRoots(PathBuf::from("/tmp")),
Error::DocumentLimitExceeded {
current: 150,
max: std::num::NonZeroUsize::new(100).unwrap(),
},
Error::SubscriptionLimitReached { max: 1000 },
];
for err in internal_errors {
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::Internal,
"expected {err:?} to classify as Internal"
);
}
}
#[test]
fn test_mcp_error_kind_file_io_not_found_is_invalid_params() {
let err = Error::FileIo {
path: PathBuf::from("/no/such/file.rs"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file or directory"),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
}
#[test]
fn test_position_beyond_document_is_invalid_params_naming_the_line() {
let err = Error::PositionBeyondDocument {
line: std::num::NonZeroU32::new(7).unwrap(),
last_line: std::num::NonZeroU32::new(3).unwrap(),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::InvalidParams);
assert_eq!(
err.to_string(),
"line 7 is beyond the end of the document (the document ends at line 3)"
);
}
#[test]
fn test_mcp_error_kind_malformed_path_is_invalid_params() {
use std::io::ErrorKind;
for kind in [
ErrorKind::NotADirectory,
ErrorKind::InvalidFilename,
ErrorKind::InvalidInput,
] {
let err = Error::MalformedPath {
path: PathBuf::from("/ws/main.rs/x"),
source: std::io::Error::new(kind, "bad path"),
};
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::InvalidParams,
"{kind:?}"
);
}
}
#[test]
fn test_mcp_error_kind_post_validation_file_io_invalid_input_stays_internal() {
let err = Error::FileIo {
path: PathBuf::from("/ws/main.rs"),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad input"),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_mcp_error_kind_file_io_other_kind_stays_internal() {
let err = Error::FileIo {
path: PathBuf::from("/root/secret.rs"),
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
};
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_mcp_error_kind_lsp_server_error_invalid_offset_is_invalid_params() {
let err = Error::LspServerError(JsonRpcError {
code: -32603,
message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
.to_string(),
data: None,
});
assert_eq!(
err.mcp_error_kind(),
McpErrorKind::InvalidPosition(RewrittenServerError::new(
-32603,
"Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
))
);
}
#[test]
fn test_display_escapes_control_characters_from_the_server() {
let server_error = Error::LspServerError(JsonRpcError {
code: -32603,
message: "boom\nERROR forged\x1b[31m".to_string(),
data: None,
});
assert_eq!(
server_error.to_string(),
"LSP server error: -32603 - boom\\nERROR forged\\u{1b}[31m"
);
let protocol_error = Error::LspProtocolError(RedactedText::fixed("bad\nline"));
assert_eq!(protocol_error.to_string(), "LSP protocol error: bad\\nline");
}
#[test]
fn test_rewritten_server_error_bounds_raw_message() {
let raw =
RewrittenServerError::new(-32603, &"x".repeat(MAX_ERROR_MESSAGE_CALLER_BYTES * 2));
assert!(raw.raw_message().len() < MAX_ERROR_MESSAGE_CALLER_BYTES * 2);
assert!(raw.raw_message().ends_with("(truncated)"));
}
#[test]
fn test_mcp_error_kind_lsp_server_error_other_message_stays_internal() {
let err = Error::LspServerError(JsonRpcError {
code: -32603,
message: "internal error".to_string(),
data: None,
});
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_mcp_error_kind_subscription_limit_reached_stays_internal() {
let err = Error::SubscriptionLimitReached { max: 1000 };
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
fn refusal_failure(refusal: UntrustedRefusal) -> ServerSpawnFailure {
ServerSpawnFailure {
server_id: ServerId::from_static("rust"),
language_id: LanguageId::new("rust").unwrap(),
command: ServerCommand::from_static("rust-analyzer"),
reason: StartupFailure::RefusedUntrustedWorkspace(refusal),
}
}
#[test]
fn test_not_allowed_refusal_names_the_server_class_and_the_remedy() {
let err =
Error::ServerFailedToStart(Box::new(refusal_failure(UntrustedRefusal::NotAllowed {
builtin: Some(BuiltinServer::RustAnalyzer),
})));
assert_eq!(
err.to_string(),
"LSP server 'rust' for language 'rust' was not started: the workspace is untrusted \
and it may run workspace code (Cargo build scripts and procedural macros); restart \
mcpls with `--allow-server rust` to start it"
);
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn test_workspace_executable_refusal_offers_no_allow_remedy() {
let err = Error::ServerFailedToStart(Box::new(refusal_failure(
UntrustedRefusal::WorkspaceExecutable {
executable: PathBuf::from("/ws/bin/rust-analyzer"),
},
)));
let text = err.to_string();
assert!(
text.contains("its executable /ws/bin/rust-analyzer lies inside the workspace"),
"{text}"
);
assert!(!text.contains("--allow-server"), "{text}");
assert!(!text.contains("not retried"), "{text}");
}
#[test]
fn test_custom_server_refusal_says_its_workspace_code_is_unknown() {
let text = UntrustedRefusal::NotAllowed { builtin: None }.to_string();
assert!(text.contains("not a built-in server"), "{text}");
}
#[test]
fn test_config_inside_workspace_is_a_startup_error_of_kind_internal() {
let err = Error::ConfigInsideWorkspace {
path: PathBuf::from("/ws/mcpls.toml"),
};
assert!(err.to_string().contains("/ws/mcpls.toml"));
assert_eq!(err.mcp_error_kind(), McpErrorKind::Internal);
}
#[test]
fn echoed_path_keeps_spaces_and_equals() {
let path = EchoedPath::new(Path::new("/tmp/out/no such dir/a=b/srv"));
assert_eq!(path.as_str(), "/tmp/out/no such dir/a=b/srv");
}
#[test]
fn echoed_path_never_cuts_an_escape_in_half() {
let escape = "\\u{202e}";
let hostile = "\u{202e}".repeat(MAX_ECHOED_PATH_BYTES);
let echoed = EchoedPath::new(Path::new(&hostile));
let kept = echoed
.as_str()
.strip_suffix(TRUNCATION_MARKER)
.expect("an over-long path is marked as truncated");
assert!(kept.len() <= MAX_ECHOED_PATH_BYTES);
assert!(
!kept.is_empty() && kept.len().is_multiple_of(escape.len()),
"{kept}"
);
assert_eq!(kept, escape.repeat(kept.len() / escape.len()));
}
#[test]
fn echoed_program_cuts_an_assignment_but_keeps_a_path() {
assert_eq!(EchoedPath::program("API_TOKEN=abc").as_str(), "API_TOKEN");
assert_eq!(EchoedPath::program("X=/tmp/secret").as_str(), "X");
assert_eq!(
EchoedPath::program("/tmp/out/a=b/srv").as_str(),
"/tmp/out/a=b/srv"
);
assert_eq!(EchoedPath::program("no such dir").as_str(), "no such dir");
}
#[test]
fn shell_flags_display_the_prefix_they_are_spelled_with() {
assert_eq!(ShellFlag::PlusC.short_spelling(), Some(('+', 'c')));
assert_eq!(ShellFlag::PlusC.to_string(), "+c");
assert_eq!(ShellFlag::DashC.to_string(), "-c");
assert_eq!(ShellFlag::DashCapitalC.short_spelling(), Some(('-', 'C')));
let long = ShellFlag::LongCommand(LongCommandName::Command);
assert_eq!(long.short_spelling(), None);
assert_eq!(long.to_string(), "--command");
}
#[test]
fn echoed_path_within_the_bound_is_not_marked() {
let path = "a".repeat(MAX_ECHOED_PATH_BYTES);
assert_eq!(EchoedPath::new(Path::new(&path)).as_str(), path);
}
#[test]
fn wrapped_program_refusals_name_the_program_its_launcher_starts() {
let program = EchoedPath::new(Path::new("/tmp/out/a=b"));
let text = UntrustedRefusal::WrappedProgramPathContainsEquals { program }.to_string();
assert!(text.contains("'/tmp/out/a=b'"), "{text}");
assert!(text.contains("the program '"), "{text}");
assert!(!text.contains("its launcher '/tmp"), "{text}");
}
}