use std::path::{Path, PathBuf};
use serde_json::Value;
use tracing::{info, warn};
use agent_contracts::backend::capability::exec::ExecRequest;
use agent_contracts::backend::capability::filesystem::ReadBytesRequest;
use agent_contracts::backend::BackendPath;
use agent_types::lsp::LspError;
use crate::host::LspEnv;
#[derive(Debug, Clone)]
pub enum AutoInstall {
None,
GoInstall { package: &'static str },
PipInstall { package: &'static str },
NpmInstall { package: &'static str },
CargoInstall { package: &'static str },
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub id: &'static str,
pub extensions: &'static [&'static str],
pub command: &'static str,
pub args: &'static [&'static str],
pub root_markers: &'static [&'static str],
pub language_id: &'static str,
pub initialization_options: Option<Value>,
pub auto_install: AutoInstall,
}
pub async fn resolve_binary(config: &ServerConfig, env: &dyn LspEnv) -> Result<PathBuf, LspError> {
if let Some(path) = env.which(config.command) {
return Ok(path);
}
info!(
server = config.id,
command = config.command,
"binary not found in PATH, attempting auto-install"
);
match try_auto_install(config, env).await {
Ok(path) => {
info!(server = config.id, ?path, "auto-install succeeded");
Ok(path)
}
Err(msg) => {
warn!(server = config.id, %msg, "auto-install failed");
Err(LspError::StartupFailed(msg))
}
}
}
async fn try_auto_install(config: &ServerConfig, env: &dyn LspEnv) -> Result<PathBuf, String> {
let bin_dir = env.global_bin_dir();
let bin_dir_bp = BackendPath(bin_dir.to_string_lossy().into_owned());
env.backend()
.files()
.create_dir_all(&bin_dir_bp)
.await
.map_err(|e| format!("failed to create bin dir: {e}"))?;
match config.auto_install {
AutoInstall::None => Err(format!(
"'{}' not found in PATH. Please install it manually.",
config.command
)),
AutoInstall::GoInstall { package } => {
if env.which("go").is_none() {
return Err(format!(
"'{}' not found and Go toolchain ('go') is required to auto-install it.",
config.command
));
}
info!(server = config.id, %package, "running: go install");
let result = env
.backend()
.exec()
.exec(ExecRequest {
command: "go".to_string(),
args: vec!["install".to_string(), package.to_string()],
shell: None,
cwd: None,
timeout_ms: None,
env: Some(vec![(
"GOBIN".to_string(),
bin_dir.to_string_lossy().into_owned(),
)]),
})
.await
.map_err(|e| e.to_string())?;
if result.exit_code != Some(0) {
return Err(format!("go install {package} failed"));
}
env.which(config.command)
.ok_or_else(|| format!("'{}' still not found after go install", config.command))
}
AutoInstall::PipInstall { package } => {
let pip = env.which("pip3").or_else(|| env.which("pip"));
let Some(pip) = pip else {
return Err(format!(
"'{}' not found and 'pip' is required to auto-install it.",
config.command
));
};
let pip_str = pip.to_string_lossy().into_owned();
info!(server = config.id, %package, pip = %pip_str, "running: pip install --user");
let result = env
.backend()
.exec()
.exec(ExecRequest {
command: pip_str,
args: vec![
"install".to_string(),
"--user".to_string(),
package.to_string(),
],
shell: None,
cwd: None,
timeout_ms: None,
env: None,
})
.await
.map_err(|e| e.to_string())?;
if result.exit_code != Some(0) {
return Err(format!("pip install {package} failed"));
}
env.which(config.command)
.ok_or_else(|| format!("'{}' still not found after pip install", config.command))
}
AutoInstall::NpmInstall { package } => {
if env.which("npm").is_none() {
return Err(format!(
"'{}' not found and 'npm' is required to auto-install it.",
config.command
));
}
info!(server = config.id, %package, "running: npm install -g");
let result = env
.backend()
.exec()
.exec(ExecRequest {
command: "npm".to_string(),
args: vec!["install".to_string(), "-g".to_string(), package.to_string()],
shell: None,
cwd: None,
timeout_ms: None,
env: None,
})
.await
.map_err(|e| e.to_string())?;
if result.exit_code != Some(0) {
return Err(format!("npm install -g {package} failed"));
}
env.which(config.command)
.ok_or_else(|| format!("'{}' still not found after npm install", config.command))
}
AutoInstall::CargoInstall { package } => {
if env.which("cargo").is_none() {
return Err(format!(
"'{}' not found and 'cargo' is required to auto-install it.",
config.command
));
}
let root_str = bin_dir.to_string_lossy().into_owned();
info!(server = config.id, %package, %root_str, "running: cargo install --root");
let result = env
.backend()
.exec()
.exec(ExecRequest {
command: "cargo".to_string(),
args: vec![
"install".to_string(),
"--root".to_string(),
root_str,
package.to_string(),
],
shell: None,
cwd: None,
timeout_ms: None,
env: None,
})
.await
.map_err(|e| e.to_string())?;
if result.exit_code != Some(0) {
return Err(format!("cargo install {package} failed"));
}
let in_root_bin = bin_dir.join("bin").join(config.command);
let in_root_bin_bp = BackendPath(in_root_bin.to_string_lossy().into_owned());
if env
.backend()
.files()
.stat(&in_root_bin_bp)
.await
.map(|s| s.exists)
.unwrap_or(false)
{
return Ok(in_root_bin);
}
env.which(config.command)
.ok_or_else(|| format!("'{}' still not found after cargo install", config.command))
}
}
}
pub fn builtin_servers() -> Vec<ServerConfig> {
vec![
ServerConfig {
id: "rust-analyzer",
extensions: &["rs"],
command: "rust-analyzer",
args: &[],
root_markers: &["Cargo.toml"],
language_id: "rust",
initialization_options: None,
auto_install: AutoInstall::None,
},
ServerConfig {
id: "gopls",
extensions: &["go"],
command: "gopls",
args: &[],
root_markers: &["go.mod"],
language_id: "go",
initialization_options: None,
auto_install: AutoInstall::GoInstall {
package: "golang.org/x/tools/gopls@latest",
},
},
ServerConfig {
id: "pyright",
extensions: &["py"],
command: "pyright-langserver",
args: &["--stdio"],
root_markers: &["pyproject.toml", "setup.py", "requirements.txt"],
language_id: "python",
initialization_options: Some(serde_json::json!({
"python": { "pythonVersion": "3.11" }
})),
auto_install: AutoInstall::PipInstall { package: "pyright" },
},
ServerConfig {
id: "typescript-language-server",
extensions: &["ts", "tsx", "js", "jsx"],
command: "typescript-language-server",
args: &["--stdio"],
root_markers: &["package.json", "tsconfig.json"],
language_id: "typescript",
initialization_options: None,
auto_install: AutoInstall::NpmInstall {
package: "typescript-language-server",
},
},
ServerConfig {
id: "clangd",
extensions: &["c", "cc", "cpp", "cxx", "h", "hh", "hpp"],
command: "clangd",
args: &[],
root_markers: &["compile_commands.json", "CMakeLists.txt", ".clangd"],
language_id: "c",
initialization_options: None,
auto_install: AutoInstall::None,
},
ServerConfig {
id: "zls",
extensions: &["zig"],
command: "zls",
args: &[],
root_markers: &["build.zig"],
language_id: "zig",
initialization_options: None,
auto_install: AutoInstall::None,
},
ServerConfig {
id: "lua-language-server",
extensions: &["lua"],
command: "lua-language-server",
args: &[],
root_markers: &[".luarc.json", ".luarc.jsonc"],
language_id: "lua",
initialization_options: None,
auto_install: AutoInstall::None,
},
]
}
pub async fn find_root(file: &Path, markers: &[&str], env: &dyn LspEnv) -> PathBuf {
if let Some(parent) = file.parent() {
for ancestor in parent.ancestors() {
for marker in markers {
let candidate = ancestor.join(marker);
let bp = BackendPath(candidate.to_string_lossy().into_owned());
if env
.backend()
.files()
.stat(&bp)
.await
.map(|s| s.exists)
.unwrap_or(false)
{
return ancestor.to_path_buf();
}
}
}
parent.to_path_buf()
} else {
file.to_path_buf()
}
}
pub async fn read_file(file: &Path, env: &dyn LspEnv) -> String {
let bp = BackendPath(file.to_string_lossy().into_owned());
env.backend()
.files()
.read_bytes(ReadBytesRequest { path: bp })
.await
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
.unwrap_or_default()
}
pub fn path_to_uri(path: &Path) -> String {
let s = path.to_string_lossy();
if s.starts_with('/') {
format!("file://{}", s)
} else {
format!("file:///{}", s.replace('\\', "/"))
}
}
/// Convert an LSP file URI back to a filesystem path string.
pub fn uri_to_path(uri: &str) -> String {
if let Some(p) = uri.strip_prefix("file:///") {
if p.contains(':') {
p.to_string()
} else {
format!("/{}", p)
}
} else if let Some(p) = uri.strip_prefix("file://") {
p.to_string()
} else {
uri.to_string()
}
}