refactor: massive codebase restructuring — naming, splitting, DRY

Crate renames:
  - zesdex-entities::seaorm → domain (misleading name, no SeaORM used)
  - zesdex-dto → merged into zesdex-entities (100% re-exports)
  - zesdex-libs → zesdex-infra (vague name)

Module renames:
  - app/harness → guard (misleading: safety gatekeeper, not test harness)
  - runtime/commands → action_dispatch (name clashed with controller/command)
  - resources → prompts (embedded prompt text, not general resources)
  - tool/seqthink → sequential_think (unreadable abbreviation)
  - msglog/query → insert (module only inserts, never queries)

Dead code removal:
  - app/mode/help.rs (orphaned — not declared in mod.rs)
  - app/mode/loading.rs (orphaned — not declared in mod.rs)

File splitting (71 new files, avg ~115 lines/file):
  - app/runtime/actions/: 1→8 files (was 2030 lines)
  - view/overlays/: 1→16 files (was 1167 lines)
  - tool/lsp/: 1→8 per-tool files (was 909 lines)
  - main.rs: 1→5 files (session, daemon, attach, event_loop)
  - workflow/engine + hive_mind: 2→10 files
  - subagent/engine + auto: 2→9 files
  - lsp/provisioner: 1→5 files
  - review/: 1→6 files
  - guard/: 1→2 files (extracted patterns)
  - state/misc: 1→3 files (input, scroll)
  - mcp/: 1→3 files (transport, adapter)
  - stream/json_repair extracted from turn.rs

DRY:
  - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent
  - 3 near-identical background spawners → 1 generic + thin wrappers
  - Shared spawn_subagent_with_drain() extracted
  - Shared create_session() in main
  - write_osc52 deduplicated

