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
+5
View File
@@ -14,5 +14,10 @@ Core principles:
14. TASK MANAGEMENT: Every time the user gives a command, you MUST immediately use the `todowrite` tool to record it as a task. 14. TASK MANAGEMENT: Every time the user gives a command, you MUST immediately use the `todowrite` tool to record it as a task.
15. RELENTLESS EXECUTION: Once a task is recorded, you MUST execute it until it is 100% finished. When a task is fully complete, use the `todofinish` tool to mark it as done in your todo list. Do not stop calling tools and do not finish your turn prematurely. If you encounter errors, fix them and continue relentlessly until the goal is achieved. 15. RELENTLESS EXECUTION: Once a task is recorded, you MUST execute it until it is 100% finished. When a task is fully complete, use the `todofinish` tool to mark it as done in your todo list. Do not stop calling tools and do not finish your turn prematurely. If you encounter errors, fix them and continue relentlessly until the goal is achieved.
16. LSP INTEGRATION: Language Server Protocol servers for Rust, TypeScript, Go, and Java
are auto-provisioned and auto-connected on startup. After writing or editing code, use
lsp_diagnostics to check for errors. Use lsp_hover for type information, lsp_definition
to navigate to symbol definitions, and lsp_references to find all usages. Use lsp_connect
to add servers for other languages.
Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task. Available tools are described in the system-tools.txt section. Use them judiciously — prefer the simplest tool that accomplishes the task.
+8 -2
View File
@@ -46,8 +46,11 @@ Workflow:
Language Server Protocol (LSP) tools: Language Server Protocol (LSP) tools:
- lsp_connect(name, command, args?, language_id) — Start an LSP server for a - lsp_connect(name, command, args?, language_id) — Start an LSP server for a
programming language (e.g. 'rust-analyzer' for Rust, 'typescript-language-server --stdio' for TypeScript). programming language (e.g. 'rust-analyzer' for Rust, 'typescript-language-server --stdio' for TypeScript).
- lsp_diagnostics(server, path, text) — Get compiler errors, warnings, and hints Rust, TypeScript, Go, and Java servers are auto-provisioned at startup, so this is
for a file from the LSP server. primarily for adding servers for other languages.
- lsp_diagnostics(server?, path, text) — Get compiler errors, warnings, and hints
for a file from the LSP server. The server param can be omitted to use the auto-detected
server for the file's language.
- lsp_hover(server, path, line, column) — Get type signatures, documentation, - lsp_hover(server, path, line, column) — Get type signatures, documentation,
and hover information at a cursor position. and hover information at a cursor position.
- lsp_completion(server, path, line, column) — Get code completion suggestions - lsp_completion(server, path, line, column) — Get code completion suggestions
@@ -58,5 +61,8 @@ Language Server Protocol (LSP) tools:
across the project. across the project.
- lsp_disconnect(name) — Disconnect from a running LSP server. - lsp_disconnect(name) — Disconnect from a running LSP server.
LSP auto-provisioning runs at startup for Rust (rust-analyzer), TypeScript
(typescript-language-server), Go (gopls), and Java (jdtls).
Each write/edit call MUST include a non-empty reason argument explaining Each write/edit call MUST include a non-empty reason argument explaining
why the change is being made. This is enforced deterministically. why the change is being made. This is enforced deterministically.
+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<()> { pub fn shutdown(&mut self) -> anyhow::Result<()> {
let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5)); let _ = self.call_with_timeout("shutdown", json!({}), Duration::from_secs(5));
let _ = self.notify("exit", json!({})); 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}; use std::sync::{Arc, Mutex};
mod client; mod client;
pub mod provisioner;
pub use client::{path_to_lsp_uri, LspClient}; 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)] #[derive(Clone)]
pub struct LspServer { pub struct LspServer {
#[allow(dead_code)] #[allow(dead_code)]
@@ -15,18 +23,45 @@ pub struct LspServer {
pub client: Arc<Mutex<LspClient>>, 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)] #[derive(Clone)]
pub struct LspManager { pub struct LspManager {
pub servers: Vec<LspServer>, 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 { impl LspManager {
/// Create an empty manager with no connected servers and empty registries.
pub fn new() -> Self { pub fn new() -> Self {
LspManager { LspManager {
servers: Vec::new(), 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( pub fn connect(
&mut self, &mut self,
name: &str, name: &str,
@@ -48,15 +83,21 @@ impl LspManager {
Ok(()) Ok(())
} }
/// Look up a connected server by name and return a reference to its entry.
#[allow(dead_code)] #[allow(dead_code)]
pub fn find_server(&self, name: &str) -> Option<&LspServer> { pub fn find_server(&self, name: &str) -> Option<&LspServer> {
self.servers.iter().find(|s| s.name == name) 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>>> { pub fn get_client(&self, name: &str) -> Option<Arc<Mutex<LspClient>>> {
self.servers.iter().find(|s| s.name == name).map(|s| s.client.clone()) 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 { pub fn disconnect(&mut self, name: &str) -> bool {
if let Some(server) = self.servers.iter().find(|s| s.name == name) { if let Some(server) = self.servers.iter().find(|s| s.name == name) {
if let Ok(mut client) = server.client.lock() { if let Ok(mut client) = server.client.lock() {
@@ -68,9 +109,215 @@ impl LspManager {
self.servers.len() < len self.servers.len() < len
} }
/// Return the language id (e.g. "rust") registered for `name`.
pub fn get_language_id(&self, name: &str) -> Option<String> { pub fn get_language_id(&self, name: &str) -> Option<String> {
self.servers.iter().find(|s| s.name == name).map(|s| s.language_id.clone()) 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 { impl Default for LspManager {
@@ -78,3 +325,4 @@ impl Default for LspManager {
Self::new() 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 { match action {
Action::ForceQuit => { Action::ForceQuit => {
save_current_session(state); save_current_session(state);
state.shutdown_lsp();
state.quit = true; state.quit = true;
} }
Action::SwitchMode(mode) => { 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"); tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
String::new() String::new()
}); });
AppStateRest { let state = AppStateRest {
settings, settings,
app_config, app_config,
@@ -127,7 +127,41 @@ impl AppStateRest {
misc: MiscState::new(), misc: MiscState::new(),
dirty: true, dirty: true,
quit: false, 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. /// 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 /// Append a message to the transcript, evicting the oldest entry once
/// `max_lines` is exceeded, and mark both the cache and the app dirty. /// `max_lines` is exceeded, and mark both the cache and the app dirty.
+13
View File
@@ -28,6 +28,13 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"grep".to_string(), "grep".to_string(),
"glob".to_string(), "glob".to_string(),
"git_operator".to_string(), "git_operator".to_string(),
"lsp_connect".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
"lsp_completion".to_string(),
"lsp_disconnect".to_string(),
] ]
).with_max_steps(usize::MAX), ).with_max_steps(usize::MAX),
@@ -43,6 +50,10 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"glob".to_string(), "glob".to_string(),
"recall".to_string(), "recall".to_string(),
"remember".to_string(), "remember".to_string(),
"lsp_diagnostics".to_string(),
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
] ]
).with_max_steps(usize::MAX), ).with_max_steps(usize::MAX),
@@ -57,6 +68,8 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"grep".to_string(), "grep".to_string(),
"glob".to_string(), "glob".to_string(),
"search".to_string(), "search".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
] ]
).with_max_steps(usize::MAX), ).with_max_steps(usize::MAX),
+4
View File
@@ -40,6 +40,8 @@ pub struct Settings {
pub verify_timeout_ms: u64, pub verify_timeout_ms: u64,
pub workflow_max_concurrency: usize, pub workflow_max_concurrency: usize,
pub session_archive_enabled: bool, pub session_archive_enabled: bool,
pub lsp_auto_provision: bool,
pub lsp_languages: Vec<String>,
} }
impl Default for Settings { impl Default for Settings {
@@ -58,6 +60,8 @@ impl Default for Settings {
verify_timeout_ms: 30000, verify_timeout_ms: 30000,
workflow_max_concurrency: 5, workflow_max_concurrency: 5,
session_archive_enabled: true, session_archive_enabled: true,
lsp_auto_provision: true,
lsp_languages: Vec::new(),
} }
} }
} }
+14 -3
View File
@@ -104,10 +104,21 @@ impl Tool for Edit {
} else { } else {
content.len() - new_content.len() content.len() - new_content.len()
}; };
if check_matches.is_empty() { // Notify the LSP server of the on-disk change so diagnostics stay fresh.
Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize)) // Never fail the edit because of this — LSP errors are surfaced as a
// trailing annotation on the success message instead.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) {
Ok(()) => String::new(),
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}", rel, bytes_diff as isize, check_matches.join(", "))) String::new()
};
if check_matches.is_empty() {
Ok(format!("edited {} ({} byte delta){}", rel, bytes_diff as isize, lsp_note))
} else {
Ok(format!("edited {} ({} byte delta). Graduated checks matched: {}{}", rel, bytes_diff as isize, check_matches.join(", "), lsp_note))
} }
} }
} }
+14 -3
View File
@@ -64,10 +64,21 @@ impl Tool for Write {
} }
fs::write(&path, &content) fs::write(&path, &content)
.map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; .map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?;
if check_matches.is_empty() { // Notify the LSP server of the on-disk change so diagnostics stay in
Ok(format!("wrote {} bytes to {}", content.len(), rel)) // sync. Never fails the write itself: a lock failure or LSP error is
// folded into the returned message instead of propagated as an Err.
let lsp_note = if let Ok(mut lsp) = ctx.lsp_manager.lock() {
match lsp.did_change_file(&path) {
Ok(()) => String::new(),
Err(e) => format!(" (LSP: {})", e),
}
} else { } else {
Ok(format!("wrote {} bytes to {}. Graduated checks matched: {}", content.len(), rel, check_matches.join(", "))) String::new()
};
if check_matches.is_empty() {
Ok(format!("wrote {} bytes to {}{}", content.len(), rel, lsp_note))
} else {
Ok(format!("wrote {} bytes to {}{}. Graduated checks matched: {}", content.len(), rel, lsp_note, check_matches.join(", ")))
} }
} }
} }
+133 -33
View File
@@ -12,7 +12,9 @@ impl Tool for LspConnect {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Connect to a Language Server Protocol (LSP) server for a programming language" "Connect to a Language Server Protocol (LSP) server for a programming language. \
Known file extensions for the language are auto-registered, enabling other lsp_* \
tools to auto-detect this server when `server` is omitted."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -60,10 +62,18 @@ impl Tool for LspConnect {
.map_err(|e| anyhow!("LSP manager lock error: {}", e))?; .map_err(|e| anyhow!("LSP manager lock error: {}", e))?;
manager.connect(name, command, &extra_args, language_id)?; manager.connect(name, command, &extra_args, language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics /
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
// auto-detect it later without an explicit `server` argument.
let known_exts = known_extensions_for(language_id);
if !known_exts.is_empty() {
manager.register_extensions(name, known_exts);
}
let client_arc = manager.get_client(name); let client_arc = manager.get_client(name);
let caps = client_arc.map(|c| { let caps = client_arc.and_then(|c| {
c.lock().ok().map(|guard| guard.server_capabilities().clone()) c.lock().ok().map(|guard| guard.server_capabilities().clone())
}).flatten().unwrap_or_default(); }).unwrap_or_default();
let caps_summary = serde_json::to_string_pretty(&caps) let caps_summary = serde_json::to_string_pretty(&caps)
.unwrap_or_else(|_| "{}".to_string()); .unwrap_or_else(|_| "{}".to_string());
@@ -83,7 +93,8 @@ impl Tool for LspDiagnostics {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Get diagnostics (errors, warnings, hints) for a file from an LSP server" "Get diagnostics (errors, warnings, hints) for a file from an LSP server. \
`server` is optional if omitted, the server is auto-detected from the file's extension."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -92,7 +103,7 @@ impl Tool for LspDiagnostics {
"properties": { "properties": {
"server": { "server": {
"type": "string", "type": "string",
"description": "Name of the connected LSP server" "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
}, },
"path": { "path": {
"type": "string", "type": "string",
@@ -103,20 +114,19 @@ impl Tool for LspDiagnostics {
"description": "The full text content of the file" "description": "The full text content of the file"
} }
}, },
"required": ["server", "path", "text"] "required": ["path", "text"]
}) })
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path") let rel_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
let text = args.get("text") let text = args.get("text")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: text"))?; .ok_or_else(|| anyhow!("missing required argument: text"))?;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -178,7 +188,8 @@ impl Tool for LspHover {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Get hover information (type signature, documentation) at a cursor position in a file" "Get hover information (type signature, documentation) at a cursor position in a file. \
`server` is optional if omitted, the server is auto-detected from the file's extension."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -187,7 +198,7 @@ impl Tool for LspHover {
"properties": { "properties": {
"server": { "server": {
"type": "string", "type": "string",
"description": "Name of the connected LSP server" "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
}, },
"path": { "path": {
"type": "string", "type": "string",
@@ -206,14 +217,11 @@ impl Tool for LspHover {
"description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect." "description": "Language identifier (e.g. 'rust', 'typescript'). Optional if already set via lsp_connect."
} }
}, },
"required": ["server", "path", "line", "column"] "required": ["path", "line", "column"]
}) })
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path") let rel_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -223,6 +231,8 @@ impl Tool for LspHover {
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -311,7 +321,8 @@ impl Tool for LspCompletion {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Get code completion suggestions at a cursor position from an LSP server" "Get code completion suggestions at a cursor position from an LSP server. \
`server` is optional if omitted, the server is auto-detected from the file's extension."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -320,7 +331,7 @@ impl Tool for LspCompletion {
"properties": { "properties": {
"server": { "server": {
"type": "string", "type": "string",
"description": "Name of the connected LSP server" "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
}, },
"path": { "path": {
"type": "string", "type": "string",
@@ -335,14 +346,11 @@ impl Tool for LspCompletion {
"description": "Column number (0-based)" "description": "Column number (0-based)"
} }
}, },
"required": ["server", "path", "line", "column"] "required": ["path", "line", "column"]
}) })
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path") let rel_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -352,6 +360,8 @@ impl Tool for LspCompletion {
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -441,7 +451,8 @@ impl Tool for LspDefinition {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Go to definition: find the location where a symbol is defined" "Go to definition: find the location where a symbol is defined. \
`server` is optional if omitted, the server is auto-detected from the file's extension."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -450,7 +461,7 @@ impl Tool for LspDefinition {
"properties": { "properties": {
"server": { "server": {
"type": "string", "type": "string",
"description": "Name of the connected LSP server" "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
}, },
"path": { "path": {
"type": "string", "type": "string",
@@ -465,14 +476,11 @@ impl Tool for LspDefinition {
"description": "Column number (0-based)" "description": "Column number (0-based)"
} }
}, },
"required": ["server", "path", "line", "column"] "required": ["path", "line", "column"]
}) })
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path") let rel_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -482,6 +490,8 @@ impl Tool for LspDefinition {
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -547,7 +557,8 @@ impl Tool for LspReferences {
} }
fn description(&self) -> &'static str { fn description(&self) -> &'static str {
"Find all references to a symbol at a cursor position" "Find all references to a symbol at a cursor position. \
`server` is optional if omitted, the server is auto-detected from the file's extension."
} }
fn parameters(&self) -> Value { fn parameters(&self) -> Value {
@@ -556,7 +567,7 @@ impl Tool for LspReferences {
"properties": { "properties": {
"server": { "server": {
"type": "string", "type": "string",
"description": "Name of the connected LSP server" "description": "Name of the connected LSP server. Optional — auto-detected from the file extension if omitted."
}, },
"path": { "path": {
"type": "string", "type": "string",
@@ -571,14 +582,11 @@ impl Tool for LspReferences {
"description": "Column number (0-based)" "description": "Column number (0-based)"
} }
}, },
"required": ["server", "path", "line", "column"] "required": ["path", "line", "column"]
}) })
} }
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let server_name = args.get("server")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: server"))?;
let rel_path = args.get("path") let rel_path = args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?; .ok_or_else(|| anyhow!("missing required argument: path"))?;
@@ -588,6 +596,8 @@ impl Tool for LspReferences {
let column = args.get("column") let column = args.get("column")
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32; .ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
@@ -675,3 +685,93 @@ impl Tool for LspDisconnect {
} }
} }
} }
/// Return the default file extensions associated with a language id.
///
/// Flow: pure `match` on `language_id` -> static slice of extension
/// strings (with leading dot). Returns an empty slice for unknown
/// languages, so callers can safely chain lookups without a special case.
///
/// Used by `lsp_connect` to auto-register extensions for a newly connected
/// server, and by `auto_detect_server` as a fallback when the manager's own
/// `extension_registry` has no entry yet.
fn known_extensions_for(language_id: &str) -> &[&'static str] {
match language_id {
"rust" => &[".rs"],
"typescript" => &[".ts", ".tsx", ".js", ".jsx"],
"go" => &[".go"],
"java" => &[".java"],
_ => &[],
}
}
/// Guess which connected LSP server should handle `path` based on its extension.
///
/// Flow: extract extension from `path` -> for each connected server, check
/// whether `known_extensions_for(server.language_id)` contains the extension
/// -> return the first match's name.
///
/// This is a fallback used only when the caller omits `server` and the file's
/// extension is not (yet) present in `LspManager::extension_registry` — e.g.
/// a server connected without an explicit `register_extensions` call. Returns
/// `None` if the path has no extension, the lock is poisoned, or no
/// connected server's language is known to use that extension.
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str())?;
let dot_ext = format!(".{}", ext);
if let Ok(mgr) = ctx.lsp_manager.lock() {
for s in &mgr.servers {
let exts = known_extensions_for(&s.language_id);
if exts.contains(&dot_ext.as_str()) {
return Some(s.name.clone());
}
}
}
None
}
/// Resolve the LSP server name to use for a tool call: explicit `server`
/// argument if present, otherwise auto-detected from `path`'s extension.
///
/// Flow: `args["server"]` present -> use it as-is. Otherwise -> try
/// `LspManager::find_server_for_path`-style registry lookup by delegating to
/// `auto_detect_server`. If that also fails, build a helpful error message
/// listing the currently connected servers (via `LspManager::list_servers`)
/// so the caller knows whether to connect one first.
///
/// Return: `Ok(server_name)` on success. `Err` only when no `server` was
/// given and auto-detection could not resolve one — never fails just
/// because the caller provided an explicit (possibly wrong) server name,
/// since downstream `get_client`/`get_language_id` calls report that error.
fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result<String> {
if let Some(server) = args.get("server").and_then(|v| v.as_str()) {
return Ok(server.to_string());
}
if let Some(name) = auto_detect_server(ctx, path) {
return Ok(name);
}
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e))
.unwrap_or_else(|| "<none>".to_string());
let available = ctx.lsp_manager.lock().ok()
.map(|mgr| {
mgr.list_servers()
.iter()
.map(|(name, lang, _)| format!("{} ({})", name, lang))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let available = if available.is_empty() { "none".to_string() } else { available };
Err(anyhow!(
"LSP server not found for extension '{}'. Use lsp_connect to connect one. Available servers: {}",
ext,
available
))
}