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,
MAX_SYMBOL_NAME_BYTES,
};
use crate::config::{
BuiltinServer, DuplicateEnvKey, EntrySummary, FileKey, FilePattern, InvalidAutoSelection,
LanguageId, ServerCommand, ServerId, ToolKind, UnsupportedFilePattern,
};
pub use crate::redaction::RedactedText;
use crate::redaction::Redactions;
pub use crate::util::SizeExceeded;
use crate::util::{MAX_ERROR_MESSAGE_CALLER_BYTES, 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";
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)]
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)]
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_SYMBOL_NAME_BYTES))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
const MAX_ECHOED_PATH_BYTES: usize = 1024;
#[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
}
}
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)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunnerSubcommand {
Run,
X,
Task,
Eval,
Repl,
Tool,
Exec,
}
impl RunnerSubcommand {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Run => "run",
Self::X => "x",
Self::Task => "task",
Self::Eval => "eval",
Self::Repl => "repl",
Self::Tool => "tool",
Self::Exec => "exec",
}
}
}
impl fmt::Display for RunnerSubcommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LongCommandName {
Command,
Commands,
InitCommand,
Execute,
}
impl LongCommandName {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Command => "--command",
Self::Commands => "--commands",
Self::InitCommand => "--init-command",
Self::Execute => "--execute",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmdSwitch {
C,
K,
R,
}
impl CmdSwitch {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::C => "/c",
Self::K => "/k",
Self::R => "/r",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerShellParameter {
Command,
CommandWithArgs,
EncodedCommand,
}
impl PowerShellParameter {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Command => "-Command",
Self::CommandWithArgs => "-CommandWithArgs",
Self::EncodedCommand => "-EncodedCommand",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellFlag {
DashC,
DashCapitalC,
DashE,
LongCommand(LongCommandName),
SlashC(CmdSwitch),
PowerShell(PowerShellParameter),
}
impl fmt::Display for ShellFlag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::DashC => "-c",
Self::DashCapitalC => "-C",
Self::DashE => "-e",
Self::LongCommand(name) => name.as_str(),
Self::SlashC(switch) => switch.as_str(),
Self::PowerShell(parameter) => parameter.as_str(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InlineFlag {
Short(char),
Eval,
Print,
Import,
Loader,
ExperimentalLoader,
}
impl InlineFlag {
#[must_use]
pub const fn long_name(self) -> Option<&'static str> {
match self {
Self::Short(_) => None,
Self::Eval => Some("--eval"),
Self::Print => Some("--print"),
Self::Import => Some("--import"),
Self::Loader => Some("--loader"),
Self::ExperimentalLoader => Some("--experimental-loader"),
}
}
}
impl fmt::Display for InlineFlag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Short(letter) => write!(f, "-{letter}"),
Self::Eval | Self::Print | Self::Import | Self::Loader | Self::ExperimentalLoader => {
f.write_str(self.long_name().unwrap_or_default())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
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 LaunchTrigger {
Always,
Subcommand(RunnerSubcommand),
CommandString(ShellFlag),
InlineProgram(InlineFlag),
NpmSpecifier,
}
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::InlineProgram(flag) => write!(f, "`{flag}` gives it a program to run"),
Self::NpmSpecifier => f.write_str("an `npm:` argument names a package to run"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnanalyzableLaunch {
UnknownOption(EchoedArgument),
MissingValue(EchoedArgument),
MissingCommand,
MalformedOperand(OperandKind),
PathAssignment,
SplitString,
RelativeProgramAfterChdir,
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::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,
},
NoSafeWorkingDirectory,
}
impl fmt::Display for UntrustedRefusal {
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::NoSafeWorkingDirectory => f.write_str(
"no directory outside the workspace is available to start it in, which \
untrusted mode requires",
),
}
}
}
impl fmt::Display for StartupFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Spawn(error) => error.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::NoSafeWorkingDirectory => 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}': {pattern}")]
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}': {error}")]
DuplicateEnvKey {
server: ServerId,
error: DuplicateEnvKey,
},
#[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)]
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}: {cause}{}{}", HintSuffix(.hint), StderrSuffix(.stderr))]
LspInitFailed {
phase: InitPhase,
cause: Box<Self>,
hint: Option<InitFailureHint>,
stderr: Option<StderrExcerpt>,
},
#[error("LSP server error: {code} - {}", sanitize_lsp_server_message(message))]
LspServerError {
code: i32,
message: String,
data: Option<serde_json::Value>,
},
#[error("failed to start MCP server: {0}")]
McpServerStart(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("{task} task failed: {source}")]
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}: {source}")]
HttpBind {
addr: std::net::SocketAddr,
#[source]
source: std::io::Error,
},
#[error("document not found: {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: {0}")]
Json(#[from] serde_json::Error),
#[error(transparent)]
TomlDe(#[from] toml::de::Error),
#[error(transparent)]
TomlSer(#[from] toml::ser::Error),
#[error("request timed out after {0:?}")]
Timeout(Duration),
#[error("failed to spawn LSP server '{command}': {source}")]
ServerSpawnFailed {
command: ServerCommand,
#[source]
source: std::io::Error,
},
#[error("failed to spawn LSP server '{command}': {source}{}", NotFoundGuidance(.command.as_str(), Platform::CURRENT))]
ServerNotFound {
command: ServerCommand,
#[source]
source: 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: {}", .0.display())]
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 {path:?}")]
MalformedPath {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("file I/O error for {path:?}")]
FileIo {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("path outside workspace: {0}")]
PathOutsideWorkspace(PathBuf),
#[error("no workspace roots configured: refusing access to {0}")]
NoWorkspaceRoots(PathBuf),
#[error(
"document limit exceeded: {current}/{max} (raise workspace.max_documents in config to increase this)"
)]
DocumentLimitExceeded {
current: usize,
max: 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: {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 { code, message, .. }
if message.contains(INVALID_OFFSET_MARKER) =>
{
McpErrorKind::InvalidPosition(RewrittenServerError::new(*code, 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,
}
}
}
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 {
let mut text = self.0.to_string();
let mut cause = self.0.source();
while let Some(error) = cause {
let part = error.to_string();
if !text.contains(&part) {
text.push_str(": ");
text.push_str(&part);
}
cause = error.source();
}
f.write_str(&text)
}
}
#[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 {
code: -32600,
message: "Invalid request".to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32600 - Invalid request"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_invalid_offset() {
let err = Error::LspServerError {
code: -32603,
message: "Invalid offset LineCol { line: 2291, col: 0 } (line index length: 100417)"
.to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32603 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_sanitizes_wrapped_invalid_offset() {
let err = Error::LspServerError {
code: -32803,
message: "request handler panicked: Invalid offset LineCol { line: 5, col: 0 } \
(line index length: 3)"
.to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32803 - position out of range for this document"
);
}
#[test]
fn test_error_display_lsp_server_error_passes_through_unrelated_message() {
let err = Error::LspServerError {
code: -32602,
message: "Invalid params: expected object".to_string(),
data: None,
};
assert_eq!(
err.to_string(),
"LSP server error: -32602 - Invalid params: expected object"
);
}
#[test]
fn test_error_display_document_not_found() {
let err = Error::DocumentNotFound(PathBuf::from("/path/to/file.rs"));
assert!(err.to_string().contains("document not found"));
assert!(err.to_string().contains("file.rs"));
}
#[test]
fn test_error_display_no_server_for_language() {
let err = 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(_));
}
#[test]
fn test_config_error_chain_does_not_repeat_source_text() {
let toml_err = toml::from_str::<toml::Value>("[invalid toml").unwrap_err();
let non_utf8 = String::from_utf8(vec![0xff]).unwrap_err();
let errors = [
Error::from(toml_err),
Error::from(ConfigError::NotUtf8(non_utf8)),
Error::from(ConfigError::UnresolvableWorkspaceRoot {
written: PathBuf::from("root"),
base_dir: PathBuf::from("base"),
probe: PathBuf::from("base/root"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such directory"),
}),
Error::from(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"access refused",
)),
Error::FileIo {
path: PathBuf::from("a.rs"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"),
},
Error::MalformedPath {
path: PathBuf::from("b.rs"),
source: std::io::Error::new(std::io::ErrorKind::NotADirectory, "not a dir"),
},
];
for err in errors {
let mut chain = vec![err.to_string()];
let mut source = std::error::Error::source(&err);
while let Some(cause) = source {
chain.push(cause.to_string());
source = cause.source();
}
for [parent, cause] in chain.array_windows() {
assert!(
!parent.contains(cause),
"'{parent}' repeats its source '{cause}'"
);
}
}
}
#[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_error_from_toml_de() {
let toml_str = "[invalid toml";
let toml_err = toml::from_str::<toml::Value>(toml_str).unwrap_err();
let err: Error = toml_err.into();
assert_matches!(err, Error::TomlDe(_));
}
#[test]
fn test_result_type_alias() {
fn _returns_error() -> Result<i32> {
Err(Error::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(),
source: 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_SYMBOL_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 {
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 {
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 {
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 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}");
}
}