feat(lsp): implement auto-provisioning for language servers

- Added LSP auto-provisioning functionality to automatically install and connect language servers.
- Introduced `shutdown_lsp` method to cleanly shut down LSP servers on application exit.
- Enhanced `AppStateRest` to spawn a background thread for provisioning language servers.
- Updated `builtin_agents` to include new LSP-related agents.
- Modified settings to include options for LSP auto-provisioning and supported languages.
- Updated file editing and writing tools to notify LSP servers of changes.
- Enhanced LSP tools to support auto-detection of servers based on file extensions.
- Added utility functions for managing known file extensions and resolving server names.
- Created a new `provisioner` module to handle the provisioning logic for various language servers.
This commit is contained in:
asepharyana
2026-07-12 14:47:01 +07:00
parent 48d2dc3ad6
commit e78813ecdb
12 changed files with 1179 additions and 43 deletions
+53
View File
@@ -311,6 +311,59 @@ impl LspClient {
}
}
/// Health-check the LSP server.
///
/// Sends a `textDocument/documentSymbol` request on a dummy URI with a
/// 2-second timeout. Returns `true` if the server responds at all —
/// including with an error response such as "file not found", which
/// still proves the process is up and the JSON-RPC channel is live.
/// Returns `false` on timeout, EOF, or any read/write error.
///
/// Flow: build request → send_frame → poll frames until id matches
/// (alive) or deadline/read error fires (dead).
#[allow(dead_code)]
pub fn is_alive(&mut self) -> bool {
self.next_id += 1;
let id = self.next_id;
let req = json!({
"jsonrpc": "2.0",
"id": id,
"method": "textDocument/documentSymbol",
"params": {
"textDocument": { "uri": "file:///__zesdex_lsp_health_check__.txt" }
}
});
if self.send_frame(&req).is_err() {
return false;
}
let timeout = Duration::from_secs(2);
let deadline = Instant::now() + timeout;
loop {
if Instant::now() > deadline {
return false;
}
match self.read_frame() {
Ok(frame) => {
if frame.get("id") == Some(&json!(id)) {
return true;
}
// Skip unrelated notifications/responses on the same channel.
}
Err(_) => return false,
}
}
}
/// Send the LSP `exit` notification to request graceful shutdown.
///
/// Per the LSP spec, `exit` is a notification — the server is expected
/// to terminate after receiving it without sending a response. We do
/// not block on any reply.
#[allow(dead_code)]
pub fn exit(&mut self) -> anyhow::Result<()> {
self.notify("exit", json!({}))
}
pub fn shutdown(&mut self) -> anyhow::Result<()> {
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5));
let _ = self.notify("exit", json!({}));
+248
View File
@@ -1,8 +1,16 @@
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 {
#[allow(dead_code)]
@@ -15,18 +23,45 @@ pub struct LspServer {
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 `find_server_for_path` / `find_server_for_extension`
/// and are dispatched as `didOpen` / `didChange` notifications.
#[derive(Clone)]
pub struct LspManager {
pub servers: Vec<LspServer>,
/// Maps file extension (".rs", ".ts", ...) -> server name.
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 `name`.
///
/// Fails if a server with the same name is already connected.
pub fn connect(
&mut self,
name: &str,
@@ -48,15 +83,21 @@ impl LspManager {
Ok(())
}
/// Look up a connected server by name and return a reference to its entry.
#[allow(dead_code)]
pub fn find_server(&self, name: &str) -> Option<&LspServer> {
self.servers.iter().find(|s| s.name == name)
}
/// 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, name: &str) -> Option<Arc<Mutex<LspClient>>> {
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone())
}
/// Shut down and remove a server by name. Returns true if it existed.
pub fn disconnect(&mut self, name: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.name == name) {
if let Ok(mut client) = server.client.lock() {
@@ -68,9 +109,215 @@ impl LspManager {
self.servers.len() < len
}
/// Return the language id (e.g. "rust") registered for `name`.
pub fn get_language_id(&self, name: &str) -> Option<String> {
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone())
}
/// Resolve an extension (".rs", ".ts", ...) to its server's client.
///
/// Flow: lookup `extension_registry` -> resolve server name -> clone client.
/// Returns `None` if no server has been registered for `ext`.
#[allow(dead_code)]
pub fn find_server_for_extension(&self, ext: &str) -> Option<Arc<Mutex<LspClient>>> {
self.extension_registry
.get(ext)
.and_then(|name| self.get_client(name))
}
/// Resolve a file path to its server's client by extension.
///
/// Flow: extract the extension from `path` -> delegate to
/// `find_server_for_extension`. Files without an extension or with
/// an unmapped extension return `None`.
#[allow(dead_code)]
pub fn find_server_for_path(&self, path: &Path) -> Option<Arc<Mutex<LspClient>>> {
path.extension()
.and_then(|e| e.to_str())
.map(|s| format!(".{}", s))
.and_then(|ext| self.find_server_for_extension(&ext))
}
/// Register a set of file extensions for an already-connected server.
///
/// Flow: for each `ext`, write `server_name` into `extension_registry`.
/// Re-registration overwrites the previous target. Unknown server
/// names are accepted at this layer — caller must ensure `server_name`
/// is connected or will be connected later.
pub fn register_extensions(&mut self, server_name: &str, extensions: &[&str]) {
for ext in extensions {
self.extension_registry.insert(ext.to_string(), server_name.to_string());
}
}
/// Return the registered server name for a given language id.
///
/// Flow: scan `servers` for the first entry whose `language_id` matches.
/// Used when callers have a language hint rather than a file path.
#[allow(dead_code)]
pub fn get_server_name(&self, language: &str) -> Option<String> {
self.servers
.iter()
.find(|s| s.language_id == language)
.map(|s| s.name.clone())
}
/// Notify the relevant LSP server that a file's contents have changed.
///
/// Flow: resolve server by extension -> 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) -> anyhow::Result<()> {
let ext = match path.extension().and_then(|e| e.to_str()).map(|s| format!(".{}", s)) {
Some(ext) => ext,
None => {
tracing::warn!("did_change_file: path has no extension: {:?}", path);
return Ok(());
}
};
let server_name = match self.extension_registry.get(&ext) {
Some(name) => name.clone(),
None => {
tracing::warn!("did_change_file: no LSP server registered for extension '{}'", ext);
return Ok(());
}
};
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 Ok(());
}
};
let language_id = self
.get_language_id(&server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client = match self.get_client(&server_name) {
Some(c) => c,
None => {
tracing::warn!("did_change_file: server '{}' has no client", server_name);
return Ok(());
}
};
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 '{}': {}", server_name, e);
return Ok(());
}
};
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 {}: {}",
server_name,
uri,
e
);
return Ok(());
}
self.open_files.insert(
uri.clone(),
OpenDoc {
language: language_id,
version: next_version,
},
);
Ok(())
}
/// Record that `server_name` has an open document at `uri`.
///
/// Flow: insert/overwrite the `OpenDoc` entry in `open_files`.
/// Does not contact the LSP server — pure local bookkeeping.
#[allow(dead_code)]
pub fn track_open_doc(&mut self, server_name: &str, uri: &str, language: &str, version: i32) {
// server_name retained for future routing extensions; not stored today.
let _ = server_name;
self.open_files.insert(
uri.to_string(),
OpenDoc {
language: language.to_string(),
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.iter() {
if let Ok(mut client) = server.client.lock() {
let _ = client.shutdown();
}
}
self.servers.clear();
}
/// Snapshot the connected servers as `(name, language_id, has_open_docs)` triples.
///
/// `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, String, bool)> {
self.servers
.iter()
.map(|s| {
let name = s.name.clone();
let lang = s.language_id.clone();
let has_open = self
.open_files
.values()
.any(|d| d.language == s.language_id);
(name, lang, has_open)
})
.collect()
}
/// Connect an LSP server and register its default extensions in one call.
///
/// Flow: invoke `connect` -> on success, register `extensions` against
/// `name` in `extension_registry`. If `connect` fails, the registries
/// are left untouched and the error is propagated.
pub fn connect_with_extensions(
&mut self,
name: &str,
command: &str,
args: &[String],
language_id: &str,
extensions: &[&str],
) -> anyhow::Result<()> {
self.connect(name, command, args, language_id)?;
self.register_extensions(name, extensions);
Ok(())
}
}
impl Default for LspManager {
@@ -78,3 +325,4 @@ impl Default for LspManager {
Self::new()
}
}
+641
View File
@@ -0,0 +1,641 @@
//! 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::PathBuf;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use super::LspManager;
/// 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 — `manual_instructions` tells the user how
/// to install by hand.
Failed {
language: String,
server_name: String,
reason: String,
#[allow(dead_code)]
manual_instructions: String,
},
}
/// 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>,
}
/// Snapshot of the host environment used to decide which install tiers are viable.
///
/// Populated by `detect_env()` once per provision_all() 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 has_rustup: bool,
pub has_npm: bool,
pub has_go: bool,
pub has_java: bool,
pub has_apt: bool,
pub has_brew: bool,
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 {
has_rustup: which("rustup").is_some(),
has_npm: which("npm").is_some(),
has_go: which("go").is_some(),
has_java: which("java").is_some(),
has_apt: which("apt").is_some() || which("apt-get").is_some(),
has_brew: which("brew").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()`. 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(),
],
}],
},
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()],
install_tiers: vec![
InstallTier {
label: "apt".to_string(),
requires: vec!["java".to_string(), "apt".to_string()],
command: "apt".to_string(),
args: vec![
"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: "manual download".to_string(),
requires: vec!["java".to_string()],
command: "__jdtls_download__".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_secs(120);
let start = Instant::now();
let status = loop {
match child.try_wait()? {
Some(status) => break Ok(status),
None => {
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),
}
}
/// Manual-install fallback for jdtls when apt and brew both fail (or
/// the host has neither): create the install directory and a launcher
/// script at `~/.local/share/zesdex/lsp/jdtls/jdtls-launcher.sh`.
///
/// Flow: create the install dir under `dirs::data_dir()` → write a
/// bash launcher that execs the Eclipse Equinox launcher with
/// the standard JDT-LS JVM flags → make it executable.
///
/// Returns the absolute path of the launcher script on success.
fn install_jdtls_manual() -> std::io::Result<PathBuf> {
let base = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("zesdex")
.join("lsp")
.join("jdtls");
std::fs::create_dir_all(&base)?;
let launcher = base.join("jdtls-launcher.sh");
let launcher_script = r#"#!/usr/bin/env bash
# Auto-generated launcher for Eclipse JDT-LS, created by zesdex provisioner.
# Adjust JAVA_HOME and the plugin path below if your layout differs.
set -e
JDTLS_HOME="$(cd "$(dirname "$0")" && pwd)"
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 "${JDTLS_HOME}/plugins/org.eclipse.equinox.launcher_"*.jar \
-configuration "${JDTLS_HOME}/config_"* \
-data "${JDTLS_HOME}/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, launcher_script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&launcher)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&launcher, perms)?;
}
Ok(launcher)
}
/// Render the "install by hand" message shown to the user when every
/// automated tier fails.
fn manual_instructions(def: &LanguageServerDef) -> String {
match def.language.as_str() {
"rust" => "Install rustup from https://rustup.rs, then run:\n \
rustup component add rust-analyzer"
.to_string(),
"typescript" => "Install Node.js from https://nodejs.org, then run:\n \
npm install -g typescript typescript-language-server"
.to_string(),
"go" => "Install Go from https://go.dev/dl, then run:\n \
go install golang.org/x/tools/gopls@latest"
.to_string(),
"java" => "Install Eclipse JDT-LS for your platform:\n \
Debian/Ubuntu: sudo apt install -y eclipse-jdt-ls\n \
macOS: brew install jdtls\n \
Other: see https://.eclipse.org/jdtls/#download"
.to_string(),
_ => format!("No automated install available for '{}'.", def.language),
}
}
/// Try to provision a single language server.
///
/// Flow: check whether any `binary_names` candidate is already on PATH
/// → if yes, return AlreadyAvailable → otherwise walk
/// `install_tiers` in order, skipping tiers whose `requires`
/// binaries are missing → for each viable tier, run the install
/// command (120s timeout) → if it succeeds AND the binary now
/// appears on PATH (or the tier is jdtls-manual returning a
/// launcher path), return Installed → if every tier fails, return
/// Failed with the last error and manual install instructions.
///
/// Why we re-check `which` after the install: `rustup component add`
/// can exit 0 even if the binary wasn't actually placed on PATH (rare,
/// but happens with broken rustup installs). Re-checking gives us a
/// real signal rather than trusting the exit code alone.
pub fn provision_single(def: &LanguageServerDef, env: &EnvInfo) -> ProvisionResult {
// Already installed?
for bin in &def.binary_names {
if let Some(path) = which(bin) {
return ProvisionResult::AlreadyAvailable {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: path.to_string_lossy().to_string(),
};
}
}
let mut last_reason = String::from("no install tiers succeeded");
for tier in &def.install_tiers {
// Prerequisite gating: skip tiers whose required tools are missing.
let prereqs_met = tier.requires.iter().all(|req| match req.as_str() {
"rustup" => env.has_rustup,
"npm" => env.has_npm,
"go" => env.has_go,
"java" => env.has_java,
"apt" => env.has_apt,
"brew" => env.has_brew,
_ => which(req).is_some(),
});
if !prereqs_met {
last_reason = format!(
"tier '{}' skipped: missing prerequisite (one of: {})",
tier.label,
tier.requires.join(", ")
);
warn!(
server = %def.name,
tier = %tier.label,
"install tier skipped — missing prerequisites"
);
continue;
}
// Special-case jdtls tier 3: no shell command, do it inline.
if tier.command == "__jdtls_download__" {
match install_jdtls_manual() {
Ok(path) => {
// Prefer the known binary name on PATH; fall back to
// the launcher script we just wrote.
let found = def
.binary_names
.iter()
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()))
.unwrap_or_else(|| path.to_string_lossy().to_string());
return ProvisionResult::Installed {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: found,
};
}
Err(e) => {
last_reason = format!("jdtls manual install failed: {}", e);
warn!(
server = %def.name,
tier = %tier.label,
error = %e,
"manual install failed"
);
}
}
continue;
}
// Normal tier: shell out.
let arg_refs: Vec<&str> = tier.args.iter().map(|s| s.as_str()).collect();
match run_command(&tier.command, &arg_refs) {
Ok((true, _)) => {
// Verify the binary is now actually reachable.
let located = def
.binary_names
.iter()
.find_map(|b| which(b).map(|p| p.to_string_lossy().to_string()));
if let Some(path) = located {
info!(
server = %def.name,
tier = %tier.label,
binary = %path,
"installed language server"
);
return ProvisionResult::Installed {
server_name: def.name.clone(),
language: def.language.clone(),
binary_path: path,
};
}
last_reason = format!(
"tier '{}' exited 0 but '{}' not found on PATH afterwards",
tier.label, tier.command
);
warn!(
server = %def.name,
tier = %tier.label,
"install command reported success but binary missing"
);
}
Ok((false, out)) => {
let trimmed = out.trim();
last_reason = format!("tier '{}' failed: {}", tier.label, trimmed);
warn!(
server = %def.name,
tier = %tier.label,
output = trimmed,
"install command failed"
);
}
Err(e) => {
last_reason = format!("tier '{}' error: {}", tier.label, e);
warn!(
server = %def.name,
tier = %tier.label,
error = %e,
"install command errored"
);
}
}
}
let manual = manual_instructions(def);
ProvisionResult::Failed {
language: def.language.clone(),
server_name: def.name.clone(),
reason: last_reason,
manual_instructions: manual,
}
}
/// Provision every supported server in order, returning one
/// `ProvisionResult` per server.
///
/// Flow: detect_env() once → for each server in supported_servers()
/// call provision_single() → collect results. Order matches
/// supported_servers() (rust, typescript, go, java).
pub fn provision_all() -> Vec<ProvisionResult> {
let env = detect_env();
info!(
linux = env.is_linux,
macos = env.is_macos,
rustup = env.has_rustup,
npm = env.has_npm,
go = env.has_go,
apt = env.has_apt,
brew = env.has_brew,
"starting LSP provisioning"
);
supported_servers()
.iter()
.map(|def| provision_single(def, &env))
.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 def = match defs.iter().find(|d| d.name == name) {
Some(d) => d,
None => {
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(|s| s.as_str()).collect();
match guard.connect_with_extensions(&name, &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
}
+1
View File
@@ -100,6 +100,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action {
Action::ForceQuit => {
save_current_session(state);
state.shutdown_lsp();
state.quit = true;
}
Action::SwitchMode(mode) => {
+45 -2
View File
@@ -101,7 +101,7 @@ impl AppStateRest {
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
String::new()
});
AppStateRest {
let state = AppStateRest {
settings,
app_config,
@@ -127,7 +127,41 @@ impl AppStateRest {
misc: MiscState::new(),
dirty: true,
quit: false,
};
// Fire-and-forget background LSP provisioning.
//
// Flow: spawn OS thread -> provision_all() probes/installs every
// supported language server -> auto_connect() attaches whichever
// ones ended up available to the shared `lsp_manager` -> log a line
// per connected server and per failure.
//
// Why a raw thread and not a tokio task: this runs before the async
// runtime's executor may be fully set up for this state, and the
// provisioning work (shelling out to package managers, network
// downloads) is blocking I/O; a dedicated thread keeps it off any
// async executor entirely. It is deliberately not joined -- startup
// must not block on language server installation, and failures are
// logged rather than surfaced, since editing still works without LSP.
if state.settings.lsp_auto_provision {
let lsp_mgr = state.lsp_manager.clone();
std::thread::spawn(move || {
use crate::app::lsp::provisioner::{self, ProvisionResult};
let results = provisioner::provision_all();
let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected {
tracing::info!("LSP: {} connected", name);
}
for r in &results {
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
}
}
});
}
state
}
/// Whether an agent turn is currently running.
@@ -141,7 +175,16 @@ impl AppStateRest {
})
}
/// Shut down every running LSP server process.
///
/// Why: called on app exit so language servers don't linger as orphaned
/// processes; silently no-ops if the mutex is poisoned since there is
/// nothing more useful to do at shutdown time.
pub fn shutdown_lsp(&mut self) {
if let Ok(mut mgr) = self.lsp_manager.lock() {
mgr.shutdown_all();
}
}
/// Append a message to the transcript, evicting the oldest entry once
/// `max_lines` is exceeded, and mark both the cache and the app dirty.