use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use crate::bridge::WorkspaceRoots;
use crate::config::{HostOs, LspServerConfig};
use crate::lsp::{ManagedEnvVar, ParentEnv, child_env_var};
fn spawn_target(host: HostOs, path: &Path) -> Option<PathBuf> {
let target = host.spawn_name(path);
host.is_executable_file(&target).then_some(target)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCommand {
pub spawn: PathBuf,
pub canonical: PathBuf,
}
pub fn resolve_command(
config: &LspServerConfig,
parent_env: impl ParentEnv,
) -> Option<ResolvedCommand> {
resolve_named(Path::new(&config.command), config, parent_env)
}
pub fn resolve_named(
name: &Path,
config: &LspServerConfig,
parent_env: impl ParentEnv,
) -> Option<ResolvedCommand> {
let host = config.env.host();
let found = if name.components().count() > 1 {
spawn_target(host, name)
} else {
let path_var = child_env_var(config, ManagedEnvVar::Path.name(), parent_env)?;
std::env::split_paths(&path_var).find_map(|dir| spawn_target(host, &dir.join(name)))
}?;
let canonical = dunce::canonicalize(&found).ok()?;
let spawn = std::path::absolute(&found).ok()?;
Some(ResolvedCommand { spawn, canonical })
}
pub fn path_outside(path_var: &OsStr, boundary: &WorkspaceRoots) -> OsString {
let kept = std::env::split_paths(path_var)
.filter(|dir| dir.is_absolute() && !boundary.contains_resolved_prefix(dir));
std::env::join_paths(kept).unwrap_or_default()
}
pub fn or_system_path(host: HostOs, path_var: OsString, parent_env: impl ParentEnv) -> OsString {
if !path_var.is_empty() {
return path_var;
}
match host {
HostOs::Windows => {
let system =
PathBuf::from(parent_env("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
.join("System32");
std::env::join_paths([system])
.unwrap_or_else(|_| OsString::from(WINDOWS_SYSTEM_PATH_FALLBACK))
}
HostOs::Other => OsString::from("/usr/bin:/bin"),
}
}
const WINDOWS_SYSTEM_PATH_FALLBACK: &str = "C:\\Windows\\System32";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_or_system_path_replaces_only_an_empty_path() {
let kept = OsString::from("/opt/tools");
assert_eq!(or_system_path(HostOs::Other, kept.clone(), |_| None), kept);
assert!(!or_system_path(HostOs::CURRENT, OsString::new(), |_| None).is_empty());
}
#[test]
fn test_or_system_path_follows_the_host_not_the_build_target() {
let windows = or_system_path(HostOs::Windows, OsString::new(), |key| {
(key == "SystemRoot").then(|| OsString::from("/win"))
});
let other = or_system_path(HostOs::Other, OsString::new(), |_| None);
assert_eq!(
windows,
PathBuf::from("/win").join("System32").into_os_string()
);
assert!(other.to_string_lossy().contains("/usr/bin"));
}
#[test]
fn test_or_system_path_falls_back_when_system_root_cannot_be_joined() {
let rejected = or_system_path(HostOs::Windows, OsString::new(), |key| {
(key == "SystemRoot").then(|| OsString::from("C:\\a\"b"))
});
assert_eq!(rejected, OsString::from(WINDOWS_SYSTEM_PATH_FALLBACK));
}
#[test]
fn test_windows_spawn_name_adds_exe_only_without_an_extension() {
let name = |path: &str, host: HostOs| host.spawn_name(Path::new(path));
assert_eq!(
name("tools/rust-analyzer", HostOs::Windows),
Path::new("tools/rust-analyzer.exe")
);
assert_eq!(
name("tools/server.cmd", HostOs::Windows),
Path::new("tools/server.cmd")
);
assert_eq!(
name("tools/rust-analyzer", HostOs::Other),
Path::new("tools/rust-analyzer")
);
}
#[test]
fn test_path_outside_drops_workspace_relative_and_empty_entries() {
let dir = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let (workspace, outside) = (base.join("ws"), base.join("outside"));
std::fs::create_dir_all(workspace.join("bin")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let boundary = WorkspaceRoots::from_paths(std::slice::from_ref(&workspace)).unwrap();
let path = std::env::join_paths([
workspace.join("bin"),
workspace.join("not-created-yet/bin"),
PathBuf::from("relative/bin"),
PathBuf::new(),
outside.clone(),
])
.unwrap();
let kept = path_outside(&path, &boundary);
assert_eq!(std::env::split_paths(&kept).collect::<Vec<_>>(), [outside]);
}
#[cfg(unix)]
mod resolve {
use std::os::unix::fs::PermissionsExt as _;
use super::super::*;
use crate::config::ServerCommand;
fn executable(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
fn config(command: &str) -> LspServerConfig {
let mut config = LspServerConfig::rust_analyzer();
config.command = ServerCommand::new(command.to_string()).unwrap().into();
config
}
fn path_env(dir: &Path) -> impl ParentEnv {
let path = std::env::join_paths([dir]).unwrap();
move |key| (key == "PATH").then(|| path.clone())
}
#[test]
fn test_resolve_command_finds_bare_name_on_path() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
executable(&root.join("bin/rust-analyzer"));
let resolved = resolve_command(&config("rust-analyzer"), path_env(&root.join("bin")));
assert_eq!(
resolved.map(|r| r.canonical),
Some(root.join("bin/rust-analyzer"))
);
}
#[test]
fn test_resolve_command_skips_non_executable_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
std::fs::create_dir_all(root.join("first")).unwrap();
std::fs::write(root.join("first/tool"), "").unwrap();
executable(&root.join("second/tool"));
let path = std::env::join_paths([root.join("first"), root.join("second")]).unwrap();
let resolved =
resolve_command(&config("tool"), |key| (key == "PATH").then(|| path.clone()));
assert_eq!(
resolved.map(|r| r.canonical),
Some(root.join("second/tool"))
);
}
#[test]
fn test_resolve_command_canonicalizes_symlinks() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
executable(&root.join("real/tool"));
std::fs::create_dir_all(root.join("bin")).unwrap();
std::os::unix::fs::symlink(root.join("real/tool"), root.join("bin/tool")).unwrap();
let resolved = resolve_command(&config("tool"), path_env(&root.join("bin"))).unwrap();
assert_eq!(resolved.canonical, root.join("real/tool"));
assert_eq!(resolved.spawn, root.join("bin/tool"));
}
#[test]
fn test_resolve_command_uses_config_env_path_over_parent() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
executable(&root.join("bin/tool"));
let mut config = config("tool");
config
.env
.insert(
crate::config::EnvKey::from_static("PATH"),
crate::config::EnvValue::new(root.join("bin").to_str().unwrap()).unwrap(),
)
.unwrap();
let resolved = resolve_command(&config, |_| Some(OsString::from("/nonexistent")));
assert_eq!(resolved.map(|r| r.canonical), Some(root.join("bin/tool")));
}
#[test]
fn test_resolve_command_uses_absolute_command_directly() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
executable(&root.join("x.sh"));
let resolved = resolve_command(&config(root.join("x.sh").to_str().unwrap()), |_| None);
assert_eq!(resolved.map(|r| r.canonical), Some(root.join("x.sh")));
}
#[test]
fn test_resolve_named_finds_a_program_other_than_the_command() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
executable(&root.join("bin/node"));
let resolved = resolve_named(
Path::new("node"),
&config("rust-analyzer"),
path_env(&root.join("bin")),
);
assert_eq!(resolved.map(|r| r.spawn), Some(root.join("bin/node")));
}
#[test]
fn test_resolve_command_returns_none_without_a_match() {
assert_eq!(
resolve_command(&config("definitely-not-installed"), |_| None),
None
);
let dir = tempfile::tempdir().unwrap();
assert_eq!(
resolve_command(&config("missing"), path_env(dir.path())),
None
);
}
}
}