Bug fixes:
  - archive_message(): sess.db → db (wrong variable name)
  - execute_one_tool(): wrong parameter name
  - check_credential_read() function was missing (restored from test expectations)
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 1f0ae9f551
commit 9a67137954
139 changed files with 9704 additions and 8858 deletions
@@ -1,849 +0,0 @@
//! Auto-provisioning engine for LSP language servers.
//!
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
//! → `provision_single()` tries install tiers in order → returns
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call `auto_connect()` to attach available servers
//! to an existing `LspManager`.
//!
//! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers.
//! Each tier is a fallback for the previous, so we try the most
//! user-friendly path first (rustup component, npm global, etc.) and
//! only fall back to package managers or manual download if those fail.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
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
/// Installed both mean the binary can be launched; Failed means we
/// gave up and the user needs to install manually (see `manual_instructions`).
#[derive(Debug, Clone)]
pub enum ProvisionResult {
/// Binary was already on PATH — no install was needed.
AlreadyAvailable {
server_name: String,
language: String,
binary_path: String,
},
/// Provisioner successfully installed the binary during this run.
Installed {
server_name: String,
language: String,
binary_path: String,
},
/// Every install tier failed. Tells the user how to install by hand.
Failed {
language: String,
server_name: String,
reason: String,
},
}
/// Sentinel command names used by `provision_single` to detect "download"
/// tiers (which are dispatched to `download_*` helpers rather than
/// `run_command`). Kept as constants so `supported_servers` stays readable.
const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
/// Static description of a single language server: how to detect it,
/// what file extensions it handles, and how to install it.
#[derive(Debug, Clone)]
pub struct LanguageServerDef {
/// Human-readable server name (e.g. "rust-analyzer").
pub name: String,
/// LSP language identifier (e.g. "rust").
pub language: String,
/// File extensions this server handles (with leading dot).
pub extensions: Vec<String>,
/// Candidate binary names — the provisioner accepts whichever appears on PATH.
pub binary_names: Vec<String>,
/// Install strategies, tried in order until one succeeds.
pub install_tiers: Vec<InstallTier>,
}
/// A single install attempt: a command (plus args) gated by a prerequisite.
///
/// `requires` lists binaries that must already be on PATH for this tier
/// to be considered. If any required binary is missing, the tier is
/// skipped (not attempted) so we don't produce misleading failures
/// like "rustup: command not found" when the real fix was to install
/// rustup first.
#[derive(Debug, Clone)]
pub struct InstallTier {
/// Short human-readable label, e.g. "rustup component".
pub label: String,
/// Binaries that must be available before this tier is attempted.
pub requires: Vec<String>,
/// Command to run.
pub command: String,
/// Arguments to pass to the command.
pub args: Vec<String>,
}
/// Rust toolchain availability on the host PATH.
#[derive(Debug, Clone)]
pub struct RustToolchain {
pub has_rustup: bool,
pub has_cargo: bool,
}
/// Web / scripting language toolchain availability.
#[derive(Debug, Clone)]
pub struct WebToolchain {
pub has_npm: bool,
pub has_go: bool,
pub has_java: bool,
}
/// General-purpose platform utilities.
#[derive(Debug, Clone)]
pub struct PlatformUtils {
pub has_curl: bool,
pub has_tar: bool,
}
/// Pacman and Brew package managers (Arch / macOS).
#[derive(Debug, Clone)]
pub struct PacmanBrew {
pub has_pacman: bool,
pub has_brew: bool,
}
/// Apt and DNF package managers (Debian / Fedora).
#[derive(Debug, Clone)]
pub struct AptDnf {
pub has_apt: bool,
pub has_dnf: bool,
}
/// Snapshot of the host environment used to decide which install tiers are viable.
///
/// Populated by `detect_env()` once per `provision_all_with_progress()` call so we
/// don't re-shell out for every server. `is_linux` / `is_macos` are
/// computed at startup (compile time would also work, but keeping the
/// shape uniform with the rest of the struct makes the call sites tidy).
#[derive(Debug, Clone)]
pub struct EnvInfo {
pub rust: RustToolchain,
pub web: WebToolchain,
pub platform: PlatformUtils,
pub pacman_brew: PacmanBrew,
pub apt_dnf: AptDnf,
pub is_linux: bool,
pub is_macos: bool,
}
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the `PathBuf`.
///
/// Returns None if `which` itself is missing, fails to spawn, or the
/// binary is not on PATH. We deliberately don't cache this — it's only
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
pub fn which(binary: &str) -> Option<PathBuf> {
let output = Command::new("which").arg(binary).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first = stdout.lines().next()?.trim();
if first.is_empty() {
None
} else {
Some(PathBuf::from(first))
}
}
/// Snapshot the host environment: which toolchains and package managers
/// are available, and what OS we're on.
///
/// Flow: shell out to `which` for each tool in parallel (sequentially,
/// actually — the calls are fast and the ordering doesn't matter)
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
/// compile time since `which` won't tell us.
///
/// Edge case: `which` may not exist on Windows; we guard with cfg so
/// this only ever runs on Unix-like targets.
pub fn detect_env() -> EnvInfo {
EnvInfo {
rust: RustToolchain {
has_rustup: which("rustup").is_some(),
has_cargo: which("cargo").is_some(),
},
web: WebToolchain {
has_npm: which("npm").is_some(),
has_go: which("go").is_some(),
has_java: which("java").is_some(),
},
platform: PlatformUtils {
has_curl: which("curl").is_some(),
has_tar: which("tar").is_some(),
},
pacman_brew: PacmanBrew {
has_pacman: which("pacman").is_some(),
has_brew: which("brew").is_some(),
},
apt_dnf: AptDnf {
has_apt: which("apt").is_some() || which("apt-get").is_some(),
has_dnf: which("dnf").is_some(),
},
is_linux: cfg!(target_os = "linux"),
is_macos: cfg!(target_os = "macos"),
}
}
/// Return the static set of supported language servers.
///
/// The order is significant: it determines provisioning order and
/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are
/// the canonical/idiomatic install for each ecosystem; later tiers
/// are fallbacks for hosts that lack the primary tooling.
///
/// Why hard-coded rather than loaded from settings: the set is small,
/// changes rarely, and bundling it lets the provisioner run before any
/// user config has been read (e.g. on first launch).
pub fn supported_servers() -> Vec<LanguageServerDef> {
vec![
LanguageServerDef {
name: "rust-analyzer".to_string(),
language: "rust".to_string(),
extensions: vec![".rs".to_string()],
binary_names: vec!["rust-analyzer".to_string()],
install_tiers: vec![
InstallTier {
label: "rustup component".to_string(),
requires: vec!["rustup".to_string()],
command: "rustup".to_string(),
args: vec![
"component".to_string(),
"add".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "pacman".to_string(),
requires: vec!["pacman".to_string()],
command: "pacman".to_string(),
args: vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "brew".to_string(),
requires: vec!["brew".to_string()],
command: "brew".to_string(),
args: vec!["install".to_string(), "rust-analyzer".to_string()],
},
InstallTier {
label: "cargo install".to_string(),
requires: vec!["cargo".to_string()],
command: "cargo".to_string(),
args: vec![
"install".to_string(),
"--locked".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "download prebuilt".to_string(),
requires: vec!["curl".to_string(), "tar".to_string()],
command: DOWNLOAD_RUST_BIN.to_string(),
args: vec![],
},
],
},
LanguageServerDef {
name: "typescript-language-server".to_string(),
language: "typescript".to_string(),
extensions: vec![
".ts".to_string(),
".tsx".to_string(),
".js".to_string(),
".jsx".to_string(),
],
binary_names: vec!["typescript-language-server".to_string()],
install_tiers: vec![InstallTier {
label: "npm global".to_string(),
requires: vec!["npm".to_string()],
command: "npm".to_string(),
args: vec![
"install".to_string(),
"-g".to_string(),
"typescript".to_string(),
"typescript-language-server".to_string(),
],
}],
},
LanguageServerDef {
name: "gopls".to_string(),
language: "go".to_string(),
extensions: vec![".go".to_string()],
binary_names: vec!["gopls".to_string()],
install_tiers: vec![InstallTier {
label: "go install".to_string(),
requires: vec!["go".to_string()],
command: "go".to_string(),
args: vec![
"install".to_string(),
"golang.org/x/tools/gopls@latest".to_string(),
],
}],
},
LanguageServerDef {
name: "jdtls".to_string(),
language: "java".to_string(),
extensions: vec![".java".to_string()],
binary_names: vec![
"jdtls".to_string(),
"eclipse-jdt-ls".to_string(),
"jdtls-launcher".to_string(),
],
install_tiers: vec![
InstallTier {
label: "pacman".to_string(),
requires: vec!["java".to_string(), "pacman".to_string()],
command: "pacman".to_string(),
args: vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"eclipse-jdt-ls".to_string(),
],
},
InstallTier {
label: "apt".to_string(),
requires: vec!["java".to_string(), "apt".to_string()],
command: "sudo".to_string(),
args: vec![
"apt".to_string(),
"install".to_string(),
"-y".to_string(),
"eclipse-jdt-ls".to_string(),
],
},
InstallTier {
label: "brew".to_string(),
requires: vec!["java".to_string(), "brew".to_string()],
command: "brew".to_string(),
args: vec!["install".to_string(), "jdtls".to_string()],
},
InstallTier {
label: "download from eclipse".to_string(),
requires: vec!["java".to_string(), "curl".to_string(), "tar".to_string()],
command: DOWNLOAD_JDTLS.to_string(),
args: vec![],
},
],
},
]
}
/// Spawn `cmd` with `args`, capture stdout, wait up to 120s, return
/// (success, stdout).
///
/// Flow: build Command with piped stdout/err → spawn → poll in 50ms
/// loops with `child.try_wait()` until the command finishes or
/// 120s elapses (in which case we kill the child).
/// Merging stderr into stdout keeps callers simple — install
/// commands tend to emit errors to stderr, and we want to surface
/// those.
///
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
let mut command = Command::new(cmd);
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command.spawn()?;
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_thread = stdout_handle.map(|s| {
std::thread::spawn(move || {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
buf
})
});
let stderr_thread = stderr_handle.map(|s| {
std::thread::spawn(move || {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
buf
})
});
let timeout = Duration::from_mins(3);
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break Ok(status);
}
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
break Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
));
}
std::thread::sleep(Duration::from_millis(50));
};
let stdout = stdout_thread
.map(|t| t.join().unwrap_or_default())
.unwrap_or_default();
let stderr = stderr_thread
.map(|t| t.join().unwrap_or_default())
.unwrap_or_default();
match status {
Ok(s) if s.success() => Ok((true, stdout)),
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
Err(e) => Err(e),
}
}
/// Resolve the directory where downloaded LSP binaries are stored.
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
let base = dirs::data_dir()
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
.join("zesdex")
.join("lsp")
.join(server);
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.
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();
info!(url = url, dest = %path_str, "downloading");
let args = [
"-fsSL",
"--connect-timeout",
"15",
"--max-time",
&max_secs.to_string(),
"-o",
&path_str,
url,
];
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
if !ok {
return Err(format!("download failed: {}", out.trim()));
}
Ok(())
}
/// 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,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
} else if env.is_macos {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-aarch64-apple-darwin.gz"
} else {
return Err("no prebuilt binary for this OS".to_string());
};
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 {
return Err(format!("gunzip: {}", out.trim()));
}
if !target.exists() {
return Err("binary missing after decompression".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
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(progress: ProgressFn) -> Result<PathBuf, String> {
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(""),
"-C",
base.to_str().unwrap_or("."),
],
)
.map_err(|e| format!("tar spawn: {e}"))?;
if !ok {
return Err(format!("tar: {}", out.trim()));
}
let _ = std::fs::remove_file(&tarball);
if !base.join("plugins").exists() {
return Err("extracted archive missing plugins/ directory".to_string());
}
let bin_dir = base.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
let launcher = bin_dir.join("jdtls");
let script = r#"#!/usr/bin/env bash
set -e
JDTLS_HOME="$(cd "$(dirname "$0")/.." && pwd)"
LAUNCHER=$(ls "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar 2>/dev/null | head -n1)
CONFIG=$(ls -d "${JDTLS_HOME}"/config_* 2>/dev/null | head -n1)
WORKSPACE="${JDTLS_HOME}/workspace"
mkdir -p "${WORKSPACE}"
exec java \
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
-Dosgi.bundles.defaultStartLevel=5 \
-Declipse.product=org.eclipse.jdt.ls.core.product \
-Dlog.level=WARN -noverify -Xmx1G \
-jar "${LAUNCHER}" -configuration "${CONFIG}" -data "${WORKSPACE}" \
--add-modules=ALL-SYSTEM \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
"$@"
"#;
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
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,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
other => Err(format!("unknown download tier '{other}'")),
}
}
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(),
binary_path: path.to_string_lossy().to_string(),
};
}
}
// 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");
for tier in &def.install_tiers {
// Prerequisite gating
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
"rustup" => env.rust.has_rustup,
"npm" => env.web.has_npm,
"go" => env.web.has_go,
"java" => env.web.has_java,
"cargo" => env.rust.has_cargo,
"curl" => env.platform.has_curl,
"tar" => env.platform.has_tar,
"pacman" => env.pacman_brew.has_pacman,
"apt" => env.apt_dnf.has_apt,
"brew" => env.pacman_brew.has_brew,
"dnf" => env.apt_dnf.has_dnf,
_ => which(req).is_some(),
});
if !prereqs_met {
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;
}
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, progress) {
Ok(path) => {
info!(server = %def.name, tier = %tier.label, binary = %path.display(), "installed");
return ProvisionResult::Installed {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: path.to_string_lossy().to_string(),
};
}
Err(e) => {
last_reason = format!("tier '{}' failed: {}", tier.label, e);
warn!(server = %def.name, tier = %tier.label, error = %e, "download failed");
continue;
}
}
}
// Normal shell-out tier.
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => {
let located = def
.binary_names
.iter()
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
if let Some(path) = located {
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 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();
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, "errored");
}
}
}
ProvisionResult::Failed {
language: def.language.clone(),
server_name: def.name.clone(),
reason: last_reason,
}
}
/// Provision every supported server with progress callbacks 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.rust.has_rustup),
("cargo", env.rust.has_cargo),
("npm", env.web.has_npm),
("go", env.web.has_go),
("java", env.web.has_java),
("curl", env.platform.has_curl),
("tar", env.platform.has_tar),
("pacman", env.pacman_brew.has_pacman),
("apt", env.apt_dnf.has_apt),
("brew", env.pacman_brew.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
/// server to the given `LspManager`.
///
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
/// up the `LanguageServerDef`, then call `manager.connect()` with
/// the binary path and empty args. On connect success, log and
/// record the name; on failure, log a warning and skip.
/// Returns the names that successfully connected.
///
/// Why empty args: most LSP servers don't need CLI flags to start;
/// the spec for each server lives in the protocol handshake, not the
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
/// constant in `supported_servers()`.
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
let defs = supported_servers();
let mut connected: Vec<String> = Vec::new();
for result in results {
let (name, language, binary) = match result {
ProvisionResult::AlreadyAvailable {
server_name,
language,
binary_path,
}
| ProvisionResult::Installed {
server_name,
language,
binary_path,
} => (server_name.clone(), language.clone(), binary_path.clone()),
ProvisionResult::Failed { .. } => continue,
};
// Sanity: only connect to servers we know about. Protects against
// future ProvisionResult variants sneaking in unknown names.
let Some(def) = defs.iter().find(|d| d.name == name) else {
warn!(name = %name, "skipping connect: unknown server");
continue;
};
let mut guard = match manager.lock() {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "LspManager mutex poisoned; skipping connect");
continue;
}
};
// Build extension slice for connect_with_extensions.
let ext_refs: Vec<&str> = def
.extensions
.iter()
.map(std::string::String::as_str)
.collect();
match guard.connect_with_extensions(&binary, &[], &language, &ext_refs) {
Ok(()) => {
info!(
name = %name,
language = %language,
binary = %binary,
"connected LSP server"
);
connected.push(name);
}
Err(e) => {
warn!(
name = %name,
error = %e,
"failed to connect LSP server"
);
}
}
}
connected
}
@@ -0,0 +1,226 @@
//! Static language server definitions and core types.
//!
//! Defines the set of supported LSP servers, their install tiers, and the
//! result/enum types used across the provisioner.
/// 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
/// Installed both mean the binary can be launched; Failed means we
/// gave up and the user needs to install manually (see `manual_instructions`).
#[derive(Debug, Clone)]
pub enum ProvisionResult {
/// Binary was already on PATH — no install was needed.
AlreadyAvailable {
server_name: String,
language: String,
binary_path: String,
},
/// Provisioner successfully installed the binary during this run.
Installed {
server_name: String,
language: String,
binary_path: String,
},
/// Every install tier failed. Tells the user how to install by hand.
Failed {
language: String,
server_name: String,
reason: String,
},
}
/// Sentinel command names used by `provision_single` to detect "download"
/// tiers (which are dispatched to `download_*` helpers rather than
/// `run_command`). Kept as constants so `supported_servers` stays readable.
pub(super) const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__";
pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__";
/// Static description of a single language server: how to detect it,
/// what file extensions it handles, and how to install it.
#[derive(Debug, Clone)]
pub struct LanguageServerDef {
/// Human-readable server name (e.g. "rust-analyzer").
pub name: String,
/// LSP language identifier (e.g. "rust").
pub language: String,
/// File extensions this server handles (with leading dot).
pub extensions: Vec<String>,
/// Candidate binary names — the provisioner accepts whichever appears on PATH.
pub binary_names: Vec<String>,
/// Install strategies, tried in order until one succeeds.
pub install_tiers: Vec<InstallTier>,
}
/// A single install attempt: a command (plus args) gated by a prerequisite.
///
/// `requires` lists binaries that must already be on PATH for this tier
/// to be considered. If any required binary is missing, the tier is
/// skipped (not attempted) so we don't produce misleading failures
/// like "rustup: command not found" when the real fix was to install
/// rustup first.
#[derive(Debug, Clone)]
pub struct InstallTier {
/// Short human-readable label, e.g. "rustup component".
pub label: String,
/// Binaries that must be available before this tier is attempted.
pub requires: Vec<String>,
/// Command to run.
pub command: String,
/// Arguments to pass to the command.
pub args: Vec<String>,
}
/// Return the static set of supported language servers.
///
/// The order is significant: it determines provisioning order and
/// the order results appear in `provision_all_with_progress()`. Tier 1 paths are
/// the canonical/idiomatic install for each ecosystem; later tiers
/// are fallbacks for hosts that lack the primary tooling.
///
/// Why hard-coded rather than loaded from settings: the set is small,
/// changes rarely, and bundling it lets the provisioner run before any
/// user config has been read (e.g. on first launch).
pub fn supported_servers() -> Vec<LanguageServerDef> {
vec![
LanguageServerDef {
name: "rust-analyzer".to_string(),
language: "rust".to_string(),
extensions: vec![".rs".to_string()],
binary_names: vec!["rust-analyzer".to_string()],
install_tiers: vec![
InstallTier {
label: "rustup component".to_string(),
requires: vec!["rustup".to_string()],
command: "rustup".to_string(),
args: vec![
"component".to_string(),
"add".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "pacman".to_string(),
requires: vec!["pacman".to_string()],
command: "pacman".to_string(),
args: vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "brew".to_string(),
requires: vec!["brew".to_string()],
command: "brew".to_string(),
args: vec!["install".to_string(), "rust-analyzer".to_string()],
},
InstallTier {
label: "cargo install".to_string(),
requires: vec!["cargo".to_string()],
command: "cargo".to_string(),
args: vec![
"install".to_string(),
"--locked".to_string(),
"rust-analyzer".to_string(),
],
},
InstallTier {
label: "download prebuilt".to_string(),
requires: vec!["curl".to_string(), "tar".to_string()],
command: DOWNLOAD_RUST_BIN.to_string(),
args: vec![],
},
],
},
LanguageServerDef {
name: "typescript-language-server".to_string(),
language: "typescript".to_string(),
extensions: vec![
".ts".to_string(),
".tsx".to_string(),
".js".to_string(),
".jsx".to_string(),
],
binary_names: vec!["typescript-language-server".to_string()],
install_tiers: vec![InstallTier {
label: "npm global".to_string(),
requires: vec!["npm".to_string()],
command: "npm".to_string(),
args: vec![
"install".to_string(),
"-g".to_string(),
"typescript".to_string(),
"typescript-language-server".to_string(),
],
}],
},
LanguageServerDef {
name: "gopls".to_string(),
language: "go".to_string(),
extensions: vec![".go".to_string()],
binary_names: vec!["gopls".to_string()],
install_tiers: vec![InstallTier {
label: "go install".to_string(),
requires: vec!["go".to_string()],
command: "go".to_string(),
args: vec![
"install".to_string(),
"golang.org/x/tools/gopls@latest".to_string(),
],
}],
},
LanguageServerDef {
name: "jdtls".to_string(),
language: "java".to_string(),
extensions: vec![".java".to_string()],
binary_names: vec![
"jdtls".to_string(),
"eclipse-jdt-ls".to_string(),
"jdtls-launcher".to_string(),
],
install_tiers: vec![
InstallTier {
label: "pacman".to_string(),
requires: vec!["java".to_string(), "pacman".to_string()],
command: "pacman".to_string(),
args: vec![
"-S".to_string(),
"--noconfirm".to_string(),
"--needed".to_string(),
"eclipse-jdt-ls".to_string(),
],
},
InstallTier {
label: "apt".to_string(),
requires: vec!["java".to_string(), "apt".to_string()],
command: "sudo".to_string(),
args: vec![
"apt".to_string(),
"install".to_string(),
"-y".to_string(),
"eclipse-jdt-ls".to_string(),
],
},
InstallTier {
label: "brew".to_string(),
requires: vec!["java".to_string(), "brew".to_string()],
command: "brew".to_string(),
args: vec!["install".to_string(), "jdtls".to_string()],
},
InstallTier {
label: "download from eclipse".to_string(),
requires: vec!["java".to_string(), "curl".to_string(), "tar".to_string()],
command: DOWNLOAD_JDTLS.to_string(),
args: vec![],
},
],
},
]
}
@@ -0,0 +1,120 @@
//! Environment discovery: finding binaries on PATH and detecting available
//! toolchains / package managers on the host system.
use std::path::PathBuf;
use std::process::Command;
/// Rust toolchain availability on the host PATH.
#[derive(Debug, Clone)]
pub struct RustToolchain {
pub has_rustup: bool,
pub has_cargo: bool,
}
/// Web / scripting language toolchain availability.
#[derive(Debug, Clone)]
pub struct WebToolchain {
pub has_npm: bool,
pub has_go: bool,
pub has_java: bool,
}
/// General-purpose platform utilities.
#[derive(Debug, Clone)]
pub struct PlatformUtils {
pub has_curl: bool,
pub has_tar: bool,
}
/// Pacman and Brew package managers (Arch / macOS).
#[derive(Debug, Clone)]
pub struct PacmanBrew {
pub has_pacman: bool,
pub has_brew: bool,
}
/// Apt and DNF package managers (Debian / Fedora).
#[derive(Debug, Clone)]
pub struct AptDnf {
pub has_apt: bool,
pub has_dnf: bool,
}
/// Snapshot of the host environment used to decide which install tiers are viable.
///
/// Populated by `detect_env()` once per `provision_all_with_progress()` call so we
/// don't re-shell out for every server. `is_linux` / `is_macos` are
/// computed at startup (compile time would also work, but keeping the
/// shape uniform with the rest of the struct makes the call sites tidy).
#[derive(Debug, Clone)]
pub struct EnvInfo {
pub rust: RustToolchain,
pub web: WebToolchain,
pub platform: PlatformUtils,
pub pacman_brew: PacmanBrew,
pub apt_dnf: AptDnf,
pub is_linux: bool,
pub is_macos: bool,
}
/// Check whether `binary` exists on PATH by shelling out to `which`.
///
/// Flow: `Command::new("which").arg(binary).output()` → on Unix
/// `which` returns exit 0 + stdout path when found, non-zero
/// otherwise. We return the first stdout line as the `PathBuf`.
///
/// Returns None if `which` itself is missing, fails to spawn, or the
/// binary is not on PATH. We deliberately don't cache this — it's only
/// called during provisioning and the results feed into install-tier
/// gating, which is already cheap.
pub fn which(binary: &str) -> Option<PathBuf> {
let output = Command::new("which").arg(binary).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first = stdout.lines().next()?.trim();
if first.is_empty() {
None
} else {
Some(PathBuf::from(first))
}
}
/// Snapshot the host environment: which toolchains and package managers
/// are available, and what OS we're on.
///
/// Flow: shell out to `which` for each tool in parallel (sequentially,
/// actually — the calls are fast and the ordering doesn't matter)
/// → set `EnvInfo` flags. Linux/macOS are detected via cfg at
/// compile time since `which` won't tell us.
///
/// Edge case: `which` may not exist on Windows; we guard with cfg so
/// this only ever runs on Unix-like targets.
pub fn detect_env() -> EnvInfo {
EnvInfo {
rust: RustToolchain {
has_rustup: which("rustup").is_some(),
has_cargo: which("cargo").is_some(),
},
web: WebToolchain {
has_npm: which("npm").is_some(),
has_go: which("go").is_some(),
has_java: which("java").is_some(),
},
platform: PlatformUtils {
has_curl: which("curl").is_some(),
has_tar: which("tar").is_some(),
},
pacman_brew: PacmanBrew {
has_pacman: which("pacman").is_some(),
has_brew: which("brew").is_some(),
},
apt_dnf: AptDnf {
has_apt: which("apt").is_some() || which("apt-get").is_some(),
has_dnf: which("dnf").is_some(),
},
is_linux: cfg!(target_os = "linux"),
is_macos: cfg!(target_os = "macos"),
}
}
@@ -0,0 +1,199 @@
//! Download and install helpers for LSP servers not available via
//! system package managers.
//!
//! Each helper downloads a prebuilt binary (or archive) and places it
//! under `~/.local/share/zesdex/lsp/<server-name>/`.
use std::path::{Path, PathBuf};
use tracing::info;
use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN};
use super::discovery::EnvInfo;
use super::manager::run_command;
/// Resolve the directory where downloaded LSP binaries are stored.
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
let base = dirs::data_dir()
.ok_or_else(|| "cannot find data directory via dirs crate".to_string())?
.join("zesdex")
.join("lsp")
.join(server);
Ok(base)
}
/// Check whether `def` was previously installed via the download tier
/// (binary/launcher lives under `~/.local/share/zesdex/lsp/<name>/`).
/// Returns the path to the binary if found.
pub(super) fn previous_download_install(def: &super::config::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.
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();
info!(url = url, dest = %path_str, "downloading");
let args = [
"-fsSL",
"--connect-timeout",
"15",
"--max-time",
&max_secs.to_string(),
"-o",
&path_str,
url,
];
let (ok, out) = run_command("curl", &args).map_err(|e| format!("curl spawn: {e}"))?;
if !ok {
return Err(format!("download failed: {}", out.trim()));
}
Ok(())
}
/// 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,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
let base = lsp_install_dir("rust-analyzer")?;
std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?;
let url = if env.is_linux {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz"
} else if env.is_macos {
"https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-aarch64-apple-darwin.gz"
} else {
return Err("no prebuilt binary for this OS".to_string());
};
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 {
return Err(format!("gunzip: {}", out.trim()));
}
if !target.exists() {
return Err("binary missing after decompression".to_string());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
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(progress: ProgressFn) -> Result<PathBuf, String> {
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(""),
"-C",
base.to_str().unwrap_or("."),
],
)
.map_err(|e| format!("tar spawn: {e}"))?;
if !ok {
return Err(format!("tar: {}", out.trim()));
}
let _ = std::fs::remove_file(&tarball);
if !base.join("plugins").exists() {
return Err("extracted archive missing plugins/ directory".to_string());
}
let bin_dir = base.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("mkdir bin: {e}"))?;
let launcher = bin_dir.join("jdtls");
let script = r#"#!/usr/bin/env bash
set -e
JDTLS_HOME="$(cd "$(dirname "$0")/.." && pwd)"
LAUNCHER=$(ls "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar 2>/dev/null | head -n1)
CONFIG=$(ls -d "${JDTLS_HOME}"/config_* 2>/dev/null | head -n1)
WORKSPACE="${JDTLS_HOME}/workspace"
mkdir -p "${WORKSPACE}"
exec java \
-Declipse.application=org.eclipse.jdt.ls.core.id1 \
-Dosgi.bundles.defaultStartLevel=5 \
-Declipse.product=org.eclipse.jdt.ls.core.product \
-Dlog.level=WARN -noverify -Xmx1G \
-jar "${LAUNCHER}" -configuration "${CONFIG}" -data "${WORKSPACE}" \
--add-modules=ALL-SYSTEM \
--add-opens java.base/java.util=ALL-UNNAMED \
--add-opens java.base/java.lang=ALL-UNNAMED \
"$@"
"#;
std::fs::write(&launcher, script).map_err(|e| format!("write launcher: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
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.
pub(super) fn run_download_tier(
name: &str,
env: &EnvInfo,
progress: ProgressFn<'_>,
) -> Result<PathBuf, String> {
match name {
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
other => Err(format!("unknown download tier '{other}'")),
}
}
@@ -0,0 +1,318 @@
//! Provisioning orchestration: running install commands, iterating over
//! supported servers, and connecting provisioned servers to the LspManager.
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult};
use super::discovery::{self, EnvInfo};
use super::install;
use crate::app::lsp::LspManager;
/// Spawn `cmd` with `args`, capture stdout, wait up to 120s, return
/// (success, stdout).
///
/// Flow: build Command with piped stdout/err → spawn → poll in 50ms
/// loops with `child.try_wait()` until the command finishes or
/// 120s elapses (in which case we kill the child).
/// Merging stderr into stdout keeps callers simple — install
/// commands tend to emit errors to stderr, and we want to surface
/// those.
///
/// Why a custom timeout: `std::process::Command` has no built-in timeout,
/// and we'd rather kill a hung `apt` than block the TUI indefinitely.
pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> {
let mut command = Command::new(cmd);
command.args(args);
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let mut child = command.spawn()?;
let stdout_handle = child.stdout.take();
let stderr_handle = child.stderr.take();
let stdout_thread = stdout_handle.map(|s| {
std::thread::spawn(move || {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
buf
})
});
let stderr_thread = stderr_handle.map(|s| {
std::thread::spawn(move || {
let mut buf = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::BufReader::new(s), &mut buf);
buf
})
});
let timeout = Duration::from_mins(3);
let start = Instant::now();
let status = loop {
if let Some(status) = child.try_wait()? {
break Ok(status);
}
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
break Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!("command '{}' timed out after {}s", cmd, timeout.as_secs()),
));
}
std::thread::sleep(Duration::from_millis(50));
};
let stdout = stdout_thread
.map(|t| t.join().unwrap_or_default())
.unwrap_or_default();
let stderr = stderr_thread
.map(|t| t.join().unwrap_or_default())
.unwrap_or_default();
match status {
Ok(s) if s.success() => Ok((true, stdout)),
Ok(_) => Ok((false, format!("{stdout}{stderr}"))),
Err(e) => Err(e),
}
}
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) = discovery::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(),
binary_path: path.to_string_lossy().to_string(),
};
}
}
// 2. Check download-install directory (~/.local/share/zesdex/lsp/<name>/...).
if let Some(path) = install::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
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
"rustup" => env.rust.has_rustup,
"npm" => env.web.has_npm,
"go" => env.web.has_go,
"java" => env.web.has_java,
"cargo" => env.rust.has_cargo,
"curl" => env.platform.has_curl,
"tar" => env.platform.has_tar,
"pacman" => env.pacman_brew.has_pacman,
"apt" => env.apt_dnf.has_apt,
"brew" => env.pacman_brew.has_brew,
"dnf" => env.apt_dnf.has_dnf,
_ => discovery::which(req).is_some(),
});
if !prereqs_met {
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;
}
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 install::run_download_tier(&tier.command, env, progress) {
Ok(path) => {
info!(server = %def.name, tier = %tier.label, binary = %path.display(), "installed");
return ProvisionResult::Installed {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: path.to_string_lossy().to_string(),
};
}
Err(e) => {
last_reason = format!("tier '{}' failed: {}", tier.label, e);
warn!(server = %def.name, tier = %tier.label, error = %e, "download failed");
continue;
}
}
}
// Normal shell-out tier.
let arg_refs: Vec<&str> = tier.args.iter().map(std::string::String::as_str).collect();
match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => {
let located = def
.binary_names
.iter()
.find_map(|b| discovery::which(b).map(|p| p.to_string_lossy().to_string()));
if let Some(path) = located {
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 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();
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, "errored");
}
}
}
ProvisionResult::Failed {
language: def.language.clone(),
server_name: def.name.clone(),
reason: last_reason,
}
}
/// Provision every supported server with progress callbacks 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 = discovery::detect_env();
if let Some(cb) = progress {
let flags = [
("rustup", env.rust.has_rustup),
("cargo", env.rust.has_cargo),
("npm", env.web.has_npm),
("go", env.web.has_go),
("java", env.web.has_java),
("curl", env.platform.has_curl),
("tar", env.platform.has_tar),
("pacman", env.pacman_brew.has_pacman),
("apt", env.apt_dnf.has_apt),
("brew", env.pacman_brew.has_brew),
];
let avail: String = flags
.iter()
.filter(|(_, v)| *v)
.map(|(k, _)| *k)
.collect::<Vec<_>>()
.join(", ");
cb(&format!("LSP: environment ready — {avail}"));
}
config::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`.
///
/// Flow: for each result, if it's `AlreadyAvailable` or Installed, look
/// up the `LanguageServerDef`, then call `manager.connect()` with
/// the binary path and empty args. On connect success, log and
/// record the name; on failure, log a warning and skip.
/// Returns the names that successfully connected.
///
/// Why empty args: most LSP servers don't need CLI flags to start;
/// the spec for each server lives in the protocol handshake, not the
/// argv. If we ever need flags (e.g. --stdio), they'll be a per-server
/// constant in `supported_servers()`.
pub fn auto_connect(manager: &Arc<Mutex<LspManager>>, results: &[ProvisionResult]) -> Vec<String> {
let defs = config::supported_servers();
let mut connected: Vec<String> = Vec::new();
for result in results {
let (name, language, binary) = match result {
ProvisionResult::AlreadyAvailable {
server_name,
language,
binary_path,
}
| ProvisionResult::Installed {
server_name,
language,
binary_path,
} => (server_name.clone(), language.clone(), binary_path.clone()),
ProvisionResult::Failed { .. } => continue,
};
// Sanity: only connect to servers we know about. Protects against
// future ProvisionResult variants sneaking in unknown names.
let Some(def) = defs.iter().find(|d| d.name == name) else {
warn!(name = %name, "skipping connect: unknown server");
continue;
};
let mut guard = match manager.lock() {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "LspManager mutex poisoned; skipping connect");
continue;
}
};
// Build extension slice for connect_with_extensions.
let ext_refs: Vec<&str> = def
.extensions
.iter()
.map(std::string::String::as_str)
.collect();
match guard.connect_with_extensions(&binary, &[], &language, &ext_refs) {
Ok(()) => {
info!(
name = %name,
language = %language,
binary = %binary,
"connected LSP server"
);
connected.push(name);
}
Err(e) => {
warn!(
name = %name,
error = %e,
"failed to connect LSP server"
);
}
}
}
connected
}
@@ -0,0 +1,41 @@
//! Auto-provisioning engine for LSP language servers.
//!
//! Flow: `detect_env()` → for each supported server in `supported_servers()`
//! → `provision_single()` tries install tiers in order → returns
//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed).
//! Caller can then call `auto_connect()` to attach available servers
//! to an existing `LspManager`.
//!
//! Why: opening a project on a fresh machine should not require the user
//! to manually hunt down and install 4 different language servers.
//! Each tier is a fallback for the previous, so we try the most
//! user-friendly path first (rustup component, npm global, etc.) and
//! only fall back to package managers or manual download if those fail.
mod config;
mod discovery;
mod install;
mod manager;
// -- Re-exports: all public items from the original monolithic provisioner.rs --
// These are kept for API compatibility even if not all are consumed internally.
// Config types and the server definitions
#[allow(unused_imports)]
pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult};
#[allow(unused_imports)]
pub use config::supported_servers;
// Environment discovery
#[allow(unused_imports)]
pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain};
#[allow(unused_imports)]
pub use discovery::which;
// Manager / orchestration
#[allow(unused_imports)]
pub use manager::{auto_connect, provision_all_with_progress, run_command};
// -- Internal plumbing for crate::app::lsp::provisioner::* compatibility --
// `install` module items are all `pub(super)` and not re-exported.
// The old `provision_single_with_progress` was private, so we don't re-export it.