diff --git a/src/app/lsp/provisioner.rs b/src/app/lsp/provisioner.rs index 0105289..68c4bf2 100644 --- a/src/app/lsp/provisioner.rs +++ b/src/app/lsp/provisioner.rs @@ -21,6 +21,11 @@ use tracing::{info, warn}; 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. /// /// The caller should switch on this variant: AlreadyAvailable and @@ -372,6 +377,30 @@ fn lsp_install_dir(server: &str) -> Result { Ok(base) } +/// Check whether `def` was previously installed via the download tier +/// (binary/lancher lives under `~/.local/share/zesdex/lsp//`). +/// Returns the path to the binary if found. +fn previous_download_install(def: &LanguageServerDef) -> Option { + 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. 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(); @@ -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 /// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. -fn install_rust_analyzer_binary(env: &EnvInfo) -> Result { +fn install_rust_analyzer_binary(env: &EnvInfo, progress: ProgressFn<'_>) -> Result { let base = lsp_install_dir("rust-analyzer")?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {}", e))?; @@ -407,7 +436,9 @@ fn install_rust_analyzer_binary(env: &EnvInfo) -> Result { let gz = base.join("rust-analyzer.gz"); let target = base.join("rust-analyzer"); + if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); } download_url(url, &gz, 120)?; + if let Some(cb) = progress { cb("Rust: decompressing..."); } let (ok, out) = run_command("gunzip", &["-f", &gz.to_string_lossy()]) .map_err(|e| format!("gunzip spawn: {}", e))?; if !ok { @@ -423,18 +454,21 @@ fn install_rust_analyzer_binary(env: &EnvInfo) -> Result { std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("chmod: {}", e))?; } + if let Some(cb) = progress { cb("Rust: installed ✓"); } Ok(target) } /// Download Eclipse JDT-LS from the official snapshot server, extract it, /// and create a launcher script at `bin/jdtls`. -fn install_jdtls_from_eclipse() -> Result { +fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result { let base = lsp_install_dir("jdtls")?; 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 tarball = base.join("jdtls.tar.gz"); + if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); } download_url(url, &tarball, 300)?; + if let Some(cb) = progress { cb("Java: extracting..."); } let (ok, out) = run_command("tar", &[ "-xzf", tarball.to_str().unwrap_or(""), @@ -478,14 +512,15 @@ exec java \ std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)) .map_err(|e| format!("chmod launcher: {}", e))?; } + if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); } Ok(launcher) } /// Dispatch a sentinel download tier to the correct helper. -fn run_download_tier(name: &str, env: &EnvInfo) -> Result { +fn run_download_tier(name: &str, env: &EnvInfo, progress: ProgressFn<'_>) -> Result { match name { - DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env), - DOWNLOAD_JDTLS => install_jdtls_from_eclipse(), + DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress), + DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress), 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, /// but happens with broken rustup installs). Re-checking gives us a /// real signal rather than trusting the exit code alone. +#[allow(dead_code)] 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 { if let Some(path) = which(bin) { + if let Some(cb) = progress { cb(&format!("{}: already installed (PATH)", def.language)); } return ProvisionResult::AlreadyAvailable { server_name: def.name.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//...). + 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"); 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() { - "rustup" => env.has_rustup, - "npm" => env.has_npm, - "go" => env.has_go, - "java" => env.has_java, - "apt" => env.has_apt, - "brew" => env.has_brew, - _ => which(req).is_some(), + "rustup" => env.has_rustup, "npm" => env.has_npm, + "go" => env.has_go, "java" => env.has_java, + "cargo" => env.has_cargo, "curl" => env.has_curl, + "tar" => env.has_tar, "pacman" => env.has_pacman, + "apt" => env.has_apt, "brew" => env.has_brew, + "dnf" => env.has_dnf, _ => which(req).is_some(), }); if !prereqs_met { - last_reason = format!( - "tier '{}' skipped: missing prerequisite (one of: {})", - tier.label, - tier.requires.join(", ") - ); - warn!( - server = %def.name, - tier = %tier.label, - "install tier skipped — missing prerequisites" - ); + let skip = format!("{}: {} — missing prerequisite", def.language, tier.label); + if let Some(cb) = progress { cb(&skip); } + last_reason = format!("tier '{}' skipped: missing prerequisite", tier.label); + warn!(server = %def.name, tier = %tier.label, "skipped — missing prerequisites"); 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("__") { - match run_download_tier(&tier.command, env) { + match run_download_tier(&tier.command, env, progress) { 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 { server_name: def.name.clone(), language: def.language.clone(), @@ -585,72 +634,49 @@ pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResu } Err(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; } } } - // Normal tier: shell out. + // Normal shell-out tier. let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect(); match run_command(&tier.command, &arg_refs) { Ok((true, _)) => { - // Verify the binary is now actually reachable. let located = def .binary_names .iter() .find_map(|b| which(b).map(|p| p.to_string_lossy().to_string())); if let Some(path) = located { - info!( - server = %def.name, - tier = %tier.label, - binary = %path, - "installed language server" - ); + if let Some(cb) = progress { cb(&format!("{}: installed ✓", def.language)); } + info!(server = %def.name, tier = %tier.label, binary = %path, "installed"); return ProvisionResult::Installed { server_name: def.name.clone(), language: def.language.clone(), binary_path: path, }; } - last_reason = format!( - "tier '{}' exited 0 but '{}' not found on PATH afterwards", - tier.label, tier.command - ); - warn!( - server = %def.name, - tier = %tier.label, - "install command reported success but binary missing" - ); + last_reason = format!("tier '{}' exited 0 but binary not on PATH", tier.label); + warn!(server = %def.name, tier = %tier.label, "success reported but binary missing"); } Ok((false, out)) => { let trimmed = out.trim(); - last_reason = format!("tier '{}' failed: {}", tier.label, trimmed); - warn!( - server = %def.name, - tier = %tier.label, - output = trimmed, - "install command failed" - ); + let snippet: String = trimmed.chars().take(300).collect(); + last_reason = format!("tier '{}' failed: {}", tier.label, snippet); + warn!(server = %def.name, tier = %tier.label, output = %snippet, "failed"); } Err(e) => { last_reason = format!("tier '{}' error: {}", tier.label, e); - warn!( - server = %def.name, - tier = %tier.label, - error = %e, - "install command errored" - ); + warn!(server = %def.name, tier = %tier.label, error = %e, "errored"); } } } let manual = manual_instructions(def); ProvisionResult::Failed { - language: def.language.clone(), - server_name: def.name.clone(), - reason: last_reason, - manual_instructions: manual, + language: def.language.clone(), server_name: def.name.clone(), + 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() /// call provision_single() → collect results. Order matches /// supported_servers() (rust, typescript, go, java). +#[allow(dead_code)] pub fn provision_all() -> Vec { let env = detect_env(); info!( @@ -684,6 +711,28 @@ pub fn provision_all() -> Vec { .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 { + 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::>().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 /// server to the given `LspManager`. /// diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs index d8a7f39..e018671 100644 --- a/src/app/state/rest.rs +++ b/src/app/state/rest.rs @@ -153,30 +153,35 @@ impl AppStateRest { std::thread::spawn(move || { use crate::app::lsp::provisioner::{self, ProvisionResult}; - fn push_msg(q: &Arc>>, msg: String) { + fn push_msg(q: &Arc>>, msg: &str) { 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()); - let results = provisioner::provision_all(); - push_msg(&msg_queue, "LSP: connecting servers...".to_string()); + // Wrap the msg_queue in a static-lifetime closure for use as ProgressFn. + let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg)); + + 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); for name in &connected { 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 { if let ProvisionResult::Failed { language, server_name, reason, .. } = r { 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() { - 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 { - 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); } }); }