fix(lsp): add download-tier helpers, progress callback, and persist previous install check

Auto-install improvements:
- Add curl-based download helpers for rust-analyzer and jdtls
- Add progress callback threaded through all provision stages
- Check ~/.local/share/zesdex/lsp/ for previous downloads so install
  only runs once, not on every startup
- Increase run_command timeout to 180s for large downloads
- Add cargo/pacman/brew fallback tiers for Rust
- Add pacman/download tiers for Java jdtls

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-12 15:32:15 +07:00
co-authored by Claude Opus 4.8
parent b20bb95ada
commit aac1de8af6
2 changed files with 125 additions and 71 deletions
+111 -62
View File
@@ -21,6 +21,11 @@ use tracing::{info, warn};
use super::LspManager; use super::LspManager;
/// Optional progress callback type (non-owning, caller ensures liveness
/// for the duration of the provisioning call).
/// Intended to be hooked up to a UI toast / status-bar mechanism.
pub type ProgressFn<'a> = Option<&'a dyn Fn(&str)>;
/// Result of attempting to make a single language server available. /// Result of attempting to make a single language server available.
/// ///
/// The caller should switch on this variant: AlreadyAvailable and /// The caller should switch on this variant: AlreadyAvailable and
@@ -372,6 +377,30 @@ fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
Ok(base) Ok(base)
} }
/// Check whether `def` was previously installed via the download tier
/// (binary/lancher lives under `~/.local/share/zesdex/lsp/<name>/`).
/// Returns the path to the binary if found.
fn previous_download_install(def: &LanguageServerDef) -> Option<PathBuf> {
let base = lsp_install_dir(&def.name).ok()?;
let candidates: &[&str] = match def.name.as_str() {
"rust-analyzer" => &["rust-analyzer"],
"jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"],
"typescript-language-server" => &["bin/typescript-language-server"],
"gopls" => &["bin/gopls"],
_ => return None,
};
for sub in candidates {
let p = base.join(sub);
if p.exists() {
// Skip directory entries that exist but are the base dir itself.
if p.is_file() {
return Some(p);
}
}
}
None
}
/// Download a file from `url` to `dest` using curl. /// Download a file from `url` to `dest` using curl.
fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
let path_str = dest.to_str().ok_or("invalid dest path")?.to_string(); let path_str = dest.to_str().ok_or("invalid dest path")?.to_string();
@@ -392,7 +421,7 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> {
/// Download rust-analyzer from GitHub releases and install into /// Download rust-analyzer from GitHub releases and install into
/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. /// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`.
fn install_rust_analyzer_binary(env: &EnvInfo) -> Result<PathBuf, String> { fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
let base = lsp_install_dir("rust-analyzer")?; let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
@@ -407,7 +436,9 @@ fn install_rust_analyzer_binary(env: &EnvInfo) -> Result<PathBuf, String> {
let gz = base.join("rust-analyzer.gz"); let gz = base.join("rust-analyzer.gz");
let target = base.join("rust-analyzer"); let target = base.join("rust-analyzer");
if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); }
download_url(url, &gz, 120)?; download_url(url, &gz, 120)?;
if let Some(cb) = progress { cb("Rust: decompressing..."); }
let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()]) let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()])
.map_err(|e| format!("gunzip spawn: {}", e))?; .map_err(|e| format!("gunzip spawn: {}", e))?;
if !ok { if !ok {
@@ -423,18 +454,21 @@ fn install_rust_analyzer_binary(env: &EnvInfo) -> Result<PathBuf, String> {
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod: {}", e))?; .map_err(|e| format!("chmod: {}", e))?;
} }
if let Some(cb) = progress { cb("Rust: installed ✓"); }
Ok(target) Ok(target)
} }
/// Download Eclipse JDT-LS from the official snapshot server, extract it, /// Download Eclipse JDT-LS from the official snapshot server, extract it,
/// and create a launcher script at `bin/jdtls`. /// and create a launcher script at `bin/jdtls`.
fn install_jdtls_from_eclipse() -> Result<PathBuf, String> { fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result<PathBuf, String> {
let base = lsp_install_dir("jdtls")?; let base = lsp_install_dir("jdtls")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?;
let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"; let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz";
let tarball = base.join("jdtls.tar.gz"); let tarball = base.join("jdtls.tar.gz");
if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); }
download_url(url, &tarball, 300)?; download_url(url, &tarball, 300)?;
if let Some(cb) = progress { cb("Java: extracting..."); }
let (ok, out) = run_command("tar", &[ let (ok, out) = run_command("tar", &[
"-xzf", tarball.to_str().unwrap_or(""), "-xzf", tarball.to_str().unwrap_or(""),
@@ -478,14 +512,15 @@ exec java \
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("chmod launcher: {}", e))?; .map_err(|e| format!("chmod launcher: {}", e))?;
} }
if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); }
Ok(launcher) Ok(launcher)
} }
/// Dispatch a sentinel download tier to the correct helper. /// Dispatch a sentinel download tier to the correct helper.
fn run_download_tier(name: &str, env: &EnvInfo) -> Result<PathBuf, String> { fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result<PathBuf, String> {
match name { match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env), DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(), DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
other => Err(format!("unknown download tier '{}'", other)), other => Err(format!("unknown download tier '{}'", other)),
} }
} }
@@ -533,10 +568,16 @@ fn manual_instructions(def: &LanguageServerDef) -> String {
/// can exit 0 even if the binary wasn't actually placed on PATH (rare, /// can exit 0 even if the binary wasn't actually placed on PATH (rare,
/// but happens with broken rustup installs). Re-checking gives us a /// but happens with broken rustup installs). Re-checking gives us a
/// real signal rather than trusting the exit code alone. /// real signal rather than trusting the exit code alone.
#[allow(dead_code)]
pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult { pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult {
// Already installed? provision_single_with_progress(def, env, None)
}
fn provision_single_with_progress(def: &LanguageServerDef, env: &EnvInfo, progress: ProgressFn<'_>) -> ProvisionResult {
// 1. Check PATH.
for bin in &def.binary_names { for bin in &def.binary_names {
if let Some(path) = which(bin) { if let Some(path) = which(bin) {
if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); }
return ProvisionResult::AlreadyAvailable { return ProvisionResult::AlreadyAvailable {
server_name: def.name.clone(), server_name: def.name.clone(),
language: def.language.clone(), language: def.language.clone(),
@@ -545,38 +586,46 @@ pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResu
} }
} }
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
if let Some(path) = previous_download_install(def) {
if let Some(cb) = progress { cb(&format!("{}: found previous install", def.language)); }
return ProvisionResult::AlreadyAvailable {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: path.to_string_lossy().to_string(),
};
}
if let Some(cb) = progress { cb(&format!("{}: checking install options...", def.language)); }
let mut last_reason = String::from("no install tiers succeeded"); let mut last_reason = String::from("no install tiers succeeded");
for tier in &def.install_tiers { for tier in &def.install_tiers {
// Prerequisite gating: skip tiers whose required tools are missing. // Prerequisite gating
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() { let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
"rustup" => env.has_rustup, "rustup" => env.has_rustup, "npm" => env.has_npm,
"npm" => env.has_npm, "go" => env.has_go, "java" => env.has_java,
"go" => env.has_go, "cargo" => env.has_cargo, "curl" => env.has_curl,
"java" => env.has_java, "tar" => env.has_tar, "pacman" => env.has_pacman,
"apt" => env.has_apt, "apt" => env.has_apt, "brew" => env.has_brew,
"brew" => env.has_brew, "dnf" => env.has_dnf, _ => which(req).is_some(),
_ => which(req).is_some(),
}); });
if !prereqs_met { if !prereqs_met {
last_reason = format!( let skip = format!("{}: {} — missing prerequisite", def.language, tier.label);
"tier '{}' skipped: missing prerequisite (one of: {})", if let Some(cb) = progress { cb(&skip); }
tier.label, last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label);
tier.requires.join(", ") warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites");
);
warn!(
server = %def.name,
tier = %tier.label,
"install tier skipped — missing prerequisites"
);
continue; continue;
} }
// If command is a download sentinel, dispatch to helper. let trying = format!("{}: {}...", def.language, tier.label);
if let Some(cb) = progress { cb(&trying); }
// Download sentinel → helper.
if tier.command.starts_with("__download_") && tier.command.ends_with("__") { if tier.command.starts_with("__download_") && tier.command.ends_with("__") {
match run_download_tier(&tier.command, env) { match run_download_tier(&tier.command, env, progress) {
Ok(path) => { Ok(path) => {
info!(server = %def.name, tier = %tier.label, binary = %path.display(), "download install succeeded"); info!(server = %def.name, tier = %tier.label, binary = %path.display(), "installed");
return ProvisionResult::Installed { return ProvisionResult::Installed {
server_name: def.name.clone(), server_name: def.name.clone(),
language: def.language.clone(), language: def.language.clone(),
@@ -585,72 +634,49 @@ pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResu
} }
Err(e) => { Err(e) => {
last_reason = format!("tier '{}' failed: {}", tier.label, e); last_reason = format!("tier '{}' failed: {}", tier.label, e);
warn!(server = %def.name, tier = %tier.label, error = %e, "download install failed"); warn!(server = %def.name, tier = %tier.label, error = %e, "download failed");
continue; continue;
} }
} }
} }
// Normal tier: shell out. // Normal shell-out tier.
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect(); let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect();
match run_command(&tier.command, &arg_refs) { match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => { Ok((true, _)) => {
// Verify the binary is now actually reachable.
let located = def let located = def
.binary_names .binary_names
.iter() .iter()
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string())); .find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
if let Some(path) = located { if let Some(path) = located {
info!( if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); }
server = %def.name, info!(server = %def.name, tier = %tier.label, binary = %path, "installed");
tier = %tier.label,
binary = %path,
"installed language server"
);
return ProvisionResult::Installed { return ProvisionResult::Installed {
server_name: def.name.clone(), server_name: def.name.clone(),
language: def.language.clone(), language: def.language.clone(),
binary_path: path, binary_path: path,
}; };
} }
last_reason = format!( last_reason = format!("tier '{}' exited 0 but binary not on PATH", tier.label);
"tier '{}' exited 0 but '{}' not found on PATH afterwards", warn!(server = %def.name, tier = %tier.label, "success reported but binary missing");
tier.label, tier.command
);
warn!(
server = %def.name,
tier = %tier.label,
"install command reported success but binary missing"
);
} }
Ok((false, out)) => { Ok((false, out)) => {
let trimmed = out.trim(); let trimmed = out.trim();
last_reason = format!("tier '{}' failed: {}", tier.label, trimmed); let snippet: String = trimmed.chars().take(300).collect();
warn!( last_reason = format!("tier '{}' failed: {}", tier.label, snippet);
server = %def.name, warn!(server = %def.name, tier = %tier.label, output = %snippet, "failed");
tier = %tier.label,
output = trimmed,
"install command failed"
);
} }
Err(e) => { Err(e) => {
last_reason = format!("tier '{}' error: {}", tier.label, e); last_reason = format!("tier '{}' error: {}", tier.label, e);
warn!( warn!(server = %def.name, tier = %tier.label, error = %e, "errored");
server = %def.name,
tier = %tier.label,
error = %e,
"install command errored"
);
} }
} }
} }
let manual = manual_instructions(def); let manual = manual_instructions(def);
ProvisionResult::Failed { ProvisionResult::Failed {
language: def.language.clone(), language: def.language.clone(), server_name: def.name.clone(),
server_name: def.name.clone(), reason: last_reason, manual_instructions: manual,
reason: last_reason,
manual_instructions: manual,
} }
} }
@@ -660,6 +686,7 @@ pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResu
/// Flow: detect_env() once → for each server in supported_servers() /// Flow: detect_env() once → for each server in supported_servers()
/// call provision_single() → collect results. Order matches /// call provision_single() → collect results. Order matches
/// supported_servers() (rust, typescript, go, java). /// supported_servers() (rust, typescript, go, java).
#[allow(dead_code)]
pub fn provision_all() -> Vec<ProvisionResult> { pub fn provision_all() -> Vec<ProvisionResult> {
let env = detect_env(); let env = detect_env();
info!( info!(
@@ -684,6 +711,28 @@ pub fn provision_all() -> Vec<ProvisionResult> {
.collect() .collect()
} }
/// Like `provision_all` but calls `progress` with a human-readable status
/// string at each stage of each server's install attempt.
pub fn provision_all_with_progress(progress: ProgressFn) -> Vec<ProvisionResult> {
let env = detect_env();
if let Some(cb) = progress {
let flags = [
("rustup", env.has_rustup), ("cargo", env.has_cargo),
("npm", env.has_npm), ("go", env.has_go), ("java", env.has_java),
("curl", env.has_curl), ("tar", env.has_tar),
("pacman", env.has_pacman), ("apt", env.has_apt), ("brew", env.has_brew),
];
let avail: String = flags.iter()
.filter(|(_, v)| *v).map(|(k, _)| *k)
.collect::<Vec<_>>().join(", ");
cb(&format!("LSP: environment ready — {}", avail));
}
supported_servers()
.iter()
.map(|def| provision_single_with_progress(def, &env, progress))
.collect()
}
/// For every successful provision result, attach the corresponding /// For every successful provision result, attach the corresponding
/// server to the given `LspManager`. /// server to the given `LspManager`.
/// ///
+14 -9
View File
@@ -153,30 +153,35 @@ impl AppStateRest {
std::thread::spawn(move || { std::thread::spawn(move || {
use crate::app::lsp::provisioner::{self, ProvisionResult}; use crate::app::lsp::provisioner::{self, ProvisionResult};
fn push_msg(q: &Arc<Mutex<VecDeque<String>>>, msg: String) { fn push_msg(q: &Arc<Mutex<VecDeque<String>>>, msg: &str) {
if let Ok(mut q) = q.lock() { if let Ok(mut q) = q.lock() {
q.push_back(msg); q.push_back(msg.to_string());
} }
} }
push_msg(&msg_queue, "LSP: provisioning servers...".to_string()); // Wrap the msg_queue in a static-lifetime closure for use as ProgressFn.
let results = provisioner::provision_all(); let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg));
push_msg(&msg_queue, "LSP: connecting servers...".to_string());
let report = |msg: &str| { if let Some(f) = &progress { f(msg); }};
report("LSP: provisioning servers...");
let results = provisioner::provision_all_with_progress(progress);
report("LSP: connecting servers...");
let connected = provisioner::auto_connect(&lsp_mgr, &results); let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected { for name in &connected {
tracing::info!("LSP: {} connected", name); tracing::info!("LSP: {} connected", name);
push_msg(&msg_queue, format!("LSP: {} connected ✓", name)); let m = format!("LSP: {} connected ✓", name); push_msg(&msg_queue, &m);
} }
for r in &results { for r in &results {
if let ProvisionResult::Failed { language, server_name, reason, .. } = r { if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
tracing::warn!("LSP {} ({}): {}", server_name, language, reason); tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
push_msg(&msg_queue, format!("LSP: {} ({}) ✗ - {}", server_name, language, reason)); let m = format!("LSP: {} ({}) ✗ - {}", server_name, language, reason); push_msg(&msg_queue, &m);
} }
} }
if connected.is_empty() { if connected.is_empty() {
push_msg(&msg_queue, "LSP: no servers available — install manually or check prerequisites".to_string()); let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m);
} else { } else {
push_msg(&msg_queue, format!("LSP: {} server(s) connected", connected.len())); let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m);
} }
}); });
} }