refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+389
View File
@@ -0,0 +1,389 @@
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{json, Value};
const LSP_INIT_TIMEOUT_MS: u64 = 60_000;
const LSP_CALL_TIMEOUT_MS: u64 = 30_000;
const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
pub struct LspClient {
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
server_capabilities: Value,
}
fn file_path_to_uri(path: &str) -> String {
let abs_path = std::path::Path::new(path);
let abs_path = if abs_path.is_relative() {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(_) => abs_path.to_path_buf(),
}
} else {
abs_path.to_path_buf()
};
let canonical = abs_path.canonicalize().unwrap_or(abs_path);
let path_str = canonical.to_string_lossy();
if cfg!(windows) {
let path_str = path_str.replace('\\', "/");
if path_str.starts_with('/') {
format!("file://{path_str}")
} else {
format!("file:///{path_str}")
}
} else {
format!("file://{path_str}")
}
}
impl LspClient {
pub fn spawn(command: &str, args: &[String]) -> anyhow::Result<Self> {
let mut cmd = Command::new(command);
cmd.args(args);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn LSP server '{command}': {e}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdin for LSP server"))?;
let stdout = BufReader::new(
child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("failed to capture stdout for LSP server"))?,
);
let mut client = LspClient {
stdin,
stdout,
next_id: 0,
server_capabilities: Value::Null,
};
let init_params = json!({
"processId": std::process::id(),
"clientInfo": {
"name": "zesdex",
"version": "0.1.0"
},
"capabilities": {
"textDocument": {
"synchronization": {
"dynamicRegistration": true,
"willSave": false,
"willSaveWaitUntil": false,
"didSave": false
},
"hover": {
"dynamicRegistration": true,
"contentFormat": ["plaintext", "markdown"]
},
"completion": {
"dynamicRegistration": true,
"completionItem": {
"snippetSupport": false
}
},
"definition": {
"dynamicRegistration": true
},
"references": {
"dynamicRegistration": true
},
"documentSymbol": {
"dynamicRegistration": true,
"hierarchicalDocumentSymbolSupport": true
}
},
"workspace": {
"workspaceFolders": true
},
"general": {
"positionEncodings": ["utf-16"]
}
}
});
let result = client.call_with_timeout(
"initialize",
&init_params,
Duration::from_millis(LSP_INIT_TIMEOUT_MS),
)?;
client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default();
client.notify("initialized", &json!({}))?;
Ok(client)
}
pub fn server_capabilities(&self) -> &Value {
&self.server_capabilities
}
pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result<Value> {
self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS))
}
fn call_with_timeout(
&mut self,
method: &str,
params: &Value,
timeout: Duration,
) -> anyhow::Result<Value> {
self.next_id += 1;
let id = self.next_id;
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params
});
self.send_frame(&req)?;
self.read_response(id, timeout)
}
pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params
});
self.send_frame(&req)
}
fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> {
let body = serde_json::to_string(msg)
.map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?;
let header = format!("Content-Length: {}\r\n\r\n", body.len());
self.stdin
.write_all(header.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame header: {e}"))?;
self.stdin
.write_all(body.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write LSP frame body: {e}"))?;
self.stdin
.flush()
.map_err(|e| anyhow::anyhow!("failed to flush LSP stdin: {e}"))?;
Ok(())
}
fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
if Instant::now() > deadline {
anyhow::bail!("LSP call timed out after {}ms", timeout.as_millis());
}
let frame = self.read_frame()?;
if frame.get("id") == Some(&json!(expected_id)) {
if let Some(err) = frame.get("error") {
let code = err
.get("code")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
anyhow::bail!("LSP error {code}: {msg}");
}
return Ok(frame.get("result").cloned().unwrap_or(Value::Null));
}
}
}
pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
if Instant::now() > deadline {
anyhow::bail!("timed out waiting for LSP notification '{method}'");
}
let frame = self.read_frame()?;
if frame.get("method") == Some(&json!(method)) {
return Ok(frame.get("params").cloned().unwrap_or(Value::Null));
}
}
}
fn read_frame(&mut self) -> anyhow::Result<Value> {
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
match self.stdout.read_line(&mut line) {
Ok(0) => anyhow::bail!("LSP server closed the connection"),
Ok(_) => {}
Err(e) => anyhow::bail!("LSP read error: {e}"),
}
let trimmed = line.trim();
if trimmed.is_empty() {
break;
}
if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") {
// Cap Content-Length at 64 MiB to prevent OOM from a
// malicious or misconfigured LSP server (CWE-400).
const MAX_CONTENT_LENGTH: usize = 64 * 1024 * 1024;
let length: usize = len_str.trim().parse::<usize>().map_err(|e| {
anyhow::anyhow!("invalid Content-Length '{}': {}", len_str.trim(), e)
})?;
if length > MAX_CONTENT_LENGTH {
anyhow::bail!(
"Content-Length {length} exceeds maximum allowed size of {MAX_CONTENT_LENGTH} bytes",
);
}
content_length = Some(length);
}
}
let length = content_length
.ok_or_else(|| anyhow::anyhow!("missing Content-Length header in LSP response"))?;
let mut body = vec![0u8; length];
self.stdout
.read_exact(&mut body)
.map_err(|e| anyhow::anyhow!("failed to read LSP body ({length} bytes): {e}"))?;
let json_str = String::from_utf8(body)
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in LSP response: {e}"))?;
serde_json::from_str(&json_str)
.map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}"))
}
pub fn did_open(
&mut self,
uri: &str,
language_id: &str,
version: i32,
text: &str,
) -> anyhow::Result<()> {
self.notify(
"textDocument/didOpen",
&json!({
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": version,
"text": text
}
}),
)
}
pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> {
self.notify(
"textDocument/didChange",
&json!({
"textDocument": {
"uri": uri,
"version": version
},
"contentChanges": [{
"text": text
}]
}),
)
}
pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> {
self.notify(
"textDocument/didClose",
&json!({
"textDocument": {
"uri": uri
}
}),
)
}
pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/hover",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
}
pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/completion",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
}
pub fn goto_definition(
&mut self,
uri: &str,
line: u32,
character: u32,
) -> anyhow::Result<Value> {
self.call(
"textDocument/definition",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character }
}),
)
}
pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result<Value> {
self.call(
"textDocument/references",
&json!({
"textDocument": { "uri": uri },
"position": { "line": line, "character": character },
"context": {
"includeDeclaration": true
}
}),
)
}
pub fn collect_diagnostics(
&mut self,
uri: &str,
language_id: &str,
text: &str,
) -> anyhow::Result<Value> {
self.did_open(uri, language_id, 1, text)?;
let result = self.read_notification(
"textDocument/publishDiagnostics",
Duration::from_millis(LSP_DIAGNOSTICS_TIMEOUT_MS),
);
self.did_close(uri)?;
match result {
Ok(params) => Ok(params
.get("diagnostics")
.cloned()
.unwrap_or_else(|| json!([]))),
Err(e) => Err(e),
}
}
pub fn shutdown(&mut self) {
let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5));
let _ = self.notify("exit", &json!({}));
}
}
impl Drop for LspClient {
fn drop(&mut self) {
let _ = self.notify("exit", &json!({}));
}
}
pub fn path_to_lsp_uri(path: &str) -> String {
file_path_to_uri(path)
}
+259
View File
@@ -0,0 +1,259 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
mod client;
pub mod provisioner;
pub use client::{path_to_lsp_uri, LspClient};
/// A tracked LSP server entry.
///
/// Holds the spawn metadata and a shared handle to the connected
/// [`LspClient`]. The `Arc<Mutex<...>>` is cloned by callers that need
/// to issue LSP requests from threads or async tasks.
#[derive(Clone)]
pub struct LspServer {
pub language_id: String,
pub client: Arc<Mutex<LspClient>>,
}
/// Metadata for a document the manager has announced to an LSP server.
///
/// Used to track the current `version` and `languageId` for files
/// already sent via `textDocument/didOpen`, so subsequent edits can be
/// replayed as `textDocument/didChange` notifications.
#[derive(Clone)]
pub struct OpenDoc {
pub language: String,
pub version: i32,
}
/// Central registry of connected LSP servers and per-extension routing.
///
/// Flow: caller calls `connect*` -> client spawned -> entry pushed to
/// `servers` -> `extension_registry` is populated by `register_extensions`.
/// File edits route through `extension_registry` and are dispatched as
/// `didOpen` / `didChange` notifications.
#[derive(Clone)]
pub struct LspManager {
pub servers: Vec<LspServer>,
/// Maps file extension (".rs", ".ts", ...) -> language id.
pub extension_registry: HashMap<String, String>,
/// Maps document URI -> tracked open document state.
pub open_files: HashMap<String, OpenDoc>,
}
impl LspManager {
/// Create an empty manager with no connected servers and empty registries.
pub fn new() -> Self {
LspManager {
servers: Vec::new(),
extension_registry: HashMap::new(),
open_files: HashMap::new(),
}
}
/// Spawn an LSP server and register it under `language_id`.
///
/// Fails if a server with the same `language_id` is already connected.
pub fn connect(
&mut self,
command: &str,
args: &[String],
language_id: &str,
) -> anyhow::Result<()> {
if self.servers.iter().any(|s| s.language_id == language_id) {
anyhow::bail!("LSP server for language '{language_id}' is already connected");
}
let client = LspClient::spawn(command, args)?;
self.servers.push(LspServer {
language_id: language_id.to_string(),
client: Arc::new(Mutex::new(client)),
});
Ok(())
}
/// Return a clone of the `Arc<Mutex<LspClient>>` for a connected server.
///
/// Cloning the `Arc` lets callers issue requests without holding a
/// borrow on the manager.
pub fn get_client(&self, language_id: &str) -> Option<Arc<Mutex<LspClient>>> {
self.servers
.iter()
.find(|s| s.language_id == language_id)
.map(|s| s.client.clone())
}
/// Shut down and remove a server by language. Returns true if it existed.
pub fn disconnect(&mut self, language_id: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.language_id == language_id) {
if let Ok(mut client) = server.client.lock() {
client.shutdown();
}
}
let len = self.servers.len();
self.servers.retain(|s| s.language_id != language_id);
self.servers.len() < len
}
/// Return the language id (e.g. "rust") registered for `language_id`.
pub fn get_language_id(&self, language_id: &str) -> Option<String> {
self.servers
.iter()
.find(|s| s.language_id == language_id)
.map(|s| s.language_id.clone())
}
/// Register a set of file extensions for an already-connected server.
///
/// Flow: for each `ext`, write `language_id` into `extension_registry`.
/// Re-registration overwrites the previous target. Unknown language IDs
/// are accepted at this layer — caller must ensure a server for
/// `language_id` is connected or will be connected later.
pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) {
for ext in extensions {
self.extension_registry
.insert(ext.to_string(), language_id.to_string());
}
}
/// Notify the relevant LSP server that a file's contents have changed.
///
/// Flow: resolve language by extension from the registry -> read file contents ->
/// either send `didOpen` (first time) or `didChange` (already tracked)
/// -> update `open_files` with the new version.
///
/// Non-critical failures (file missing, server unreachable, send
/// error) are logged with `tracing::warn!` rather than propagated,
/// so a stale notification cannot abort the calling flow.
pub fn did_change_file(&mut self, path: &Path) {
let Some(ext) = path
.extension()
.and_then(|e| e.to_str())
.map(|s| format!(".{s}"))
else {
tracing::warn!("did_change_file: path has no extension: {:?}", path);
return;
};
let Some(language_id) = self.extension_registry.get(&ext).cloned() else {
tracing::warn!(
"did_change_file: no LSP server registered for extension '{}'",
ext
);
return;
};
let uri = path_to_lsp_uri(&path.to_string_lossy());
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
tracing::warn!("did_change_file: failed to read {:?}: {}", path, e);
return;
}
};
let Some(client) = self.get_client(&language_id) else {
tracing::warn!("did_change_file: no client for language '{}'", language_id);
return;
};
let next_version = match self.open_files.get(&uri) {
Some(existing) => existing.version + 1,
None => 1,
};
let send_result = {
let mut client = match client.lock() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"did_change_file: client mutex poisoned for '{}': {}",
language_id,
e
);
return;
}
};
if self.open_files.contains_key(&uri) {
client.did_change(&uri, next_version, &text)
} else {
client.did_open(&uri, &language_id, next_version, &text)
}
};
if let Err(e) = send_result {
tracing::warn!(
"did_change_file: failed to notify '{}' for {}: {}",
language_id,
uri,
e
);
return;
}
self.open_files.insert(
uri.clone(),
OpenDoc {
language: language_id,
version: next_version,
},
);
}
/// Shut down every connected server and clear the server list.
///
/// Flow: iterate `servers` -> call `client.shutdown()` on each ->
/// drop the vec. Failures from individual shutdowns are swallowed
/// because the goal is best-effort termination during teardown.
pub fn shutdown_all(&mut self) {
for server in &self.servers {
if let Ok(mut client) = server.client.lock() {
client.shutdown();
}
}
self.servers.clear();
}
/// Snapshot the connected servers as `(language_id, has_open_docs)` pairs.
///
/// `has_open_docs` is true if any tracked `OpenDoc` was registered
/// against this server's clients. Useful for status displays.
pub fn list_servers(&self) -> Vec<(String, bool)> {
self.servers
.iter()
.map(|s| {
let lang = s.language_id.clone();
let has_open = self
.open_files
.values()
.any(|d| d.language == s.language_id);
(lang, has_open)
})
.collect()
}
/// Connect an LSP server and register its default extensions in one call.
///
/// Flow: invoke `connect` -> on success, register `extensions` against
/// `language_id` in `extension_registry`. If `connect` fails, the registries
/// are left untouched and the error is propagated.
pub fn connect_with_extensions(
&mut self,
command: &str,
args: &[String],
language_id: &str,
extensions: &[&str],
) -> anyhow::Result<()> {
self.connect(command, args, language_id)?;
self.register_extensions(language_id, extensions);
Ok(())
}
}
impl Default for LspManager {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,849 @@
//! 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
}