use std::borrow::Cow;
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use serde::{Deserialize, Serialize};
use super::bounded_secs::TimeoutSecs;
use super::command_stem::CommandStem;
use super::host_os::HostOs;
use super::language_id::LanguageId;
#[cfg(test)]
use super::limits::DEFAULT_HEURISTICS_MAX_DEPTH;
use super::limits::SearchDepth;
use super::patterns::{FilePattern, ProjectMarker};
use super::routing::{ServerId, ToolSet};
use super::server_args::ServerArgs;
use super::server_env::{EnvKey, EnvValue, InvalidServerEnv, ServerEnv};
use super::settings::LspSettings;
use super::text_newtype::{contains_nul, impl_text_newtype};
use crate::bridge::IndexingPolicy;
use crate::error::{ChainedError, ConfigError, spelled_enum};
const EXCLUDED_DIRECTORIES: &[&str] = &[
"node_modules",
"target",
".git",
"__pycache__",
".venv",
"venv",
".tox",
".mypy_cache",
".pytest_cache",
"build",
"dist",
".cargo",
".rustup",
"vendor",
"coverage",
".next",
".nuxt",
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ServerHeuristics {
#[serde(default)]
pub project_markers: Vec<ProjectMarker>,
}
impl ServerHeuristics {
#[must_use]
pub fn with_markers(markers: impl IntoIterator<Item = ProjectMarker>) -> Self {
Self {
project_markers: markers.into_iter().collect(),
}
}
}
fn walk_names(
workspace_root: &Path,
max_depth: SearchDepth,
mut visit: impl FnMut(&str) -> ControlFlow<()>,
) -> ControlFlow<()> {
let mut builder = WalkBuilder::new(workspace_root);
builder
.standard_filters(false)
.max_depth(Some(max_depth.get()))
.hidden(false)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.follow_links(false)
.filter_entry(|entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir())
&& let Some(name) = entry.file_name().to_str()
&& EXCLUDED_DIRECTORIES.contains(&name)
{
return false;
}
true
});
for entry in builder.build().flatten() {
if let Some(name) = entry.path().file_name().and_then(OsStr::to_str) {
visit(name)?;
}
}
ControlFlow::Continue(())
}
#[derive(Debug, Clone, Default)]
pub struct MarkerScan {
found: Vec<ProjectMarker>,
}
impl MarkerScan {
#[must_use]
pub fn collect<'a>(
roots: &[PathBuf],
servers: impl IntoIterator<Item = &'a LspServerConfig>,
max_depth: SearchDepth,
) -> Self {
let mut missing: Vec<&ProjectMarker> = Vec::new();
for marker in servers
.into_iter()
.filter_map(|server| server.heuristics.as_ref())
.flat_map(|heuristics| &heuristics.project_markers)
{
if !missing.contains(&marker) {
missing.push(marker);
}
}
let mut found = Vec::new();
for root in roots {
if missing.is_empty() {
break;
}
missing.retain(|marker| {
let present = root.join(marker.as_str()).exists();
if present {
found.push((*marker).clone());
}
!present
});
if missing.is_empty() {
break;
}
let all_found = walk_names(root, max_depth, |name| {
if let Some(index) = missing.iter().position(|marker| *marker == name) {
found.push(missing.swap_remove(index).clone());
}
if missing.is_empty() {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
})
.is_break();
if all_found {
break;
}
}
Self { found }
}
#[must_use]
pub fn applies_to(&self, server: &LspServerConfig) -> bool {
server.heuristics.as_ref().is_none_or(|heuristics| {
heuristics.project_markers.is_empty()
|| heuristics
.project_markers
.iter()
.any(|marker| self.found.contains(marker))
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ServerSelection {
#[default]
Explicit,
Auto,
}
impl ServerSelection {
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde skip_serializing_if passes a reference"
)]
#[must_use]
pub(crate) const fn is_explicit(&self) -> bool {
matches!(self, Self::Explicit)
}
}
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InvalidServerCommand {
#[error("command cannot be blank")]
Blank,
#[error("command cannot contain NUL")]
ContainsNul,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ServerCommand(Cow<'static, str>);
impl ServerCommand {
#[must_use]
pub const fn from_static(text: &'static str) -> Self {
assert!(
text.is_ascii() && !text.trim_ascii().is_empty() && !contains_nul(text),
"command must be ASCII and not blank, and hold no NUL"
);
Self(Cow::Borrowed(text))
}
pub fn new(text: impl Into<String>) -> Result<Self, InvalidServerCommand> {
let text = text.into();
if text.trim().is_empty() {
return Err(InvalidServerCommand::Blank);
}
if contains_nul(&text) {
return Err(InvalidServerCommand::ContainsNul);
}
Ok(Self(Cow::Owned(text)))
}
}
impl_text_newtype!(ServerCommand, InvalidServerCommand);
impl AsRef<OsStr> for ServerCommand {
fn as_ref(&self) -> &OsStr {
OsStr::new(self.as_str())
}
}
impl AsRef<Path> for ServerCommand {
fn as_ref(&self) -> &Path {
Path::new(self.as_str())
}
}
#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)]
#[error("selection = \"auto\" is only valid for typescript-language-server entries")]
pub struct InvalidAutoSelection;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LaunchCommand {
command: ServerCommand,
selection: ServerSelection,
}
impl LaunchCommand {
#[must_use]
pub const fn explicit(command: ServerCommand) -> Self {
Self {
command,
selection: ServerSelection::Explicit,
}
}
pub fn auto(command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
Self::new(command, ServerSelection::Auto)
}
pub fn new(
command: ServerCommand,
selection: ServerSelection,
) -> Result<Self, InvalidAutoSelection> {
if selection == ServerSelection::Auto
&& !BuiltinServer::TypescriptLanguageServer.matches_command(command.as_str())
{
return Err(InvalidAutoSelection);
}
Ok(Self { command, selection })
}
const fn typescript() -> Self {
Self {
command: ServerCommand::from_static(BuiltinServer::TypescriptLanguageServer.as_str()),
selection: ServerSelection::Auto,
}
}
pub fn retarget(self, command: ServerCommand) -> Result<Self, InvalidAutoSelection> {
Self::new(command, self.selection)
}
#[must_use]
pub const fn server_command(&self) -> &ServerCommand {
&self.command
}
#[must_use]
pub const fn selection(&self) -> ServerSelection {
self.selection
}
#[must_use]
pub const fn is_explicit(&self) -> bool {
self.selection.is_explicit()
}
#[must_use]
pub fn as_str(&self) -> &str {
self.command.as_str()
}
fn into_parts(self) -> (ServerCommand, ServerSelection) {
(self.command, self.selection)
}
}
impl From<ServerCommand> for LaunchCommand {
fn from(command: ServerCommand) -> Self {
Self::explicit(command)
}
}
impl std::fmt::Display for LaunchCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.command, f)
}
}
impl AsRef<OsStr> for LaunchCommand {
fn as_ref(&self) -> &OsStr {
self.command.as_ref()
}
}
impl AsRef<Path> for LaunchCommand {
fn as_ref(&self) -> &Path {
self.command.as_ref()
}
}
impl PartialEq<str> for LaunchCommand {
fn eq(&self, other: &str) -> bool {
self.command == *other
}
}
impl PartialEq<&str> for LaunchCommand {
fn eq(&self, other: &&str) -> bool {
self.command == *other
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "RawLspServerConfig", into = "RawLspServerConfig")]
pub struct LspServerConfig {
pub language_id: LanguageId,
pub command: LaunchCommand,
pub args: ServerArgs,
pub env: ServerEnv,
pub file_patterns: Vec<FilePattern>,
pub initialization_options: Option<serde_json::Value>,
pub settings: Option<LspSettings>,
pub timeout_seconds: TimeoutSecs,
pub request_timeout_seconds: TimeoutSecs,
pub heuristics: Option<ServerHeuristics>,
pub name: Option<ServerId>,
pub handles: Option<ToolSet>,
pub indexing: IndexingPolicy,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLspServerConfig {
language_id: LanguageId,
command: ServerCommand,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: BTreeMap<EnvKey, String>,
#[serde(default)]
file_patterns: Vec<String>,
#[serde(default)]
initialization_options: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
settings: Option<LspSettings>,
#[serde(default)]
timeout_seconds: TimeoutSecs,
#[serde(default)]
request_timeout_seconds: TimeoutSecs,
#[serde(default)]
heuristics: Option<ServerHeuristics>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<ServerId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
handles: Option<ToolSet>,
#[serde(default, skip_serializing_if = "IndexingPolicy::is_auto")]
indexing: IndexingPolicy,
#[serde(default, skip_serializing_if = "ServerSelection::is_explicit")]
selection: ServerSelection,
}
impl From<LspServerConfig> for RawLspServerConfig {
fn from(config: LspServerConfig) -> Self {
let LspServerConfig {
language_id,
command,
args,
env,
file_patterns,
initialization_options,
settings,
timeout_seconds,
request_timeout_seconds,
heuristics,
name,
handles,
indexing,
} = config;
let (command, selection) = command.into_parts();
Self {
language_id,
command,
args: args.into(),
env: env.into(),
file_patterns: file_patterns.into_iter().map(String::from).collect(),
initialization_options,
settings,
timeout_seconds,
request_timeout_seconds,
heuristics,
name,
handles,
indexing,
selection,
}
}
}
impl TryFrom<RawLspServerConfig> for LspServerConfig {
type Error = ChainedError<ConfigError>;
fn try_from(raw: RawLspServerConfig) -> Result<Self, Self::Error> {
let RawLspServerConfig {
language_id,
command,
args,
env,
file_patterns,
initialization_options,
settings,
timeout_seconds,
request_timeout_seconds,
heuristics,
name,
handles,
indexing,
selection,
} = raw;
let entry = entry_id(name.as_ref(), &language_id);
let command = LaunchCommand::new(command, selection).map_err(|InvalidAutoSelection| {
ConfigError::SelectionAutoOnNonTypescript {
language: language_id.clone(),
}
})?;
let env = env
.into_iter()
.map(|(key, value)| match EnvValue::new(value) {
Ok(value) => Ok((key, value)),
Err(error) => Err(ConfigError::InvalidEnvValue {
server: entry.clone(),
key,
error,
}),
})
.collect::<Result<Vec<_>, _>>()?;
let env = ServerEnv::from_entries(env, HostOs::CURRENT).map_err(|error| match error {
InvalidServerEnv::Duplicate(error) => ConfigError::DuplicateEnvKey {
server: entry.clone(),
error,
},
InvalidServerEnv::TooLarge(error) => ConfigError::EnvTooLarge {
server: entry.clone(),
error,
},
})?;
let args = ServerArgs::try_from(args).map_err(|error| ConfigError::ArgContainsNul {
server: entry.clone(),
error,
})?;
let file_patterns = file_patterns
.iter()
.map(|pattern| {
FilePattern::parse(pattern).map_err(|pattern| ConfigError::UnsupportedFilePattern {
server: entry.clone(),
pattern,
})
})
.collect::<Result<_, _>>()?;
Ok(Self {
language_id,
command,
args,
env,
file_patterns,
initialization_options,
settings,
timeout_seconds,
request_timeout_seconds,
heuristics,
name,
handles,
indexing,
})
}
}
spelled_enum! {
BuiltinServer {
RustAnalyzer => "rust-analyzer",
Pyright => "pyright-langserver",
TypescriptLanguageServer => "typescript-language-server",
Gopls => "gopls",
Clangd => "clangd",
Zls => "zls",
}
}
impl BuiltinServer {
#[must_use]
pub const fn workspace_code(self) -> &'static str {
match self {
Self::RustAnalyzer => "Cargo build scripts and procedural macros",
Self::Pyright => {
"the project's Python environment, plugins and configured interpreters"
}
Self::TypescriptLanguageServer => {
"the workspace's tsserver and tsconfig plugins, unless pinned"
}
Self::Gopls => "the Go toolchain, including toolchain downloads requested by go.mod",
Self::Clangd => "commands from compile_commands.json and .clangd configuration",
Self::Zls => "build.zig through its build runner",
}
}
#[must_use]
pub const fn install_hint(self) -> &'static str {
match self {
Self::RustAnalyzer => "rustup component add rust-analyzer",
Self::Pyright => "npm install -g pyright",
Self::TypescriptLanguageServer => {
"npm install -g typescript-language-server typescript@6"
}
Self::Gopls => "go install golang.org/x/tools/gopls@latest",
Self::Clangd => {
"install clangd via your package manager (e.g. apt install clangd, brew install llvm) or see https://clangd.llvm.org/installation"
}
Self::Zls => {
"install a zls matching your Zig version: https://zigtools.org/zls/install/"
}
}
}
#[must_use]
pub const fn early_exit_hint(self) -> Option<&'static str> {
match self {
Self::RustAnalyzer => Some(
"the `rust-analyzer` found on PATH may be a rustup proxy without the component \
installed -- run `rustup component add rust-analyzer`",
),
Self::Pyright
| Self::TypescriptLanguageServer
| Self::Gopls
| Self::Clangd
| Self::Zls => None,
}
}
#[must_use]
pub const fn is_npm_package(self) -> bool {
matches!(self, Self::Pyright | Self::TypescriptLanguageServer)
}
#[must_use]
pub fn matches_command(self, command: &str) -> bool {
CommandStem::of(command).is(self.as_str())
}
#[must_use]
pub fn from_command(command: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.as_str() == command)
}
}
impl LspServerConfig {
#[must_use]
pub fn id(&self) -> ServerId {
entry_id(self.name.as_ref(), &self.language_id)
}
fn builtin(
language_id: LanguageId,
server: BuiltinServer,
args: &'static [&'static str],
file_patterns: &[&'static str],
markers: impl IntoIterator<Item = &'static str>,
) -> Self {
Self {
language_id,
command: ServerCommand::from_static(server.as_str()).into(),
args: ServerArgs::from_static(args),
env: crate::config::ServerEnv::default(),
file_patterns: file_patterns
.iter()
.map(|pattern| FilePattern::from_static(pattern))
.collect(),
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::DEFAULT,
request_timeout_seconds: TimeoutSecs::DEFAULT,
heuristics: Some(ServerHeuristics::with_markers(
markers.into_iter().map(ProjectMarker::from_static),
)),
name: None,
handles: None,
indexing: IndexingPolicy::Auto,
}
}
#[must_use]
pub fn rust_analyzer() -> Self {
Self::builtin(
const { LanguageId::from_static("rust") },
BuiltinServer::RustAnalyzer,
&[],
&["**/*.rs"],
["Cargo.toml", "rust-toolchain.toml"],
)
}
#[must_use]
pub fn pyright() -> Self {
Self::builtin(
const { LanguageId::from_static("python") },
BuiltinServer::Pyright,
&["--stdio"],
&["**/*.py"],
[
"pyproject.toml",
"setup.py",
"requirements.txt",
"pyrightconfig.json",
],
)
}
#[must_use]
pub fn typescript() -> Self {
Self {
command: LaunchCommand::typescript(),
..Self::builtin(
const { LanguageId::from_static("typescript") },
BuiltinServer::TypescriptLanguageServer,
&["--stdio"],
&["**/*.ts", "**/*.tsx"],
["package.json", "tsconfig.json", "jsconfig.json"],
)
}
}
#[must_use]
pub fn gopls() -> Self {
Self::builtin(
const { LanguageId::from_static("go") },
BuiltinServer::Gopls,
&["serve"],
&["**/*.go"],
["go.mod", "go.sum"],
)
}
#[must_use]
pub fn clangd() -> Self {
Self::builtin(
const { LanguageId::from_static("cpp") },
BuiltinServer::Clangd,
&[],
&["**/*.c", "**/*.cpp", "**/*.h", "**/*.hpp"],
[
"CMakeLists.txt",
"compile_commands.json",
"Makefile",
".clangd",
],
)
}
#[must_use]
pub fn zls() -> Self {
Self::builtin(
const { LanguageId::from_static("zig") },
BuiltinServer::Zls,
&[],
&["**/*.zig"],
["build.zig", "build.zig.zon"],
)
}
}
fn entry_id(name: Option<&ServerId>, language: &LanguageId) -> ServerId {
name.cloned()
.unwrap_or_else(|| ServerId::from(language.clone()))
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
use crate::config::ToolRouter;
fn heuristics_with<const N: usize>(markers: [&'static str; N]) -> ServerHeuristics {
ServerHeuristics::with_markers(markers.map(ProjectMarker::from_static))
}
#[test]
fn test_server_command_rejects_blank_and_round_trips() {
assert_eq!(ServerCommand::new(""), Err(InvalidServerCommand::Blank));
assert_eq!(ServerCommand::new(" \t"), Err(InvalidServerCommand::Blank));
assert_eq!(
ServerCommand::new("a\0b"),
Err(InvalidServerCommand::ContainsNul)
);
let command: ServerCommand = serde_json::from_str("\"gopls\"").unwrap();
assert_eq!(command, "gopls");
assert_eq!(serde_json::to_string(&command).unwrap(), "\"gopls\"");
assert!(serde_json::from_str::<ServerCommand>("\" \"").is_err());
}
#[test]
#[should_panic(expected = "command must be ASCII and not blank")]
fn test_server_command_from_static_panics_on_blank() {
let _ = ServerCommand::from_static(" ");
}
#[test]
#[should_panic(expected = "command must be ASCII and not blank")]
fn test_server_command_from_static_panics_on_non_ascii_blank() {
let _ = ServerCommand::from_static("\u{a0}");
}
#[test]
fn test_matches_command_accepts_stem_and_rejects_other_servers() {
let tsls = BuiltinServer::TypescriptLanguageServer;
for command in [
"typescript-language-server",
"/usr/local/bin/typescript-language-server",
"typescript-language-server.cmd",
] {
assert!(tsls.matches_command(command), "{command}");
}
for command in ["tsc", "", "rust-analyzer", "typescript-language-server-x"] {
assert!(!tsls.matches_command(command), "{command}");
}
}
#[test]
fn test_selection_serde_and_defaults() {
assert!(ServerSelection::default().is_explicit());
assert_eq!(
serde_json::from_str::<ServerSelection>("\"auto\"").unwrap(),
ServerSelection::Auto
);
assert!(serde_json::from_str::<ServerSelection>("\"native\"").is_err());
assert_eq!(
LspServerConfig::typescript().command.selection(),
ServerSelection::Auto
);
assert_eq!(
LspServerConfig::rust_analyzer().command.selection(),
ServerSelection::Explicit
);
}
#[test]
fn test_default_typescript_command_is_a_valid_auto_selection() {
let command = LspServerConfig::typescript().command;
assert_eq!(
LaunchCommand::auto(command.server_command().clone()),
Ok(command)
);
}
#[test]
fn test_retarget_keeps_the_selection_and_rechecks_auto() {
let tsls = |text: &str| ServerCommand::new(text).unwrap();
let auto = LaunchCommand::auto(tsls("typescript-language-server")).unwrap();
let moved = auto
.clone()
.retarget(tsls("/opt/bin/typescript-language-server"))
.unwrap();
assert_eq!(moved.selection(), ServerSelection::Auto);
assert_eq!(moved, "/opt/bin/typescript-language-server");
assert_eq!(
auto.retarget(tsls("rust-analyzer")),
Err(InvalidAutoSelection)
);
let explicit = LaunchCommand::explicit(tsls("rust-analyzer"));
assert!(explicit.retarget(tsls("anything")).is_ok());
}
#[test]
fn test_selection_key_is_written_only_for_auto() {
let typescript = toml::to_string(&LspServerConfig::typescript()).unwrap();
assert!(typescript.contains("selection = \"auto\""), "{typescript}");
let rust = toml::to_string(&LspServerConfig::rust_analyzer()).unwrap();
assert!(!rust.contains("selection"), "{rust}");
}
#[test]
fn test_early_exit_hint_only_for_rust_analyzer() {
for builtin in BuiltinServer::ALL {
assert_eq!(
builtin.early_exit_hint().is_some(),
builtin == BuiltinServer::RustAnalyzer,
"{builtin:?}"
);
}
}
#[test]
fn test_rust_analyzer_defaults() {
let config = LspServerConfig::rust_analyzer();
assert_eq!(config.language_id, "rust");
assert_eq!(config.command, "rust-analyzer");
assert_eq!(config.args.len(), 0);
assert!(config.env.is_empty());
assert_eq!(config.file_patterns, ["**/*.rs"]);
assert!(config.initialization_options.is_none());
assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
}
#[test]
fn test_pyright_defaults() {
let config = LspServerConfig::pyright();
assert_eq!(config.language_id, "python");
assert_eq!(config.command, "pyright-langserver");
assert_eq!(config.args, vec!["--stdio"]);
assert!(config.env.is_empty());
assert_eq!(config.file_patterns, ["**/*.py"]);
assert!(config.initialization_options.is_none());
assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
}
#[test]
fn test_typescript_defaults() {
let config = LspServerConfig::typescript();
assert_eq!(config.language_id, "typescript");
assert_eq!(config.command, "typescript-language-server");
assert_eq!(config.args, vec!["--stdio"]);
assert!(config.env.is_empty());
assert_eq!(config.file_patterns, ["**/*.ts", "**/*.tsx"]);
assert!(config.initialization_options.is_none());
assert_eq!(config.timeout_seconds, TimeoutSecs::DEFAULT);
}
#[test]
fn test_custom_config() {
let mut env = ServerEnv::default();
env.insert(
EnvKey::from_static("RUST_LOG"),
EnvValue::from_static("debug"),
)
.unwrap();
let config = LspServerConfig {
language_id: LanguageId::from_static("custom"),
command: ServerCommand::from_static("custom-lsp").into(),
args: crate::config::ServerArgs::try_from(vec!["--flag".to_string()]).unwrap(),
env: env.clone(),
file_patterns: vec![FilePattern::from_static("**/*.custom")],
initialization_options: Some(serde_json::json!({"key": "value"})),
settings: None,
timeout_seconds: TimeoutSecs::new(60).unwrap(),
request_timeout_seconds: TimeoutSecs::new(45).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
};
assert_eq!(config.language_id, "custom");
assert_eq!(config.command, "custom-lsp");
assert_eq!(config.args, vec!["--flag"]);
assert_eq!(config.env.get("RUST_LOG"), Some("debug"));
assert_eq!(config.file_patterns, ["**/*.custom"]);
assert!(config.initialization_options.is_some());
assert_eq!(config.timeout_seconds.get(), 60);
}
#[test]
fn test_serde_roundtrip() {
let original = LspServerConfig::rust_analyzer();
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: LspServerConfig = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.language_id, original.language_id);
assert_eq!(deserialized.command, original.command);
assert_eq!(deserialized.args, original.args);
assert_eq!(deserialized.timeout_seconds, original.timeout_seconds);
assert_eq!(
deserialized.request_timeout_seconds,
original.request_timeout_seconds
);
}
#[test]
fn test_indexing_defaults_to_auto_when_omitted() {
assert_eq!(
LspServerConfig::rust_analyzer().indexing,
crate::bridge::IndexingPolicy::Auto
);
}
#[test]
fn test_indexing_disabled_round_trips_through_toml() {
let toml_str = r#"
language_id = "rust"
command = "rust-analyzer"
indexing = "disabled"
"#;
let config: LspServerConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Disabled);
let serialized = toml::to_string(&config).unwrap();
assert!(serialized.contains(r#"indexing = "disabled""#));
let round_tripped: LspServerConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
round_tripped.indexing,
crate::bridge::IndexingPolicy::Disabled
);
}
#[test]
fn test_nul_in_env_value_is_rejected_at_load() {
let toml_str =
"language_id = \"rust\"\ncommand = \"rust-analyzer\"\n[env]\nA = \"x\\u0000y\"\n";
let error = toml::from_str::<LspServerConfig>(toml_str).unwrap_err();
assert!(
error.to_string().contains(
"lsp_servers entry 'rust': environment variable 'A': environment variable value cannot contain NUL"
),
"{error}"
);
}
#[test]
fn test_nul_in_arg_is_rejected_at_load() {
let toml_str =
"language_id = \"rust\"\ncommand = \"rust-analyzer\"\nargs = [\"ok\", \"a\\u0000b\"]\n";
let error = toml::from_str::<LspServerConfig>(toml_str).unwrap_err();
assert!(
error
.to_string()
.contains("lsp_servers entry 'rust': args[1] cannot contain NUL"),
"{error}"
);
}
#[test]
fn test_oversized_env_is_rejected_at_load() {
let toml_str = format!(
"language_id = \"rust\"\ncommand = \"rust-analyzer\"\n[env]\nA = \"{}\"\n",
"x".repeat(1 << 20)
);
let error = toml::from_str::<LspServerConfig>(&toml_str).unwrap_err();
assert!(
error
.to_string()
.contains("lsp_servers entry 'rust': environment variable 'A' is"),
"{error}"
);
}
#[test]
fn test_indexing_omitted_from_toml_defaults_to_auto() {
let toml_str = r#"
language_id = "rust"
command = "rust-analyzer"
"#;
let config: LspServerConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
}
#[test]
fn test_indexing_auto_is_omitted_from_serialized_toml() {
let config = LspServerConfig::rust_analyzer();
assert_eq!(config.indexing, crate::bridge::IndexingPolicy::Auto);
let serialized = toml::to_string(&config).unwrap();
assert!(
!serialized.contains("indexing"),
"the default IndexingPolicy::Auto must be omitted, not serialized as \
indexing = \"auto\""
);
}
#[test]
fn test_clone() {
let config = LspServerConfig::rust_analyzer();
let cloned = config.clone();
assert_eq!(cloned.language_id, config.language_id);
assert_eq!(cloned.command, config.command);
assert_eq!(cloned.timeout_seconds, config.timeout_seconds);
}
#[test]
fn test_empty_env() {
let config = LspServerConfig::rust_analyzer();
assert!(config.env.is_empty());
}
#[test]
fn test_multiple_file_patterns() {
let config = LspServerConfig::typescript();
assert_eq!(config.file_patterns.len(), 2);
assert!(
config
.file_patterns
.contains(&FilePattern::from_static("**/*.ts"))
);
assert!(
config
.file_patterns
.contains(&FilePattern::from_static("**/*.tsx"))
);
}
#[test]
fn test_initialization_options_none_by_default() {
let configs = vec![
LspServerConfig::rust_analyzer(),
LspServerConfig::pyright(),
LspServerConfig::typescript(),
];
for config in configs {
assert!(config.initialization_options.is_none());
}
}
#[test]
fn test_heuristics_empty_always_applicable() {
let heuristics = ServerHeuristics::default();
let tmp = TempDir::new().unwrap();
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_heuristics_marker_present() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_heuristics_marker_absent() {
let tmp = TempDir::new().unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_heuristics_any_marker_matches() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("setup.py"), "").unwrap();
let heuristics = heuristics_with(["pyproject.toml", "setup.py", "requirements.txt"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_should_spawn_without_heuristics() {
let config = LspServerConfig {
language_id: LanguageId::from_static("test"),
command: ServerCommand::from_static("test-lsp").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
};
let tmp = TempDir::new().unwrap();
assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_should_spawn_with_heuristics() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let config = LspServerConfig::rust_analyzer();
assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_should_not_spawn_without_markers() {
let tmp = TempDir::new().unwrap();
let config = LspServerConfig::rust_analyzer();
assert!(!spawns(&config, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_heuristics_serde_roundtrip() {
let heuristics = heuristics_with(["Cargo.toml", "rust-toolchain.toml"]);
let json = serde_json::to_string(&heuristics).unwrap();
let deserialized: ServerHeuristics = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.project_markers, heuristics.project_markers);
}
#[test]
fn test_default_rust_analyzer_heuristics() {
let config = LspServerConfig::rust_analyzer();
assert!(config.heuristics.is_some());
let markers = &config.heuristics.unwrap().project_markers;
assert!(markers.contains(&ProjectMarker::from_static("Cargo.toml")));
}
#[test]
fn test_gopls_defaults() {
let config = LspServerConfig::gopls();
assert_eq!(config.language_id, "go");
assert_eq!(config.command, "gopls");
assert_eq!(config.args, vec!["serve"]);
assert!(config.heuristics.is_some());
let markers = &config.heuristics.unwrap().project_markers;
assert!(markers.contains(&ProjectMarker::from_static("go.mod")));
assert!(markers.contains(&ProjectMarker::from_static("go.sum")));
}
#[test]
fn test_clangd_defaults() {
let config = LspServerConfig::clangd();
assert_eq!(config.language_id, "cpp");
assert_eq!(config.command, "clangd");
assert_eq!(config.args.len(), 0);
assert!(config.heuristics.is_some());
let markers = &config.heuristics.unwrap().project_markers;
assert!(markers.contains(&ProjectMarker::from_static("CMakeLists.txt")));
assert!(markers.contains(&ProjectMarker::from_static("compile_commands.json")));
}
#[test]
fn test_zls_defaults() {
let config = LspServerConfig::zls();
assert_eq!(config.language_id, "zig");
assert_eq!(config.command, "zls");
assert_eq!(config.args.len(), 0);
assert!(config.heuristics.is_some());
let markers = &config.heuristics.unwrap().project_markers;
assert!(markers.contains(&ProjectMarker::from_static("build.zig")));
assert!(markers.contains(&ProjectMarker::from_static("build.zig.zon")));
}
#[test]
fn test_recursive_empty_markers_always_applicable() {
let heuristics = ServerHeuristics::default();
let tmp = TempDir::new().unwrap();
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_marker_at_root() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_nested_python_project() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let python_dir = tmp.path().join("python");
std::fs::create_dir(&python_dir).unwrap();
std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
let heuristics = heuristics_with(["pyproject.toml", "setup.py"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_deeply_nested_marker() {
let tmp = TempDir::new().unwrap();
let deep_path = tmp.path().join("level1").join("level2").join("level3");
std::fs::create_dir_all(&deep_path).unwrap();
std::fs::write(deep_path.join("go.mod"), "").unwrap();
let heuristics = heuristics_with(["go.mod"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_no_marker_found() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src").join("main.rs"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_max_depth_respected() {
let tmp = TempDir::new().unwrap();
let deep_path = tmp.path().join("a").join("b").join("c").join("d").join("e");
std::fs::create_dir_all(&deep_path).unwrap();
std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(!applies(
&heuristics,
tmp.path(),
SearchDepth::new(3).unwrap()
));
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_excludes_node_modules() {
let tmp = TempDir::new().unwrap();
let node_modules = tmp.path().join("node_modules").join("some-package");
std::fs::create_dir_all(&node_modules).unwrap();
std::fs::write(node_modules.join("package.json"), "").unwrap();
let heuristics = heuristics_with(["package.json"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_excludes_target_directory() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("target").join("debug");
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_excludes_git_directory() {
let tmp = TempDir::new().unwrap();
let git_dir = tmp.path().join(".git").join("hooks");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_excludes_pycache() {
let tmp = TempDir::new().unwrap();
let pycache = tmp.path().join("__pycache__");
std::fs::create_dir_all(&pycache).unwrap();
std::fs::write(pycache.join("pyproject.toml"), "").unwrap();
let heuristics = heuristics_with(["pyproject.toml"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_excludes_venv() {
let tmp = TempDir::new().unwrap();
let venv = tmp.path().join(".venv").join("lib");
std::fs::create_dir_all(&venv).unwrap();
std::fs::write(venv.join("setup.py"), "").unwrap();
let heuristics = heuristics_with(["setup.py"]);
assert!(!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_respects_gitignore_in_marker_search() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
std::fs::write(tmp.path().join(".gitignore"), "ignored/\n").unwrap();
let ignored_dir = tmp.path().join("ignored");
std::fs::create_dir_all(&ignored_dir).unwrap();
std::fs::write(ignored_dir.join("Cargo.toml"), "").unwrap();
let heuristics = heuristics_with(["Cargo.toml"]);
assert!(
!applies(&heuristics, tmp.path(), SearchDepth::DEFAULT),
"marker inside a gitignored directory must not make the server applicable"
);
}
#[test]
fn test_recursive_finds_marker_outside_excluded() {
let tmp = TempDir::new().unwrap();
let node_modules = tmp.path().join("node_modules");
std::fs::create_dir_all(&node_modules).unwrap();
std::fs::write(node_modules.join("package.json"), "").unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("package.json"), "").unwrap();
let heuristics = heuristics_with(["package.json"]);
assert!(applies(&heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_recursive_monorepo_structure() {
let tmp = TempDir::new().unwrap();
let rust_pkg = tmp.path().join("packages").join("rust-lib");
let python_pkg = tmp.path().join("packages").join("python-bindings");
let ts_pkg = tmp.path().join("packages").join("typescript-client");
std::fs::create_dir_all(&rust_pkg).unwrap();
std::fs::create_dir_all(&python_pkg).unwrap();
std::fs::create_dir_all(&ts_pkg).unwrap();
std::fs::write(rust_pkg.join("Cargo.toml"), "").unwrap();
std::fs::write(python_pkg.join("pyproject.toml"), "").unwrap();
std::fs::write(ts_pkg.join("package.json"), "").unwrap();
let rust_heuristics = heuristics_with(["Cargo.toml"]);
let python_heuristics = heuristics_with(["pyproject.toml"]);
let ts_heuristics = heuristics_with(["package.json"]);
assert!(applies(&rust_heuristics, tmp.path(), SearchDepth::DEFAULT));
assert!(applies(
&python_heuristics,
tmp.path(),
SearchDepth::DEFAULT
));
assert!(applies(&ts_heuristics, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_should_spawn_recursive() {
let tmp = TempDir::new().unwrap();
let python_dir = tmp.path().join("bindings").join("python");
std::fs::create_dir_all(&python_dir).unwrap();
std::fs::write(python_dir.join("pyproject.toml"), "").unwrap();
let config = LspServerConfig::pyright();
assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
}
#[test]
fn test_should_spawn_with_custom_max_depth() {
let tmp = TempDir::new().unwrap();
let deep_path = tmp.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep_path).unwrap();
std::fs::write(deep_path.join("Cargo.toml"), "").unwrap();
let config = LspServerConfig::rust_analyzer();
assert!(!spawns(&config, tmp.path(), SearchDepth::new(2).unwrap()));
assert!(spawns(&config, tmp.path(), SearchDepth::DEFAULT));
}
fn applies(heuristics: &ServerHeuristics, root: &Path, depth: SearchDepth) -> bool {
let server = LspServerConfig {
heuristics: Some(heuristics.clone()),
..LspServerConfig::rust_analyzer()
};
spawns(&server, root, depth)
}
fn spawns(server: &LspServerConfig, root: &Path, depth: SearchDepth) -> bool {
scan(&[root], std::slice::from_ref(server), depth).applies_to(server)
}
fn scan(roots: &[&Path], servers: &[LspServerConfig], depth: SearchDepth) -> MarkerScan {
let roots: Vec<PathBuf> = roots.iter().map(|root| root.to_path_buf()).collect();
MarkerScan::collect(&roots, servers, depth)
}
fn all_builtins() -> Vec<LspServerConfig> {
vec![
LspServerConfig::rust_analyzer(),
LspServerConfig::pyright(),
LspServerConfig::typescript(),
LspServerConfig::gopls(),
LspServerConfig::clangd(),
LspServerConfig::zls(),
]
}
#[test]
fn test_marker_scan_finds_the_markers_of_every_server_in_one_pass() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let python = tmp.path().join("bindings").join("python");
std::fs::create_dir_all(&python).unwrap();
std::fs::write(python.join("pyproject.toml"), "").unwrap();
let servers = all_builtins();
let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
let applying: Vec<_> = servers
.iter()
.filter(|server| found.applies_to(server))
.map(|server| server.language_id.as_str())
.collect();
assert_eq!(applying, ["rust", "python"]);
}
#[test]
fn test_marker_scan_skips_excluded_directories_and_markers_below_the_depth() {
let tmp = TempDir::new().unwrap();
for (dir, file) in [
("a/b", "go.mod"),
("c", "package.json"),
("node_modules/x", "Cargo.toml"),
("d/e/f/g/h/i/j/k/l/m/n", "build.zig"),
] {
std::fs::create_dir_all(tmp.path().join(dir)).unwrap();
std::fs::write(tmp.path().join(dir).join(file), "").unwrap();
}
let servers = all_builtins();
let found = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
let applying: Vec<_> = servers
.iter()
.filter(|server| found.applies_to(server))
.map(|server| server.language_id.as_str())
.collect();
assert_eq!(applying, ["typescript", "go"]);
}
#[test]
fn test_marker_scan_unions_the_roots() {
let (first, second) = (TempDir::new().unwrap(), TempDir::new().unwrap());
std::fs::write(first.path().join("Cargo.toml"), "").unwrap();
let nested = second.path().join("svc");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("go.mod"), "").unwrap();
let servers = all_builtins();
let found = scan(
&[first.path(), second.path()],
&servers,
SearchDepth::DEFAULT,
);
assert!(found.applies_to(&LspServerConfig::rust_analyzer()));
assert!(found.applies_to(&LspServerConfig::gopls()));
assert!(!found.applies_to(&LspServerConfig::zls()));
}
#[test]
fn test_marker_scan_honours_the_depth_limit() {
let tmp = TempDir::new().unwrap();
let deep = tmp.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("Cargo.toml"), "").unwrap();
let servers = [LspServerConfig::rust_analyzer()];
let shallow = scan(&[tmp.path()], &servers, SearchDepth::new(2).unwrap());
let default = scan(&[tmp.path()], &servers, SearchDepth::DEFAULT);
assert!(!shallow.applies_to(&servers[0]));
assert!(default.applies_to(&servers[0]));
}
#[test]
fn test_marker_scan_applies_a_server_without_markers_everywhere() {
let tmp = TempDir::new().unwrap();
let mut no_markers = LspServerConfig::rust_analyzer();
no_markers.heuristics = None;
let mut empty_markers = LspServerConfig::rust_analyzer();
empty_markers.heuristics = Some(ServerHeuristics::default());
let found = scan(&[tmp.path()], &[], SearchDepth::DEFAULT);
assert!(found.applies_to(&no_markers));
assert!(found.applies_to(&empty_markers));
assert!(!found.applies_to(&LspServerConfig::rust_analyzer()));
}
#[test]
fn test_default_heuristics_max_depth() {
assert_eq!(DEFAULT_HEURISTICS_MAX_DEPTH, 10);
}
#[test]
fn test_excluded_directories_constant() {
assert!(EXCLUDED_DIRECTORIES.contains(&"node_modules"));
assert!(EXCLUDED_DIRECTORIES.contains(&"target"));
assert!(EXCLUDED_DIRECTORIES.contains(&".git"));
assert!(EXCLUDED_DIRECTORIES.contains(&"__pycache__"));
assert!(EXCLUDED_DIRECTORIES.contains(&".venv"));
}
const NATIVE_ENTRY: &str = r#"[[lsp_servers]]
language_id = "typescript"
command = "/home/me/ts7/node_modules/.bin/tsc"
args = ["--lsp", "--stdio"]
file_patterns = ["**/*.ts", "**/*.tsx"]"#;
fn repo_file(relative: &str) -> String {
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
Into::into,
);
std::fs::read_to_string(manifest.join("../..").join(relative))
.unwrap()
.replace("\r\n", "\n")
}
fn native_entries(extra: &str) -> Vec<LspServerConfig> {
let text = format!("{NATIVE_ENTRY}\n{extra}");
toml::from_str::<crate::config::ServerConfig>(&text)
.unwrap()
.lsp_servers
}
#[test]
fn test_docs_agree_with_typescript_install_hint() {
let hint = BuiltinServer::TypescriptLanguageServer.install_hint();
let pin = hint.split_whitespace().last().unwrap();
assert!(pin.starts_with("typescript@"), "{hint}");
let mut checked = 0;
for file in [
"README.md",
"book/src/getting-started/installation.md",
"book/src/guide/language-servers.md",
"book/src/advanced/typescript.md",
] {
for line in repo_file(file).lines() {
if !(line.contains("npm install -g") && line.contains("typescript")) {
continue;
}
checked += 1;
assert!(
line.split(|c: char| !(c.is_alphanumeric() || matches!(c, '@' | '.' | '-')))
.any(|word| word == pin),
"{file}: {line}"
);
if line.contains("typescript-language-server") {
assert!(line.contains(hint), "{file}: {line}");
}
}
}
assert!(checked >= 4, "only {checked} install lines found");
}
#[test]
fn test_documented_native_entry_is_in_the_book() {
assert!(repo_file("book/src/advanced/typescript.md").contains(NATIVE_ENTRY));
}
#[test]
fn test_native_entry_replacing_default_typescript_entry_routes() {
let router = ToolRouter::from_configs(&native_entries("")).unwrap();
assert!(router.has_language("typescript"));
}
#[test]
fn test_native_entry_appended_to_default_is_rejected() {
let mut configs = vec![LspServerConfig::typescript()];
configs.extend(native_entries(""));
let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
assert!(err.contains("duplicate server id"), "{err}");
}
#[test]
fn test_named_native_entry_appended_to_default_is_rejected() {
let mut named = native_entries("");
named[0].name = Some(ServerId::from_static("native-ts"));
let mut configs = vec![LspServerConfig::typescript()];
configs.extend(named);
let err = ToolRouter::from_configs(&configs).unwrap_err().to_string();
assert!(err.contains("two catch-all servers"), "{err}");
}
}