diff --git a/Cargo.lock b/Cargo.lock index 8acd61b..68e942a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,7 @@ dependencies = [ "anyhow", "argh", "flume", + "ignore", "ls-types", "notify", "notify-debouncer-full", @@ -99,6 +100,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -117,6 +128,25 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -318,6 +348,19 @@ dependencies = [ "wasip3", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -357,6 +400,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.14.0" diff --git a/Cargo.toml b/Cargo.toml index f9ad89b..11e3f5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ anyhow = "1" ansi-regex = "0.1.0" argh = "0.1.19" flume = "0.12.0" +ignore = "0.4" ls-types = "0.0.6" notify = "8.2.0" notify-debouncer-full = "0.7.0" diff --git a/README.md b/README.md index 1a234c5..19c24ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ codebases where `rust-analyzer` can become slow dealing with diagnostics. * [Configuration](#configuration) * [Choosing a backend](#choosing-a-backend) * [Cargo backend options](#cargo-backend-options) + * [Live diagnostics as you type (cargo backend only)](#live-diagnostics-as-you-type-(cargo-backend-only)) * [Bacon backend options](#bacon-backend-options) * [Manually triggering diagnostics](#manually-triggering-diagnostics) * [Changing configuration at runtime](#changing-configuration-at-runtime) @@ -140,7 +141,8 @@ backend starts with sensible defaults. The complete schema is: "refreshIntervalSeconds": 1, // partial publish interval; null/negative = wait until done "separateChildDiagnostics": null, // override "related information" support; null = follow client "checkOnSave": true, // trigger cargo on textDocument/didSave - "clearDiagnosticsOnCheck": false // clear existing diagnostics before each run + "clearDiagnosticsOnCheck": false, // clear existing diagnostics before each run + "updateOnInsertDebounceMillis": 500 // debounce for live diagnostics; updateOnInsert itself is in init_options }, "bacon": { @@ -204,6 +206,100 @@ diagnostics — no `bacon` process required. files that previously had any before starting the new run. Useful if you want the editor's diagnostic counters to drop to zero immediately at the start of a check. +* `updateOnInsertDebounceMillis` (default `500`): when live diagnostics are on + (see below), how long the server waits after the last keystroke before + triggering a cargo run against the shadow workspace. Lower values feel + snappier; higher values reduce the number of cargo invocations during a + burst of edits. + +### Live diagnostics as you type (cargo backend only) + +The cargo backend can publish diagnostics on every keystroke instead of +waiting for a save. This is opt-in and turned off by default: when it's off, +the server doesn't even ask the editor for change events. + +How it works: on the first dirty buffer, `bacon-ls` builds a "shadow" +workspace at `target/bacon-ls-live/shadow/` by hardlinking every +`.gitignore`-respected file from the real workspace. Subsequent keystrokes +write only the dirty buffer's bytes into the shadow (breaking the hardlink +so the real file stays untouched), and a debounced cargo run targets the +shadow with `--target-dir=target/bacon-ls-live/target` and +`--remap-path-prefix==` so diagnostics open the user's source +file rather than a `target/` copy. On `didSave` / `didClose` the file's +shadow entry is replaced with a fresh hardlink to disk. + +To enable it the flag has to come through **`initialization_options`**, not +workspace settings. The reason is timing: the LSP `textDocument/didChange` +sync capability has to be advertised statically before workspace +configuration arrives, and clients (Neovim in particular) don't reliably +retrofit already-attached buffers when the server tries to register that +capability dynamically after `initialized`. + +For Neovim's `vim.lsp.config`: + +```lua +vim.lsp.config('bacon-ls', { + init_options = { + cargo = { updateOnInsert = true }, + }, + settings = { + bacon_ls = { + backend = "cargo", + cargo = { + command = "clippy", + -- updateOnInsert lives in init_options above; only the + -- runtime knob lives here: + updateOnInsertDebounceMillis = 500, + }, + }, + }, +}) +``` + +For LazyVim: + +```lua +bacon_ls = { + enabled = true, + init_options = { + cargo = { updateOnInsert = true }, + }, + settings = { + bacon_ls = { + backend = "cargo", + cargo = { + command = "clippy", + updateOnInsertDebounceMillis = 500, + }, + }, + }, +}, +``` + +Tradeoffs and caveats: + +* **Linux-first.** Hardlinking and `--remap-path-prefix` work cross-platform, + but the integration tests cover Linux only. Mileage on macOS/Windows may + vary. +* **Separate target directory.** The shadow run uses its own + `target/bacon-ls-live/target/` so the live cargo invocation doesn't + invalidate caches for the real `cargo build` you might run in a terminal. + Cost: extra disk space (typically the size of one debug build). +* **First run is cold.** Building the shadow workspace and the + separate-target cargo cache is a one-shot cost that can take a few seconds + on a large project. Subsequent runs are incremental. +* **No filesystem watcher.** Files added or deleted on disk while the editor + is open won't be reflected in the shadow until the next time the server + rebuilds it (currently: an LSP restart). Touching a file you've already + opened works, because we mirror its dirty state through `didChange`. +* **`.gitignore` is respected.** The shadow walker uses the same logic as + ripgrep (`ignore` crate) with `require_git(false)`, so non-git workspaces + are handled too. Hidden files (`.cargo/`, `.git/`, etc.) are skipped. + +This feature complements rather than replaces `rust-analyzer`: keeping +`rust-analyzer` running alongside (with its own diagnostics turned off, see +the editor setup sections) gives you completion, hover, and go-to-definition +on top of bacon-ls's live diagnostics. ### Bacon backend options @@ -360,8 +456,10 @@ vim.lsp.config('bacon-ls', { }) ``` -Settings can also be passed via `init_options` as the same `bacon_ls = { ... }` -table — the server reads from both sources. +All runtime settings live under the `settings.bacon_ls` table above. The one +setting that has to be in `init_options` instead is `cargo.updateOnInsert` +(see [Live diagnostics as you type](#live-diagnostics-as-you-type-cargo-backend-only) +for why and the exact shape). When using [codesettings](https://github.com/mrjones2014/codesettings.nvim) to manage project local settings diff --git a/src/lib.rs b/src/lib.rs index 812f164..3717d89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ use ls_types::{Diagnostic, DiagnosticSeverity, MessageType, ProgressToken, Range use native::Cargo; use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; use serde_json::{Map, Value}; +use shadow::ShadowWorkspace; use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -21,6 +22,7 @@ use tracing_subscriber::fmt::format::FmtSpan; mod bacon; mod lsp; mod native; +mod shadow; const PKG_NAME: &str = env!("CARGO_PKG_NAME"); pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -145,6 +147,16 @@ pub(crate) struct CargoOptions { pub(crate) separate_child_diagnostics: Option, pub(crate) check_on_save: bool, pub(crate) clear_diagnostics_on_check: bool, + /// Live-as-you-type diagnostics. When true, the server mirrors the + /// workspace into a hardlinked shadow under + /// `target/bacon-ls-live/shadow/`, replaces dirty buffers in the shadow + /// on `did_change`, and runs cargo against the shadow with a separate + /// target dir. Off by default. + pub(crate) update_on_insert: bool, + /// Quiet period after the most recent `did_change` before the live + /// cargo run is triggered. Coalesces bursts of keystrokes into a single + /// run. + pub(crate) update_on_insert_debounce: Duration, } impl CargoOptions { @@ -274,6 +286,13 @@ impl CargoOptions { .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?; } + if let Some(value) = cargo_obj.get("updateOnInsertDebounceMillis") { + let millis = value + .as_u64() + .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?; + self.update_on_insert_debounce = Duration::from_millis(millis); + } + Ok(()) } @@ -295,6 +314,8 @@ impl Default for CargoOptions { separate_child_diagnostics: None, check_on_save: true, clear_diagnostics_on_check: false, + update_on_insert: false, + update_on_insert_debounce: Duration::from_millis(500), } } } @@ -391,6 +412,15 @@ impl Default for BaconOptions { } } +/// Per-invocation overrides used to redirect a cargo run from the real +/// workspace into the hardlinked shadow workspace for live diagnostics. +#[derive(Debug)] +pub(crate) struct LiveCheckContext { + pub(crate) shadow_root: PathBuf, + pub(crate) shadow_target_dir: PathBuf, + pub(crate) real_root: PathBuf, +} + #[derive(Debug)] pub(crate) struct CargoRuntime { cancel_token: CancellationToken, @@ -403,6 +433,19 @@ pub(crate) struct CargoRuntime { // just triggered (e.g. the initial run from `initialized` immediately // followed by the client's first `didOpen`). last_run_started: Option, + /// Hardlinked shadow of the workspace used for live "as you type" + /// diagnostics. None until the first did_change with `update_on_insert` + /// enabled — building it eagerly at backend init would block startup on + /// large workspaces for users who never trigger live mode. + pub(crate) shadow: Option, + /// File URIs that currently have a dirty buffer overlaid in the shadow. + /// On did_save / did_close we restore each entry to a hardlink so the + /// next live run reads the on-disk version. + pub(crate) dirty_files: HashSet, + /// Pending debounced live-cargo trigger. Each `did_change` cancels the + /// prior handle and schedules a new one so only the last keystroke fires + /// a check. + pub(crate) live_debounce: Option>, } impl Default for CargoRuntime { @@ -414,6 +457,9 @@ impl Default for CargoRuntime { diagnostics_version: 0, build_folder: PathBuf::new(), last_run_started: None, + shadow: None, + dirty_files: HashSet::new(), + live_debounce: None, } } } @@ -437,6 +483,13 @@ struct State { diagnostics_data_supported: bool, related_information_supported: bool, backend: Option, + /// Set by `initialize()` from `initialization_options.cargo.updateOnInsert`. + /// We need this at initialize-time to advertise a `Full` text-document + /// sync capability, because dynamic `client/registerCapability` for + /// `textDocument/didChange` after `initialized` doesn't reliably retrofit + /// already-attached buffers (Neovim, in particular, ignores it). A + /// statically-advertised capability is honored at attach. + init_update_on_insert: bool, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -485,7 +538,7 @@ struct DiagnosticData { corrections: Vec, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct BaconLs { client: Arc, state: Arc>, @@ -499,29 +552,36 @@ impl BaconLs { } } - fn configure_tracing(log_level: Option) { + fn configure_tracing(log_level: Option, log_path: Option<&Path>) { // Configure logging to file. let level = log_level.unwrap_or_else(|| env::var("RUST_LOG").unwrap_or("off".to_string())); if level == "off" { return; } - let log_path = format!("{PKG_NAME}.log"); + let default_path = PathBuf::from(format!("{PKG_NAME}.log")); + let log_path = log_path.unwrap_or(&default_path); let file = match std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) - .open(&log_path) + .open(log_path) { Ok(file) => file, Err(e) => { // stdin/stdout are the LSP jsonrpc pipes; stderr is usually // captured by the client's trace window. One line there is the // best we can do to tell the user why logging is silent. - eprintln!("{PKG_NAME}: could not open log file {log_path}: {e} (tracing disabled)"); + eprintln!( + "{PKG_NAME}: could not open log file {}: {e} (tracing disabled)", + log_path.display() + ); return; } }; - tracing_subscriber::fmt() + // try_init: tests may install the subscriber more than once across the + // process lifetime (cargo runs them in a single binary). Don't panic + // if a global subscriber is already set — the first one wins. + let _ = tracing_subscriber::fmt() .with_env_filter(level) .with_writer(file) .with_thread_names(true) @@ -529,12 +589,12 @@ impl BaconLs { .with_target(true) .with_file(true) .with_line_number(true) - .init(); + .try_init(); } /// Run the LSP server. pub async fn serve() { - Self::configure_tracing(None); + Self::configure_tracing(None, None); // Lock stdin / stdout. let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); @@ -724,6 +784,15 @@ impl BaconLs { } BackendChoice::Cargo => { let mut config = CargoOptions::default(); + // `update_on_insert` is sourced exclusively from + // `initialization_options.cargo.updateOnInsert` (read in + // `initialize` and stashed on `State`). The static + // `textDocument/didChange` capability has to be decided + // before workspace settings even arrive, so the runtime + // gate has to come from the same place. + if state.init_update_on_insert { + config.update_on_insert = true; + } if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object()) && let Err(e) = config.update_from_json_obj(cargo_obj) { @@ -765,9 +834,13 @@ impl BaconLs { } let project_root = state.project_root.clone(); + let init_update_on_insert = state.init_update_on_insert; match &mut state.backend { Some(BackendRuntime::Cargo { config, runtime }) => { config.reset(); + if init_update_on_insert { + config.update_on_insert = true; + } if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object()) && let Err(e) = config.update_from_json_obj(cargo_obj) { @@ -827,8 +900,38 @@ impl BaconLs { Ok(()) } + /// Trigger a save-time cargo run against the real workspace. async fn publish_cargo_diagnostics(&self) { - tracing::info!("starting cargo diagnostics run"); + self.publish_cargo_diagnostics_inner(None).await; + } + + /// Trigger a live "as you type" cargo run against the hardlinked shadow + /// workspace. Builds the shadow on first call. Returns silently if + /// `update_on_insert` isn't on or the shadow can't be built. + pub(crate) async fn publish_cargo_diagnostics_live(&self) { + let live_on = { + let state = self.state.read().await; + matches!( + &state.backend, + Some(BackendRuntime::Cargo { config, .. }) if config.update_on_insert + ) + }; + if !live_on { + return; + } + let Some(shadow) = self.ensure_shadow_built().await else { + return; + }; + let ctx = LiveCheckContext { + shadow_root: shadow.shadow_root().to_path_buf(), + shadow_target_dir: shadow.target_dir().to_path_buf(), + real_root: shadow.real_root().to_path_buf(), + }; + self.publish_cargo_diagnostics_inner(Some(&ctx)).await; + } + + async fn publish_cargo_diagnostics_inner(&self, live: Option<&LiveCheckContext>) { + tracing::info!(live = live.is_some(), "starting cargo diagnostics run"); let mut guard = self.state.write().await; let project_root = guard.project_root.clone(); let related_information_supported = guard.related_information_supported; @@ -840,12 +943,33 @@ impl BaconLs { .separate_child_diagnostics .unwrap_or(!related_information_supported); let cargo_command = config.command.clone(); - let cargo_env = config.env.clone(); - let cmd_args = config.build_command_args(); + let mut cargo_env = config.env.clone(); + let mut cmd_args = config.build_command_args(); let publish_mode = config.publish_mode; let clear_diagnostics_on_check = config.clear_diagnostics_on_check; - let build_folder = runtime.build_folder.clone(); - runtime.diagnostics_version += 1; + let build_folder = match live { + Some(ctx) => { + cmd_args.push(format!("--target-dir={}", ctx.shadow_target_dir.display())); + // `--remap-path-prefix` makes rustc emit diagnostic spans with + // the real workspace path in place of the shadow path, so the + // editor opens the user's source file instead of a target/ copy. + let rustflags = format!( + "--remap-path-prefix={}={}", + ctx.shadow_root.display(), + ctx.real_root.display() + ); + // Honor any RUSTFLAGS the user already set in their config. + if let Some(slot) = cargo_env.iter_mut().find(|(k, _)| k == "RUSTFLAGS") { + slot.1.push(' '); + slot.1.push_str(&rustflags); + } else { + cargo_env.push(("RUSTFLAGS".to_string(), rustflags)); + } + ctx.shadow_root.clone() + } + None => runtime.build_folder.clone(), + }; + runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1); runtime.last_run_started = Some(Instant::now()); let version = runtime.diagnostics_version; let refresh_interval = config.refresh_interval_seconds; @@ -1107,6 +1231,162 @@ impl BaconLs { } } + /// Lazy-build (or fetch) the live shadow workspace. Returns `None` if the + /// project root isn't known or the build fails — callers should treat + /// that as "skip this live update", not as a hard error. + pub(crate) async fn ensure_shadow_built(&self) -> Option { + // Fast path: shadow already built. + { + let state = self.state.read().await; + if let Some(BackendRuntime::Cargo { runtime, .. }) = &state.backend + && let Some(shadow) = &runtime.shadow + { + return Some(shadow.clone()); + } + } + + let project_root = { + let state = self.state.read().await; + state.project_root.clone() + }; + let Some(root) = project_root else { + tracing::warn!("updateOnInsert: no project root; cannot build live shadow"); + return None; + }; + + tracing::info!(root = ?root, "updateOnInsert: building live shadow workspace"); + // Surface this to the user — it's a one-time, multi-second cost + // (tree walk + hardlink fan-out + cold cargo target dir) and + // without a heads-up they'd just see the editor go quiet on the + // first keystroke. + self.client + .show_message( + MessageType::INFO, + "bacon-ls: building live diagnostics shadow workspace (first run only)…", + ) + .await; + let shadow = match ShadowWorkspace::build(root).await { + Ok(s) => s, + Err(e) => { + tracing::error!("updateOnInsert: failed to build shadow: {e}"); + self.client + .show_message( + MessageType::ERROR, + format!("bacon-ls: failed to build live shadow workspace: {e}"), + ) + .await; + return None; + } + }; + + // Stash; if a parallel did_change raced us and built one too, ours + // overwrites — both reflect the same on-disk tree. + let mut state = self.state.write().await; + if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend { + runtime.shadow = Some(shadow.clone()); + } + drop(state); + // Quieter signal that the shadow is ready — goes to the LSP trace + // pane rather than popping a second toast. + self.client + .log_message( + MessageType::INFO, + "bacon-ls: live diagnostics shadow ready; subsequent edits will be checked as you type.", + ) + .await; + Some(shadow) + } + + /// Apply a dirty buffer (from `did_change`) to the shadow workspace. + /// Tracks the URI in `dirty_files` so we can revert it later via + /// `restore_shadow_link_if_dirty` on `did_save` / `did_close`. + pub(crate) async fn live_update_dirty(&self, uri: Uri, content: String) { + let Some(real_path_cow) = uri.to_file_path() else { + tracing::warn!(uri = uri.as_str(), "updateOnInsert: did_change uri is not a file path"); + return; + }; + let real_path = real_path_cow.into_owned(); + + let Some(shadow) = self.ensure_shadow_built().await else { + tracing::warn!("updateOnInsert: shadow workspace not available; skipping live update"); + return; + }; + if let Err(e) = shadow.write_dirty(&real_path, &content).await { + tracing::warn!(path = ?real_path, ?e, "updateOnInsert: shadow write failed (file outside workspace?)"); + return; + } + + let debounce = { + let mut state = self.state.write().await; + let Some(BackendRuntime::Cargo { config, runtime }) = &mut state.backend else { + return; + }; + runtime.dirty_files.insert(uri.clone()); + config.update_on_insert_debounce + }; + + tracing::info!( + uri = uri.as_str(), + debounce_ms = debounce.as_millis() as u64, + "updateOnInsert: shadow updated, scheduling live cargo run" + ); + self.schedule_live_run(debounce).await; + } + + /// Schedule (or reschedule) a live cargo run to fire after `delay` of + /// idle time. Cancels any previously-scheduled live trigger so a burst of + /// keystrokes coalesces into a single run. + pub(crate) async fn schedule_live_run(&self, delay: Duration) { + let mut state = self.state.write().await; + let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else { + return; + }; + if let Some(prev) = runtime.live_debounce.take() { + prev.abort(); + } + let bacon = self.clone(); + runtime.live_debounce = Some(tokio::spawn(async move { + tokio::time::sleep(delay).await; + bacon.publish_cargo_diagnostics_live().await; + })); + } + + /// Cancel any pending debounced live trigger. Called on `did_save` so + /// the on-save cargo run (against the real workspace) is the canonical + /// one and a soon-to-be-stale live run doesn't race it. + pub(crate) async fn cancel_live_debounce(&self) { + let mut state = self.state.write().await; + if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend + && let Some(handle) = runtime.live_debounce.take() + { + handle.abort(); + } + } + + /// On `did_save` / `did_close`, replace the (possibly dirty) shadow file + /// with a fresh hardlink to the on-disk version, and forget the URI. + pub(crate) async fn restore_shadow_link_if_dirty(&self, uri: &Uri) { + let (shadow, real_path) = { + let mut state = self.state.write().await; + let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else { + return; + }; + if !runtime.dirty_files.remove(uri) { + return; + } + let Some(shadow) = runtime.shadow.clone() else { + return; + }; + let Some(path_cow) = uri.to_file_path() else { + return; + }; + (shadow, path_cow.into_owned()) + }; + if let Err(e) = shadow.restore_link(&real_path).await { + tracing::warn!(path = ?real_path, ?e, "updateOnInsert: failed to restore shadow link"); + } + } + async fn publish_bacon_diagnostics(&self, uri: &Uri) { let mut guard = self.state.write().await; let workspace_folders = guard.workspace_folders.clone(); @@ -1140,7 +1420,12 @@ mod tests { #[test] fn test_can_configure_tracing() { - BaconLs::configure_tracing(Some("info".to_string())); + // Direct the test's log file into a tempdir so we don't clobber the + // developer's `bacon-ls.log` in the workspace root (which is what + // `cargo run` / a live editor session writes to). + let tmp = tempfile::tempdir().expect("tempdir"); + let log_path = tmp.path().join("bacon-ls.log"); + BaconLs::configure_tracing(Some("info".to_string()), Some(&log_path)); } #[test] @@ -1321,6 +1606,7 @@ mod tests { "separateChildDiagnostics": true, "checkOnSave": false, "clearDiagnosticsOnCheck": true, + "updateOnInsertDebounceMillis": 250, }); let obj = json.as_object().unwrap(); opts.update_from_json_obj(obj).expect("should parse"); @@ -1334,6 +1620,21 @@ mod tests { assert_eq!(opts.separate_child_diagnostics, Some(true)); assert!(!opts.check_on_save); assert!(opts.clear_diagnostics_on_check); + assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(250)); + } + + #[test] + fn test_cargo_options_update_on_insert_defaults_off() { + let opts = CargoOptions::default(); + assert!(!opts.update_on_insert); + assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(500)); + } + + #[test] + fn test_cargo_options_update_on_insert_debounce_rejects_negative() { + let mut opts = CargoOptions::default(); + let json = serde_json::json!({"updateOnInsertDebounceMillis": -50}); + assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err()); } #[test] diff --git a/src/lsp.rs b/src/lsp.rs index 341434b..5984b61 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -63,11 +63,31 @@ impl LanguageServer for BaconLs { tracing::warn!("client does not support diagnostics data"); } + // Initialization options are the only place we can read user + // configuration before responding to `initialize`. We need that for + // `cargo.updateOnInsert`: the LSP capability `textDocument/didChange` + // sync mode has to be advertised statically — clients (Neovim + // included) don't reliably retrofit already-attached buffers when we + // try to register it dynamically post-`initialized`. + let init_update_on_insert = params + .initialization_options + .as_ref() + .and_then(|v| v.get("cargo")) + .and_then(|v| v.get("updateOnInsert")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if init_update_on_insert { + tracing::info!( + "initialization_options.cargo.updateOnInsert = true; advertising textDocument/didChange (Full sync)" + ); + } + let mut state = self.state.write().await; state.project_root = project_root; state.workspace_folders = params.workspace_folders; state.diagnostics_data_supported = diagnostics_data_supported; state.related_information_supported = related_information_supported; + state.init_update_on_insert = init_update_on_insert; tracing::trace!("loaded state from lsp settings: {state:#?}"); drop(state); @@ -91,13 +111,18 @@ impl LanguageServer for BaconLs { capabilities: ServerCapabilities { // Only support UTF-16 positions for now, which is the default when unspecified position_encoding: Some(PositionEncodingKind::UTF16), - // We never read document text — diagnostics come from bacon's locations file - // or from cargo's JSON output. Ask only for open/close + save notifications; - // skip change events so the client doesn't ship the whole buffer on every - // keystroke. + // Default: no change events — diagnostics come from bacon's + // locations file or from cargo's JSON output. The cargo + // backend's `updateOnInsert` mode flips this to Full when the + // user opts in via `initialization_options.cargo.updateOnInsert`, + // so non-users never pay for buffer-shipping. text_document_sync: Some(TextDocumentSyncCapability::Options(TextDocumentSyncOptions { open_close: Some(true), - change: Some(TextDocumentSyncKind::NONE), + change: Some(if init_update_on_insert { + TextDocumentSyncKind::FULL + } else { + TextDocumentSyncKind::NONE + }), save: Some(TextDocumentSyncSaveOptions::Supported(true)), ..Default::default() })), @@ -141,13 +166,20 @@ impl LanguageServer for BaconLs { self.pull_configuration().await; let mut state = self.state.write().await; - if state.backend.is_none() - && let Err(e) = Self::init_cargo_backend(&mut state, CargoOptions::default()) - { - tracing::error!("{e}"); - drop(state); - self.client.show_message(MessageType::ERROR, e).await; - return; + if state.backend.is_none() { + // No workspace/configuration response (or empty). Still honor + // the init-options seed so live mode works for clients that only + // provide settings via `initialization_options`. + let mut config = CargoOptions::default(); + if state.init_update_on_insert { + config.update_on_insert = true; + } + if let Err(e) = Self::init_cargo_backend(&mut state, config) { + tracing::error!("{e}"); + drop(state); + self.client.show_message(MessageType::ERROR, e).await; + return; + } } let backend_chosen = state .backend @@ -221,7 +253,13 @@ impl LanguageServer for BaconLs { runtime.open_files.remove(¶ms.text_document.uri); drop(state); self.publish_bacon_diagnostics(¶ms.text_document.uri).await; + return; } + drop(state); + // Cargo backend with live shadow: revert any dirty buffer for the + // closed file back to a hardlink so subsequent live runs read the + // on-disk version. + self.restore_shadow_link_if_dirty(¶ms.text_document.uri).await; } async fn did_save(&self, params: DidSaveTextDocumentParams) { @@ -241,16 +279,54 @@ impl LanguageServer for BaconLs { } } BackendRuntime::Cargo { config, .. } => { - if config.check_on_save { - drop(state); + let check_on_save = config.check_on_save; + drop(state); + // A pending live run would race with the canonical save run + // and publish stale (pre-save) shadow diagnostics on top. + // Cancel it before doing anything else. + self.cancel_live_debounce().await; + // Save makes the shadow's dirty override stale: the on-disk + // file now matches what the user wants checked. Restore the + // hardlink before the cargo run so the live target dir picks + // up the saved content next time it's used. + self.restore_shadow_link_if_dirty(¶ms.text_document.uri).await; + if check_on_save { self.publish_cargo_diagnostics().await; } } } } - async fn did_change(&self, _params: DidChangeTextDocumentParams) { - tracing::trace!("client sent didChange request, nothing to do"); + async fn did_change(&self, params: DidChangeTextDocumentParams) { + // Live mode is only meaningful for the cargo backend; the bacon + // backend reads diagnostics from a file written by an external bacon + // process. Bail early to keep this hot path cheap when disabled. + let live_on = { + let state = self.state.read().await; + matches!( + &state.backend, + Some(BackendRuntime::Cargo { config, .. }) if config.update_on_insert + ) + }; + if !live_on { + tracing::debug!("did_change ignored: updateOnInsert is off"); + return; + } + tracing::info!( + uri = params.text_document.uri.as_str(), + changes = params.content_changes.len(), + "did_change received (live mode)" + ); + + // We register the change capability dynamically with `Full` sync + // (one entry, range = None, full text). Anything else is a client + // mismatch — log and skip rather than guess. + let Some(content) = params.content_changes.into_iter().find(|c| c.range.is_none()) else { + tracing::warn!("did_change without full-sync content; client may not honor dynamic registration"); + return; + }; + + self.live_update_dirty(params.text_document.uri, content.text).await; } async fn did_delete_files(&self, params: DeleteFilesParams) { @@ -383,8 +459,14 @@ impl LanguageServer for BaconLs { Err(_) => tracing::warn!("sync files task timed out during shutdown"), } } - BackendRuntime::Cargo { runtime, .. } => { + BackendRuntime::Cargo { mut runtime, .. } => { runtime.cancel_token.cancel(); + // Abort any pending live debounced trigger so the spawned + // sleep doesn't outlive the server and try to invoke + // cargo against a torn-down backend. + if let Some(handle) = runtime.live_debounce.take() { + handle.abort(); + } } } } diff --git a/src/shadow.rs b/src/shadow.rs new file mode 100644 index 0000000..c25e050 --- /dev/null +++ b/src/shadow.rs @@ -0,0 +1,406 @@ +//! Hardlinked shadow workspace used for live "as you type" diagnostics. +//! +//! cargo can only check files on disk. To surface diagnostics for unsaved +//! editor buffers, we mirror the workspace into +//! `/target/bacon-ls-live/shadow/` using **hardlinks** (one inode +//! per file, no data copy), then on `did_change` we replace the hardlink for +//! the dirty file with a real file containing the buffer content. Cargo runs +//! against the shadow with its own target dir at +//! `/target/bacon-ls-live/target/` so it can't deadlock with the +//! user's regular `cargo build` against the real `target/`. +//! +//! What gets mirrored: everything that wouldn't be excluded by `git status` +//! — the `ignore` crate (same engine `ripgrep` uses) walks the workspace +//! respecting `.gitignore`, `.ignore`, `.git/info/exclude`, the global +//! gitignore, and the hidden-file filter. Plus a hardcoded skip for our own +//! `target/bacon-ls-live/` so the shadow can't recursively mirror itself if +//! a workspace happens not to gitignore `target/`. +//! +//! Path remapping back to the real workspace happens at the cargo invocation +//! site via `--remap-path-prefix`, so diagnostics published to the editor +//! still carry the user's real source paths. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone)] +pub(crate) struct ShadowWorkspace { + real_root: PathBuf, + shadow_root: PathBuf, + target_dir: PathBuf, +} + +impl ShadowWorkspace { + /// Build (or rebuild) the shadow tree for `real_root`. Wipes any stale + /// shadow contents from a previous LSP session — keeping them risks + /// resurrecting a forgotten dirty buffer — but **preserves** the live + /// target dir so cargo's incremental cache survives across restarts. + pub(crate) async fn build(real_root: PathBuf) -> std::io::Result { + let live_root = real_root.join("target").join("bacon-ls-live"); + let shadow_root = live_root.join("shadow"); + let target_dir = live_root.join("target"); + + if tokio::fs::try_exists(&shadow_root).await? { + tokio::fs::remove_dir_all(&shadow_root).await?; + } + tokio::fs::create_dir_all(&shadow_root).await?; + tokio::fs::create_dir_all(&target_dir).await?; + + // Off-thread the filesystem walk: `ignore`'s walker is sync, and on + // a large workspace we'd otherwise stall the LSP runtime. + let real = real_root.clone(); + let shadow = shadow_root.clone(); + let live = live_root.clone(); + tokio::task::spawn_blocking(move || mirror_blocking(&real, &shadow, &live)) + .await + .map_err(std::io::Error::other)??; + + Ok(Self { + real_root, + shadow_root, + target_dir, + }) + } + + pub(crate) fn real_root(&self) -> &Path { + &self.real_root + } + + pub(crate) fn shadow_root(&self) -> &Path { + &self.shadow_root + } + + pub(crate) fn target_dir(&self) -> &Path { + &self.target_dir + } + + /// Translate a real-workspace path into its position inside the shadow. + /// Errors if `real_path` is not inside the workspace root. + pub(crate) fn shadow_path_for(&self, real_path: &Path) -> std::io::Result { + let rel = real_path.strip_prefix(&self.real_root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "path {} is not inside workspace {}", + real_path.display(), + self.real_root.display(), + ), + ) + })?; + Ok(self.shadow_root.join(rel)) + } + + /// Replace the shadow entry for `real_path` with a real file containing + /// `content`. Implemented as write-tmp-then-rename so the rename is atomic + /// and so the real file's inode is **not** modified — only the directory + /// entry inside the shadow. + pub(crate) async fn write_dirty(&self, real_path: &Path, content: &str) -> std::io::Result<()> { + let shadow_path = self.shadow_path_for(real_path)?; + if let Some(parent) = shadow_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let tmp = shadow_path.with_extension("bacon-ls-tmp"); + tokio::fs::write(&tmp, content).await?; + tokio::fs::rename(&tmp, &shadow_path).await?; + Ok(()) + } + + /// Restore the shadow entry for `real_path` back to a hardlink of the + /// on-disk file. Used after `did_save` / `did_close` so subsequent cargo + /// runs see the saved content. + pub(crate) async fn restore_link(&self, real_path: &Path) -> std::io::Result<()> { + let shadow_path = self.shadow_path_for(real_path)?; + if tokio::fs::try_exists(&shadow_path).await? { + tokio::fs::remove_file(&shadow_path).await?; + } + if let Some(parent) = shadow_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::hard_link(real_path, &shadow_path).await?; + Ok(()) + } +} + +/// Sync mirror walk. Runs inside `spawn_blocking`. Uses the `ignore` crate so +/// gitignored / hidden / `.ignore`'d files are skipped, and adds a hardcoded +/// guard against descending into our own shadow dir (`live_root`). +fn mirror_blocking(real: &Path, shadow: &Path, live_root: &Path) -> std::io::Result<()> { + use ignore::WalkBuilder; + + let walker = WalkBuilder::new(real) + // `ignore`'s defaults already enable .gitignore / .ignore / + // .git/info/exclude / global gitignore + hidden-file filtering, but + // make the intent explicit so a future audit doesn't have to read + // the crate's source. + .hidden(true) + .git_ignore(true) + .git_exclude(true) + .git_global(true) + // Crucially: don't require the workspace to be a git repo before + // applying .gitignore rules. Users with jj / hg / no VCS still write + // .gitignore files, and the rules are exactly what we want. + .require_git(false) + .parents(true) + .filter_entry({ + let live_root = live_root.to_path_buf(); + move |e| !e.path().starts_with(&live_root) + }) + .build(); + + for result in walker { + let entry = match result { + Ok(e) => e, + Err(err) => { + tracing::warn!(?err, "skipping entry while mirroring shadow"); + continue; + } + }; + let path = entry.path(); + // The walker yields the root itself first; skip it (the shadow root + // already exists). + let rel = match path.strip_prefix(real) { + Ok(r) if !r.as_os_str().is_empty() => r, + _ => continue, + }; + let shadow_path = shadow.join(rel); + let ft = match entry.file_type() { + Some(ft) => ft, + None => continue, + }; + if ft.is_dir() { + std::fs::create_dir_all(&shadow_path)?; + } else if ft.is_file() { + if let Some(parent) = shadow_path.parent() { + std::fs::create_dir_all(parent)?; + } + // If shadow_path already exists from a partially-completed previous + // run, `hard_link` would error with EEXIST. We removed the shadow + // root at the top of `build`, so this shouldn't happen, but be + // defensive: replace if present. + if std::fs::metadata(&shadow_path).is_ok() { + std::fs::remove_file(&shadow_path)?; + } + std::fs::hard_link(path, &shadow_path)?; + } + // Symlinks: skipped in v1. Rare in Rust workspaces and the semantics + // (target inside vs outside the workspace, broken vs valid) need more + // thought than they're worth right now. + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[cfg(unix)] + fn inode(path: &Path) -> u64 { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).unwrap().ino() + } + + fn mk_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + #[tokio::test] + #[cfg(unix)] + async fn test_build_mirrors_files_via_hardlink_same_inode() { + let tmp = TempDir::new().unwrap(); + let lib_rs = tmp.path().join("src/lib.rs"); + mk_file(&lib_rs, "// content"); + + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs"); + assert!(shadow_lib_rs.exists()); + assert_eq!( + inode(&lib_rs), + inode(&shadow_lib_rs), + "shadow file must hardlink to the real file (same inode)" + ); + } + + #[tokio::test] + async fn test_build_excludes_gitignored_paths() { + let tmp = TempDir::new().unwrap(); + // Put `target/` in .gitignore explicitly so the test doesn't rely on + // the user's global gitignore. Also gitignore a custom build output. + mk_file(&tmp.path().join(".gitignore"), "target/\nbuild-output/\n"); + mk_file(&tmp.path().join("Cargo.toml"), "[package]\nname=\"x\""); + mk_file(&tmp.path().join("src/lib.rs"), "// real"); + mk_file(&tmp.path().join("target/release/big.rlib"), "binary"); + mk_file(&tmp.path().join("build-output/snapshot.bin"), "junk"); + + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + + assert!(shadow.shadow_root().join("Cargo.toml").exists()); + assert!(shadow.shadow_root().join("src/lib.rs").exists()); + // gitignored content must NOT be mirrored. + assert!(!shadow.shadow_root().join("target").exists()); + assert!(!shadow.shadow_root().join("build-output").exists()); + } + + #[tokio::test] + async fn test_build_excludes_hidden_dirs() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join("Cargo.toml"), "x"); + mk_file(&tmp.path().join(".git/HEAD"), "ref:"); + mk_file(&tmp.path().join(".idea/workspace.xml"), ""); + + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + assert!(!shadow.shadow_root().join(".git").exists()); + assert!(!shadow.shadow_root().join(".idea").exists()); + } + + #[tokio::test] + async fn test_build_does_not_recurse_into_its_own_live_dir() { + // A workspace that doesn't gitignore target/ at all (unusual but + // possible): we must still skip target/bacon-ls-live/ to avoid the + // shadow recursively containing itself. + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join("Cargo.toml"), "x"); + mk_file(&tmp.path().join("src/lib.rs"), "x"); + // No .gitignore at all → ignore crate's defaults still skip `.git` + // (hidden) and respect any global gitignore, but won't filter + // target/. We populate target/bacon-ls-live before calling build to + // simulate a prior run. + mk_file( + &tmp.path().join("target/bacon-ls-live/shadow/should-not-recurse.rs"), + "x", + ); + + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + + // The shadow root must not contain a nested `target/bacon-ls-live/` + // (which would mean we recursed into our own output). + assert!( + !shadow.shadow_root().join("target/bacon-ls-live").exists(), + "shadow must not recurse into its own live dir" + ); + } + + #[tokio::test] + async fn test_build_wipes_stale_shadow_from_prior_session() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + mk_file(&tmp.path().join("src/lib.rs"), "fresh"); + // Simulate leftover shadow with a stale dirty buffer. + mk_file( + &tmp.path().join("target/bacon-ls-live/shadow/src/lib.rs"), + "stale dirty content", + ); + mk_file( + &tmp.path().join("target/bacon-ls-live/shadow/src/gone.rs"), + "deleted in real workspace", + ); + + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + + assert!( + !shadow.shadow_root().join("src/gone.rs").exists(), + "stale shadow entries from prior runs must not survive a rebuild" + ); + let mirrored = std::fs::read_to_string(shadow.shadow_root().join("src/lib.rs")).unwrap(); + assert_eq!(mirrored, "fresh", "stale dirty content must be replaced"); + } + + #[tokio::test] + async fn test_build_preserves_target_dir_for_cache_reuse() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + mk_file(&tmp.path().join("src/lib.rs"), "x"); + let cache_marker = tmp.path().join("target/bacon-ls-live/target/CACHE_MARKER"); + mk_file(&cache_marker, "previous build artifacts"); + + ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + assert!( + cache_marker.exists(), + "live target dir must persist across rebuilds so cargo can reuse its cache" + ); + } + + #[tokio::test] + #[cfg(unix)] + async fn test_write_dirty_replaces_hardlink_with_distinct_inode() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + let lib_rs = tmp.path().join("src/lib.rs"); + mk_file(&lib_rs, "saved"); + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs"); + assert_eq!(inode(&lib_rs), inode(&shadow_lib_rs)); + + shadow.write_dirty(&lib_rs, "dirty buffer").await.unwrap(); + + assert_ne!( + inode(&lib_rs), + inode(&shadow_lib_rs), + "write_dirty must break the hardlink" + ); + assert_eq!( + std::fs::read_to_string(&shadow_lib_rs).unwrap(), + "dirty buffer", + "shadow now carries dirty content" + ); + assert_eq!( + std::fs::read_to_string(&lib_rs).unwrap(), + "saved", + "real file must be untouched" + ); + } + + #[tokio::test] + #[cfg(unix)] + async fn test_restore_link_reverts_to_hardlink() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + let lib_rs = tmp.path().join("src/lib.rs"); + mk_file(&lib_rs, "saved"); + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + let shadow_lib_rs = shadow.shadow_root().join("src/lib.rs"); + + shadow.write_dirty(&lib_rs, "dirty").await.unwrap(); + assert_ne!(inode(&lib_rs), inode(&shadow_lib_rs)); + + shadow.restore_link(&lib_rs).await.unwrap(); + assert_eq!( + inode(&lib_rs), + inode(&shadow_lib_rs), + "restore_link must hardlink shadow back to the real file" + ); + assert_eq!(std::fs::read_to_string(&shadow_lib_rs).unwrap(), "saved"); + } + + #[tokio::test] + async fn test_write_dirty_creates_missing_parent_dirs() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + mk_file(&tmp.path().join("src/lib.rs"), "x"); + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + + // A new file the user just created in the editor — parent shadow dir + // doesn't exist yet (file was added after the initial mirror). + let new_file = tmp.path().join("src/new/nested/mod.rs"); + std::fs::create_dir_all(new_file.parent().unwrap()).unwrap(); + std::fs::write(&new_file, "real").unwrap(); + + shadow.write_dirty(&new_file, "in-editor draft").await.unwrap(); + let shadow_path = shadow.shadow_root().join("src/new/nested/mod.rs"); + assert_eq!(std::fs::read_to_string(&shadow_path).unwrap(), "in-editor draft"); + } + + #[tokio::test] + async fn test_shadow_path_for_outside_workspace_errors() { + let tmp = TempDir::new().unwrap(); + mk_file(&tmp.path().join(".gitignore"), "target/\n"); + mk_file(&tmp.path().join("src/lib.rs"), "x"); + let shadow = ShadowWorkspace::build(tmp.path().to_path_buf()).await.unwrap(); + + let outside = std::path::PathBuf::from("/etc/passwd"); + let err = shadow.shadow_path_for(&outside).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } +} diff --git a/tests/cargo_backend.rs b/tests/cargo_backend.rs index d98f902..ab3b75d 100644 --- a/tests/cargo_backend.rs +++ b/tests/cargo_backend.rs @@ -77,17 +77,17 @@ fn spawn_reader(mut stdout: ChildStdout) -> mpsc::Receiver { } /// Auto-respond to server-initiated requests so the server doesn't stall. -/// `workspace/configuration` is answered with a single empty object so the -/// server proceeds with cargo defaults; everything else (e.g. -/// `window/workDoneProgress/create`) gets a `null` result. -fn auto_respond(stdin: &mut ChildStdin, msg: &Value) { +/// `workspace/configuration` is answered with `config_response`; everything +/// else (e.g. `window/workDoneProgress/create`, `client/registerCapability`) +/// gets a `null` result. +fn auto_respond(stdin: &mut ChildStdin, msg: &Value, config_response: &Value) { if msg.get("method").is_none() || msg.get("id").is_none() { return; } let id = &msg["id"]; let method = msg["method"].as_str().unwrap_or(""); let result = match method { - "workspace/configuration" => json!([{}]), + "workspace/configuration" => config_response.clone(), _ => Value::Null, }; send(stdin, &json!({"jsonrpc": "2.0", "id": id, "result": result})); @@ -96,7 +96,13 @@ fn auto_respond(stdin: &mut ChildStdin, msg: &Value) { /// Read messages off `rx` until `pred` matches, auto-responding to every /// server-initiated request along the way. Returns the matched message or /// `None` on timeout. -fn pump(rx: &mpsc::Receiver, stdin: &mut ChildStdin, timeout: Duration, mut pred: F) -> Option +fn pump( + rx: &mpsc::Receiver, + stdin: &mut ChildStdin, + timeout: Duration, + config_response: &Value, + mut pred: F, +) -> Option where F: FnMut(&Value) -> bool, { @@ -105,7 +111,7 @@ where let Ok(msg) = rx.recv_timeout(remaining) else { return None; }; - auto_respond(stdin, &msg); + auto_respond(stdin, &msg, config_response); if pred(&msg) { return Some(msg); } @@ -113,48 +119,94 @@ where None } +/// Default `workspace/configuration` reply: empty object → server uses +/// cargo backend defaults. Existing tests rely on this. +fn empty_config() -> Value { + json!([{}]) +} + fn root_uri(dir: &Path) -> String { format!("file://{}", dir.display()) } fn spawn_server(workdir: &Path) -> Child { - Command::new(BIN) - .current_dir(workdir) - .env_remove("RUST_LOG") + spawn_server_with_log(workdir, None) +} + +fn spawn_server_with_log(workdir: &Path, rust_log: Option<&str>) -> Child { + let mut cmd = Command::new(BIN); + cmd.current_dir(workdir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn bacon-ls") + .stderr(Stdio::null()); + if let Some(level) = rust_log { + cmd.env("RUST_LOG", level); + } else { + cmd.env_remove("RUST_LOG"); + } + cmd.spawn().expect("spawn bacon-ls") +} + +fn read_server_log(workdir: &Path) -> String { + std::fs::read_to_string(workdir.join("bacon-ls.log")).unwrap_or_else(|e| format!("")) +} + +fn initialize( + stdin: &mut ChildStdin, + rx: &mpsc::Receiver, + workdir: &Path, + related_info_support: bool, + config_response: &Value, +) { + initialize_with_init_options(stdin, rx, workdir, related_info_support, config_response, None); } -fn initialize(stdin: &mut ChildStdin, rx: &mpsc::Receiver, workdir: &Path, related_info_support: bool) { +fn initialize_with_init_options( + stdin: &mut ChildStdin, + rx: &mpsc::Receiver, + workdir: &Path, + related_info_support: bool, + config_response: &Value, + init_options: Option<&Value>, +) { + let mut params = json!({ + "processId": null, + "rootUri": root_uri(workdir), + "workspaceFolders": [{"uri": root_uri(workdir), "name": "fixture"}], + "capabilities": { + "textDocument": { + "publishDiagnostics": { + "dataSupport": true, + "relatedInformation": related_info_support + }, + "synchronization": { + "dynamicRegistration": true + } + } + } + }); + if let Some(opts) = init_options { + params["initializationOptions"] = opts.clone(); + } let init = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", - "params": { - "processId": null, - "rootUri": root_uri(workdir), - "workspaceFolders": [{"uri": root_uri(workdir), "name": "fixture"}], - "capabilities": { - "textDocument": { - "publishDiagnostics": { - "dataSupport": true, - "relatedInformation": related_info_support - } - } - } - } + "params": params, }); send(stdin, &init); - pump(rx, stdin, Duration::from_secs(5), |m| m.get("id") == Some(&json!(1))).expect("initialize response"); + pump(rx, stdin, Duration::from_secs(5), config_response, |m| { + m.get("id") == Some(&json!(1)) + }) + .expect("initialize response"); send(stdin, &json!({"jsonrpc": "2.0", "method": "initialized", "params": {}})); } -fn shutdown_and_wait(stdin: &mut ChildStdin, rx: &mpsc::Receiver, child: &mut Child) { +fn shutdown_and_wait(stdin: &mut ChildStdin, rx: &mpsc::Receiver, child: &mut Child, config_response: &Value) { send(stdin, &json!({"jsonrpc": "2.0", "id": 999, "method": "shutdown"})); - let _ = pump(rx, stdin, Duration::from_secs(5), |m| m.get("id") == Some(&json!(999))); + let _ = pump(rx, stdin, Duration::from_secs(5), config_response, |m| { + m.get("id") == Some(&json!(999)) + }); send(stdin, &json!({"jsonrpc": "2.0", "method": "exit"})); // Best-effort wait so the harness doesn't leave zombies behind on // assertion failures. @@ -198,9 +250,9 @@ fn cargo_backend_publishes_error_diagnostic() { let mut stdin = child.stdin.take().expect("stdin"); let rx = spawn_reader(stdout); - initialize(&mut stdin, &rx, tmp.path(), true); + initialize(&mut stdin, &rx, tmp.path(), true, &empty_config()); - let msg = pump(&rx, &mut stdin, Duration::from_secs(60), |m| { + let msg = pump(&rx, &mut stdin, Duration::from_secs(60), &empty_config(), |m| { diagnostics_for(m, "lib.rs").is_some() }) .expect("publishDiagnostics for src/lib.rs"); @@ -230,7 +282,7 @@ fn cargo_backend_publishes_error_diagnostic() { ); } - shutdown_and_wait(&mut stdin, &rx, &mut child); + shutdown_and_wait(&mut stdin, &rx, &mut child, &empty_config()); } #[test] @@ -249,9 +301,9 @@ fn cargo_backend_code_action_replaces_unused_variable() { // related_info_support=false forces the server to emit the help-child // span as its own diagnostic with a `data` payload (corrections), // which is what powers the QuickFix code action. - initialize(&mut stdin, &rx, tmp.path(), false); + initialize(&mut stdin, &rx, tmp.path(), false, &empty_config()); - let msg = pump(&rx, &mut stdin, Duration::from_secs(60), |m| { + let msg = pump(&rx, &mut stdin, Duration::from_secs(60), &empty_config(), |m| { let Some(diags) = diagnostics_for(m, "lib.rs") else { return false; }; @@ -293,7 +345,7 @@ fn cargo_backend_code_action_replaces_unused_variable() { }); send(&mut stdin, &req); - let resp = pump(&rx, &mut stdin, Duration::from_secs(10), |m| { + let resp = pump(&rx, &mut stdin, Duration::from_secs(10), &empty_config(), |m| { m.get("id") == Some(&json!(2)) }) .expect("codeAction response"); @@ -324,5 +376,132 @@ fn cargo_backend_code_action_replaces_unused_variable() { .expect("workspace edit with changes"); assert!(edit.contains_key(&uri), "edit must target the diagnostic's URI"); - shutdown_and_wait(&mut stdin, &rx, &mut child); + shutdown_and_wait(&mut stdin, &rx, &mut child, &empty_config()); +} + +#[test] +fn cargo_backend_live_diagnostics_without_save() { + let tmp = TempDir::new().expect("tempdir"); + // Start with code that compiles cleanly. The live path must turn the + // diagnostic on the moment we tell the server about a dirty buffer + // containing broken code, *without* a save. + let clean_source = "pub fn ok() -> i32 { 42 }\n"; + write_fixture(tmp.path(), clean_source); + + let mut child = spawn_server_with_log(tmp.path(), Some("bacon_ls=debug")); + let stdout = child.stdout.take().expect("stdout"); + let mut stdin = child.stdin.take().expect("stdin"); + let rx = spawn_reader(stdout); + + // `cargo.updateOnInsert` MUST come through `initialization_options`: + // it drives the static `textDocument/didChange` sync capability advertised + // in the `initialize` response, so the client knows to ship change events + // from buffer attach onwards. The runtime debounce is still a workspace + // setting (it's safe to tweak at runtime). + let init_options = json!({ + "cargo": { "updateOnInsert": true } + }); + let live_config = json!([{ + "cargo": { + "updateOnInsertDebounceMillis": 100, + } + }]); + + initialize_with_init_options(&mut stdin, &rx, tmp.path(), false, &live_config, Some(&init_options)); + + // `initialized` runs `pull_configuration` then `init_cargo_backend` and + // *only then* logs the "lsp server initialized with backend: …" line. + // Until that's done, `state.backend = None` and `did_change` would bail + // with `updateOnInsert is off`. Wait for the log_message notification + // before we send any change events. + pump(&rx, &mut stdin, Duration::from_secs(10), &live_config, |m| { + if m.get("method").and_then(|s| s.as_str()) != Some("window/logMessage") { + return false; + } + m.get("params") + .and_then(|p| p.get("message")) + .and_then(|s| s.as_str()) + .is_some_and(|s| s.contains("lsp server initialized with backend")) + }) + .expect("server should announce post-initialized state via window/logMessage"); + + let lib_uri = format!("{}/src/lib.rs", root_uri(tmp.path())); + + // didOpen mirrors what real LSP clients do; the buffer matches disk so + // there's nothing dirty yet. + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": lib_uri, + "languageId": "rust", + "version": 1, + "text": clean_source, + } + } + }), + ); + + // didChange with broken code. Full sync (range = None on the single + // change) is what we registered for via dynamic capability. + let dirty_source = "pub fn ok() -> i32 { undefined_symbol_for_live_test }\n"; + send( + &mut stdin, + &json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": lib_uri, + "version": 2, + }, + "contentChanges": [{ "text": dirty_source }] + } + }), + ); + + // Wait for an ERROR publishDiagnostics for src/lib.rs. The initial + // real-workspace run (kicked off in `initialized`) may publish empty + // diagnostics for the clean source first; we ignore those and keep + // waiting. Live mode goes through a cold-cache cargo build the first + // time so the timeout is generous. + let result = pump(&rx, &mut stdin, Duration::from_secs(120), &live_config, |m| { + let Some(diags) = diagnostics_for(m, "lib.rs") else { + return false; + }; + diags + .iter() + .any(|d| d.get("severity").and_then(|s| s.as_i64()) == Some(1)) + }); + let msg = match result { + Some(m) => m, + None => { + eprintln!("=== bacon-ls.log ===\n{}", read_server_log(tmp.path())); + panic!("live ERROR publishDiagnostics for src/lib.rs (no save was sent)"); + } + }; + + let diags = diagnostics_for(&msg, "lib.rs").unwrap(); + let has_expected_error = diags.iter().any(|d| { + let severity = d.get("severity").and_then(|s| s.as_i64()); + let message = d.get("message").and_then(|s| s.as_str()).unwrap_or(""); + severity == Some(1) && (message.contains("undefined_symbol_for_live_test") || message.contains("cannot find")) + }); + assert!( + has_expected_error, + "expected an ERROR diagnostic mentioning the dirty-buffer symbol; got {diags:#?}" + ); + + // The proof the diagnostic is from the live path, not a phantom save: + // the on-disk file is still the clean version we wrote at the start. + let on_disk = std::fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap(); + assert_eq!( + on_disk, clean_source, + "on-disk source must remain clean; the dirty diagnostic must have come from the live shadow" + ); + + shutdown_and_wait(&mut stdin, &rx, &mut child, &live_config); }