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:
@@ -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!({}));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user