diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e3fc6e858..f0b5c52e0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1356,6 +1356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3919,6 +3920,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -5198,7 +5200,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5370,6 +5372,7 @@ version = "0.7.3" dependencies = [ "bytes", "dirs 6.0.0", + "flate2", "futures-util", "globset", "grep-matcher", @@ -5387,6 +5390,7 @@ dependencies = [ "serde", "serde_json", "shared_child", + "tar", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -5401,6 +5405,7 @@ dependencies = [ "tempfile", "tokio", "windows-sys 0.61.2", + "zip 2.4.2", ] [[package]] @@ -7077,6 +7082,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -7095,6 +7117,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d8daf0f42..d30ca4363 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,12 +41,17 @@ tauri-plugin-notification = "2" reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "stream", + "blocking", + "json", ] } bytes = "1" futures-util = "0.3" tokio = { version = "1", default-features = false, features = ["rt"] } tempfile = "3" notify = "8.2.0" +zip = { version = "2", default-features = false, features = ["deflate"] } +tar = "0.4" +flate2 = "1" [dev-dependencies] proptest = "1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad5064bc1..09530ccf5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,6 @@ pub mod modules; -use modules::{agent, fs, git, history, net, pty, secrets, shell, workspace}; +use modules::{agent, fs, git, history, lsp, net, pty, secrets, shell, workspace}; use std::sync::Mutex; use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; #[cfg(target_os = "macos")] @@ -136,12 +136,13 @@ pub fn run() { .build(), ) .plugin(tauri_plugin_opener::init()) - .setup(|_app| { + .setup(|app| { + lsp::init(&app.handle()); // macOS skips parent() for the settings window, so tie its lifecycle // to the main window here instead. Other platforms keep parent(). #[cfg(target_os = "macos")] - if let Some(main) = _app.get_webview_window("main") { - let handle = _app.handle().clone(); + if let Some(main) = app.get_webview_window("main") { + let handle = app.handle().clone(); main.on_window_event(move |event| { if matches!( event, @@ -157,6 +158,7 @@ pub fn run() { }) .manage(pty::PtyState::default()) .manage(shell::ShellState::default()) + .manage(lsp::LspState::default()) .manage(secrets::SecretsState::default()) .manage(fs::watch::FsWatchState::default()) .manage(history::HistoryState::default()) @@ -220,6 +222,15 @@ pub fn run() { shell::shell_bg_logs, shell::shell_bg_kill, shell::shell_bg_list, + lsp::lsp_probe_binary, + lsp::lsp_install, + lsp::lsp_link_binary, + lsp::lsp_unlink_binary, + lsp::lsp_probe_wsl_binary, + lsp::lsp_resolve_root, + lsp::lsp_spawn, + lsp::lsp_send, + lsp::lsp_close, workspace::wsl_list_distros, workspace::wsl_default_distro, workspace::wsl_home, diff --git a/src-tauri/src/modules/lsp/install.rs b/src-tauri/src/modules/lsp/install.rs new file mode 100644 index 000000000..dbcfc5e67 --- /dev/null +++ b/src-tauri/src/modules/lsp/install.rs @@ -0,0 +1,516 @@ +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use flate2::read::GzDecoder; +use tauri::ipc::Channel; +use zip::ZipArchive; + +use super::local::{ + bin_dir, go_executable, node_executable, node_runtime_dir, npm_executable, root, + server_prefix, set_manifest_entry, +}; +use super::resolve::{binary_stem, find_system_go, resolve_lsp}; + +const NODE_VERSION: &str = "22.16.0"; +const GO_VERSION: &str = "1.23.4"; + +fn emit(progress: &Channel, message: &str) { + let _ = progress.send(message.to_string()); +} + +fn http_get(url: &str) -> Result, String> { + let client = reqwest::blocking::Client::builder() + .user_agent("Terax-LSP-Installer") + .build() + .map_err(|e| e.to_string())?; + let response = client.get(url).send().map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("download failed ({}) for {url}", response.status())); + } + response.bytes().map_err(|e| e.to_string()).map(|b| b.to_vec()) +} + +fn github_latest_asset_url(repo: &str, asset_contains: &str) -> Result { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + let client = reqwest::blocking::Client::builder() + .user_agent("Terax-LSP-Installer") + .build() + .map_err(|e| e.to_string())?; + let response = client.get(&url).send().map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("GitHub API error ({})", response.status())); + } + let json: serde_json::Value = response.json().map_err(|e| e.to_string())?; + let assets = json + .get("assets") + .and_then(|a| a.as_array()) + .ok_or_else(|| "release has no assets".to_string())?; + for asset in assets { + let name = asset.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name.contains(asset_contains) { + if let Some(url) = asset.get("browser_download_url").and_then(|u| u.as_str()) { + return Ok(url.to_string()); + } + } + } + Err(format!("asset not found: {asset_contains}")) +} + +fn extract_zip(data: &[u8], dest: &Path) -> Result<(), String> { + fs::create_dir_all(dest).map_err(|e| e.to_string())?; + let reader = Cursor::new(data); + let mut archive = ZipArchive::new(reader).map_err(|e| e.to_string())?; + for i in 0..archive.len() { + let mut file = archive.by_index(i).map_err(|e| e.to_string())?; + let Some(name) = file.enclosed_name().map(|p| p.to_owned()) else { + continue; + }; + let out = dest.join(name); + if file.name().ends_with('/') { + fs::create_dir_all(&out).map_err(|e| e.to_string())?; + continue; + } + if let Some(parent) = out.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let mut out_file = fs::File::create(&out).map_err(|e| e.to_string())?; + std::io::copy(&mut file, &mut out_file).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = file.unix_mode() { + if mode & 0o111 != 0 { + let _ = fs::set_permissions(&out, fs::Permissions::from_mode(mode)); + } + } + } + } + Ok(()) +} + +fn extract_tar_gz(data: &[u8], dest: &Path) -> Result<(), String> { + fs::create_dir_all(dest).map_err(|e| e.to_string())?; + let gz = GzDecoder::new(Cursor::new(data)); + let mut archive = tar::Archive::new(gz); + archive.unpack(dest).map_err(|e| e.to_string()) +} + +fn find_file_named(dir: &Path, name: &str) -> Option { + if dir.join(name).is_file() { + return Some(dir.join(name)); + } + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) { + return Some(path); + } + if path.is_dir() { + if let Some(found) = find_file_named(&path, name) { + return Some(found); + } + } + } + None +} + +fn platform_rust_analyzer_asset() -> Result<&'static str, String> { + #[cfg(all(windows, target_arch = "x86_64"))] + return Ok("rust-analyzer-x86_64-pc-windows-msvc.zip"); + #[cfg(all(windows, target_arch = "aarch64"))] + return Ok("rust-analyzer-aarch64-pc-windows-msvc.zip"); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok("rust-analyzer-aarch64-apple-darwin.gz"); + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok("rust-analyzer-x86_64-apple-darwin.gz"); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok("rust-analyzer-x86_64-unknown-linux-gnu.gz"); + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok("rust-analyzer-aarch64-unknown-linux-gnu.gz"); + #[cfg(not(any( + all(windows, target_arch = "x86_64"), + all(windows, target_arch = "aarch64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + )))] + Err("unsupported platform for rust-analyzer".into()) +} + +fn platform_go_archive() -> Result { + #[cfg(all(windows, target_arch = "x86_64"))] + return Ok(format!("go{GO_VERSION}.windows-amd64.zip")); + #[cfg(all(windows, target_arch = "aarch64"))] + return Ok(format!("go{GO_VERSION}.windows-arm64.zip")); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok(format!("go{GO_VERSION}.darwin-arm64.tar.gz")); + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok(format!("go{GO_VERSION}.darwin-amd64.tar.gz")); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok(format!("go{GO_VERSION}.linux-amd64.tar.gz")); + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok(format!("go{GO_VERSION}.linux-arm64.tar.gz")); + #[cfg(not(any( + all(windows, target_arch = "x86_64"), + all(windows, target_arch = "aarch64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + )))] + Err("unsupported platform for Go toolchain".into()) +} + +fn ensure_go_runtime(progress: &Channel) -> Result<(), String> { + if go_executable().is_some() { + return Ok(()); + } + emit(progress, "Downloading Go toolchain for Terax…"); + let archive = platform_go_archive()?; + let url = format!("https://go.dev/dl/{archive}"); + let data = http_get(&url)?; + let staging = root().join("runtime").join("_go-staging"); + let _ = fs::remove_dir_all(&staging); + if archive.ends_with(".zip") { + extract_zip(&data, &staging)?; + } else { + extract_tar_gz(&data, &staging)?; + } + let extracted = fs::read_dir(&staging) + .map_err(|e| e.to_string())? + .flatten() + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| "Go archive layout unexpected".to_string())?; + relocate_dir(&extracted, &super::local::go_runtime_dir())?; + let _ = fs::remove_dir_all(&staging); + if go_executable().is_none() { + return Err("Go toolchain install failed".into()); + } + Ok(()) +} + +fn go_for_install(progress: &Channel) -> Result { + if let Some(go) = find_system_go() { + return Ok(go); + } + ensure_go_runtime(progress)?; + go_executable().ok_or_else(|| "Go toolchain not available".into()) +} + +fn install_gopls(progress: &Channel) -> Result { + emit(progress, "Installing gopls…"); + let go = go_for_install(progress)?; + let binary_name = if cfg!(windows) { "gopls.exe" } else { "gopls" }; + let dest = bin_dir().join(binary_name); + let mut cmd = Command::new(&go); + cmd.args([ + "install", + "-o", + &dest.to_string_lossy(), + "golang.org/x/tools/gopls@latest", + ]) + .env("GOTOOLCHAIN", "auto") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + super::local::apply_lsp_environment(&mut cmd); + crate::modules::proc::hide_console(&mut cmd); + let output = cmd.output().map_err(|e| format!("failed to run go: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("go install gopls failed: {stderr}")); + } + if !dest.is_file() { + return Err(format!("gopls binary missing: {}", dest.display())); + } + set_manifest_entry("gopls", dest.clone(), None)?; + Ok(dest) +} + +fn platform_node_archive() -> Result { + #[cfg(all(windows, target_arch = "x86_64"))] + return Ok(format!("node-v{NODE_VERSION}-win-x64.zip")); + #[cfg(all(windows, target_arch = "aarch64"))] + return Ok(format!("node-v{NODE_VERSION}-win-arm64.zip")); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok(format!("node-v{NODE_VERSION}-darwin-arm64.tar.gz")); + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok(format!("node-v{NODE_VERSION}-darwin-x64.tar.gz")); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok(format!("node-v{NODE_VERSION}-linux-x64.tar.gz")); + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok(format!("node-v{NODE_VERSION}-linux-arm64.tar.gz")); + #[cfg(not(any( + all(windows, target_arch = "x86_64"), + all(windows, target_arch = "aarch64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + )))] + Err("unsupported platform for Node.js runtime".into()) +} + +fn install_rust_analyzer(progress: &Channel) -> Result { + emit(progress, "Downloading rust-analyzer…"); + let asset = platform_rust_analyzer_asset()?; + let url = github_latest_asset_url("rust-lang/rust-analyzer", asset)?; + let data = http_get(&url)?; + let staging = bin_dir().join("_staging-ra"); + let _ = fs::remove_dir_all(&staging); + if asset.ends_with(".zip") { + extract_zip(&data, &staging)?; + } else { + extract_tar_gz(&data, &staging)?; + } + let binary_name = if cfg!(windows) { + "rust-analyzer.exe" + } else { + "rust-analyzer" + }; + let found = find_file_named(&staging, binary_name) + .ok_or_else(|| "rust-analyzer binary missing in archive".to_string())?; + let dest = bin_dir().join(binary_name); + fs::copy(&found, &dest).map_err(|e| e.to_string())?; + let _ = fs::remove_dir_all(&staging); + set_manifest_entry("rust-analyzer", dest.clone(), None)?; + Ok(dest) +} + +fn install_clangd(progress: &Channel) -> Result { + emit(progress, "Downloading clangd…"); + #[cfg(windows)] + let asset_hint = "clangd-windows"; + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let asset_hint = "clangd-mac-aarch64"; + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + let asset_hint = "clangd-mac"; + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + let asset_hint = "clangd-linux"; + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + let asset_hint = "clangd-linux-aarch64"; + let url = github_latest_asset_url("clangd/clangd", asset_hint)?; + let data = http_get(&url)?; + let staging = bin_dir().join("_staging-clangd"); + let _ = fs::remove_dir_all(&staging); + extract_zip(&data, &staging)?; + let binary_name = if cfg!(windows) { "clangd.exe" } else { "clangd" }; + let found = find_file_named(&staging, binary_name) + .ok_or_else(|| "clangd binary missing in archive".to_string())?; + let dest = bin_dir().join(binary_name); + fs::copy(&found, &dest).map_err(|e| e.to_string())?; + let _ = fs::remove_dir_all(&staging); + set_manifest_entry("clangd", dest.clone(), None)?; + Ok(dest) +} + +fn relocate_dir(from: &Path, to: &Path) -> Result<(), String> { + if to.exists() { + fs::remove_dir_all(to).map_err(|e| e.to_string())?; + } + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + if fs::rename(from, to).is_err() { + copy_dir_recursive(from, to)?; + fs::remove_dir_all(from).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn copy_dir_recursive(from: &Path, to: &Path) -> Result<(), String> { + fs::create_dir_all(to).map_err(|e| e.to_string())?; + for entry in fs::read_dir(from).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let dest = to.join(entry.file_name()); + if entry.file_type().map_err(|e| e.to_string())?.is_dir() { + copy_dir_recursive(&entry.path(), &dest)?; + } else { + fs::copy(entry.path(), &dest).map_err(|e| e.to_string())?; + } + } + Ok(()) +} + +fn ensure_node_runtime(progress: &Channel) -> Result<(), String> { + if node_executable().is_some() { + return Ok(()); + } + emit(progress, "Downloading Node.js runtime for Terax…"); + let archive = platform_node_archive()?; + let url = format!("https://nodejs.org/dist/v{NODE_VERSION}/{archive}"); + let data = http_get(&url)?; + // Staging must live outside node_runtime_dir — remove_dir_all(node) would delete it otherwise. + let staging = root().join("runtime").join("_node-staging"); + let _ = fs::remove_dir_all(&staging); + if archive.ends_with(".zip") { + extract_zip(&data, &staging)?; + } else { + extract_tar_gz(&data, &staging)?; + } + let extracted = fs::read_dir(&staging) + .map_err(|e| e.to_string())? + .flatten() + .find(|e| e.path().is_dir()) + .map(|e| e.path()) + .ok_or_else(|| "Node.js archive layout unexpected".to_string())?; + relocate_dir(&extracted, &node_runtime_dir())?; + let _ = fs::remove_dir_all(&staging); + if node_executable().is_none() { + return Err("Node.js runtime install failed".into()); + } + Ok(()) +} + +fn npm_command() -> Result { + let npm = npm_executable().ok_or_else(|| "npm not found in Terax runtime".to_string())?; + Ok(crate::modules::proc::command_for_executable(&npm)) +} + +fn npm_bin_path(prefix: &Path, stem: &str) -> PathBuf { + #[cfg(windows)] + { + return prefix + .join("node_modules") + .join(".bin") + .join(format!("{stem}.cmd")); + } + #[cfg(not(windows))] + { + prefix + .join("node_modules") + .join(".bin") + .join(stem) + } +} + +fn install_npm_server( + progress: &Channel, + server_id: &str, + stem: &str, + packages: &[&str], +) -> Result { + ensure_node_runtime(progress)?; + let prefix = server_prefix(server_id); + let _ = fs::remove_dir_all(&prefix); + fs::create_dir_all(&prefix).map_err(|e| e.to_string())?; + emit( + progress, + &format!( + "Installing {} via npm (local to Terax)…", + packages.join(", ") + ), + ); + let mut cmd = npm_command()?; + cmd.args([ + "install", + "--prefix", + &prefix.to_string_lossy(), + "--no-audit", + "--no-fund", + "--loglevel=error", + ]) + .args(packages) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + super::local::apply_lsp_environment(&mut cmd); + crate::modules::proc::hide_console(&mut cmd); + let output = cmd + .output() + .map_err(|e| format!("failed to run npm at {}: {e}", npm_executable().map(|p| p.display().to_string()).unwrap_or_else(|| "?".into())))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("npm install failed: {stderr}")); + } + let bin = npm_bin_path(&prefix, stem); + if !bin.is_file() { + return Err(format!("expected binary missing: {}", bin.display())); + } + set_manifest_entry(stem, bin.clone(), None)?; + Ok(bin) +} + +fn platform_deps_lsp_asset() -> Result<&'static str, String> { + #[cfg(all(windows, target_arch = "x86_64"))] + return Ok("deps-lsp-x86_64-pc-windows-msvc"); + #[cfg(all(windows, target_arch = "aarch64"))] + return Ok("deps-lsp-aarch64-pc-windows-msvc"); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return Ok("deps-lsp-aarch64-apple-darwin"); + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return Ok("deps-lsp-x86_64-apple-darwin"); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return Ok("deps-lsp-x86_64-unknown-linux-gnu"); + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + return Ok("deps-lsp-aarch64-unknown-linux-gnu"); + #[cfg(not(any( + all(windows, target_arch = "x86_64"), + all(windows, target_arch = "aarch64"), + all(target_os = "macos", target_arch = "aarch64"), + all(target_os = "macos", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "x86_64"), + all(target_os = "linux", target_arch = "aarch64"), + )))] + Err("unsupported platform for deps-lsp".into()) +} + +fn install_deps_lsp(progress: &Channel) -> Result { + emit(progress, "Downloading deps-lsp…"); + let asset = platform_deps_lsp_asset()?; + let url = github_latest_asset_url("bug-ops/deps-lsp", asset)?; + let data = http_get(&url)?; + let staging = bin_dir().join("_staging-deps-lsp"); + let _ = fs::remove_dir_all(&staging); + if asset.contains("windows") { + extract_zip(&data, &staging)?; + } else { + extract_tar_gz(&data, &staging)?; + } + let binary_name = if cfg!(windows) { + "deps-lsp.exe" + } else { + "deps-lsp" + }; + let found = find_file_named(&staging, binary_name) + .ok_or_else(|| "deps-lsp binary missing in archive".to_string())?; + let dest = bin_dir().join(binary_name); + fs::copy(&found, &dest).map_err(|e| e.to_string())?; + let _ = fs::remove_dir_all(&staging); + set_manifest_entry("deps-lsp", dest.clone(), None)?; + Ok(dest) +} + +pub fn install_server(command: &str, progress: &Channel) -> Result { + if let Ok(existing) = resolve_lsp(command) { + emit(progress, "Using existing installation."); + return Ok(PathBuf::from(existing.display_path())); + } + let stem = binary_stem(command); + match stem { + "rust-analyzer" => install_rust_analyzer(progress), + "gopls" => install_gopls(progress), + "clangd" => install_clangd(progress), + "typescript-language-server" => install_npm_server( + progress, + "typescript", + stem, + &["typescript", "typescript-language-server"], + ), + "pyright-langserver" => install_npm_server(progress, "pyright", stem, &["pyright"]), + "json-languageserver" | "vscode-json-language-server" => install_npm_server( + progress, + "json", + "vscode-json-language-server", + &["vscode-langservers-extracted"], + ), + "bash-language-server" => { + install_npm_server(progress, "shell", stem, &["bash-language-server"]) + } + "deps-lsp" => install_deps_lsp(progress), + "intelephense" => install_npm_server(progress, "php", stem, &["intelephense"]), + other => Err(format!("no Terax installer for {other}")), + } +} diff --git a/src-tauri/src/modules/lsp/links.rs b/src-tauri/src/modules/lsp/links.rs new file mode 100644 index 000000000..13f0806f5 --- /dev/null +++ b/src-tauri/src/modules/lsp/links.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::local::root; +use super::resolve::binary_stem; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum LspBinaryLink { + Path { path: String }, + Wsl { distro: String, command: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct LinksFile { + #[serde(default)] + entries: HashMap, +} + +fn links_path() -> std::path::PathBuf { + root().join("links.json") +} + +fn read_links() -> LinksFile { + let path = links_path(); + if !path.is_file() { + return LinksFile::default(); + } + let raw = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str(&raw).unwrap_or_default() +} + +fn write_links(links: &LinksFile) -> Result<(), String> { + let path = links_path(); + let json = serde_json::to_string_pretty(links).map_err(|e| e.to_string())?; + fs::write(path, json).map_err(|e| e.to_string()) +} + +pub fn get_link(stem: &str) -> Option { + read_links().entries.get(stem).cloned() +} + +pub fn set_link(command: &str, link: LspBinaryLink) -> Result<(), String> { + let stem = binary_stem(command).to_string(); + let link = match link { + LspBinaryLink::Path { path } => { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("path is empty".into()); + } + if !Path::new(trimmed).is_file() { + return Err(format!("file not found: {trimmed}")); + } + LspBinaryLink::Path { + path: trimmed.to_string(), + } + } + LspBinaryLink::Wsl { distro, command: cmd } => { + crate::modules::workspace::validate_wsl_distro_name(&distro)?; + let trimmed = cmd.trim(); + if trimmed.is_empty() { + return Err("WSL command is empty".into()); + } + #[cfg(windows)] + let resolved = if trimmed.contains('/') { + trimmed.to_string() + } else { + probe_wsl_command(&distro, trimmed)? + }; + #[cfg(not(windows))] + let resolved = trimmed.to_string(); + LspBinaryLink::Wsl { + distro, + command: resolved, + } + } + }; + let mut links = read_links(); + links.entries.insert(stem, link); + write_links(&links) +} + +pub fn clear_link(command: &str) -> Result<(), String> { + let stem = binary_stem(command).to_string(); + let mut links = read_links(); + links.entries.remove(&stem); + write_links(&links) +} + +#[cfg(windows)] +pub fn probe_wsl_command(distro: &str, command: &str) -> Result { + use crate::modules::workspace::{normalize_wsl_value, validate_wsl_distro_name, wsl_exec_capture}; + + validate_wsl_distro_name(distro)?; + let stem = binary_stem(command); + let script = format!( + "command -v {stem} 2>/dev/null || which {stem} 2>/dev/null || type -p {stem} 2>/dev/null" + ); + let out = wsl_exec_capture(distro, "sh", &["-lc", &script])?; + let path = normalize_wsl_value(out, ""); + if path.is_empty() { + return Err(format!("{stem} not found in WSL ({distro})")); + } + Ok(path) +} + +#[cfg(not(windows))] +pub fn probe_wsl_command(_distro: &str, _command: &str) -> Result { + Err("WSL linking is only available on Windows".into()) +} diff --git a/src-tauri/src/modules/lsp/local.rs b/src-tauri/src/modules/lsp/local.rs new file mode 100644 index 000000000..4db75d1ed --- /dev/null +++ b/src-tauri/src/modules/lsp/local.rs @@ -0,0 +1,175 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{PathBuf}; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri::Manager; + +static ROOT: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct ManifestFile { + #[serde(default)] + entries: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManifestEntry { + path: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, +} + +pub fn init(app: &AppHandle) { + if let Ok(dir) = app.path().app_local_data_dir() { + let root = dir.join("lsp"); + let _ = fs::create_dir_all(&root); + let _ = fs::create_dir_all(root.join("bin")); + let _ = fs::create_dir_all(root.join("servers")); + let _ = fs::create_dir_all(root.join("runtime")); + let _ = ROOT.set(root); + } +} + +pub fn root() -> PathBuf { + ROOT.get().cloned().unwrap_or_else(|| { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("Terax") + .join("lsp") + }) +} + +fn manifest_path() -> PathBuf { + root().join("manifest.json") +} + +fn read_manifest() -> ManifestFile { + let path = manifest_path(); + if !path.is_file() { + return ManifestFile::default(); + } + let raw = fs::read_to_string(&path).unwrap_or_default(); + serde_json::from_str(&raw).unwrap_or_default() +} + +fn write_manifest(manifest: &ManifestFile) -> Result<(), String> { + let path = manifest_path(); + let json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?; + fs::write(path, json).map_err(|e| e.to_string()) +} + +pub fn set_manifest_entry(stem: &str, path: PathBuf, version: Option) -> Result<(), String> { + let mut manifest = read_manifest(); + manifest.entries.insert( + stem.to_string(), + ManifestEntry { + path: path.to_string_lossy().into_owned(), + version, + }, + ); + write_manifest(&manifest) +} + +pub fn find_local_binary(stem: &str) -> Option { + let manifest = read_manifest(); + let entry = manifest.entries.get(stem)?; + let path = PathBuf::from(&entry.path); + if path.is_file() { + return Some(path); + } + None +} + +pub fn bin_dir() -> PathBuf { + root().join("bin") +} + +pub fn servers_dir() -> PathBuf { + root().join("servers") +} + +pub fn server_prefix(id: &str) -> PathBuf { + servers_dir().join(id) +} + +pub fn node_runtime_dir() -> PathBuf { + root().join("runtime").join("node") +} + +pub fn go_runtime_dir() -> PathBuf { + root().join("runtime").join("go") +} + +pub fn go_executable() -> Option { + #[cfg(windows)] + let exe = go_runtime_dir().join("bin").join("go.exe"); + #[cfg(not(windows))] + let exe = go_runtime_dir().join("bin").join("go"); + if exe.is_file() { + Some(exe) + } else { + None + } +} + +pub fn node_executable() -> Option { + #[cfg(windows)] + { + let exe = node_runtime_dir().join("node.exe"); + if exe.is_file() { + return Some(exe); + } + } + #[cfg(not(windows))] + { + let exe = node_runtime_dir().join("bin").join("node"); + if exe.is_file() { + return Some(exe); + } + } + None +} + +pub fn npm_executable() -> Option { + #[cfg(windows)] + { + let cmd = node_runtime_dir().join("npm.cmd"); + if cmd.is_file() { + return Some(cmd); + } + } + #[cfg(not(windows))] + { + let bin = node_runtime_dir().join("bin").join("npm"); + if bin.is_file() { + return Some(bin); + } + } + None +} + +/// Prepend Terax-managed binaries (portable Node, native LSP bin dir) to PATH so +/// npm-based language servers work without a system Node install. +pub fn apply_lsp_environment(cmd: &mut std::process::Command) { + let mut paths: Vec = Vec::new(); + if let Some(node) = node_executable() { + if let Some(parent) = node.parent() { + paths.push(parent.to_path_buf()); + } + } + if let Some(go) = go_executable() { + if let Some(parent) = go.parent() { + paths.push(parent.to_path_buf()); + } + } + paths.push(bin_dir()); + if let Some(path_var) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&path_var)); + } + if let Ok(joined) = std::env::join_paths(paths.iter().map(|p| p.as_os_str())) { + cmd.env("PATH", joined); + } +} diff --git a/src-tauri/src/modules/lsp/mod.rs b/src-tauri/src/modules/lsp/mod.rs new file mode 100644 index 000000000..8f969b00f --- /dev/null +++ b/src-tauri/src/modules/lsp/mod.rs @@ -0,0 +1,199 @@ +mod install; +mod links; +mod local; +mod root; +mod resolve; +mod session; +mod transport; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; +use tauri::{AppHandle, State}; + +use crate::modules::workspace::{authorize_spawn_cwd, WorkspaceEnv, WorkspaceRegistry}; + +pub fn init(app: &AppHandle) { + local::init(app); +} + +pub struct LspState { + sessions: RwLock>>, + next_id: AtomicU32, +} + +impl Default for LspState { + fn default() -> Self { + Self { + sessions: RwLock::new(HashMap::new()), + next_id: AtomicU32::new(1), + } + } +} + +#[derive(Serialize)] +pub struct LspSpawnResult { + pub id: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspBinaryProbe { + pub command: String, + pub found: bool, + pub path: Option, + pub error: Option, + pub local: bool, + pub linked: bool, + pub wsl: bool, + pub source: Option, +} + +fn probe_result(command: String, result: Result) -> LspBinaryProbe { + match result { + Ok(target) => LspBinaryProbe { + local: target.is_terax_local(), + linked: target.is_linked(), + wsl: target.is_wsl(), + source: Some(target.source_label().to_string()), + command, + found: true, + path: Some(target.display_path()), + error: None, + }, + Err(error) => LspBinaryProbe { + command, + found: false, + path: None, + error: Some(error), + local: false, + linked: false, + wsl: false, + source: None, + }, + } +} + +pub fn probe_binary(command: &str, _local_only: bool) -> LspBinaryProbe { + probe_result( + command.to_string(), + resolve::resolve_lsp(command.trim()), + ) +} + +#[tauri::command] +pub fn lsp_probe_binary(command: String, local_only: Option) -> LspBinaryProbe { + probe_binary(&command, local_only.unwrap_or(false)) +} + +#[tauri::command] +pub async fn lsp_install( + command: String, + on_progress: Channel, +) -> Result { + let progress = on_progress.clone(); + let cmd = command.clone(); + tauri::async_runtime::spawn_blocking(move || install::install_server(&cmd, &progress)) + .await + .map_err(|e| e.to_string())??; + Ok(probe_result( + command.clone(), + resolve::resolve_lsp(&command), + )) +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum LspLinkInput { + Path { path: String }, + Wsl { distro: String, command: String }, +} + +#[tauri::command] +pub fn lsp_link_binary(command: String, link: LspLinkInput) -> Result { + let stored = match link { + LspLinkInput::Path { path } => links::LspBinaryLink::Path { path }, + LspLinkInput::Wsl { distro, command: cmd } => links::LspBinaryLink::Wsl { + distro, + command: cmd, + }, + }; + links::set_link(&command, stored)?; + Ok(probe_result( + command.clone(), + resolve::resolve_lsp(&command), + )) +} + +#[tauri::command] +pub fn lsp_unlink_binary(command: String) -> Result { + links::clear_link(&command)?; + Ok(probe_result( + command.clone(), + resolve::resolve_lsp(&command), + )) +} + +#[tauri::command] +pub fn lsp_probe_wsl_binary(distro: String, command: String) -> Result { + links::probe_wsl_command(&distro, &command) +} + +#[tauri::command] +pub fn lsp_resolve_root( + file_path: String, + command: String, + workspace: Option, +) -> String { + let workspace = WorkspaceEnv::from_option(workspace); + root::resolve_project_root(&file_path, &command, &workspace) +} + +#[tauri::command] +pub async fn lsp_spawn( + state: State<'_, LspState>, + registry: State<'_, WorkspaceRegistry>, + command: String, + args: Vec, + cwd: String, + workspace: Option, + on_message: Channel, + on_stderr: Channel, +) -> Result { + let workspace = WorkspaceEnv::from_option(workspace); + authorize_spawn_cwd(®istry, Some(&cwd), &workspace)?; + let id = state.next_id.fetch_add(1, Ordering::Relaxed); + let session = tauri::async_runtime::spawn_blocking(move || { + session::spawn(command, args, cwd, workspace, on_message, on_stderr) + }) + .await + .map_err(|e| e.to_string())??; + state.sessions.write().unwrap().insert(id, session); + log::info!("lsp spawned id={id}"); + Ok(LspSpawnResult { id }) +} + +#[tauri::command] +pub fn lsp_send(state: State<'_, LspState>, id: u32, message: String) -> Result<(), String> { + let session = state + .sessions + .read() + .unwrap() + .get(&id) + .cloned() + .ok_or_else(|| format!("lsp session {id} not found"))?; + session.send(&message) +} + +#[tauri::command] +pub fn lsp_close(state: State<'_, LspState>, id: u32) -> Result<(), String> { + let session = state.sessions.write().unwrap().remove(&id); + if let Some(s) = session { + s.kill(); + log::info!("lsp closed id={id}"); + } + Ok(()) +} diff --git a/src-tauri/src/modules/lsp/resolve.rs b/src-tauri/src/modules/lsp/resolve.rs new file mode 100644 index 000000000..c7b00d5d3 --- /dev/null +++ b/src-tauri/src/modules/lsp/resolve.rs @@ -0,0 +1,338 @@ +use std::path::{Path, PathBuf}; + +const ALLOWED_BINARIES: &[&str] = &[ + "typescript-language-server", + "rust-analyzer", + "pyright-langserver", + "pylsp", + "gopls", + "clangd", + "bash-language-server", + "json-languageserver", + "vscode-json-language-server", + "deps-lsp", + "intelephense", +]; + +pub fn binary_stem(command: &str) -> &str { + Path::new(command) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(command) +} + +pub fn is_allowed_command(command: &str) -> bool { + ALLOWED_BINARIES.contains(&binary_stem(command)) +} + +fn candidate_names(stem: &str) -> Vec { + #[cfg(windows)] + { + return vec![ + format!("{stem}.cmd"), + format!("{stem}.exe"), + format!("{stem}.bat"), + stem.to_string(), + ]; + } + #[cfg(not(windows))] + { + vec![stem.to_string()] + } +} + +#[cfg(windows)] +fn normalize_windows_executable(path: PathBuf) -> PathBuf { + if !path.is_file() { + return path; + } + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if matches!(ext.as_str(), "cmd" | "exe" | "bat") { + return path; + } + if ext.is_empty() { + for alt in ["cmd", "exe", "bat"] { + let sibling = path.with_extension(alt); + if sibling.is_file() { + return sibling; + } + } + } + path +} + +fn search_path_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".cargo").join("bin")); + dirs.push(home.join(".local").join("bin")); + #[cfg(windows)] + { + dirs.push(home.join("AppData").join("Roaming").join("npm")); + dirs.push(home.join("AppData").join("Local").join("pnpm")); + dirs.push( + home.join("AppData") + .join("Local") + .join("Programs") + .join("nodejs"), + ); + dirs.push(home.join(".local").join("share").join("pnpm")); + if let Ok(nvm_home) = std::env::var("NVM_HOME") { + let nodejs = PathBuf::from(&nvm_home).join("nodejs"); + dirs.push(nodejs.join("node_modules").join(".bin")); + dirs.push(nodejs); + } + if let Ok(nvm_symlink) = std::env::var("NVM_SYMLINK") { + dirs.push(PathBuf::from(nvm_symlink)); + } + dirs.push(PathBuf::from(r"C:\nvm4w\nodejs")); + dirs.push( + PathBuf::from(r"C:\nvm4w\nodejs").join("node_modules").join(".bin"), + ); + } + #[cfg(windows)] + { + dirs.push(home.join("go").join("bin")); + dirs.push(home.join("scoop").join("shims")); + dirs.push(PathBuf::from(r"C:\Program Files\Go\bin")); + dirs.push(PathBuf::from(r"C:\Program Files (x86)\Go\bin")); + } + #[cfg(not(windows))] + { + dirs.push(home.join("go").join("bin")); + dirs.push(PathBuf::from("/usr/local/go/bin")); + } + if let Ok(gopath) = std::env::var("GOPATH") { + if !gopath.is_empty() { + dirs.push(PathBuf::from(gopath).join("bin")); + } + } + #[cfg(target_os = "macos")] + { + dirs.push(home.join(".npm-global").join("bin")); + dirs.push(PathBuf::from("/opt/homebrew/bin")); + dirs.push(PathBuf::from("/usr/local/bin")); + } + } + dirs.sort(); + dirs.dedup(); + dirs +} + +fn which_in_paths(name: &str) -> Option { + for dir in search_path_dirs() { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn normalize_executable(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + return normalize_windows_executable(path); + } + #[cfg(not(windows))] + { + path + } +} + +fn resolve_stems(stem: &str) -> Vec { + match stem { + "vscode-json-language-server" | "json-languageserver" => { + vec![ + "vscode-json-language-server".into(), + "json-languageserver".into(), + ] + } + other => vec![other.to_string()], + } +} + +fn find_on_system(stem: &str, trimmed: &str) -> Option { + let as_path = PathBuf::from(trimmed); + if as_path.is_absolute() && as_path.is_file() { + return Some(normalize_executable(as_path)); + } + for stem in resolve_stems(stem) { + for name in candidate_names(&stem) { + if let Some(found) = which_in_paths(&name) { + return Some(normalize_executable(found)); + } + } + } + None +} + +pub fn find_system_go() -> Option { + #[cfg(windows)] + let names = ["go.exe", "go"]; + #[cfg(not(windows))] + let names = ["go"]; + for name in names { + if let Some(found) = which_in_paths(name) { + return Some(found); + } + } + None +} + +pub fn resolve_lsp(command: &str) -> Result { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("empty lsp command".into()); + } + let stem = binary_stem(trimmed); + if !ALLOWED_BINARIES.contains(&stem) { + return Err(format!("lsp command not allowed: {trimmed}")); + } + + for stem in resolve_stems(stem) { + if let Some(super::links::LspBinaryLink::Path { path }) = super::links::get_link(&stem) { + return link_to_target(super::links::LspBinaryLink::Path { path }); + } + } + + if let Some(found) = find_on_system(stem, trimmed) { + return Ok(LspTarget::Host { + path: found, + source: LspSource::System, + }); + } + + for stem in resolve_stems(stem) { + if let Some(local) = super::local::find_local_binary(&stem) { + return Ok(LspTarget::Host { + path: normalize_executable(local), + source: LspSource::Terax, + }); + } + } + + for stem in resolve_stems(stem) { + if let Some(super::links::LspBinaryLink::Wsl { distro, command }) = + super::links::get_link(&stem) + { + return link_to_target(super::links::LspBinaryLink::Wsl { distro, command }); + } + } + + Err(format!( + "language server not installed: {stem}. Link an existing binary, or use Install for Terax in Settings → Languages." + )) +} + +fn link_to_target(link: super::links::LspBinaryLink) -> Result { + match link { + super::links::LspBinaryLink::Path { path } => { + let path_buf = PathBuf::from(path.trim()); + if !path_buf.is_file() { + return Err(format!("linked file not found: {}", path_buf.display())); + } + Ok(LspTarget::Host { + path: normalize_executable(path_buf), + source: LspSource::Linked, + }) + } + super::links::LspBinaryLink::Wsl { distro, command } => Ok(LspTarget::Wsl { + distro, + command, + }), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LspSource { + Linked, + System, + Terax, +} + +#[derive(Debug, Clone)] +pub enum LspTarget { + Host { + path: PathBuf, + source: LspSource, + }, + Wsl { + distro: String, + command: String, + }, +} + +impl LspTarget { + pub fn display_path(&self) -> String { + match self { + LspTarget::Host { path, .. } => path.to_string_lossy().into_owned(), + LspTarget::Wsl { distro, command } => { + if command.starts_with('/') { + format!("wsl:{distro}:{command}") + } else { + format!("wsl:{distro}/{command}") + } + } + } + } + + pub fn is_wsl(&self) -> bool { + matches!(self, LspTarget::Wsl { .. }) + } + + pub fn is_linked(&self) -> bool { + matches!( + self, + LspTarget::Host { + source: LspSource::Linked, + .. + } + ) || self.is_wsl() + } + + pub fn is_terax_local(&self) -> bool { + matches!( + self, + LspTarget::Host { + source: LspSource::Terax, + .. + } + ) + } + + pub fn source_label(&self) -> &'static str { + match self { + LspTarget::Wsl { .. } => "linked", + LspTarget::Host { source: LspSource::Linked, .. } => "linked", + LspTarget::Host { source: LspSource::System, .. } => "system", + LspTarget::Host { source: LspSource::Terax, .. } => "terax", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_cmd_extension() { + assert!(is_allowed_command("typescript-language-server.cmd")); + assert!(is_allowed_command( + r"C:\Users\dev\AppData\Roaming\npm\rust-analyzer.exe" + )); + } + + #[test] + fn windows_prefers_cmd_before_shim() { + let names = candidate_names("typescript-language-server"); + assert_eq!(names[0], "typescript-language-server.cmd"); + } +} diff --git a/src-tauri/src/modules/lsp/root.rs b/src-tauri/src/modules/lsp/root.rs new file mode 100644 index 000000000..97b6df1c3 --- /dev/null +++ b/src-tauri/src/modules/lsp/root.rs @@ -0,0 +1,87 @@ +use std::path::Path; + +use crate::modules::workspace::{resolve_path, WorkspaceEnv}; + +fn command_base(command: &str) -> &str { + Path::new(command) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(command) +} + +fn markers_for(command: &str) -> &'static [&'static str] { + match command_base(command) { + "rust-analyzer" => &["Cargo.toml"], + "typescript-language-server" => &["tsconfig.json", "jsconfig.json", "package.json"], + "pyright-langserver" | "pylsp" => { + &["pyproject.toml", "pyrightconfig.json", "setup.cfg", "setup.py"] + } + "gopls" => &["go.mod"], + "clangd" => &["compile_commands.json", "CMakeLists.txt", "Cargo.toml"], + "json-languageserver" | "vscode-json-language-server" => &["package.json", "tsconfig.json"], + "deps-lsp" => &[ + "package.json", + "Cargo.toml", + "pyproject.toml", + "go.mod", + "composer.json", + "Gemfile", + "pubspec.yaml", + ], + "intelephense" => &["composer.json", "phpunit.xml", "phpunit.xml.dist"], + _ => &["Cargo.toml", "package.json", "go.mod"], + } +} + +fn path_display(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +/// Walk upward from `file_path` until a language-specific project marker is found. +pub fn resolve_project_root(file_path: &str, command: &str, workspace: &WorkspaceEnv) -> String { + let resolved = resolve_path(file_path, workspace); + let mut dir = resolved + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| resolved.clone()); + let fallback = dir.clone(); + let markers = markers_for(command); + + loop { + for marker in markers { + let candidate = dir.join(marker); + if candidate.is_file() { + return path_display(&dir); + } + } + if !dir.pop() { + break; + } + } + + path_display(&fallback) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn finds_cargo_toml_above_src() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + let file = root.join("src").join("lib.rs"); + fs::write(&file, "fn main() {}\n").unwrap(); + + let got = resolve_project_root( + &file.to_string_lossy(), + "rust-analyzer", + &WorkspaceEnv::Local, + ); + assert_eq!(got, path_display(root)); + } +} diff --git a/src-tauri/src/modules/lsp/session.rs b/src-tauri/src/modules/lsp/session.rs new file mode 100644 index 000000000..8a922daed --- /dev/null +++ b/src-tauri/src/modules/lsp/session.rs @@ -0,0 +1,240 @@ +use std::io::{Read, Write}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use shared_child::SharedChild; +use tauri::ipc::Channel; + +use super::resolve::{resolve_lsp, LspTarget}; +use super::transport::{encode_message, FrameReader}; +use crate::modules::workspace::{host_path_to_wsl_cd, resolve_path, validate_wsl_distro_name, WorkspaceEnv}; + +pub struct LspSession { + child: Arc, + stdin: Mutex, + alive: Arc, +} + +impl LspSession { + pub fn send(&self, json: &str) -> Result<(), String> { + if !self.alive.load(Ordering::Acquire) { + return Err("lsp session closed".into()); + } + let framed = encode_message(json.as_bytes()); + let mut stdin = self.stdin.lock().map_err(|e| e.to_string())?; + stdin + .write_all(&framed) + .map_err(|e| e.to_string())?; + stdin.flush().map_err(|e| e.to_string()) + } + + pub fn kill(&self) { + self.alive.store(false, Ordering::Release); + let _ = self.child.kill(); + } +} + +impl Drop for LspSession { + fn drop(&mut self) { + self.kill(); + } +} + +fn is_allowed_command(command: &str) -> bool { + super::resolve::is_allowed_command(command) +} + +fn spawn_reader( + stdout: impl Read + Send + 'static, + alive: Arc, + on_message: Channel, +) { + thread::spawn(move || { + let mut pipe = stdout; + let mut buf = [0u8; 8192]; + let mut reader = FrameReader::default(); + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + for frame in reader.push(&buf[..n]) { + if on_message.send(frame).is_err() { + alive.store(false, Ordering::Release); + return; + } + } + } + } + } + alive.store(false, Ordering::Release); + }); +} + +fn spawn_stderr_reader( + stderr: impl Read + Send + 'static, + on_stderr: Channel, +) { + thread::spawn(move || { + let mut buf = [0u8; 4096]; + let mut pipe = stderr; + loop { + match pipe.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if let Ok(text) = std::str::from_utf8(&buf[..n]) { + for line in text.lines() { + if line.is_empty() { + continue; + } + log::debug!("lsp stderr: {line}"); + if on_stderr.send(line.to_string()).is_err() { + break; + } + } + } + } + } + } + }); +} + +fn finish_spawn( + child: Arc, + on_message: Channel, + on_stderr: Channel, +) -> Result, String> { + let stdin = child.take_stdin().ok_or("lsp stdin unavailable")?; + let stdout = child.take_stdout().ok_or("lsp stdout unavailable")?; + let stderr = child.take_stderr(); + if let Some(stderr_pipe) = stderr { + spawn_stderr_reader(stderr_pipe, on_stderr); + } + let alive = Arc::new(AtomicBool::new(true)); + spawn_reader(stdout, Arc::clone(&alive), on_message); + Ok(Arc::new(LspSession { + child, + stdin: Mutex::new(stdin), + alive, + })) +} + +#[cfg(windows)] +fn sh_single_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +#[cfg(windows)] +fn wsl_exec_script(command: &str, args: &[String]) -> String { + let mut parts = vec![sh_single_quote(command)]; + parts.extend(args.iter().map(|a| sh_single_quote(a))); + format!("exec {}", parts.join(" ")) +} + +#[cfg(windows)] +fn wsl_command_is_absolute(command: &str) -> bool { + command.starts_with('/') || command.starts_with("~/") || command.starts_with("./") +} + +#[cfg(windows)] +fn spawn_wsl( + distro: &str, + command: &str, + args: &[String], + cwd_host: &Path, + on_message: Channel, + on_stderr: Channel, +) -> Result, String> { + validate_wsl_distro_name(distro)?; + let wsl_cwd = host_path_to_wsl_cd(distro, cwd_host)?; + log::info!( + "lsp spawning WSL {distro}: {command} (cwd={wsl_cwd})" + ); + let mut cmd = Command::new("wsl.exe"); + cmd.arg("-d").arg(distro).arg("--cd").arg(&wsl_cwd); + + if wsl_command_is_absolute(command) { + cmd.arg("--exec").arg(command).args(args); + } else { + // Bare names like `rust-analyzer` live on PATH in login shells (~/.cargo/bin), + // not in the minimal environment used by `wsl --exec`. + let script = wsl_exec_script(command, args); + cmd.arg("sh").arg("-lc").arg(script); + } + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + crate::modules::proc::hide_console(&mut cmd); + let child = Arc::new(SharedChild::spawn(&mut cmd).map_err(|e| e.to_string())?); + finish_spawn(child, on_message, on_stderr) +} + +fn spawn_host( + executable: &Path, + args: &[String], + cwd: &Path, + on_message: Channel, + on_stderr: Channel, +) -> Result, String> { + log::info!("lsp resolved executable: {}", executable.display()); + let mut cmd = crate::modules::proc::command_for_executable(executable); + cmd.args(args) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + super::local::apply_lsp_environment(&mut cmd); + crate::modules::proc::hide_console(&mut cmd); + let child = Arc::new(SharedChild::spawn(&mut cmd).map_err(|e| e.to_string())?); + finish_spawn(child, on_message, on_stderr) +} + +pub fn spawn( + command: String, + args: Vec, + cwd: String, + workspace: WorkspaceEnv, + on_message: Channel, + on_stderr: Channel, +) -> Result, String> { + let trimmed = command.trim(); + if trimmed.is_empty() { + return Err("empty lsp command".into()); + } + if !is_allowed_command(trimmed) { + return Err(format!("lsp command not allowed: {trimmed}")); + } + + let target = resolve_lsp(trimmed)?; + let resolved_cwd = resolve_path(&cwd, &workspace); + if !resolved_cwd.is_dir() { + return Err(format!("cwd is not a directory: {cwd}")); + } + + match target { + LspTarget::Wsl { distro, command: wsl_cmd } => { + #[cfg(windows)] + { + return spawn_wsl( + &distro, + &wsl_cmd, + &args, + &resolved_cwd, + on_message, + on_stderr, + ); + } + #[cfg(not(windows))] + { + let _ = (distro, wsl_cmd, args, resolved_cwd, on_message, on_stderr); + return Err("WSL language servers are only supported on Windows".into()); + } + } + LspTarget::Host { path, .. } => { + spawn_host(&path, &args, &resolved_cwd, on_message, on_stderr) + } + } +} diff --git a/src-tauri/src/modules/lsp/transport.rs b/src-tauri/src/modules/lsp/transport.rs new file mode 100644 index 000000000..dac3b0bc8 --- /dev/null +++ b/src-tauri/src/modules/lsp/transport.rs @@ -0,0 +1,81 @@ +//! LSP stdio framing: `Content-Length: N\r\n\r\n` + JSON body. + +pub fn encode_message(body: &[u8]) -> Vec { + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + let mut out = header.into_bytes(); + out.extend_from_slice(body); + out +} + +pub struct FrameReader { + buffer: Vec, +} + +impl Default for FrameReader { + fn default() -> Self { + Self { + buffer: Vec::with_capacity(4096), + } + } +} + +impl FrameReader { + pub fn push(&mut self, chunk: &[u8]) -> Vec { + self.buffer.extend_from_slice(chunk); + let mut frames = Vec::new(); + while let Some((len, header_end)) = parse_content_length(&self.buffer) { + let frame_end = header_end + len; + if self.buffer.len() < frame_end { + break; + } + let body = &self.buffer[header_end..frame_end]; + frames.push(String::from_utf8_lossy(body).into_owned()); + self.buffer.drain(..frame_end); + } + frames + } +} + +fn parse_content_length(buf: &[u8]) -> Option<(usize, usize)> { + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n")? + 4; + let header = std::str::from_utf8(&buf[..header_end]).ok()?; + for line in header.lines() { + let (key, value) = line.split_once(':')?; + if key.trim().eq_ignore_ascii_case("Content-Length") { + let len: usize = value.trim().parse().ok()?; + return Some((len, header_end)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_round_trip() { + let body = br#"{"jsonrpc":"2.0","id":1}"#; + let encoded = encode_message(body); + let mut reader = FrameReader::default(); + let frames = reader.push(&encoded); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0], r#"{"jsonrpc":"2.0","id":1}"#); + } + + #[test] + fn parses_chunked_frames() { + let a = encode_message(br#"{"a":1}"#); + let b = encode_message(br#"{"b":2}"#); + let mut reader = FrameReader::default(); + let mut combined = a.clone(); + combined.extend_from_slice(&b); + let mid = combined.len() / 2; + let f1 = reader.push(&combined[..mid]); + let f2 = reader.push(&combined[mid..]); + let all: Vec<_> = f1.into_iter().chain(f2).collect(); + assert_eq!(all.len(), 2); + assert_eq!(all[0], r#"{"a":1}"#); + assert_eq!(all[1], r#"{"b":2}"#); + } +} diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index ee38e5de7..013ef51dd 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod fs; pub mod git; pub mod history; +pub mod lsp; pub mod net; pub mod proc; pub mod pty; diff --git a/src-tauri/src/modules/proc.rs b/src-tauri/src/modules/proc.rs index 79be26bc1..a0e66f7b9 100644 --- a/src-tauri/src/modules/proc.rs +++ b/src-tauri/src/modules/proc.rs @@ -1,5 +1,27 @@ +use std::path::Path; use std::process::Command; +#[cfg(windows)] +pub fn command_for_executable(path: &Path) -> Command { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext == "cmd" || ext == "bat" { + let path_str = path.to_string_lossy().into_owned(); + let mut cmd = Command::new("cmd"); + cmd.args(["/C", &path_str]); + return cmd; + } + Command::new(path) +} + +#[cfg(not(windows))] +pub fn command_for_executable(path: &Path) -> Command { + Command::new(path) +} + #[cfg(windows)] pub fn hide_console(cmd: &mut Command) { use std::os::windows::process::CommandExt; diff --git a/src-tauri/src/modules/workspace.rs b/src-tauri/src/modules/workspace.rs index d5c266f9a..0b66e4ac1 100644 --- a/src-tauri/src/modules/workspace.rs +++ b/src-tauri/src/modules/workspace.rs @@ -326,6 +326,45 @@ pub fn wsl_path_to_host(distro: &str, path: &str) -> PathBuf { wsl_drvfs_to_windows(path).unwrap_or_else(|| wsl_path_to_unc(distro, path)) } +#[cfg(windows)] +pub fn host_path_to_wsl_cd(distro: &str, host: &Path) -> Result { + validate_wsl_distro_name(distro)?; + let s = host.to_string_lossy(); + let unc_local = format!(r"\\wsl.localhost\{distro}\"); + let unc_legacy = format!(r"\\wsl$\{distro}\"); + for prefix in [&unc_local, &unc_legacy] { + if s.starts_with(prefix.as_str()) { + let rest = s[prefix.len()..].replace('\\', "/"); + let linux = if rest.starts_with('/') { + rest + } else { + format!("/{rest}") + }; + return Ok(linux); + } + } + if let Some(mnt) = windows_drive_to_wsl_mnt(host) { + return Ok(mnt); + } + Err(format!( + "cannot map Windows path to WSL: {}", + host.display() + )) +} + +#[cfg(windows)] +fn windows_drive_to_wsl_mnt(path: &Path) -> Option { + let s = path.to_string_lossy(); + let bytes = s.as_bytes(); + if bytes.len() >= 3 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let rest = s[3..].replace('\\', "/"); + let trimmed = rest.trim_start_matches('/'); + return Some(format!("/mnt/{drive}/{trimmed}")); + } + None +} + #[cfg(windows)] pub fn decode_command_output(bytes: &[u8]) -> String { if bytes.starts_with(&[0xff, 0xfe]) || looks_utf16le(bytes) { diff --git a/src/app/App.tsx b/src/app/App.tsx index 844ec1b87..1939917de 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -27,10 +27,12 @@ import { createCommandItems, } from "@/modules/command-palette"; import { + LspDebugDialog, NewEditorDialog, useEditorFileSync, type EditorPaneHandle, } from "@/modules/editor"; +import { useLspDebugStore } from "@/modules/editor/lib/lsp/debugStore"; import { FileExplorer, type FileExplorerHandle } from "@/modules/explorer"; import type { GitHistorySearchHandle } from "@/modules/git-history"; import { @@ -764,6 +766,7 @@ export default function App() { askAiSelection: askFromSelection, openSettings: () => void openSettingsWindow(), openKeyboardShortcuts: () => void openSettingsWindow("shortcuts"), + openLspDebug: () => useLspDebugStore.getState().setPanelOpen(true), }) : [], [ @@ -916,6 +919,7 @@ export default function App() { registerEditorHandle={registerEditorHandle} onEditorDirtyChange={handleEditorDirty} onEditorCloseTab={disposeTab} + onOpenDefinition={openContentHit} registerPreviewHandle={registerPreviewHandle} onPreviewUrlChange={handlePreviewUrl} onAiDiffAccept={(id) => respondToApproval(id, true)} @@ -953,9 +957,12 @@ export default function App() { privateActive={ activeTab?.kind === "terminal" && activeTab.private === true } + editorActive={isEditorTab} /> )} + +
void; openSettings: () => void; openKeyboardShortcuts: () => void; + openLspDebug: () => void; }; const noop = () => {}; @@ -102,6 +104,18 @@ export function createCommandItems( icon: KeyboardIcon, run: ctx.openKeyboardShortcuts, }, + ...(LSP_DEV_TOOLS + ? [ + { + id: "lsp.debug", + title: "LSP debug log", + group: "General" as const, + keywords: ["lsp", "language server", "diagnostics", "editor"], + icon: SourceCodeIcon, + run: ctx.openLspDebug, + }, + ] + : []), { id: "tab.new", title: "New terminal", diff --git a/src/modules/editor/EditorPane.tsx b/src/modules/editor/EditorPane.tsx index 09ddc1108..ee98437c6 100644 --- a/src/modules/editor/EditorPane.tsx +++ b/src/modules/editor/EditorPane.tsx @@ -10,6 +10,7 @@ import { } from "@codemirror/search"; import { type Extension, Prec } from "@codemirror/state"; import { EditorView, keymap } from "@codemirror/view"; +import { setDiagnostics } from "@codemirror/lint"; import { vim } from "@replit/codemirror-vim"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { @@ -25,12 +26,14 @@ import { inlineCompletion } from "./lib/autocomplete/inlineExtension"; import { buildSharedExtensions, languageCompartment, + lspCompartment, vimCompartment, } from "./lib/extensions"; import { resolveLanguage } from "./lib/languageResolver"; import { EDITOR_THEME_EXT } from "./lib/themes"; import { useDocument } from "./lib/useDocument"; import { initVimGlobals, vimHandlersExtension } from "./lib/vim"; +import { LspEditorStatus } from "./LspEditorStatus"; initVimGlobals(); @@ -56,6 +59,7 @@ type Props = { onDirtyChange?: (dirty: boolean) => void; onSaved?: () => void; onClose?: () => void; + onOpenDefinition?: (path: string, line: number) => void; }; function formatBytes(n: number): string { @@ -65,7 +69,7 @@ function formatBytes(n: number): string { } export const EditorPane = forwardRef( - function EditorPane({ path, onDirtyChange, onSaved, onClose }, ref) { + function EditorPane({ path, onDirtyChange, onSaved, onClose, onOpenDefinition }, ref) { const { doc, onChange, save, reload } = useDocument({ path, onDirtyChange, @@ -75,6 +79,7 @@ export const EditorPane = forwardRef( const cmRef = useRef(null); const editorThemeId = usePreferencesStore((s) => s.editorTheme); const vimMode = usePreferencesStore((s) => s.vimMode); + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); const languageRef = useRef(null); const apiKeyRef = useRef(null); @@ -121,6 +126,8 @@ export const EditorPane = forwardRef( onSavedRef.current = onSaved; const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const onOpenDefinitionRef = useRef(onOpenDefinition); + onOpenDefinitionRef.current = onOpenDefinition; const pathRef = useRef(path); pathRef.current = path; @@ -165,6 +172,7 @@ export const EditorPane = forwardRef( })), ...buildSharedExtensions(), languageCompartment.of([]), + lspCompartment.of([]), inlineCompletion({ getPrefs: () => { const s = usePreferencesStore.getState(); @@ -248,6 +256,47 @@ export const EditorPane = forwardRef( }; }, [path, doc.status]); + useEffect(() => { + if (doc.status !== "ready" || !lspEnabled) { + const view = cmRef.current?.view; + if (view) { + view.dispatch(setDiagnostics(view.state, [])); + view.dispatch({ effects: lspCompartment.reconfigure([]) }); + } + return; + } + let cancelled = false; + let release: (() => Promise) | undefined; + + void import("./lib/lsp/extension").then(async ({ buildLspExtensions }) => { + const view = cmRef.current?.view; + const initialText = view?.state.doc.toString() ?? ""; + const bundle = await buildLspExtensions({ + path, + initialText, + onOpenDefinition: (targetPath, line) => + onOpenDefinitionRef.current?.(targetPath, line), + }); + if (cancelled || !bundle) return; + release = bundle.release; + const active = cmRef.current?.view; + if (!active) return; + active.dispatch({ + effects: lspCompartment.reconfigure(bundle.extensions), + }); + }); + + return () => { + cancelled = true; + const view = cmRef.current?.view; + if (view) { + view.dispatch(setDiagnostics(view.state, [])); + view.dispatch({ effects: lspCompartment.reconfigure([]) }); + } + void release?.(); + }; + }, [path, doc.status, lspEnabled]); + useImperativeHandle( ref, () => ({ @@ -385,6 +434,7 @@ export const EditorPane = forwardRef( return (
+ ( foldGutter: true, bracketMatching: true, closeBrackets: true, - autocompletion: true, + autocompletion: !lspEnabled, highlightActiveLine: true, highlightSelectionMatches: true, searchKeymap: true, diff --git a/src/modules/editor/EditorStack.tsx b/src/modules/editor/EditorStack.tsx index 383a831f4..b5abc5e34 100644 --- a/src/modules/editor/EditorStack.tsx +++ b/src/modules/editor/EditorStack.tsx @@ -9,6 +9,7 @@ type Props = { onDirtyChange: (id: number, dirty: boolean) => void; registerHandle: (id: number, handle: EditorPaneHandle | null) => void; onCloseTab: (id: number) => void; + onOpenDefinition?: (path: string, line: number) => void; }; export function EditorStack({ @@ -17,6 +18,7 @@ export function EditorStack({ onDirtyChange, registerHandle, onCloseTab, + onOpenDefinition, }: Props) { const editors = tabs.filter((t): t is EditorTab => t.kind === "editor"); @@ -27,6 +29,7 @@ export function EditorStack({ const registerRef = useRef(registerHandle); const dirtyRef = useRef(onDirtyChange); const closeRef = useRef(onCloseTab); + const openDefinitionRef = useRef(onOpenDefinition); useEffect(() => { registerRef.current = registerHandle; }, [registerHandle]); @@ -36,6 +39,9 @@ export function EditorStack({ useEffect(() => { closeRef.current = onCloseTab; }, [onCloseTab]); + useEffect(() => { + openDefinitionRef.current = onOpenDefinition; + }, [onOpenDefinition]); const refCallbacks = useRef( new Map void>(), @@ -102,6 +108,9 @@ export function EditorStack({ path={t.path} onDirtyChange={getDirtyCallback(t.id)} onClose={getCloseCallback(t.id)} + onOpenDefinition={(targetPath, line) => + openDefinitionRef.current?.(targetPath, line) + } />
diff --git a/src/modules/editor/LspDebugButton.tsx b/src/modules/editor/LspDebugButton.tsx new file mode 100644 index 000000000..88b81faab --- /dev/null +++ b/src/modules/editor/LspDebugButton.tsx @@ -0,0 +1,83 @@ +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + useLspDebugStore, + LSP_DEV_TOOLS, + type LspSessionState, +} from "@/modules/editor/lib/lsp/debugStore"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { SourceCodeIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +const HINT: Record = { + idle: "LSP idle. Open a supported editor file.", + unsupported: "This file type has no LSP server mapping.", + spawning: "Starting language server...", + ready: "Language server connected.", + error: "LSP error. Open debug for details.", + closed: "LSP session closed.", +}; + +type Props = { + editorActive: boolean; +}; + +export function LspDebugButton({ editorActive }: Props) { + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); + const state = useLspDebugStore((s) => s.session.state); + const error = useLspDebugStore((s) => s.session.error); + const toggle = useLspDebugStore((s) => s.togglePanel); + + if (!LSP_DEV_TOOLS) return null; + + const displayState: LspSessionState = + lspEnabled ? state : "idle"; + const active = lspEnabled && editorActive && displayState !== "idle"; + + return ( + + + + + + {!lspEnabled + ? "Language servers are off. Enable in Settings → General." + : (error ?? HINT[displayState])} + + Click for LSP debug log. + + + + ); +} diff --git a/src/modules/editor/LspDebugDialog.tsx b/src/modules/editor/LspDebugDialog.tsx new file mode 100644 index 000000000..2a78124d7 --- /dev/null +++ b/src/modules/editor/LspDebugDialog.tsx @@ -0,0 +1,199 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { + formatLspPayload, + LSP_DEV_TOOLS, + useLspDebugStore, + type LspDebugEntry, + type LspSessionState, +} from "@/modules/editor/lib/lsp/debugStore"; + +const STATE_LABEL: Record = { + idle: "Idle", + unsupported: "Unsupported file", + spawning: "Spawning server", + ready: "Connected", + error: "Error", + closed: "Closed", +}; + +const STATE_CLASS: Record = { + idle: "bg-muted text-muted-foreground", + unsupported: "bg-muted text-muted-foreground", + spawning: "bg-amber-500/15 text-amber-700 dark:text-amber-400", + ready: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400", + error: "bg-destructive/15 text-destructive", + closed: "bg-muted text-muted-foreground", +}; + +const LEVEL_CLASS: Record = { + info: "text-muted-foreground", + out: "text-sky-700 dark:text-sky-400", + in: "text-violet-700 dark:text-violet-400", + warn: "text-amber-700 dark:text-amber-400", + error: "text-destructive", +}; + +function formatTime(at: number): string { + const d = new Date(at); + const base = d.toLocaleTimeString(undefined, { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + return `${base}.${String(d.getMilliseconds()).padStart(3, "0")}`; +} + +export function LspDebugDialog() { + const open = useLspDebugStore((s) => s.panelOpen); + const setOpen = useLspDebugStore((s) => s.setPanelOpen); + const session = useLspDebugStore((s) => s.session); + const entries = useLspDebugStore((s) => s.entries); + const clear = useLspDebugStore((s) => s.clear); + + const copyLogs = async () => { + const text = entries + .map((e) => { + const line = `[${formatTime(e.at)}] ${e.level.toUpperCase()} ${e.message}`; + return e.detail ? `${line}\n ${e.detail}` : line; + }) + .join("\n"); + await navigator.clipboard.writeText(text); + }; + + if (!LSP_DEV_TOOLS) return null; + + return ( + + + + LSP debug + + +
+
+ + {STATE_LABEL[session.state]} + + {session.diagnosticCount > 0 ? ( + + {session.diagnosticCount} diagnostic + {session.diagnosticCount === 1 ? "" : "s"} + + ) : null} +
+ +
+ + + + + + + + {session.openDocuments.length > 0 ? ( + + ) : null} + {session.error ? ( +

{session.error}

+ ) : null} +
+ +
+ + +
+
+ +
+ +
+ {entries.length === 0 ? ( +

+ No LSP events yet. Open a supported file in the editor + (ts, rs, py, go, ...). +

+ ) : ( + entries.map((entry) => ( +
+ + {formatTime(entry.at)}{" "} + + + {entry.level.toUpperCase()}{" "} + + {entry.message} + {entry.detail ? ( +
+ {entry.detail.startsWith("{") || + entry.detail.startsWith("[") + ? formatLspPayload(entry.detail, 400) + : entry.detail} +
+ ) : null} +
+ )) + )} +
+
+
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string | null }) { + if (!value) return null; + return ( +

+ {label}: + {value} +

+ ); +} diff --git a/src/modules/editor/LspEditorStatus.tsx b/src/modules/editor/LspEditorStatus.tsx new file mode 100644 index 000000000..275be8687 --- /dev/null +++ b/src/modules/editor/LspEditorStatus.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/utils"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { + useLspDebugStore, + LSP_DEV_TOOLS, + type LspSessionState, +} from "@/modules/editor/lib/lsp/debugStore"; + +const MESSAGES: Partial> = { + unsupported: "No language server configured for this file type.", + spawning: "Starting language server…", +}; + +type Props = { + path: string; +}; + +export function LspEditorStatus({ path }: Props) { + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); + const state = useLspDebugStore((s) => s.session.state); + const error = useLspDebugStore((s) => s.session.error); + const lastPath = useLspDebugStore((s) => s.session.lastPath); + const setPanelOpen = useLspDebugStore((s) => s.setPanelOpen); + + if (!lspEnabled || lastPath !== path) return null; + if (state !== "error" && state !== "unsupported" && state !== "spawning") { + return null; + } + + const text = + state === "error" + ? (error ?? "Language server error.") + : (MESSAGES[state] ?? null); + if (!text) return null; + + return ( +
+ {text} + {state === "error" && LSP_DEV_TOOLS ? ( + + ) : null} +
+ ); +} diff --git a/src/modules/editor/index.ts b/src/modules/editor/index.ts index edacef316..7a42c636e 100644 --- a/src/modules/editor/index.ts +++ b/src/modules/editor/index.ts @@ -1,3 +1,4 @@ +export { LspDebugDialog } from "./LspDebugDialog"; export type { EditorPaneHandle } from "./EditorPane"; export { EditorStack } from "./EditorStackLazy"; export { AiDiffStack } from "./AiDiffStackLazy"; diff --git a/src/modules/editor/lib/extensions.ts b/src/modules/editor/lib/extensions.ts index ccf4598c4..407c0d16d 100644 --- a/src/modules/editor/lib/extensions.ts +++ b/src/modules/editor/lib/extensions.ts @@ -10,6 +10,7 @@ export const languageCompartment = new Compartment(); export const readOnlyCompartment = new Compartment(); export const wrapCompartment = new Compartment(); export const vimCompartment = new Compartment(); +export const lspCompartment = new Compartment(); // Only what basicSetup doesn't already cover, to avoid duplicate extensions. // basicSetup gives us line numbers, fold gutter, history, indentOnInput, @@ -43,12 +44,59 @@ export function buildSharedExtensions(): Extension[] { color: "var(--muted-foreground)", }, ".cm-gutter-lint": { - width: "0px", + width: "14px", + }, + ".cm-lint-marker-error": { + content: '"●"', + color: "var(--destructive)", + }, + ".cm-lint-marker-warning": { + content: '"●"', + color: "color-mix(in srgb, var(--foreground) 55%, transparent)", + }, + ".cm-lintRange-error": { + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l2 -2 l1 0 l2 2' fill='none' stroke='%23ef4444' stroke-width='1'/%3E%3C/svg%3E\")", + backgroundRepeat: "repeat-x", + backgroundPosition: "left bottom", + paddingBottom: "2px", + }, + ".cm-lintRange-warning": { + backgroundImage: + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='3'%3E%3Cpath d='m0 3 l2 -2 l1 0 l2 2' fill='none' stroke='%23eab308' stroke-width='1'/%3E%3C/svg%3E\")", + backgroundRepeat: "repeat-x", + backgroundPosition: "left bottom", + paddingBottom: "2px", + }, + ".cm-lsp-definition-link": { + textDecoration: "underline", + textDecorationColor: "color-mix(in srgb, var(--primary) 75%, transparent)", + textUnderlineOffset: "2px", + cursor: "pointer", }, ".cm-gutter": { backgroundColor: "transparent !important" }, ".cm-lineNumbers .cm-gutterElement": { opacity: "0.55", }, + ".cm-lineNumbers .cm-gutterElement.cm-lsp-inactive-lineNumber": { + opacity: "0.28", + color: "color-mix(in srgb, var(--muted-foreground) 70%, transparent)", + }, + ".cm-line.cm-lsp-inactive-line": { + opacity: "0.42", + }, + ".cm-lsp-inlay-hint": { + marginLeft: "0.35em", + padding: "0 0.25em", + borderRadius: "3px", + fontSize: "0.85em", + fontStyle: "italic", + color: "color-mix(in srgb, var(--muted-foreground) 88%, transparent)", + backgroundColor: + "color-mix(in srgb, var(--foreground) 5%, transparent)", + pointerEvents: "none", + userSelect: "none", + }, ".cm-foldGutter": { width: "10px" }, ".cm-foldGutter .cm-gutterElement": { color: "var(--muted-foreground)", @@ -86,6 +134,17 @@ export function buildSharedExtensions(): Extension[] { backgroundColor: "color-mix(in srgb, var(--foreground) 18%, transparent) !important", }, + ".cm-tooltip.cm-tooltip-hover": { + backgroundColor: "var(--popover)", + color: "var(--popover-foreground)", + border: "1px solid var(--border)", + borderRadius: "6px", + boxShadow: "0 4px 12px color-mix(in srgb, var(--foreground) 12%, transparent)", + maxWidth: "32rem", + }, + ".cm-tooltip.cm-tooltip-hover .cm-lsp-hover": { + maxHeight: "20rem", + }, ".cm-panels": { backgroundColor: "var(--popover)", color: "var(--popover-foreground)", diff --git a/src/modules/editor/lib/languageResolver.ts b/src/modules/editor/lib/languageResolver.ts index 2d4a67920..08081e5fc 100644 --- a/src/modules/editor/lib/languageResolver.ts +++ b/src/modules/editor/lib/languageResolver.ts @@ -78,6 +78,7 @@ const loaders: Record = { css: () => import("@codemirror/lang-css").then((m) => m.css()), php: () => import("@codemirror/lang-php").then((m) => m.php({ plain: true })), + phtml: () => import("@codemirror/lang-php").then((m) => m.php({ plain: true })), rb: rubyLoader, rake: rubyLoader, gemspec: rubyLoader, @@ -132,6 +133,10 @@ const filenameOverrides: Record = { fastfile: rubyLoader, guardfile: rubyLoader, brewfile: rubyLoader, + ".prettierrc": jsonLoader, + ".babelrc": jsonLoader, + ".stylelintrc": jsonLoader, + "go.mod": () => import("@codemirror/lang-go").then((m) => m.go()), }; function extOf(name: string): string | null { diff --git a/src/modules/editor/lib/lsp/bridge.ts b/src/modules/editor/lib/lsp/bridge.ts new file mode 100644 index 000000000..a5128c363 --- /dev/null +++ b/src/modules/editor/lib/lsp/bridge.ts @@ -0,0 +1,107 @@ +import { invoke, Channel } from "@tauri-apps/api/core"; +import { currentWorkspaceEnv } from "@/modules/workspace"; +import { + formatLspPayload, + LSP_DEV_TOOLS, + lspDebugPatch, + lspDebugPush, +} from "./debugStore"; + +export type LspTransport = { + id: number; + send: (json: string) => Promise; + close: () => Promise; + onMessage: (handler: (json: string) => void) => void; +}; + +export async function openLspTransport( + command: string, + args: string[], + cwd: string, +): Promise { + lspDebugPush("info", "spawning transport", `${command} ${args.join(" ")}`); + lspDebugPatch({ state: "spawning", command, args, cwd }); + + const onMessage = new Channel(); + const onStderr = new Channel(); + let handler: ((json: string) => void) | null = null; + onMessage.onmessage = (msg) => handler?.(msg); + onStderr.onmessage = (line) => { + if (LSP_DEV_TOOLS) lspDebugPush("warn", "stderr", line); + }; + + let id: number; + try { + const result = await invoke<{ id: number }>("lsp_spawn", { + command, + args, + cwd, + workspace: currentWorkspaceEnv(), + onMessage, + onStderr, + }); + id = result.id; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "lsp_spawn failed", message); + lspDebugPatch({ state: "error", error: message }); + throw e; + } + + lspDebugPush("info", "transport ready", `id=${id}`); + lspDebugPatch({ transportId: id, state: "spawning", error: null }); + + let closed = false; + return { + id, + send: async (json) => { + if (LSP_DEV_TOOLS) { + const parsed = JSON.parse(json) as { method?: string; id?: number }; + const tag = parsed.method + ? `${parsed.method}` + : parsed.id != null + ? `response id=${parsed.id}` + : "message"; + lspDebugPush("out", tag, formatLspPayload(json)); + } + try { + await invoke("lsp_send", { id, message: json }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "lsp_send failed", message); + lspDebugPatch({ state: "error", error: message }); + throw e; + } + }, + close: async () => { + if (closed) return; + closed = true; + handler = null; + onMessage.onmessage = () => {}; + lspDebugPush("info", "closing transport", `id=${id}`); + await invoke("lsp_close", { id }); + lspDebugPatch({ state: "closed", transportId: null }); + }, + onMessage: (fn) => { + handler = LSP_DEV_TOOLS + ? (json) => { + try { + const parsed = JSON.parse(json) as { + method?: string; + id?: number; + }; + const tag = parsed.method + ? `${parsed.method}` + : parsed.id != null + ? `response id=${parsed.id}` + : "message"; + lspDebugPush("in", tag, formatLspPayload(json)); + } catch { + lspDebugPush("in", "raw", json.slice(0, 280)); + } + fn(json); + } + : fn; + }, + }; +} diff --git a/src/modules/editor/lib/lsp/client.ts b/src/modules/editor/lib/lsp/client.ts new file mode 100644 index 000000000..792c9a9d4 --- /dev/null +++ b/src/modules/editor/lib/lsp/client.ts @@ -0,0 +1,706 @@ +import type { Diagnostic } from "@codemirror/lint"; +import type { + Completion, + CompletionContext, + CompletionResult, +} from "@codemirror/autocomplete"; +import { + isInactiveCodeDiagnostic, + lspSeverityToCm, + offsetToPosition, + pathToUri, + uriToPath, + type LspDiagnosticCode, + type LspRange, +} from "./protocol"; +import type { LspConnection } from "./connection"; +import { lspDebugPatch, lspDebugPush } from "./debugStore"; +import { parseHoverContents, type HoverBlock } from "./hoverContent"; +import type { LspInlayHintDisplay } from "./editorClient"; + +type LspDiagnostic = { + range: LspRange; + message: string; + severity?: number; + source?: string; + code?: LspDiagnosticCode; +}; + +type PublishDiagnosticsParams = { + uri: string; + diagnostics: LspDiagnostic[]; +}; + +function rangeToOffsets( + text: string, + range: LspRange, +): { from: number; to: number } { + const lines = text.split("\n"); + const lineStart = (line: number) => { + let off = 0; + for (let i = 0; i < line && i < lines.length; i++) { + off += lines[i].length + 1; + } + return off; + }; + const from = lineStart(range.start.line) + range.start.character; + const to = lineStart(range.end.line) + range.end.character; + return { from, to }; +} + +type CompletionItem = { + label: string | { label: string }; + kind?: number; + detail?: string; + filterText?: string; + insertText?: string; + sortText?: string; + textEdit?: { range: LspRange; newText: string }; + data?: unknown; +}; + +function itemLabel(item: CompletionItem): string { + return typeof item.label === "string" ? item.label : String(item.label); +} + +function isAfterTriggerChar(before: string): boolean { + return ( + before.endsWith("::") || + before.endsWith(".") || + before.endsWith("(") || + before.endsWith("#") || + before.endsWith("'") || + before.endsWith('"') + ); +} + +function keepCompletionItem(label: string, typed: string): boolean { + if (label.startsWith("__cmd_")) return false; + if (label.startsWith("__") && label.endsWith("!")) return false; + if (typed.includes("::")) { + if (label === "self::" || label === "crate::" || label === "super::") { + return false; + } + } + return true; +} + +type LspLocation = { + uri: string; + range: LspRange; +}; + +type LspLocationLink = { + originSelectionRange?: LspRange; + targetUri: string; + targetRange: LspRange; + targetSelectionRange?: LspRange; +}; + +export type LspDefinitionTarget = { + path: string; + line: number; + character: number; + originFrom: number; + originTo: number; +}; + +function pickDefinitionLocation( + result: unknown, + text: string, + offset: number, +): { uri: string; range: LspRange; originFrom: number; originTo: number } | null { + if (!result) return null; + const items = Array.isArray(result) ? result : [result]; + const first = items[0] as LspLocation | LspLocationLink | undefined; + if (!first) return null; + if ("targetUri" in first && first.targetUri) { + const range = first.targetSelectionRange ?? first.targetRange; + const origin = first.originSelectionRange + ? rangeToOffsets(text, first.originSelectionRange) + : expandWordRange(text, offset); + return { + uri: first.targetUri, + range, + originFrom: origin.from, + originTo: origin.to, + }; + } + if ("uri" in first && first.uri && first.range) { + const origin = expandWordRange(text, offset); + return { + uri: first.uri, + range: first.range, + originFrom: origin.from, + originTo: origin.to, + }; + } + return null; +} + +function expandWordRange(text: string, offset: number): { from: number; to: number } { + const word = /[\w$]+/g; + let from = offset; + let to = offset; + let match: RegExpExecArray | null; + while ((match = word.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (offset >= start && offset <= end) { + from = start; + to = end; + break; + } + } + return { from, to }; +} + +function completionKind(kind: number): string { + const map: Record = { + 3: "function", + 6: "variable", + 7: "constant", + 8: "type", + 9: "class", + 14: "keyword", + 15: "text", + }; + return map[kind] ?? "text"; +} + +function formatHoverContents(contents: unknown): HoverBlock[] { + return parseHoverContents(contents); +} + +export class LspDocumentClient { + private readonly uri: string; + private version = 0; + /** Last document text acknowledged by didOpen/didChange. */ + private syncedText: string; + private rawDiagnostics: PublishDiagnosticsParams["diagnostics"] = []; + private changeTimer: ReturnType | null = null; + private pullTimer: ReturnType | null = null; + private refreshTimer: ReturnType | null = null; + private readonly pullDiagnosticsEnabled: boolean; + private readonly inlayHintsEnabled: boolean; + private inlayHintTimer: ReturnType | null = null; + private inlayHintListeners = new Set<() => void>(); + private diagnosticsListeners = new Set<() => void>(); + private completionRequestId = 0; + /** Bumped on each didChange / local edit — stale pull responses are ignored. */ + private diagnosticPullId = 0; + + constructor( + readonly path: string, + private text: string, + private readonly languageId: string, + private readonly connection: LspConnection, + opts?: { pullDiagnostics?: boolean; inlayHints?: boolean }, + ) { + this.uri = pathToUri(path); + this.syncedText = text; + this.pullDiagnosticsEnabled = opts?.pullDiagnostics ?? false; + this.inlayHintsEnabled = opts?.inlayHints ?? false; + this.openDocument(text); + } + + private applyDiagnosticItems( + items: PublishDiagnosticsParams["diagnostics"], + ) { + this.rawDiagnostics = items; + // Explorer badges are updated only from textDocument/publishDiagnostics in + // manager.ts — pull results must not wipe counts on other open documents. + lspDebugPush( + "info", + "diagnostics", + `${items.length} for ${this.path}`, + ); + lspDebugPatch({ diagnosticCount: items.length }); + for (const listener of this.diagnosticsListeners) listener(); + if (this.inlayHintsEnabled) this.scheduleInlayHintRefresh(); + } + + hasInlayHints(): boolean { + return this.inlayHintsEnabled; + } + + onInlayHints(listener: () => void): () => void { + this.inlayHintListeners.add(listener); + return () => this.inlayHintListeners.delete(listener); + } + + private scheduleInlayHintRefresh() { + if (this.inlayHintTimer) clearTimeout(this.inlayHintTimer); + this.inlayHintTimer = setTimeout(() => { + this.inlayHintTimer = null; + for (const listener of this.inlayHintListeners) listener(); + }, 400); + } + + /** Ranges for #[cfg]-inactive code (styled separately, not as lint squiggles). */ + getInactiveRegions(): LspRange[] { + return this.rawDiagnostics + .filter(isInactiveCodeDiagnostic) + .map((d) => d.range); + } + + /** Map LSP ranges to CM offsets using the live editor text. */ + getDiagnosticsForText(text: string): Diagnostic[] { + const len = text.length; + return this.rawDiagnostics + .filter((d) => !isInactiveCodeDiagnostic(d)) + .map((d) => { + const { from, to } = rangeToOffsets(text, d.range); + const clampedFrom = Math.max(0, Math.min(from, len)); + let clampedTo = Math.max(0, Math.min(to, len)); + if (clampedTo <= clampedFrom && clampedFrom < len) { + clampedTo = clampedFrom + 1; + } + return { + from: clampedFrom, + to: clampedTo, + severity: lspSeverityToCm(d.severity), + message: d.message, + source: d.source, + }; + }); + } + + hasDiagnostics(): boolean { + return this.rawDiagnostics.length > 0; + } + + applyExternalDiagnostics(items: PublishDiagnosticsParams["diagnostics"]) { + // Server push is authoritative — drop any in-flight pull responses. + this.diagnosticPullId += 1; + this.applyDiagnosticItems(items); + } + + private schedulePullDiagnostics() { + if (!this.pullDiagnosticsEnabled || this.version < 1) return; + this.diagnosticPullId += 1; + if (this.pullTimer) clearTimeout(this.pullTimer); + const delay = this.languageId === "rust" ? 900 : 350; + const pullId = this.diagnosticPullId; + this.pullTimer = setTimeout(() => { + this.pullTimer = null; + void this.pullDiagnostics(pullId); + }, delay); + } + + /** Reattach editor view — never send duplicate didOpen. */ + syncEditor(text: string) { + this.text = text; + if (this.version < 1) { + this.openDocument(text); + return; + } + if (text === this.syncedText) { + if (this.changeTimer) { + clearTimeout(this.changeTimer); + this.changeTimer = null; + } + return; + } + if (this.changeTimer) { + clearTimeout(this.changeTimer); + this.changeTimer = null; + } + this.pushChange(text); + } + + private async pullDiagnostics( + pullId: number, + attempt = 0, + ): Promise { + if (pullId !== this.diagnosticPullId) return; + const versionAtStart = this.version; + try { + if (this.text !== this.syncedText) { + this.pushChange(this.text); + } + if (pullId !== this.diagnosticPullId) return; + const result = (await this.connection.request("textDocument/diagnostic", { + textDocument: { uri: this.uri }, + })) as { + kind?: string; + items?: PublishDiagnosticsParams["diagnostics"]; + } | null; + if (pullId !== this.diagnosticPullId) return; + if (versionAtStart !== this.version) return; + if (result?.kind === "full") { + this.applyDiagnosticItems(result.items ?? []); + } + } catch (e) { + if (pullId !== this.diagnosticPullId) return; + const message = e instanceof Error ? e.message : String(e); + if (message.includes("cancelled") && attempt < 4) { + await new Promise((resolve) => + setTimeout(resolve, 300 * (attempt + 1)), + ); + return this.pullDiagnostics(pullId, attempt + 1); + } + if (attempt === 0) { + lspDebugPush("warn", "pullDiagnostics failed", message); + } + } + } + + onDiagnostics(listener: () => void): () => void { + this.diagnosticsListeners.add(listener); + return () => this.diagnosticsListeners.delete(listener); + } + + openDocument(text: string) { + this.text = text; + this.syncedText = text; + this.version = 1; + this.connection.notify("textDocument/didOpen", { + textDocument: { + uri: this.uri, + languageId: this.languageId, + version: this.version, + text, + }, + }); + this.schedulePullDiagnostics(); + if (this.inlayHintsEnabled) this.scheduleInlayHintRefresh(); + } + + scheduleChange(text: string) { + const changed = text !== this.syncedText; + this.text = text; + if (!changed) { + if (this.changeTimer) { + clearTimeout(this.changeTimer); + this.changeTimer = null; + } + return; + } + // Local edit — ignore in-flight diagnostic pulls for the previous server snapshot. + this.diagnosticPullId += 1; + if (this.changeTimer) clearTimeout(this.changeTimer); + const debounce = this.languageId === "rust" ? 500 : 300; + this.changeTimer = setTimeout(() => { + this.changeTimer = null; + this.pushChange(text); + }, debounce); + } + + /** Flush pending edits and give the server a moment before queries. */ + private async ensureSyncedForQuery( + text: string, + immediate = false, + ): Promise { + if (this.changeTimer) { + clearTimeout(this.changeTimer); + this.changeTimer = null; + } + if (text !== this.syncedText) { + this.pushChange(text); + if (!immediate) { + const delay = this.languageId === "rust" ? 80 : 25; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + private lspCompletionContext( + context: CompletionContext, + text: string, + ): { triggerKind: number; triggerCharacter?: string } { + if (context.explicit) return { triggerKind: 1 }; + const before = text.slice(0, context.pos); + const trigger = before.slice(-1); + if ( + trigger === ":" || + trigger === "." || + trigger === "(" || + trigger === "'" || + trigger === "#" + ) { + return { triggerKind: 2, triggerCharacter: trigger }; + } + return { triggerKind: 1 }; + } + + /** CM filters options by text between `from` and cursor — use only the leaf token. */ + private completionReplaceFrom(context: CompletionContext, text: string): number { + const pos = context.pos; + const before = text.slice(0, pos); + if (isAfterTriggerChar(before)) return pos; + const leaf = before.match(/[\w$]+$/); + if (leaf) return pos - leaf[0].length; + return pos; + } + + private async requestCompletionItems( + pos: ReturnType, + context: { triggerKind: number; triggerCharacter?: string }, + ) { + return (await this.connection.request("textDocument/completion", { + textDocument: { uri: this.uri }, + position: pos, + context, + })) as { items?: CompletionItem[]; isIncomplete?: boolean } | CompletionItem[] | null; + } + + private textEditFrom( + text: string, + range: LspRange, + cursorOffset: number, + ): number | null { + const cursorLine = offsetToPosition(text, cursorOffset).line; + if (range.start.line !== cursorLine) return null; + return rangeToOffsets(text, range).from; + } + + private pushChange(text: string) { + this.text = text; + this.syncedText = text; + this.version += 1; + this.connection.notify("textDocument/didChange", { + textDocument: { uri: this.uri, version: this.version }, + contentChanges: [{ text }], + }); + this.schedulePullDiagnostics(); + if (this.inlayHintsEnabled) this.scheduleInlayHintRefresh(); + } + + closeDocument() { + if (this.changeTimer) clearTimeout(this.changeTimer); + if (this.inlayHintTimer) clearTimeout(this.inlayHintTimer); + if (this.pullTimer) clearTimeout(this.pullTimer); + if (this.refreshTimer) clearTimeout(this.refreshTimer); + if (this.version < 1) return; + this.version = 0; + this.connection.notify("textDocument/didClose", { + textDocument: { uri: this.uri }, + }); + } + + getDiagnostics(): Diagnostic[] { + return this.getDiagnosticsForText(this.text); + } + + async definitionAt( + offset: number, + text = this.text, + ): Promise { + await this.ensureSyncedForQuery(text); + const pos = offsetToPosition(text, offset); + const result = await this.connection.request("textDocument/definition", { + textDocument: { uri: this.uri }, + position: pos, + }); + const location = pickDefinitionLocation(result, text, offset); + if (!location) { + lspDebugPush("info", "definition empty", `at ${pos.line}:${pos.character}`); + return null; + } + return { + path: uriToPath(location.uri), + line: location.range.start.line + 1, + character: location.range.start.character, + originFrom: location.originFrom, + originTo: location.originTo, + }; + } + + async linkableRangeAt( + offset: number, + text = this.text, + ): Promise<{ from: number; to: number } | null> { + await this.ensureSyncedForQuery(text); + const pos = offsetToPosition(text, offset); + const [defResult, hoverResult] = await Promise.all([ + this.connection.request("textDocument/definition", { + textDocument: { uri: this.uri }, + position: pos, + }), + this.connection.request("textDocument/hover", { + textDocument: { uri: this.uri }, + position: pos, + }), + ]); + if (!pickDefinitionLocation(defResult, text, offset)) return null; + const hover = hoverResult as { range?: LspRange } | null; + if (hover?.range) return rangeToOffsets(text, hover.range); + const location = pickDefinitionLocation(defResult, text, offset); + if (location) return { from: location.originFrom, to: location.originTo }; + return null; + } + + async hoverAt(offset: number, text = this.text): Promise { + await this.ensureSyncedForQuery(text); + const pos = offsetToPosition(text, offset); + const result = (await this.connection.request("textDocument/hover", { + textDocument: { uri: this.uri }, + position: pos, + })) as { contents?: unknown } | null; + const blocks = formatHoverContents(result?.contents); + return blocks.length > 0 ? blocks : null; + } + + async documentHighlightAt( + offset: number, + ): Promise> { + const pos = offsetToPosition(this.text, offset); + const result = (await this.connection.request( + "textDocument/documentHighlight", + { + textDocument: { uri: this.uri }, + position: pos, + }, + )) as Array<{ range: LspRange }> | null; + if (!result?.length) return []; + return result.map((entry) => { + const { from, to } = rangeToOffsets(this.text, entry.range); + return { from, to }; + }); + } + + async completionAt( + context: CompletionContext, + text: string, + ): Promise { + const requestId = ++this.completionRequestId; + await this.ensureSyncedForQuery(text, context.explicit); + if (requestId !== this.completionRequestId) return null; + + const pos = offsetToPosition(text, context.pos); + const completionContext = this.lspCompletionContext(context, text); + let result = await this.requestCompletionItems(pos, completionContext); + + const empty = + !result || + (Array.isArray(result) ? result.length === 0 : (result.items ?? []).length === 0); + if (empty && completionContext?.triggerKind === 2) { + result = await this.requestCompletionItems(pos, { triggerKind: 1 }); + } + + if (requestId !== this.completionRequestId) return null; + if (!result) return null; + + const replaceFrom = this.completionReplaceFrom(context, text); + let from = replaceFrom; + const typed = text.slice(from, context.pos); + const triggered = completionContext.triggerKind === 2; + + let rawItems = Array.isArray(result) ? result : (result.items ?? []); + if (!triggered) { + const filtered = rawItems.filter((item) => + keepCompletionItem(itemLabel(item), typed), + ); + rawItems = + filtered.length > 0 + ? filtered + : rawItems.filter((item) => !itemLabel(item).startsWith("__cmd_")); + } else { + rawItems = rawItems.filter( + (item) => !itemLabel(item).startsWith("__cmd_"), + ); + } + if (rawItems.length === 0) return null; + + const afterTrigger = triggered || replaceFrom === context.pos; + if (!afterTrigger) { + for (const item of rawItems) { + if (!item.textEdit) continue; + const editFrom = this.textEditFrom(text, item.textEdit.range, context.pos); + if (editFrom != null && editFrom >= replaceFrom) { + from = Math.min(from, editFrom); + } + } + from = Math.max(from, replaceFrom); + if (from > context.pos) from = replaceFrom; + } + + const options: Completion[] = rawItems.slice(0, 64).map((item) => { + const label = itemLabel(item); + let apply = item.insertText ?? label; + if (item.textEdit) { + const editFrom = this.textEditFrom(text, item.textEdit.range, context.pos); + if ( + afterTrigger || + (editFrom != null && editFrom >= replaceFrom) + ) { + apply = item.textEdit.newText; + } + } + return { + label, + type: item.kind ? completionKind(item.kind) : undefined, + detail: item.detail, + apply, + boost: item.sortText?.startsWith("0") ? 1 : 0, + }; + }); + + lspDebugPush( + "info", + "completion", + `${options.length} at ${pos.line}:${pos.character} from=${from}${afterTrigger ? ` trigger${completionContext.triggerCharacter ? `:${completionContext.triggerCharacter}` : ""}` : ""}`, + ); + if (afterTrigger) { + // After :: . # ( — CM must not match options against the full path prefix. + return { from, options, filter: false }; + } + return { + from, + options, + validFor: /^[\w$.:#'(-]*$/, + }; + } + + async inlayHintsAt(text: string): Promise { + if (!this.inlayHintsEnabled) return []; + await this.ensureSyncedForQuery(text); + const lines = text.split("\n"); + const lastLine = lines.length > 0 ? lines.length - 1 : 0; + const lastChar = lines[lastLine]?.length ?? 0; + try { + const result = (await this.connection.request("textDocument/inlayHint", { + textDocument: { uri: this.uri }, + range: { + start: { line: 0, character: 0 }, + end: { line: lastLine, character: lastChar }, + }, + })) as + | Array<{ position: LspRange["start"]; label: unknown }> + | { hints?: Array<{ position: LspRange["start"]; label: unknown }> } + | null; + const raw = Array.isArray(result) ? result : (result?.hints ?? []); + return raw.map((hint) => ({ + pos: rangeToOffsets(text, { + start: hint.position, + end: hint.position, + }).from, + label: formatInlayLabel(hint.label), + })); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("warn", "inlayHint failed", message); + return []; + } + } +} + +function formatInlayLabel(label: unknown): string { + if (typeof label === "string") return label; + if (Array.isArray(label)) { + return label + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && "value" in part) { + return String((part as { value: unknown }).value); + } + return ""; + }) + .join(""); + } + if (label && typeof label === "object" && "value" in label) { + return String((label as { value: unknown }).value); + } + return ""; +} diff --git a/src/modules/editor/lib/lsp/connection.ts b/src/modules/editor/lib/lsp/connection.ts new file mode 100644 index 000000000..a1bdb1dc1 --- /dev/null +++ b/src/modules/editor/lib/lsp/connection.ts @@ -0,0 +1,160 @@ +import { + rpcNotification, + rpcRequest, + rpcResponse, + type JsonRpcMessage, +} from "./protocol"; +import type { LspTransport } from "./bridge"; +import { lspDebugPush } from "./debugStore"; + +type Pending = { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; +}; + +export class LspConnection { + private readonly pending = new Map(); + private readonly listeners = new Set<(msg: JsonRpcMessage) => void>(); + private readonly refreshListeners = new Set<() => void>(); + readonly ready: Promise; + + private constructor( + private readonly transport: LspTransport, + readonly rootUri: string, + private readonly serverCommand: string, + ) { + this.transport.onMessage((json) => this.onRawMessage(json)); + this.ready = this.initialize(); + } + + static async open( + transport: LspTransport, + rootUri: string, + serverCommand: string, + ): Promise { + const conn = new LspConnection(transport, rootUri, serverCommand); + await conn.ready; + return conn; + } + + private onRawMessage(json: string) { + let msg: JsonRpcMessage; + try { + msg = JSON.parse(json) as JsonRpcMessage; + } catch { + return; + } + if (msg.id != null && msg.method && !this.pending.has(msg.id as number)) { + this.handleServerRequest(msg); + return; + } + if (msg.id != null && this.pending.has(msg.id as number)) { + const entry = this.pending.get(msg.id as number); + this.pending.delete(msg.id as number); + if (msg.error) { + entry?.reject(new Error(msg.error.message)); + } else { + entry?.resolve(msg.result); + } + return; + } + for (const listener of this.listeners) listener(msg); + } + + private handleServerRequest(msg: JsonRpcMessage) { + const id = msg.id as number; + const method = msg.method ?? "unknown"; + lspDebugPush("in", `server request ${method}`, ""); + switch (method) { + case "workspace/diagnostic/refresh": + for (const cb of this.refreshListeners) cb(); + void this.transport.send(rpcResponse(id, null)); + break; + case "workspace/applyEdit": + case "client/registerCapability": + case "window/workDoneProgress/create": + case "window/showMessageRequest": + void this.transport.send(rpcResponse(id, null)); + break; + default: + lspDebugPush("warn", "unhandled server request", method); + void this.transport.send(rpcResponse(id, null)); + } + } + + subscribe(listener: (msg: JsonRpcMessage) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onDiagnosticRefresh(cb: () => void): () => void { + this.refreshListeners.add(cb); + return () => this.refreshListeners.delete(cb); + } + + request(method: string, params: unknown): Promise { + const { id, payload } = rpcRequest(method, params); + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + void this.transport.send(payload).catch(reject); + }); + } + + notify(method: string, params: unknown) { + void this.transport.send(rpcNotification(method, params)); + } + + private initOptions(): unknown | undefined { + if (this.serverCommand.includes("rust-analyzer")) { + return { + cargo: { buildScripts: { enable: true } }, + procMacro: { enable: true }, + }; + } + if (this.serverCommand.includes("deps-lsp")) { + return { + inlay_hints: { + enabled: true, + needs_update_text: "→ {}", + }, + diagnostics: { + outdated_severity: "hint", + }, + }; + } + return undefined; + } + + private async initialize() { + const rootName = + decodeURIComponent(this.rootUri.split("/").pop() ?? "") || "workspace"; + const initOptions = this.initOptions(); + await this.request("initialize", { + processId: null, + rootUri: this.rootUri, + workspaceFolders: [{ uri: this.rootUri, name: rootName }], + ...(initOptions ? { initializationOptions: initOptions } : {}), + capabilities: { + general: { positionEncodings: ["utf-8"] }, + textDocument: { + completion: { + completionItem: { + snippetSupport: false, + labelDetailsSupport: true, + }, + }, + publishDiagnostics: { relatedInformation: false }, + hover: { dynamicRegistration: false }, + diagnostic: { dynamicRegistration: false }, + definition: { dynamicRegistration: false }, + inlayHint: { dynamicRegistration: false }, + }, + }, + }); + this.notify("initialized", {}); + } + + async close() { + await this.transport.close(); + } +} diff --git a/src/modules/editor/lib/lsp/debugStore.ts b/src/modules/editor/lib/lsp/debugStore.ts new file mode 100644 index 000000000..2d2f7114b --- /dev/null +++ b/src/modules/editor/lib/lsp/debugStore.ts @@ -0,0 +1,115 @@ +import { create } from "zustand"; + +/** LSP debug panel, status-bar button, and verbose RPC logging — dev builds only. */ +export const LSP_DEV_TOOLS = import.meta.env.DEV; + +export type LspDebugLevel = "info" | "out" | "in" | "error" | "warn"; + +export type LspDebugEntry = { + id: number; + at: number; + level: LspDebugLevel; + message: string; + detail?: string; +}; + +export type LspSessionState = + | "idle" + | "unsupported" + | "spawning" + | "ready" + | "error" + | "closed"; + +export type LspSessionSnapshot = { + state: LspSessionState; + lastPath: string | null; + command: string | null; + args: string[]; + cwd: string | null; + rootUri: string | null; + languageId: string | null; + transportId: number | null; + poolKey: string | null; + diagnosticCount: number; + openDocuments: string[]; + error: string | null; +}; + +const MAX_ENTRIES = 300; + +let entryId = 0; + +const initialSession = (): LspSessionSnapshot => ({ + state: "idle", + lastPath: null, + command: null, + args: [], + cwd: null, + rootUri: null, + languageId: null, + transportId: null, + poolKey: null, + diagnosticCount: 0, + openDocuments: [], + error: null, +}); + +type LspDebugStore = { + panelOpen: boolean; + entries: LspDebugEntry[]; + session: LspSessionSnapshot; + setPanelOpen: (open: boolean) => void; + togglePanel: () => void; + push: (level: LspDebugLevel, message: string, detail?: string) => void; + patchSession: (patch: Partial) => void; + clear: () => void; +}; + +export const useLspDebugStore = create((set, get) => ({ + panelOpen: false, + entries: [], + session: initialSession(), + setPanelOpen: (open) => set({ panelOpen: open }), + togglePanel: () => set({ panelOpen: !get().panelOpen }), + push: (level, message, detail) => { + if (!LSP_DEV_TOOLS) return; + const entry: LspDebugEntry = { + id: ++entryId, + at: Date.now(), + level, + message, + detail, + }; + set((s) => ({ + entries: [...s.entries, entry].slice(-MAX_ENTRIES), + })); + }, + patchSession: (patch) => + set((s) => ({ session: { ...s.session, ...patch } })), + clear: () => set({ entries: [], session: initialSession() }), +})); + +export function lspDebugPush( + level: LspDebugLevel, + message: string, + detail?: string, +) { + if (!LSP_DEV_TOOLS) return; + useLspDebugStore.getState().push(level, message, detail); +} + +export function lspDebugPatch(patch: Partial) { + useLspDebugStore.getState().patchSession(patch); +} + +export function formatLspPayload(json: string, max = 280): string { + try { + const parsed = JSON.parse(json) as Record; + const compact = JSON.stringify(parsed); + if (compact.length <= max) return compact; + return `${compact.slice(0, max)}...`; + } catch { + return json.length <= max ? json : `${json.slice(0, max)}...`; + } +} diff --git a/src/modules/editor/lib/lsp/diagnosticStore.test.ts b/src/modules/editor/lib/lsp/diagnosticStore.test.ts new file mode 100644 index 000000000..920653b54 --- /dev/null +++ b/src/modules/editor/lib/lsp/diagnosticStore.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { useLspDiagnosticStore } from "./diagnosticStore"; + +describe("diagnosticStore", () => { + it("counts errors and warnings separately", () => { + useLspDiagnosticStore.getState().setForPath("C:/proj/main.rs", [ + { severity: 1 }, + { severity: 1 }, + { severity: 2 }, + ]); + const counts = + useLspDiagnosticStore.getState().byPath["c:/proj/main.rs"]; + expect(counts).toEqual({ errors: 2, warnings: 1 }); + }); + + it("clears entry when diagnostics become empty", () => { + useLspDiagnosticStore.getState().setForPath("C:/proj/a.ts", [ + { severity: 1 }, + ]); + useLspDiagnosticStore.getState().setForPath("C:/proj/a.ts", []); + expect( + useLspDiagnosticStore.getState().byPath["c:/proj/a.ts"], + ).toBeUndefined(); + }); +}); diff --git a/src/modules/editor/lib/lsp/diagnosticStore.ts b/src/modules/editor/lib/lsp/diagnosticStore.ts new file mode 100644 index 000000000..833192412 --- /dev/null +++ b/src/modules/editor/lib/lsp/diagnosticStore.ts @@ -0,0 +1,76 @@ +import { create } from "zustand"; +import { uriToPath } from "./protocol"; + +export type FileDiagnosticCounts = { + errors: number; + warnings: number; +}; + +type DiagnosticItem = { severity?: number }; + +export function normFilePath(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +function countDiagnostics(items: DiagnosticItem[]): FileDiagnosticCounts { + let errors = 0; + let warnings = 0; + for (const item of items) { + if (item.severity === 1) errors += 1; + else if (item.severity === 2) warnings += 1; + } + return { errors, warnings }; +} + +type LspDiagnosticStore = { + byPath: Record; + setForPath: (path: string, items: DiagnosticItem[]) => void; + clearPath: (path: string) => void; +}; + +export const useLspDiagnosticStore = create((set) => ({ + byPath: {}, + setForPath: (path, items) => { + const key = normFilePath(path); + const counts = countDiagnostics(items); + set((state) => { + const next = { ...state.byPath }; + if (counts.errors === 0 && counts.warnings === 0) { + delete next[key]; + } else { + next[key] = counts; + } + return { byPath: next }; + }); + }, + clearPath: (path) => { + const key = normFilePath(path); + set((state) => { + if (!(key in state.byPath)) return state; + const next = { ...state.byPath }; + delete next[key]; + return { byPath: next }; + }); + }, +})); + +export function setFileDiagnostics( + pathOrUri: string, + items: DiagnosticItem[], +): void { + const path = pathOrUri.startsWith("file://") + ? uriToPath(pathOrUri) + : pathOrUri; + useLspDiagnosticStore.getState().setForPath(path, items); +} + +export function useFileDiagnosticCounts( + path: string, +): FileDiagnosticCounts | null { + const key = normFilePath(path); + return useLspDiagnosticStore((s) => s.byPath[key] ?? null); +} + +export function formatDiagnosticCount(count: number): string { + return count > 99 ? "99+" : String(count); +} diff --git a/src/modules/editor/lib/lsp/editorClient.ts b/src/modules/editor/lib/lsp/editorClient.ts new file mode 100644 index 000000000..af4eef1e3 --- /dev/null +++ b/src/modules/editor/lib/lsp/editorClient.ts @@ -0,0 +1,134 @@ +import type { Diagnostic } from "@codemirror/lint"; +import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import type { LspDocumentClient } from "./client"; +import type { HoverBlock } from "./hoverContent"; +import type { LspDefinitionTarget } from "./client"; +import type { LspRange } from "./protocol"; + +export type LspInlayHintDisplay = { + pos: number; + label: string; +}; + +/** Surface shared by single and composite LSP document clients. */ +export type LspEditorClient = { + path: string; + getInactiveRegions(): LspRange[]; + getDiagnosticsForText(text: string): Diagnostic[]; + onDiagnostics(listener: () => void): () => void; + scheduleChange(text: string): void; + syncEditor(text: string): void; + closeDocument(): void; + hoverAt(offset: number, text?: string): Promise; + completionAt( + context: CompletionContext, + text: string, + ): Promise; + definitionAt( + offset: number, + text?: string, + ): Promise; + linkableRangeAt( + offset: number, + text?: string, + ): Promise<{ from: number; to: number } | null>; + hasInlayHints(): boolean; + inlayHintsAt(text: string): Promise; + onInlayHints(listener: () => void): () => void; +}; + +export class CompositeLspDocumentClient implements LspEditorClient { + readonly path: string; + + constructor( + path: string, + private readonly clients: LspDocumentClient[], + private readonly inlayClient: LspDocumentClient | null, + ) { + this.path = path; + } + + getInactiveRegions(): LspRange[] { + return this.clients.flatMap((c) => c.getInactiveRegions()); + } + + getDiagnosticsForText(text: string): Diagnostic[] { + return this.clients.flatMap((c) => c.getDiagnosticsForText(text)); + } + + onDiagnostics(listener: () => void): () => void { + const unsubs = this.clients.map((c) => c.onDiagnostics(listener)); + return () => unsubs.forEach((u) => u()); + } + + scheduleChange(text: string) { + for (const c of this.clients) c.scheduleChange(text); + } + + syncEditor(text: string) { + for (const c of this.clients) c.syncEditor(text); + } + + closeDocument() { + for (const c of this.clients) c.closeDocument(); + } + + async hoverAt(offset: number, text?: string): Promise { + for (const c of this.clients) { + const blocks = await c.hoverAt(offset, text); + if (blocks?.length) return blocks; + } + return null; + } + + async completionAt( + context: CompletionContext, + text: string, + ): Promise { + for (const c of this.clients) { + const result = await c.completionAt(context, text); + if (result?.options?.length) return result; + } + return null; + } + + async definitionAt( + offset: number, + text?: string, + ): Promise { + for (const c of this.clients) { + const target = await c.definitionAt(offset, text); + if (target) return target; + } + return null; + } + + async linkableRangeAt( + offset: number, + text?: string, + ): Promise<{ from: number; to: number } | null> { + for (const c of this.clients) { + const range = await c.linkableRangeAt(offset, text); + if (range) return range; + } + return null; + } + + hasInlayHints(): boolean { + return this.inlayClient?.hasInlayHints() ?? false; + } + + async inlayHintsAt(text: string): Promise { + if (!this.inlayClient) return []; + return this.inlayClient.inlayHintsAt(text); + } + + onInlayHints(listener: () => void): () => void { + return this.inlayClient?.onInlayHints(listener) ?? (() => {}); + } + + /** Underlying clients — used when releasing pooled server refs. */ + underlyingClients(): LspDocumentClient[] { + return this.clients; + } +} diff --git a/src/modules/editor/lib/lsp/extension.ts b/src/modules/editor/lib/lsp/extension.ts new file mode 100644 index 000000000..c4124f98e --- /dev/null +++ b/src/modules/editor/lib/lsp/extension.ts @@ -0,0 +1,592 @@ +import { autocompletion, startCompletion, type CompletionContext } from "@codemirror/autocomplete"; +import { setDiagnostics, type Diagnostic } from "@codemirror/lint"; +import type { Extension, Range } from "@codemirror/state"; +import { Prec, RangeSet, StateEffect, StateField } from "@codemirror/state"; +import { + Decoration, + DecorationSet, + EditorView, + GutterMarker, + hoverTooltip, + keymap, + lineNumberMarkers, + ViewPlugin, + WidgetType, + type ViewUpdate, +} from "@codemirror/view"; +import { lspDebugPatch, lspDebugPush } from "./debugStore"; +import { acquireLspClient, releaseLspClient } from "./manager"; +import type { LspEditorClient } from "./editorClient"; +import { isExternalLibraryPath, sameFilePath } from "./protocol"; +import { renderHoverDom } from "./hoverContent"; + +import type { LspRange } from "./protocol"; + +/** Push LSP diagnostics straight into CodeMirror — forceLinting is a no-op after lint settled. */ +function pushLspDiagnostics( + view: EditorView, + getClient: () => LspEditorClient | null, +) { + const diags: Diagnostic[] = + getClient()?.getDiagnosticsForText(view.state.doc.toString()) ?? []; + view.dispatch(setDiagnostics(view.state, diags)); +} + +function lspDiagnosticsPlugin(getClient: () => LspEditorClient | null) { + return ViewPlugin.fromClass( + class { + private unsub: (() => void) | undefined; + + constructor(private view: EditorView) { + const client = getClient(); + if (!client) return; + const push = () => pushLspDiagnostics(this.view, getClient); + this.unsub = client.onDiagnostics(push); + push(); + } + + destroy() { + this.unsub?.(); + } + }, + ); +} + +class InactiveLineNumberMarker extends GutterMarker { + elementClass = "cm-lsp-inactive-lineNumber"; + eq() { + return true; + } +} + +const inactiveLineNumberMarker = new InactiveLineNumberMarker(); + +function buildInactiveVisuals( + doc: { lines: number; line: (n: number) => { from: number } }, + regions: LspRange[], +): { lines: DecorationSet; numbers: RangeSet } { + const lineDecos: Range[] = []; + const numberRanges: Range[] = []; + const seen = new Set(); + + for (const region of regions) { + const start = region.start.line + 1; + const end = region.end.line + 1; + for (let ln = start; ln <= end && ln <= doc.lines; ln++) { + if (seen.has(ln)) continue; + seen.add(ln); + const line = doc.line(ln); + lineDecos.push( + Decoration.line({ class: "cm-lsp-inactive-line" }).range(line.from), + ); + numberRanges.push(inactiveLineNumberMarker.range(line.from)); + } + } + + return { + lines: lineDecos.length ? Decoration.set(lineDecos, true) : Decoration.none, + numbers: numberRanges.length + ? RangeSet.of(numberRanges, true) + : RangeSet.empty, + }; +} + +const refreshInactiveRegions = StateEffect.define(); + +function inactiveRegionsPlugin(getClient: () => LspEditorClient | null) { + const inactiveField = StateField.define<{ + lines: DecorationSet; + numbers: RangeSet; + }>({ + create() { + return { lines: Decoration.none, numbers: RangeSet.empty }; + }, + update(value, tr) { + let { lines, numbers } = value; + lines = lines.map(tr.changes); + numbers = numbers.map(tr.changes); + if ( + tr.docChanged || + tr.effects.some((e) => e.is(refreshInactiveRegions)) + ) { + const client = getClient(); + const regions = client?.getInactiveRegions() ?? []; + return buildInactiveVisuals(tr.state.doc, regions); + } + return { lines, numbers }; + }, + provide: (field) => [ + EditorView.decorations.compute([field], (s) => s.field(field).lines), + lineNumberMarkers.compute([field], (s) => s.field(field).numbers), + ], + }); + + const plugin = ViewPlugin.fromClass( + class { + private unsub: (() => void) | undefined; + + constructor(private view: EditorView) { + const client = getClient(); + if (!client) return; + const push = () => + this.view.dispatch({ effects: refreshInactiveRegions.of(null) }); + this.unsub = client.onDiagnostics(push); + push(); + } + + destroy() { + this.unsub?.(); + } + }, + ); + + return [inactiveField, plugin]; +} + +function lspChangePlugin(getClient: () => LspEditorClient | null) { + return ViewPlugin.fromClass( + class { + update(update: ViewUpdate) { + if (!update.docChanged) return; + const client = getClient(); + if (!client) return; + const text = update.state.doc.toString(); + client.scheduleChange(text); + + if (update.transactions.some((tr) => tr.isUserEvent("input.type"))) { + const pos = update.state.selection.main.head; + const before = text.slice(0, pos); + if (isAfterTriggerChar(before)) { + startCompletion(update.view); + } + } + } + }, + { + eventHandlers: { + blur(_event, view) { + const client = getClient(); + if (!client) return; + client.scheduleChange(view.state.doc.toString()); + }, + }, + }, + ); +} + +function isAfterTriggerChar(before: string): boolean { + return ( + before.endsWith("::") || + before.endsWith(".") || + before.endsWith("(") || + before.endsWith("#") || + before.endsWith("'") || + before.endsWith('"') + ); +} + +function completionDelay(context: CompletionContext, text: string): number { + if (context.explicit) return 0; + const before = text.slice(0, context.pos); + if (isAfterTriggerChar(before)) return 0; + return 40; +} + +function lspCompletion(getClient: () => LspEditorClient | null) { + return autocompletion({ + activateOnTyping: true, + defaultKeymap: true, + maxRenderedOptions: 24, + icons: true, + override: [ + (context) => + new Promise((resolve) => { + const client = getClient(); + if (!client) { + resolve(null); + return; + } + const text = context.state.doc.toString(); + const delay = completionDelay(context, text); + const run = async () => { + if (context.aborted) { + resolve(null); + return; + } + try { + const result = await client.completionAt(context, text); + resolve(context.aborted ? null : result); + } catch { + resolve(null); + } + }; + if (delay === 0) void run(); + else setTimeout(run, delay); + }), + ], + }); +} + +function goToLspPosition(view: EditorView, line: number, character: number) { + const lineNum = Math.min(Math.max(1, line), view.state.doc.lines); + const lineObj = view.state.doc.line(lineNum); + const pos = Math.min(lineObj.from + character, lineObj.to); + view.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + view.focus(); +} + +function lspHover(getClient: () => LspEditorClient | null) { + return hoverTooltip( + async (view, pos, side) => { + const client = getClient(); + if (!client) return null; + const text = view.state.doc.toString(); + const hoverBlocks = await client.hoverAt(pos, text); + if (!hoverBlocks?.length) return null; + return { + pos, + above: side === -1, + create() { + return { dom: renderHoverDom(hoverBlocks) }; + }, + }; + }, + { hoverTime: 350 }, + ); +} + +const setDefinitionLink = StateEffect.define<{ from: number; to: number } | null>(); + +const definitionLinkField = StateField.define({ + create() { + return Decoration.none; + }, + update(decs, tr) { + decs = decs.map(tr.changes); + for (const effect of tr.effects) { + if (effect.is(setDefinitionLink)) { + if (!effect.value) return Decoration.none; + return Decoration.set([ + Decoration.mark({ class: "cm-lsp-definition-link" }).range( + effect.value.from, + effect.value.to, + ), + ]); + } + } + return decs; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +function lspDefinitionLinkHighlight(getClient: () => LspEditorClient | null) { + return ViewPlugin.fromClass( + class { + timer: ReturnType | null = null; + lastPos = -1; + + constructor(readonly view: EditorView) {} + + destroy() { + if (this.timer) clearTimeout(this.timer); + } + + clearLink() { + this.lastPos = -1; + this.view.dispatch({ effects: setDefinitionLink.of(null) }); + } + + schedule(pos: number) { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + void this.refresh(pos); + }, 80); + } + + async refresh(pos: number) { + const client = getClient(); + if (!client) { + this.clearLink(); + return; + } + try { + const text = this.view.state.doc.toString(); + const range = await client.linkableRangeAt(pos, text); + if (range) { + this.view.dispatch({ effects: setDefinitionLink.of(range) }); + } else { + this.clearLink(); + } + } catch { + this.clearLink(); + } + } + + onMouseMove(event: MouseEvent, view: EditorView) { + if (!event.ctrlKey && !event.metaKey) { + if (this.lastPos !== -1) this.clearLink(); + return; + } + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos == null) { + this.clearLink(); + return; + } + if (pos === this.lastPos) return; + this.lastPos = pos; + this.schedule(pos); + } + }, + { + eventHandlers: { + mousemove(event, view) { + this.onMouseMove(event, view); + return false; + }, + keydown(event) { + if (!event.ctrlKey && !event.metaKey) this.clearLink(); + return false; + }, + mouseleave() { + this.clearLink(); + return false; + }, + }, + }, + ); +} + +async function navigateDefinition( + view: EditorView, + path: string, + pos: number, + getClient: () => LspEditorClient | null, + onOpenDefinition?: (path: string, line: number) => void, +) { + const client = getClient(); + if (!client) return; + const text = view.state.doc.toString(); + try { + const target = await client.definitionAt(pos, text); + if (!target) return; + if (sameFilePath(target.path, path)) { + goToLspPosition(view, target.line, target.character); + return; + } + if (isExternalLibraryPath(target.path)) { + onOpenDefinition?.(target.path, target.line); + return; + } + onOpenDefinition?.(target.path, target.line); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "definition failed", message); + } +} + +function lspDefinitionKeymap( + path: string, + getClient: () => LspEditorClient | null, + onOpenDefinition?: (path: string, line: number) => void, +) { + return keymap.of([ + { + key: "F12", + run: (view) => { + const pos = view.state.selection.main.head; + void navigateDefinition(view, path, pos, getClient, onOpenDefinition); + return true; + }, + }, + ]); +} + +function lspDefinition( + path: string, + getClient: () => LspEditorClient | null, + onOpenDefinition?: (path: string, line: number) => void, +): Extension { + return EditorView.domEventHandlers({ + mousedown(event, view) { + if (event.button !== 0 || (!event.ctrlKey && !event.metaKey)) return false; + const client = getClient(); + if (!client) return false; + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos == null) return false; + event.preventDefault(); + void navigateDefinition(view, path, pos, getClient, onOpenDefinition); + return true; + }, + }); +} + +class InlayHintWidget extends WidgetType { + constructor(readonly label: string) { + super(); + } + + eq(other: InlayHintWidget) { + return other.label === this.label; + } + + toDOM() { + const span = document.createElement("span"); + span.className = "cm-lsp-inlay-hint"; + span.textContent = this.label; + span.setAttribute("aria-hidden", "true"); + return span; + } + + ignoreEvent() { + return true; + } +} + +const setInlayHints = StateEffect.define(); + +function buildInlayDecorations( + hints: Array<{ pos: number; label: string }>, + docLen: number, +): DecorationSet { + const ranges: Range[] = []; + for (const hint of hints) { + if (!hint.label) continue; + const pos = Math.max(0, Math.min(hint.pos, docLen)); + ranges.push( + Decoration.widget({ + widget: new InlayHintWidget(hint.label), + side: 1, + }).range(pos), + ); + } + return ranges.length ? Decoration.set(ranges, true) : Decoration.none; +} + +function lspInlayHintsPlugin(getClient: () => LspEditorClient | null) { + const inlayField = StateField.define({ + create() { + return Decoration.none; + }, + update(decs, tr) { + decs = decs.map(tr.changes); + for (const effect of tr.effects) { + if (effect.is(setInlayHints)) return effect.value; + } + return decs; + }, + provide: (field) => EditorView.decorations.from(field), + }); + + const plugin = ViewPlugin.fromClass( + class { + private unsubDiag: (() => void) | undefined; + private unsubInlay: (() => void) | undefined; + private timer: ReturnType | null = null; + private fetchId = 0; + + constructor(private view: EditorView) { + const client = getClient(); + if (!client?.hasInlayHints()) return; + const schedule = () => this.scheduleRefresh(); + this.unsubDiag = client.onDiagnostics(schedule); + this.unsubInlay = client.onInlayHints(schedule); + schedule(); + } + + destroy() { + if (this.timer) clearTimeout(this.timer); + this.unsubDiag?.(); + this.unsubInlay?.(); + } + + scheduleRefresh() { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + void this.refresh(); + }, 200); + } + + async refresh() { + const client = getClient(); + if (!client?.hasInlayHints()) { + this.view.dispatch({ effects: setInlayHints.of(Decoration.none) }); + return; + } + const fetchId = ++this.fetchId; + const text = this.view.state.doc.toString(); + try { + const hints = await client.inlayHintsAt(text); + if (fetchId !== this.fetchId) return; + const decos = buildInlayDecorations(hints, text.length); + this.view.dispatch({ effects: setInlayHints.of(decos) }); + } catch { + if (fetchId !== this.fetchId) return; + this.view.dispatch({ effects: setInlayHints.of(Decoration.none) }); + } + } + + update(update: ViewUpdate) { + if (!update.docChanged) return; + const client = getClient(); + if (!client?.hasInlayHints()) return; + this.scheduleRefresh(); + } + }, + ); + + return [inlayField, plugin]; +} + +export type AttachLspOptions = { + path: string; + initialText: string; + onOpenDefinition?: (path: string, line: number) => void; +}; + +export async function buildLspExtensions( + opts: AttachLspOptions, +): Promise<{ extensions: Extension[]; release: () => Promise } | null> { + lspDebugPush("info", "attach LSP", opts.path); + try { + const client = await acquireLspClient(opts.path, opts.initialText); + if (!client) { + lspDebugPush("warn", "LSP extensions skipped", opts.path); + return null; + } + lspDebugPush("info", "LSP extensions active", opts.path); + + let current: LspEditorClient | null = client; + const getClient = () => current; + + const extensions: Extension[] = [ + definitionLinkField, + lspDiagnosticsPlugin(getClient), + ...inactiveRegionsPlugin(getClient), + ...lspInlayHintsPlugin(getClient), + lspChangePlugin(getClient), + Prec.highest(lspCompletion(getClient)), + lspHover(getClient), + lspDefinitionLinkHighlight(getClient), + lspDefinition(opts.path, getClient, opts.onOpenDefinition), + lspDefinitionKeymap(opts.path, getClient, opts.onOpenDefinition), + ]; + + return { + extensions, + release: async () => { + current = null; + await releaseLspClient(opts.path); + }, + }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "attach failed", message); + lspDebugPatch({ state: "error", error: message }); + return null; + } +} diff --git a/src/modules/editor/lib/lsp/hoverContent.test.ts b/src/modules/editor/lib/lsp/hoverContent.test.ts new file mode 100644 index 000000000..38fbb8437 --- /dev/null +++ b/src/modules/editor/lib/lsp/hoverContent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseHoverContents } from "./hoverContent"; + +describe("parseHoverContents", () => { + it("parses markdown MarkupContent", () => { + const blocks = parseHoverContents({ + kind: "markdown", + value: "```rs\nfn foo() {}\n```\n\nHello", + }); + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe("markdown"); + expect(blocks[0].text).toContain("```rs"); + }); + + it("parses MarkedString array", () => { + const blocks = parseHoverContents([ + "pub fn sleep()", + { kind: "markdown", value: "Waits until duration has elapsed." }, + ]); + expect(blocks).toHaveLength(2); + expect(blocks[0].kind).toBe("plaintext"); + expect(blocks[1].kind).toBe("markdown"); + }); + + it("truncates extremely long documentation", () => { + const blocks = parseHoverContents({ + kind: "markdown", + value: `# Tokio\n\n${"word ".repeat(2000)}`, + }); + expect(blocks[0].text.length).toBeLessThan(2000); + expect(blocks[0].text.endsWith("…")).toBe(true); + }); +}); diff --git a/src/modules/editor/lib/lsp/hoverContent.ts b/src/modules/editor/lib/lsp/hoverContent.ts new file mode 100644 index 000000000..04af0cc17 --- /dev/null +++ b/src/modules/editor/lib/lsp/hoverContent.ts @@ -0,0 +1,203 @@ +export type HoverBlock = { + kind: "markdown" | "plaintext"; + text: string; +}; + +const MAX_TOTAL_CHARS = 3200; +const MAX_BLOCK_CHARS = 1800; + +function truncateText(text: string, max: number): string { + if (text.length <= max) return text; + const cut = text.slice(0, max); + const boundary = cut.lastIndexOf("\n\n"); + const trimmed = + boundary > max * 0.5 ? cut.slice(0, boundary) : cut.replace(/\s+\S*$/, ""); + return `${trimmed.trimEnd()}\n\n…`; +} + +function normalizeBlock( + kind: "markdown" | "plaintext", + text: string, +): HoverBlock | null { + const trimmed = text.trim(); + if (!trimmed) return null; + const effectiveKind = + kind === "plaintext" && /```[\w-]*\r?\n/.test(trimmed) + ? "markdown" + : kind; + return { kind: effectiveKind, text: truncateText(trimmed, MAX_BLOCK_CHARS) }; +} + +function parseMarkedString(value: unknown): HoverBlock | null { + if (typeof value === "string") { + return normalizeBlock("plaintext", value); + } + if (typeof value === "object" && value !== null) { + const obj = value as Record; + if (typeof obj.value === "string") { + if (obj.kind === "markdown") { + return normalizeBlock("markdown", obj.value); + } + if (typeof obj.language === "string" && obj.language !== "markdown") { + return normalizeBlock( + "markdown", + `\`\`\`${obj.language}\n${obj.value}\n\`\`\``, + ); + } + return normalizeBlock("plaintext", obj.value); + } + } + return null; +} + +/** Parse LSP `Hover.contents` into display blocks. */ +export function parseHoverContents(contents: unknown): HoverBlock[] { + if (contents == null) return []; + const raw: HoverBlock[] = []; + if (Array.isArray(contents)) { + for (const item of contents) { + const block = parseMarkedString(item); + if (block) raw.push(block); + } + } else { + const block = parseMarkedString(contents); + if (block) raw.push(block); + } + + const blocks: HoverBlock[] = []; + let total = 0; + for (const block of raw) { + if (total >= MAX_TOTAL_CHARS) break; + const room = MAX_TOTAL_CHARS - total; + const text = + block.text.length > room ? truncateText(block.text, room) : block.text; + blocks.push({ ...block, text }); + total += text.length; + } + return blocks; +} + +function appendInlineMarkdown(el: HTMLElement, text: string) { + const re = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g; + let last = 0; + for (const match of text.matchAll(re)) { + const index = match.index ?? 0; + if (index > last) { + el.appendChild(document.createTextNode(text.slice(last, index))); + } + const token = match[0]; + if (token.startsWith("`")) { + const code = document.createElement("code"); + code.className = + "rounded bg-muted/70 px-1 py-px font-mono text-[10px] text-foreground"; + code.textContent = token.slice(1, -1); + el.appendChild(code); + } else if (token.startsWith("**")) { + const strong = document.createElement("strong"); + strong.className = "font-semibold text-foreground"; + strong.textContent = token.slice(2, -2); + el.appendChild(strong); + } else { + const linkMatch = token.match(/\[([^\]]+)\]\(([^)]+)\)/); + if (linkMatch) { + const a = document.createElement("a"); + a.href = linkMatch[2]; + a.textContent = linkMatch[1]; + a.className = "text-primary underline underline-offset-2 hover:opacity-80"; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + el.appendChild(a); + } + } + last = index + token.length; + } + if (last < text.length) { + el.appendChild(document.createTextNode(text.slice(last))); + } +} + +function appendProse(parent: HTMLElement, text: string) { + for (const para of text.split(/\n{2,}/)) { + const line = para.trim(); + if (!line || /^-{3,}$/.test(line)) continue; + + if (line.startsWith("#")) { + const heading = document.createElement("p"); + heading.className = "my-1.5 font-semibold text-foreground first:mt-0"; + heading.textContent = line.replace(/^#+\s*/, ""); + parent.appendChild(heading); + continue; + } + + const p = document.createElement("p"); + p.className = "my-1 text-popover-foreground first:mt-0"; + for (const row of line.split("\n")) { + if (p.childNodes.length > 0) p.appendChild(document.createElement("br")); + appendInlineMarkdown(p, row); + } + parent.appendChild(p); + } +} + +function appendMarkdown(parent: HTMLElement, text: string) { + const segments = text.split(/(```[\w-]*\r?\n[\s\S]*?```)/g); + for (const seg of segments) { + const fence = seg.match(/^```([\w-]*)\r?\n([\s\S]*)```$/); + if (fence) { + const pre = document.createElement("pre"); + pre.className = + "cm-lsp-hover-code my-1.5 overflow-x-auto rounded-md border border-border/50 bg-muted/40 px-2 py-1.5 font-mono text-[10px] leading-relaxed text-foreground"; + const code = document.createElement("code"); + if (fence[1]) code.dataset.lang = fence[1]; + code.textContent = fence[2].replace(/\n$/, ""); + pre.appendChild(code); + parent.appendChild(pre); + continue; + } + if (seg.trim()) appendProse(parent, seg); + } +} + +function appendPlaintext(parent: HTMLElement, text: string, signature: boolean) { + const el = document.createElement("div"); + el.className = signature + ? "whitespace-pre-wrap font-mono text-[10.5px] leading-relaxed text-foreground" + : "whitespace-pre-wrap text-[11px] leading-relaxed text-popover-foreground"; + el.textContent = text; + parent.appendChild(el); +} + +function looksLikeSignature(text: string): boolean { + const first = text.split("\n")[0] ?? ""; + return ( + text.length < 600 && + /\b(fn|struct|enum|trait|type|const|mod|impl|async)\b/.test(first) + ); +} + +/** Build DOM for a CodeMirror hover tooltip. */ +export function renderHoverDom(blocks: HoverBlock[]): HTMLElement { + const root = document.createElement("div"); + root.className = + "cm-lsp-hover max-w-lg max-h-80 overflow-y-auto overflow-x-hidden px-2.5 py-2"; + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const section = document.createElement("div"); + const signature = i === 0 && looksLikeSignature(block.text); + if (signature) { + section.className = + "border-b border-border/60 pb-2 mb-2 last:mb-0 last:border-0 last:pb-0"; + } + + if (block.kind === "markdown") { + appendMarkdown(section, block.text); + } else { + appendPlaintext(section, block.text, signature); + } + + if (section.childNodes.length > 0) root.appendChild(section); + } + + return root; +} diff --git a/src/modules/editor/lib/lsp/languageCatalog.ts b/src/modules/editor/lib/lsp/languageCatalog.ts new file mode 100644 index 000000000..e30265c6a --- /dev/null +++ b/src/modules/editor/lib/lsp/languageCatalog.ts @@ -0,0 +1,158 @@ +/** Supported language groups — one row per LSP binary (Zed-style Languages settings). */ +export type LspLanguageGroup = { + id: string; + label: string; + extensions: string[]; + command: string; + installHint: string; + docsUrl?: string; +}; + +export const LSP_LANGUAGE_GROUPS: LspLanguageGroup[] = [ + { + id: "rust", + label: "Rust", + extensions: [".rs"], + command: "rust-analyzer", + installHint: "Install for Terax downloads rust-analyzer into app data.", + docsUrl: "https://rust-analyzer.github.io/", + }, + { + id: "typescript", + label: "TypeScript & JavaScript", + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + command: "typescript-language-server", + installHint: "Install for Terax includes a private Node.js runtime — no system Node required.", + docsUrl: "https://github.com/typescript-language-server/typescript-language-server", + }, + { + id: "python", + label: "Python", + extensions: [".py"], + command: "pyright-langserver", + installHint: "Install for Terax includes pyright and a private Node.js runtime.", + docsUrl: "https://github.com/microsoft/pyright", + }, + { + id: "go", + label: "Go", + extensions: [".go"], + command: "gopls", + installHint: "Install for Terax downloads gopls into app data.", + docsUrl: "https://pkg.go.dev/golang.org/x/tools/gopls", + }, + { + id: "cpp", + label: "C / C++", + extensions: [".c", ".h", ".cpp", ".hpp"], + command: "clangd", + installHint: "Install for Terax downloads clangd into app data.", + docsUrl: "https://clangd.llvm.org/installation", + }, + { + id: "php", + label: "PHP", + extensions: [".php", ".phtml"], + command: "intelephense", + installHint: + "Install for Terax includes intelephense and a private Node.js runtime — no system Node required.", + docsUrl: "https://intelephense.com/", + }, + { + id: "dependencies", + label: "Dependencies (npm, Cargo, …)", + extensions: [ + "package.json", + "Cargo.toml", + "pyproject.toml", + "go.mod", + "composer.json", + "Gemfile", + "pubspec.yaml", + ], + command: "deps-lsp", + installHint: + "Shows latest package versions in package.json and other manifest files (npm, Cargo, PyPI, Go, …).", + docsUrl: "https://github.com/bug-ops/deps-lsp", + }, + { + id: "json", + label: "JSON & config", + extensions: [ + ".json", + ".jsonc", + "package.json", + "tsconfig.json", + "jsconfig.json", + ".vscode/*.json", + ], + command: "vscode-json-language-server", + installHint: + "Validates tsconfig and other JSON config files (schemas, completions). package.json also uses Dependencies LSP for version hints.", + }, + { + id: "shell", + label: "Shell", + extensions: [".sh", ".bash", ".zsh"], + command: "bash-language-server", + installHint: "Install for Terax includes a private Node.js runtime.", + docsUrl: "https://github.com/bash-lsp/bash-language-server", + }, +]; + +export type LspBinaryLink = + | { kind: "path"; path: string } + | { kind: "wsl"; distro: string; command: string }; + +export type LspBinaryProbe = { + command: string; + found: boolean; + path: string | null; + error: string | null; + local: boolean; + linked: boolean; + wsl: boolean; + source: "linked" | "system" | "terax" | null; +}; + +export async function probeLspBinary( + command: string, + options?: { localOnly?: boolean }, +): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("lsp_probe_binary", { + command, + localOnly: options?.localOnly ?? false, + }); +} + +export async function installLspBinary( + command: string, + onProgress: (message: string) => void, +): Promise { + const { invoke, Channel } = await import("@tauri-apps/api/core"); + const progress = new Channel(); + progress.onmessage = onProgress; + return invoke("lsp_install", { command, onProgress: progress }); +} + +export async function linkLspBinary( + command: string, + link: LspBinaryLink, +): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("lsp_link_binary", { command, link }); +} + +export async function unlinkLspBinary(command: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("lsp_unlink_binary", { command }); +} + +export async function probeWslLspBinary( + distro: string, + command: string, +): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("lsp_probe_wsl_binary", { distro, command }); +} diff --git a/src/modules/editor/lib/lsp/manager.ts b/src/modules/editor/lib/lsp/manager.ts new file mode 100644 index 000000000..1550bb599 --- /dev/null +++ b/src/modules/editor/lib/lsp/manager.ts @@ -0,0 +1,429 @@ +import { invoke } from "@tauri-apps/api/core"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { currentWorkspaceEnv } from "@/modules/workspace"; +import { openLspTransport } from "./bridge"; +import { LspConnection } from "./connection"; +import { LspDocumentClient } from "./client"; +import { lspDebugPatch, lspDebugPush } from "./debugStore"; +import { setFileDiagnostics } from "./diagnosticStore"; +import { isExternalLibraryPath, pathToUri, sameUri, uriToPath } from "./protocol"; +import { + resolveLspServerSpecs, + serverPoolKey, + type LspServerSpec, +} from "./servers"; +import { + CompositeLspDocumentClient, + type LspEditorClient, +} from "./editorClient"; + +type PoolEntry = { + spec: LspServerSpec; + rootUri: string; + cwd: string; + connection: Promise; + documents: Map; + refs: number; + idleTimer?: ReturnType; + diagnosticCache: Map; + diagnosticSubscribed?: boolean; +}; + +type PublishDiagnosticsParams = { + uri: string; + diagnostics: Array<{ + range: LspRange; + message: string; + severity?: number; + source?: string; + }>; +}; + +type LspRange = { + start: { line: number; character: number }; + end: { line: number; character: number }; +}; + +const pool = new Map(); + +/** Keep server alive while preview-tab hopping between files in the same project. */ +const POOL_IDLE_MS = 30 * 60 * 1000; +const RELEASE_DEBOUNCE_MS = 2500; + +const pendingRelease = new Map>(); +const attachInflight = new Map>(); + +/** Tracks which server specs were attached per document path. */ +const attachedSpecs = new Map(); + +function supportsPullDiagnostics(spec: LspServerSpec): boolean { + return spec.command.includes("rust-analyzer"); +} + +function supportsInlayHints(spec: LspServerSpec): boolean { + return spec.command.includes("deps-lsp"); +} + +function normPath(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +function rootUriToCwd(rootUri: string): string { + if (rootUri.startsWith("file:///")) { + const path = rootUri.slice("file:///".length); + if (/^[A-Za-z]:/.test(path)) return path; + return `/${path}`; + } + if (rootUri.startsWith("file://")) { + return rootUri.slice("file://".length); + } + return rootUri; +} + +function parentDir(filePath: string): string { + return filePath.replace(/[/\\][^/\\]+$/, "") || filePath; +} + +async function resolveRootUri( + filePath: string, + spec: LspServerSpec, +): Promise { + let root: string; + try { + root = await invoke("lsp_resolve_root", { + filePath, + command: spec.command, + workspace: currentWorkspaceEnv(), + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("warn", "lsp_resolve_root failed", message); + root = parentDir(filePath); + } + lspDebugPush("info", "root resolved", root); + return pathToUri(root); +} + +function poolKey(rootUri: string, spec: LspServerSpec): string { + return `${rootUri}::${serverPoolKey(spec)}`; +} + +function syncPoolSnapshot() { + const docs = [...pool.values()].flatMap((e) => [...e.documents.keys()]); + lspDebugPatch({ openDocuments: docs }); +} + +function cancelPoolIdle(entry: PoolEntry) { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; + } +} + +async function closePoolEntry(key: string, entry: PoolEntry) { + cancelPoolIdle(entry); + try { + const connection = await entry.connection; + await connection.close(); + } catch { + // spawn may have failed + } + pool.delete(key); + lspDebugPush("info", "pool entry removed", key); + lspDebugPatch({ + state: "closed", + poolKey: null, + transportId: null, + openDocuments: [], + }); +} + +function schedulePoolIdle(key: string, entry: PoolEntry) { + cancelPoolIdle(entry); + entry.idleTimer = setTimeout(() => { + const current = pool.get(key); + if (!current || current.documents.size > 0 || current.refs > 0) return; + void closePoolEntry(key, current); + }, POOL_IDLE_MS); +} + +function findDocument( + entry: PoolEntry, + uri: string, +): LspDocumentClient | undefined { + const key = normPath(uriToPath(uri)); + const direct = entry.documents.get(key); + if (direct) return direct; + for (const client of entry.documents.values()) { + if (sameUri(uri, pathToUri(client.path))) return client; + } + return undefined; +} + +function wireDiagnosticRouting(entry: PoolEntry) { + if (entry.diagnosticSubscribed) return; + entry.diagnosticSubscribed = true; + void entry.connection.then((conn) => { + for (const [docKey, items] of entry.diagnosticCache) { + setFileDiagnostics(docKey, items); + } + conn.subscribe((msg) => { + if (msg.method !== "textDocument/publishDiagnostics") return; + const params = msg.params as PublishDiagnosticsParams; + const docKey = normPath(uriToPath(params.uri)); + entry.diagnosticCache.set(docKey, params.diagnostics); + // Authoritative source for explorer badges — one URI per notification. + setFileDiagnostics(params.uri, params.diagnostics); + findDocument(entry, params.uri)?.applyExternalDiagnostics( + params.diagnostics, + ); + }); + }); +} + +function cancelPendingRelease(path: string) { + const key = normPath(path); + const timer = pendingRelease.get(key); + if (timer) { + clearTimeout(timer); + pendingRelease.delete(key); + } +} + +export async function acquireLspClient( + path: string, + text: string, +): Promise { + return acquireLspClientInner(path, text); +} + +async function acquireSingleDocumentClient( + path: string, + text: string, + spec: LspServerSpec, +): Promise { + const rootUri = await resolveRootUri(path, spec); + const key = poolKey(rootUri, spec); + lspDebugPatch({ rootUri, poolKey: key }); + + let entry = pool.get(key); + + if (!entry) { + const cwd = rootUriToCwd(rootUri); + lspDebugPatch({ cwd }); + const connection = openLspTransport(spec.command, spec.args, cwd) + .then(async (transport) => { + const conn = await LspConnection.open(transport, rootUri, spec.command); + lspDebugPush("info", "LSP initialized", `${spec.command} @ ${rootUri}`); + lspDebugPatch({ state: "ready", error: null }); + return conn; + }) + .catch((e: unknown) => { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "connection failed", message); + lspDebugPatch({ state: "error", error: message }); + pool.delete(key); + throw e; + }); + entry = { + spec, + rootUri, + cwd, + connection, + documents: new Map(), + refs: 0, + diagnosticCache: new Map(), + }; + pool.set(key, entry); + } + + wireDiagnosticRouting(entry); + cancelPoolIdle(entry); + + const docKey = normPath(path); + const existing = entry.documents.get(docKey); + if (existing) { + entry.refs += 1; + existing.syncEditor(text); + syncPoolSnapshot(); + lspDebugPush("info", "reuse document client", `${spec.command} ${path}`); + return existing; + } + + try { + const connection = await entry.connection; + const client = new LspDocumentClient( + path, + text, + spec.languageId, + connection, + { + pullDiagnostics: supportsPullDiagnostics(spec), + inlayHints: supportsInlayHints(spec), + }, + ); + entry.documents.set(docKey, client); + entry.refs += 1; + const cached = entry.diagnosticCache.get(docKey); + if (cached) client.applyExternalDiagnostics(cached); + syncPoolSnapshot(); + lspDebugPush("info", "document opened", `${spec.command} ${path}`); + lspDebugPatch({ state: "ready" }); + return client; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + lspDebugPush("error", "acquire failed", message); + lspDebugPatch({ state: "error", error: message }); + pool.delete(key); + return null; + } +} + +async function acquireLspClientInner( + path: string, + text: string, +): Promise { + cancelPendingRelease(path); + + const { lspEnabled } = usePreferencesStore.getState(); + if (!lspEnabled) { + lspDebugPush("info", "LSP disabled in settings", path); + lspDebugPatch({ state: "idle", lastPath: path, error: null }); + return null; + } + + if (isExternalLibraryPath(path)) { + lspDebugPush("info", "LSP skipped for library file", path); + lspDebugPatch({ state: "idle", lastPath: path, error: null }); + return null; + } + + lspDebugPatch({ lastPath: path, error: null }); + const specs = resolveLspServerSpecs(path); + if (specs.length === 0) { + lspDebugPush("warn", "unsupported file type", path); + lspDebugPatch({ + state: "unsupported", + command: null, + languageId: null, + }); + return null; + } + + lspDebugPush( + "info", + "resolve server", + specs.map((s) => `${s.command} (${s.languageId})`).join(" + "), + ); + lspDebugPatch({ + command: specs[0]?.command ?? null, + args: specs[0]?.args ?? [], + languageId: specs[0]?.languageId ?? null, + }); + + const docKey = normPath(path); + const pendingCreate = attachInflight.get(docKey); + if (pendingCreate) { + const client = await pendingCreate; + if (!client) return null; + client.syncEditor(text); + lspDebugPush("info", "reuse document client (inflight)", path); + return client; + } + + const createPromise = (async (): Promise => { + const clients: LspDocumentClient[] = []; + for (const spec of specs) { + const client = await acquireSingleDocumentClient(path, text, spec); + if (client) clients.push(client); + } + if (clients.length === 0) return null; + attachedSpecs.set(docKey, specs); + if (clients.length === 1) return clients[0]!; + const inlay = + clients.find((c) => c.hasInlayHints()) ?? + clients.find((c) => c.path === path) ?? + null; + return new CompositeLspDocumentClient(path, clients, inlay); + })(); + + attachInflight.set(docKey, createPromise); + try { + return await createPromise; + } finally { + attachInflight.delete(docKey); + } +} + +export async function releaseLspClient(path: string): Promise { + const keyPath = normPath(path); + cancelPendingRelease(path); + pendingRelease.set( + keyPath, + setTimeout(() => { + pendingRelease.delete(keyPath); + void releaseLspClientNow(keyPath, path); + }, RELEASE_DEBOUNCE_MS), + ); +} + +async function releaseLspClientNow(keyPath: string, path: string) { + const specs = attachedSpecs.get(keyPath) ?? []; + attachedSpecs.delete(keyPath); + + const targets = + specs.length > 0 + ? specs + : [...pool.values()] + .filter((e) => e.documents.has(keyPath)) + .map((e) => e.spec); + + for (const spec of targets) { + const sk = serverPoolKey(spec); + for (const [key, entry] of pool) { + if (serverPoolKey(entry.spec) !== sk) continue; + const client = entry.documents.get(keyPath); + if (!client) continue; + entry.refs = Math.max(0, entry.refs - 1); + if (entry.refs > 0) { + lspDebugPush( + "info", + "document ref released", + `${entry.spec.command} ${path} refs=${entry.refs}`, + ); + break; + } + client.closeDocument(); + entry.documents.delete(keyPath); + lspDebugPush("info", "document closed", `${entry.spec.command} ${path}`); + syncPoolSnapshot(); + if (entry.documents.size === 0) { + schedulePoolIdle(key, entry); + } + break; + } + } +} + +/** Immediately tear down every pooled server (e.g. when LSP is disabled in settings). */ +export async function shutdownAllLsp(): Promise { + for (const timer of pendingRelease.values()) { + clearTimeout(timer); + } + pendingRelease.clear(); + attachInflight.clear(); + attachedSpecs.clear(); + + const entries = [...pool.entries()]; + await Promise.all( + entries.map(async ([key, entry]) => { + cancelPoolIdle(entry); + for (const client of entry.documents.values()) { + client.closeDocument(); + } + entry.documents.clear(); + entry.refs = 0; + await closePoolEntry(key, entry); + }), + ); +} diff --git a/src/modules/editor/lib/lsp/navigation.ts b/src/modules/editor/lib/lsp/navigation.ts new file mode 100644 index 000000000..2572e495f --- /dev/null +++ b/src/modules/editor/lib/lsp/navigation.ts @@ -0,0 +1,17 @@ +export type LspNavigationTarget = { + path: string; + line: number; + character?: number; +}; + +let navigate: ((target: LspNavigationTarget) => void) | null = null; + +export function setLspNavigationHandler( + handler: ((target: LspNavigationTarget) => void) | null, +): void { + navigate = handler; +} + +export function lspNavigateTo(target: LspNavigationTarget): void { + navigate?.(target); +} diff --git a/src/modules/editor/lib/lsp/protocol.test.ts b/src/modules/editor/lib/lsp/protocol.test.ts new file mode 100644 index 000000000..70c041142 --- /dev/null +++ b/src/modules/editor/lib/lsp/protocol.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + getDiagnosticCode, + isInactiveCodeDiagnostic, + offsetToPosition, + pathToUri, + sameUri, + uriToPath, +} from "./protocol"; + +describe("pathToUri", () => { + it("normalizes Windows paths", () => { + expect(pathToUri("C:\\Users\\dev\\main.ts")).toBe( + "file:///C:/Users/dev/main.ts", + ); + }); + + it("keeps Unix paths", () => { + expect(pathToUri("/home/dev/main.ts")).toBe("file:///home/dev/main.ts"); + }); +}); + +describe("uriToPath", () => { + it("restores Windows paths", () => { + expect(uriToPath("file:///C:/Users/dev/main.ts")).toBe( + "C:/Users/dev/main.ts", + ); + }); + + it("restores Unix paths", () => { + expect(uriToPath("file:///home/dev/main.ts")).toBe("/home/dev/main.ts"); + }); +}); + +describe("sameUri", () => { + it("ignores Windows drive letter case", () => { + expect( + sameUri( + "file:///C:/Users/dev/main.rs", + "file:///c:/Users/dev/main.rs", + ), + ).toBe(true); + }); +}); + +describe("offsetToPosition", () => { + it("maps offsets to line/character", () => { + const text = "abc\ndef\nghi"; + expect(offsetToPosition(text, 0)).toEqual({ line: 0, character: 0 }); + expect(offsetToPosition(text, 5)).toEqual({ line: 1, character: 1 }); + }); +}); + +describe("isInactiveCodeDiagnostic", () => { + it("detects rust-analyzer inactive-code", () => { + expect( + isInactiveCodeDiagnostic({ code: "inactive-code" }), + ).toBe(true); + expect( + isInactiveCodeDiagnostic({ code: { value: "inactive-code" } }), + ).toBe(true); + expect(isInactiveCodeDiagnostic({ code: "E0001" })).toBe(false); + expect(getDiagnosticCode({ value: "foo" })).toBe("foo"); + }); +}); diff --git a/src/modules/editor/lib/lsp/protocol.ts b/src/modules/editor/lib/lsp/protocol.ts new file mode 100644 index 000000000..06c9c7a11 --- /dev/null +++ b/src/modules/editor/lib/lsp/protocol.ts @@ -0,0 +1,124 @@ +export type LspPosition = { line: number; character: number }; + +export type LspRange = { + start: LspPosition; + end: LspPosition; +}; + +export type JsonRpcMessage = { + jsonrpc?: string; + id?: number | string | null; + method?: string; + params?: unknown; + result?: unknown; + error?: { code: number; message: string }; +}; + +let nextId = 1; + +export function rpcRequest( + method: string, + params: unknown, +): { id: number; payload: string } { + const id = nextId++; + return { + id, + payload: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }; +} + +export function rpcNotification(method: string, params: unknown): string { + return JSON.stringify({ jsonrpc: "2.0", method, params }); +} + +export function rpcResponse(id: number, result: unknown): string { + return JSON.stringify({ jsonrpc: "2.0", id, result }); +} + +export function pathToUri(path: string): string { + const normalized = path.replace(/\\/g, "/"); + if (/^[A-Za-z]:\//.test(normalized)) { + return `file:///${normalized}`; + } + return normalized.startsWith("/") ? `file://${normalized}` : `file:///${normalized}`; +} + +export function uriToPath(uri: string): string { + if (!uri.startsWith("file://")) return uri; + let path = decodeURIComponent(uri.slice("file://".length)); + if (path.startsWith("/") && /^\/[A-Za-z]:/.test(path)) { + path = path.slice(1); + } + return path; +} + +export function sameFilePath(a: string, b: string): boolean { + return a.replace(/\\/g, "/").toLowerCase() === b.replace(/\\/g, "/").toLowerCase(); +} + +export function sameUri(a: string, b: string): boolean { + return sameFilePath(uriToPath(a), uriToPath(b)); +} + +/** Toolchain / vendor sources — attach a separate LSP session there. */ +export function isExternalLibraryPath(path: string): boolean { + const norm = path.replace(/\\/g, "/").toLowerCase(); + return ( + norm.includes("/.rustup/") || + norm.includes("/rustlib/") || + norm.includes("/node_modules/") || + norm.includes("/target/debug/build/") || + norm.includes("/target/release/build/") + ); +} + +export function offsetToPosition(text: string, offset: number): LspPosition { + let line = 0; + let character = 0; + for (let i = 0; i < offset && i < text.length; i++) { + if (text[i] === "\n") { + line++; + character = 0; + } else { + character++; + } + } + return { line, character }; +} + +export type LspDiagnosticCode = + | string + | number + | { value?: string; target?: unknown }; + +export function getDiagnosticCode( + code?: LspDiagnosticCode, +): string | undefined { + if (code == null) return undefined; + if (typeof code === "string") return code; + if (typeof code === "number") return String(code); + if (typeof code === "object" && "value" in code) return code.value; + return undefined; +} + +/** rust-analyzer marks #[cfg]-disabled regions with this diagnostic code. */ +export function isInactiveCodeDiagnostic(d: { + code?: LspDiagnosticCode; +}): boolean { + return getDiagnosticCode(d.code) === "inactive-code"; +} + +export function lspSeverityToCm( + severity?: number, +): "error" | "warning" | "info" | "hint" { + switch (severity) { + case 1: + return "error"; + case 2: + return "warning"; + case 3: + return "info"; + default: + return "hint"; + } +} diff --git a/src/modules/editor/lib/lsp/servers.test.ts b/src/modules/editor/lib/lsp/servers.test.ts new file mode 100644 index 000000000..d26b53508 --- /dev/null +++ b/src/modules/editor/lib/lsp/servers.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { resolveLspServer, resolveLspServerSpecs } from "./servers"; + +describe("resolveLspServer", () => { + it("maps TypeScript files", () => { + expect(resolveLspServer("/proj/foo.ts")?.languageId).toBe("typescript"); + expect(resolveLspServer("/proj/foo.tsx")?.languageId).toBe( + "typescriptreact", + ); + expect(resolveLspServer("/proj/foo.js")?.languageId).toBe("javascript"); + }); + + it("maps Rust files", () => { + expect(resolveLspServer("/proj/lib.rs")?.command).toBe("rust-analyzer"); + }); + + it("maps PHP files to intelephense", () => { + expect(resolveLspServer("/proj/index.php")?.command).toBe("intelephense"); + expect(resolveLspServer("/proj/index.php")?.languageId).toBe("php"); + expect(resolveLspServer("/proj/view.phtml")?.command).toBe("intelephense"); + }); + + it("maps package.json to deps-lsp plus JSON schema server", () => { + const specs = resolveLspServerSpecs("/proj/package.json"); + expect(specs.map((s) => s.command)).toEqual([ + "deps-lsp", + "vscode-json-language-server", + ]); + expect(specs[0]?.languageId).toBe("json"); + expect(resolveLspServer("/proj/package-lock.json")?.languageId).toBe( + "json", + ); + }); + + it("maps Cargo.toml to deps-lsp", () => { + expect(resolveLspServer("/proj/Cargo.toml")?.command).toBe("deps-lsp"); + expect(resolveLspServer("/proj/Cargo.toml")?.languageId).toBe("toml"); + }); + + it("maps tsconfig and vscode json to jsonc", () => { + expect(resolveLspServer("/proj/tsconfig.json")?.languageId).toBe("jsonc"); + expect(resolveLspServer("/proj/tsconfig.app.json")?.languageId).toBe( + "jsonc", + ); + expect( + resolveLspServer("/proj/.vscode/settings.json")?.languageId, + ).toBe("jsonc"); + }); + + it("maps extensionless dotfiles often written as JSON", () => { + expect(resolveLspServer("/proj/.prettierrc")?.languageId).toBe("json"); + expect(resolveLspServer("/proj/.babelrc")?.command).toBe( + "vscode-json-language-server", + ); + }); + + it("returns null for unsupported extensions", () => { + expect(resolveLspServer("/proj/readme.md")).toBeNull(); + }); +}); diff --git a/src/modules/editor/lib/lsp/servers.ts b/src/modules/editor/lib/lsp/servers.ts new file mode 100644 index 000000000..a8e782272 --- /dev/null +++ b/src/modules/editor/lib/lsp/servers.ts @@ -0,0 +1,184 @@ +export type LspServerSpec = { + command: string; + args: string[]; + languageId: string; +}; + +const TS_FAMILY = new Set([ + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "mts", + "cts", +]); + +const depsServerBase = { + command: "deps-lsp", + args: ["--stdio"], +} as const; + +const jsonServer: LspServerSpec = { + command: "vscode-json-language-server", + args: ["--stdio"], + languageId: "json", +}; + +const jsoncServer: LspServerSpec = { + command: "vscode-json-language-server", + args: ["--stdio"], + languageId: "jsonc", +}; + +/** Strict JSON — no comments in the language mode. */ +const JSON_FILENAMES = new Set([ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "composer.json", + "manifest.json", + "lerna.json", + "vercel.json", + "netlify.json", + "firebase.json", + "components.json", + ".babelrc", + ".prettierrc", + ".stylelintrc", +]); + +/** JSON with comments (jsonc). */ +const JSONC_FILENAMES = new Set([ + "jsconfig.json", + "deno.json", + "deno.jsonc", + ".eslintrc.json", + ".prettierrc.json", + "settings.json", + "launch.json", + "tasks.json", + "extensions.json", +]); + +const serversByExt: Record = { + rs: { command: "rust-analyzer", args: [], languageId: "rust" }, + py: { command: "pyright-langserver", args: ["--stdio"], languageId: "python" }, + go: { command: "gopls", args: [], languageId: "go" }, + json: jsonServer, + jsonc: jsoncServer, + sh: { command: "bash-language-server", args: ["start"], languageId: "shellscript" }, + bash: { command: "bash-language-server", args: ["start"], languageId: "shellscript" }, + zsh: { command: "bash-language-server", args: ["start"], languageId: "shellscript" }, + c: { command: "clangd", args: [], languageId: "c" }, + cpp: { command: "clangd", args: [], languageId: "cpp" }, + h: { command: "clangd", args: [], languageId: "c" }, + hpp: { command: "clangd", args: [], languageId: "cpp" }, + php: { command: "intelephense", args: ["--stdio"], languageId: "php" }, + phtml: { command: "intelephense", args: ["--stdio"], languageId: "php" }, +}; + +const tsServer: LspServerSpec = { + command: "typescript-language-server", + args: ["--stdio"], + languageId: "typescript", +}; + +function fileBaseName(path: string): string { + const norm = path.replace(/\\/g, "/"); + const parts = norm.split("/"); + return (parts[parts.length - 1] ?? norm).toLowerCase(); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, "/").toLowerCase(); +} + +/** Manifest files handled by deps-lsp (npm versions, Cargo crates, …). */ +const DEPS_MANIFESTS: Record = { + "package.json": "json", + "composer.json": "json", + "cargo.toml": "toml", + "pyproject.toml": "toml", + "go.mod": "go.mod", + gemfile: "ruby", + "pubspec.yaml": "yaml", +}; + +/** JSON manifests where deps-lsp complements schema validation. */ +const DEPS_WITH_JSON_SCHEMA = new Set(["package.json", "composer.json"]); + +function resolveDepsManifest(path: string): LspServerSpec | null { + const name = fileBaseName(path); + const languageId = DEPS_MANIFESTS[name]; + if (!languageId) return null; + return { ...depsServerBase, languageId }; +} + +function resolveJsonServer(path: string): LspServerSpec | null { + const name = fileBaseName(path); + const norm = normalizePath(path); + + if (JSONC_FILENAMES.has(name)) return { ...jsoncServer }; + if (JSON_FILENAMES.has(name)) return { ...jsonServer }; + + if (name.startsWith("tsconfig") && name.endsWith(".json")) { + return { ...jsoncServer }; + } + + if (norm.includes("/.vscode/") && name.endsWith(".json")) { + return { ...jsoncServer }; + } + + if (name.endsWith(".jsonc")) return { ...jsoncServer }; + if (name.endsWith(".json")) return { ...jsonServer }; + + return null; +} + +export function resolveLspServerSpecs(path: string): LspServerSpec[] { + const deps = resolveDepsManifest(path); + if (deps) { + const specs: LspServerSpec[] = [deps]; + const name = fileBaseName(path); + if (DEPS_WITH_JSON_SCHEMA.has(name)) { + const json = resolveJsonServer(path); + if (json) specs.push(json); + } + return specs; + } + + const single = resolvePrimaryLspServer(path); + return single ? [single] : []; +} + +export function resolveLspServer(path: string): LspServerSpec | null { + return resolveLspServerSpecs(path)[0] ?? null; +} + +function resolvePrimaryLspServer(path: string): LspServerSpec | null { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + + if (ext && TS_FAMILY.has(ext)) { + return { + ...tsServer, + languageId: + ext === "tsx" || ext === "jsx" + ? "typescriptreact" + : ext === "ts" || ext === "mts" || ext === "cts" + ? "typescript" + : "javascript", + }; + } + + const jsonSpec = resolveJsonServer(path); + if (jsonSpec) return jsonSpec; + + if (!ext) return null; + return serversByExt[ext] ?? null; +} + +export function serverPoolKey(spec: LspServerSpec): string { + return spec.command; +} diff --git a/src/modules/editor/lib/lsp/shutdown.ts b/src/modules/editor/lib/lsp/shutdown.ts new file mode 100644 index 000000000..ba175b646 --- /dev/null +++ b/src/modules/editor/lib/lsp/shutdown.ts @@ -0,0 +1,24 @@ +import { lspDebugPatch, lspDebugPush } from "./debugStore"; +import { useLspDiagnosticStore } from "./diagnosticStore"; +import { shutdownAllLsp } from "./manager"; + +/** Stop all language servers, clear diagnostics, reset status UI. */ +export async function disableLsp(): Promise { + await shutdownAllLsp(); + useLspDiagnosticStore.setState({ byPath: {} }); + lspDebugPatch({ + state: "idle", + lastPath: null, + command: null, + args: [], + cwd: null, + rootUri: null, + languageId: null, + transportId: null, + poolKey: null, + diagnosticCount: 0, + openDocuments: [], + error: null, + }); + lspDebugPush("info", "LSP disabled", "All language servers stopped."); +} diff --git a/src/modules/explorer/TreeRow.tsx b/src/modules/explorer/TreeRow.tsx index 7c449a9bf..1dfc04f4b 100644 --- a/src/modules/explorer/TreeRow.tsx +++ b/src/modules/explorer/TreeRow.tsx @@ -9,6 +9,10 @@ import { cn } from "@/lib/utils"; import { ArrowRight01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { memo, useState } from "react"; +import { + formatDiagnosticCount, + useFileDiagnosticCounts, +} from "@/modules/editor/lib/lsp/diagnosticStore"; import { InlineInput } from "./InlineInput"; import { copyToClipboard, @@ -61,6 +65,7 @@ function EntryRowImpl(props: EntryRowProps) { } = props; const [isConfirming, setIsConfirming] = useState(false); + const diagnosticCounts = useFileDiagnosticCounts(path); const iconUrl = isDir ? folderIconUrl(name, isExpanded) : fileIconUrl(name); const createTarget = isDir ? path : path.slice(0, path.lastIndexOf("/")) || rootPath; const paddingLeft = 6 + depth * 12; @@ -123,6 +128,23 @@ function EntryRowImpl(props: EntryRowProps) { )} {name} + {!isDir && + diagnosticCounts && + (diagnosticCounts.errors > 0 || + diagnosticCounts.warnings > 0) && ( + + {diagnosticCounts.errors > 0 && ( + + {formatDiagnosticCount(diagnosticCounts.errors)} + + )} + {diagnosticCounts.warnings > 0 && ( + + {formatDiagnosticCount(diagnosticCounts.warnings)} + + )} + + )} )} diff --git a/src/modules/settings/openSettingsWindow.ts b/src/modules/settings/openSettingsWindow.ts index 0d4857a30..eef889294 100644 --- a/src/modules/settings/openSettingsWindow.ts +++ b/src/modules/settings/openSettingsWindow.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; export type SettingsTab = | "general" + | "languages" | "themes" | "shortcuts" | "models" diff --git a/src/modules/settings/preferences.ts b/src/modules/settings/preferences.ts index 8e6dc80bd..ea1813d62 100644 --- a/src/modules/settings/preferences.ts +++ b/src/modules/settings/preferences.ts @@ -60,6 +60,11 @@ export const usePreferencesStore = create((set) => ({ const s = usePreferencesStore.getState(); mirrorBgFastPath(s.backgroundKind, s.backgroundImageId); } + if (key === "lspEnabled" && value === false) { + void import("@/modules/editor/lib/lsp/shutdown").then(({ disableLsp }) => + disableLsp(), + ); + } }); }, })); diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index c8abbd6b4..a7778fd2f 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -91,6 +91,7 @@ export type Preferences = { shortcuts: Record; editorAutoSave: boolean; editorAutoSaveDelay: number; + lspEnabled: boolean; }; const STORE_PATH = "terax-settings.json"; @@ -136,6 +137,7 @@ const KEY_AGENT_NOTIFICATIONS = "agentNotifications"; const KEY_SHORTCUTS = "shortcuts"; const KEY_EDITOR_AUTO_SAVE = "editorAutoSave"; const KEY_EDITOR_AUTO_SAVE_DELAY = "editorAutoSaveDelay"; +const KEY_LSP_ENABLED = "lspEnabled"; export const TERMINAL_FONT_SIZE_DEFAULT = 14; export const TERMINAL_FONT_SIZE_MIN = 8; @@ -194,6 +196,7 @@ export const DEFAULT_PREFERENCES: Preferences = { shortcuts: {} as Record, editorAutoSave: false, editorAutoSaveDelay: 1000, + lspEnabled: false, }; const store = new LazyStore(STORE_PATH, { defaults: {}, autoSave: 200 }); @@ -336,6 +339,7 @@ export async function loadPreferences(): Promise { get(KEY_EDITOR_AUTO_SAVE_DELAY) ?? DEFAULT_PREFERENCES.editorAutoSaveDelay, ), + lspEnabled: get(KEY_LSP_ENABLED) ?? DEFAULT_PREFERENCES.lspEnabled, }; } @@ -539,6 +543,14 @@ export async function setEditorAutoSaveDelay(value: number): Promise { await writePref(KEY_EDITOR_AUTO_SAVE_DELAY, clampAutoSaveDelay(value)); } +export async function setLspEnabled(value: boolean): Promise { + await writePref(KEY_LSP_ENABLED, value); + if (!value) { + const { disableLsp } = await import("@/modules/editor/lib/lsp/shutdown"); + await disableLsp(); + } +} + export async function setAgentNotifications(value: boolean): Promise { await writePref(KEY_AGENT_NOTIFICATIONS, value); } @@ -601,6 +613,7 @@ export async function onPreferencesChange( [KEY_SHORTCUTS]: "shortcuts", [KEY_EDITOR_AUTO_SAVE]: "editorAutoSave", [KEY_EDITOR_AUTO_SAVE_DELAY]: "editorAutoSaveDelay", + [KEY_LSP_ENABLED]: "lspEnabled", }; // Same-process writes still fire onChange immediately; cross-window writes // arrive via the Tauri event emitted by writePref(). diff --git a/src/modules/statusbar/StatusBar.tsx b/src/modules/statusbar/StatusBar.tsx index 4420f711f..87cc6c117 100644 --- a/src/modules/statusbar/StatusBar.tsx +++ b/src/modules/statusbar/StatusBar.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/tooltip"; import { IncognitoIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; +import { LspDebugButton } from "@/modules/editor/LspDebugButton"; import { CwdBreadcrumb } from "./CwdBreadcrumb"; import { WorkspaceEnvSelector } from "./WorkspaceEnvSelector"; import type { WorkspaceEnv } from "@/modules/workspace"; @@ -25,6 +26,7 @@ type Props = { /** Only rendered when the AI panel is open and a key is loaded. */ hasComposer: boolean; privateActive: boolean; + editorActive?: boolean; }; export function StatusBar({ @@ -36,6 +38,7 @@ export function StatusBar({ onOpenMini, hasComposer, privateActive, + editorActive = false, }: Props) { const panelOpen = useChatStore((s) => s.panelOpen); const openPanel = useChatStore((s) => s.openPanel); @@ -61,6 +64,7 @@ export function StatusBar({ ) : null}
+ {panelOpen && hasComposer ? ( diff --git a/src/settings/SettingsApp.tsx b/src/settings/SettingsApp.tsx index 74e56450e..fac6f4c47 100644 --- a/src/settings/SettingsApp.tsx +++ b/src/settings/SettingsApp.tsx @@ -7,6 +7,7 @@ import { AiScanIcon, InformationCircleIcon, PaintBoardIcon, + SourceCodeIcon, Settings01Icon, UserMultiple02Icon, KeyboardIcon, @@ -17,14 +18,28 @@ import { type JSX, useEffect, useState } from "react"; import { AboutSection } from "./sections/AboutSection"; import { AgentsSection } from "./sections/AgentsSection"; import { GeneralSection } from "./sections/GeneralSection"; +import { LanguagesSection } from "./sections/LanguagesSection"; import { ModelsSection } from "./sections/ModelsSection"; import { ShortcutsSection } from "./sections/ShortcutsSection"; import { ThemesSection } from "./sections/ThemesSection"; -const TABS: { id: SettingsTab; label: string; icon: typeof Settings01Icon, component: () => JSX.Element }[] = - [ - { id: "general", label: "General", icon: Settings01Icon, component: GeneralSection }, - { id: "themes", label: "Themes", icon: PaintBoardIcon, component: ThemesSection }, +const ALL_TABS: { + id: SettingsTab; + label: string; + icon: typeof Settings01Icon; + component: () => JSX.Element; + /** Shown only when LSP is enabled (Zed-style Languages panel). */ + lspOnly?: boolean; +}[] = [ + { id: "general", label: "General", icon: Settings01Icon, component: GeneralSection }, + { + id: "languages", + label: "Languages", + icon: SourceCodeIcon, + component: LanguagesSection, + lspOnly: true, + }, + { id: "themes", label: "Themes", icon: PaintBoardIcon, component: ThemesSection }, { id: "shortcuts", label: "Shortcuts", icon: KeyboardIcon, component: ShortcutsSection }, { id: "models", label: "Models", icon: AiScanIcon, component: ModelsSection }, { id: "agents", label: "Agents", icon: UserMultiple02Icon, component: AgentsSection }, @@ -33,6 +48,7 @@ const TABS: { id: SettingsTab; label: string; icon: typeof Settings01Icon, compo const VALID_TABS: SettingsTab[] = [ "general", + "languages", "themes", "shortcuts", "models", @@ -53,12 +69,20 @@ function readInitialTab(): SettingsTab { export function SettingsApp() { const [active, setActive] = useState(readInitialTab); const init = usePreferencesStore((s) => s.init); - const ActiveSection = TABS.find(t => t.id === active)?.component; + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); + const tabs = ALL_TABS.filter((t) => !t.lspOnly || lspEnabled); + const ActiveSection = tabs.find((t) => t.id === active)?.component; useEffect(() => { void init(); }, [init]); + useEffect(() => { + if (!lspEnabled && active === "languages") { + setActive("general"); + } + }, [lspEnabled, active]); + useEffect(() => { const apply = (detail: string) => { if (detail === "ai" || detail === "connections") { @@ -93,7 +117,7 @@ export function SettingsApp() { data-tauri-drag-region > - {TABS.map((t) => ( + {tabs.map((t) => ( s.vimMode); const editorAutoSave = usePreferencesStore((s) => s.editorAutoSave); const editorAutoSaveDelay = usePreferencesStore((s) => s.editorAutoSaveDelay); + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); const showHidden = usePreferencesStore((s) => s.showHidden); const terminalWebglEnabled = usePreferencesStore( (s) => s.terminalWebglEnabled, @@ -194,6 +197,23 @@ export function GeneralSection() { onChange={(v) => void setEditorAutoSaveDelay(v)} /> )} + + { + await setLspEnabled(v); + if (v) { + void getCurrentWebviewWindow().emit( + "terax:settings-tab", + "languages", + ); + } + }} + /> +
diff --git a/src/settings/sections/LanguagesSection.tsx b/src/settings/sections/LanguagesSection.tsx new file mode 100644 index 000000000..424fb7f38 --- /dev/null +++ b/src/settings/sections/LanguagesSection.tsx @@ -0,0 +1,478 @@ +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { IS_WINDOWS } from "@/lib/platform"; +import { cn } from "@/lib/utils"; +import { + installLspBinary, + linkLspBinary, + LSP_LANGUAGE_GROUPS, + probeLspBinary, + probeWslLspBinary, + unlinkLspBinary, + type LspBinaryProbe, +} from "@/modules/editor/lib/lsp/languageCatalog"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { setLspEnabled } from "@/modules/settings/store"; +import { + useWorkspaceEnvStore, + type WslDistro, +} from "@/modules/workspace/env"; +import { RefreshIcon, SourceCodeIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useCallback, useEffect, useState } from "react"; +import { SectionHeader } from "../components/SectionHeader"; +import { SettingRow } from "../components/SettingRow"; + +export function LanguagesSection() { + const lspEnabled = usePreferencesStore((s) => s.lspEnabled); + const [probes, setProbes] = useState>({}); + const [probing, setProbing] = useState(false); + + const runProbe = useCallback(async () => { + setProbing(true); + try { + const results = await Promise.all( + LSP_LANGUAGE_GROUPS.map((g) => probeLspBinary(g.command)), + ); + const next: Record = {}; + for (const r of results) next[r.command] = r; + setProbes(next); + } catch { + /* ignore — non-Tauri dev */ + } finally { + setProbing(false); + } + }, []); + + useEffect(() => { + if (lspEnabled) void runProbe(); + }, [lspEnabled, runProbe]); + + return ( +
+ + + + void setLspEnabled(v)} + /> + + + {!lspEnabled ? ( +

+ Turn on language servers to install and manage LSP binaries here. +

+ ) : ( + <> +
+ + Supported languages + + +
+ +
+ {LSP_LANGUAGE_GROUPS.map((group) => ( + + setProbes((prev) => ({ ...prev, [group.command]: probe })) + } + /> + ))} +
+ + )} +
+ ); +} + +function LanguageGroupRow({ + group, + probe, + probing, + onUpdated, +}: { + group: (typeof LSP_LANGUAGE_GROUPS)[number]; + probe?: LspBinaryProbe; + probing: boolean; + onUpdated: (probe: LspBinaryProbe) => void; +}) { + const [installing, setInstalling] = useState(false); + const [progress, setProgress] = useState(null); + const [installError, setInstallError] = useState(null); + const [linkOpen, setLinkOpen] = useState(false); + const [linkPath, setLinkPath] = useState(""); + const [linkBusy, setLinkBusy] = useState(false); + const [linkError, setLinkError] = useState(null); + const [wslDistro, setWslDistro] = useState(""); + const [wslDetected, setWslDetected] = useState(null); + + const distros = useWorkspaceEnvStore((s) => s.distros); + const refreshDistros = useWorkspaceEnvStore((s) => s.refreshDistros); + + const found = probe?.found === true; + const linked = probe?.linked === true; + const unknown = !probe && !probing; + + useEffect(() => { + if (linkOpen && IS_WINDOWS && distros.length === 0) { + void refreshDistros(); + } + }, [linkOpen, distros.length, refreshDistros]); + + useEffect(() => { + if (!wslDistro && distros.length > 0) { + const preferred = + distros.find((d: WslDistro) => d.default) ?? distros[0]; + setWslDistro(preferred.name); + } + }, [distros, wslDistro]); + + const handleInstall = async () => { + setInstalling(true); + setInstallError(null); + setProgress("Starting…"); + try { + const result = await installLspBinary(group.command, (msg) => + setProgress(msg), + ); + onUpdated(result); + if (!result.found) { + setInstallError(result.error ?? "Install failed"); + } + } catch (e) { + setInstallError(e instanceof Error ? e.message : String(e)); + } finally { + setInstalling(false); + setProgress(null); + } + }; + + const handleLinkPath = async () => { + const path = linkPath.trim(); + if (!path) return; + setLinkBusy(true); + setLinkError(null); + try { + const result = await linkLspBinary(group.command, { kind: "path", path }); + onUpdated(result); + if (result.found) { + setLinkOpen(false); + setLinkPath(""); + } else { + setLinkError(result.error ?? "Link failed"); + } + } catch (e) { + setLinkError(e instanceof Error ? e.message : String(e)); + } finally { + setLinkBusy(false); + } + }; + + const handleDetectWsl = async () => { + if (!wslDistro) return; + setLinkBusy(true); + setLinkError(null); + setWslDetected(null); + try { + const path = await probeWslLspBinary(wslDistro, group.command); + setWslDetected(path); + } catch (e) { + setLinkError(e instanceof Error ? e.message : String(e)); + } finally { + setLinkBusy(false); + } + }; + + const handleLinkWsl = async () => { + if (!wslDistro) return; + setLinkBusy(true); + setLinkError(null); + try { + const result = await linkLspBinary(group.command, { + kind: "wsl", + distro: wslDistro, + command: wslDetected ?? group.command, + }); + onUpdated(result); + if (result.found) { + setLinkOpen(false); + setWslDetected(null); + } else { + setLinkError(result.error ?? "WSL link failed"); + } + } catch (e) { + setLinkError(e instanceof Error ? e.message : String(e)); + } finally { + setLinkBusy(false); + } + }; + + const handleUnlink = async () => { + setLinkBusy(true); + setLinkError(null); + try { + const result = await unlinkLspBinary(group.command); + onUpdated(result); + } catch (e) { + setLinkError(e instanceof Error ? e.message : String(e)); + } finally { + setLinkBusy(false); + } + }; + + return ( +
+
+
+ +
+
{group.label}
+
+ {group.extensions.join(", ")} +
+ + {group.command} + +
+
+
+
+ + {!probing && linked ? ( + + ) : !probing && found ? ( + + ) : !probing && !found ? ( + + ) : null} +
+ {!found && !probing ? ( + + ) : null} +
+
+ + {progress ? ( +

{progress}

+ ) : null} + + {(installError || linkError) && !linkOpen ? ( +

+ {installError ?? linkError} +

+ ) : null} + + {probe?.found && probe.path ? ( +

+ {probe.path} +

+ ) : null} + + {linkOpen ? ( +
+
+ +
+ setLinkPath(e.target.value)} + placeholder="C:\path\to\rust-analyzer.exe or /usr/bin/clangd" + className="h-8 text-[11px]" + /> + +
+
+ + {IS_WINDOWS ? ( +
+ +
+ + + +
+ {wslDetected ? ( +

+ Found in WSL: {wslDetected} +

+ ) : null} +
+ ) : null} + + {linkError ? ( +

{linkError}

+ ) : null} +
+ ) : null} + + {probe && !probe.found && !installing && !installError && !linkOpen ? ( +
+

+ {group.installHint} Or link a binary you already installed (Windows + path or WSL). +

+ {group.docsUrl ? ( + + Documentation + + ) : null} +
+ ) : null} +
+ ); +} + +function StatusBadge({ + probe, + unknown, + probing, +}: { + probe?: LspBinaryProbe; + unknown: boolean; + probing: boolean; +}) { + if (probing && unknown) { + return ( + + Checking… + + ); + } + if (probe?.found) { + const label = probe.wsl + ? "WSL" + : probe.linked + ? "Linked" + : probe.local + ? "Terax" + : "System"; + return ( + + {label} + + ); + } + if (unknown) { + return ( + + Unknown + + ); + } + return ( + + Not installed + + ); +}