Skip to main content

bacon_ls/
lib.rs

1//! Bacon Language Server
2use std::collections::{HashMap, HashSet};
3use std::env;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use argh::FromArgs;
9use bacon::Bacon;
10use flume::RecvError;
11use ls_types::{Diagnostic, DiagnosticSeverity, MessageType, ProgressToken, Range, Uri, WorkspaceFolder};
12use native::Cargo;
13use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
14use serde_json::{Map, Value};
15use shadow::ShadowWorkspace;
16use tokio::sync::{RwLock, RwLockWriteGuard};
17use tokio::task::JoinHandle;
18use tokio_util::sync::CancellationToken;
19use tower_lsp_server::{Client, LspService, Server, jsonrpc};
20use tracing_subscriber::fmt::format::FmtSpan;
21
22mod bacon;
23mod lsp;
24mod native;
25mod shadow;
26
27const PKG_NAME: &str = env!("CARGO_PKG_NAME");
28pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
29const LOCATIONS_FILE: &str = ".bacon-locations";
30const BACON_BACKGROUND_COMMAND: &str = "bacon";
31const BACON_BACKGROUND_COMMAND_ARGS: &str = "--headless -j bacon-ls";
32
33// Characters that must be percent-encoded when putting an OS path into a
34// `file://` URI. We keep `/` unencoded so it continues to split the path into
35// segments (clients expect multi-segment URIs). This covers the reserved URI
36// characters plus a few that break `Uri` parsing in practice (space, `#`,
37// `?`, `%`, `[`/`]`, backslash, etc.).
38const PATH_ENCODE_SET: &AsciiSet = &CONTROLS
39    .add(b' ')
40    .add(b'"')
41    .add(b'#')
42    .add(b'<')
43    .add(b'>')
44    .add(b'?')
45    .add(b'[')
46    .add(b'\\')
47    .add(b']')
48    .add(b'^')
49    .add(b'`')
50    .add(b'{')
51    .add(b'|')
52    .add(b'}')
53    .add(b'%');
54
55/// Build a `file://...` URI string from an OS path. Percent-encodes any
56/// characters that would otherwise break URI parsing (spaces, `#`, `?`, `%`,
57/// etc.), while leaving `/` intact so path segments survive.
58pub(crate) fn path_to_file_uri(path: &str) -> String {
59    format!("file://{}", utf8_percent_encode(path, PATH_ENCODE_SET))
60}
61
62/// Hash key for deduplicating diagnostics that share the same range, severity,
63/// and message. `DiagnosticSeverity` is `Eq` but not `Hash` in `ls-types`, so we
64/// project it down to a small integer tag.
65pub(crate) type DiagKey = (Range, i32, String);
66
67pub(crate) fn diag_key(d: &Diagnostic) -> DiagKey {
68    (d.range, severity_tag(d.severity), d.message.clone())
69}
70
71fn severity_tag(s: Option<DiagnosticSeverity>) -> i32 {
72    match s {
73        None => 0,
74        Some(s) if s == DiagnosticSeverity::ERROR => 1,
75        Some(s) if s == DiagnosticSeverity::WARNING => 2,
76        Some(s) if s == DiagnosticSeverity::INFORMATION => 3,
77        Some(s) if s == DiagnosticSeverity::HINT => 4,
78        Some(_) => -1,
79    }
80}
81
82/// bacon-ls - https://github.com/crisidev/bacon-ls
83#[derive(Debug, FromArgs)]
84pub struct Args {
85    /// display version information
86    #[argh(switch, short = 'v')]
87    pub version: bool,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum BackendChoice {
92    Cargo,
93    Bacon,
94}
95
96#[derive(Debug)]
97enum BackendRuntime {
98    Bacon {
99        config: BaconOptions,
100        runtime: BaconRuntime,
101    },
102    Cargo {
103        config: CargoOptions,
104        runtime: CargoRuntime,
105    },
106}
107
108impl BackendRuntime {
109    fn backend_choice(&self) -> BackendChoice {
110        match self {
111            Self::Bacon { .. } => BackendChoice::Bacon,
112            Self::Cargo { .. } => BackendChoice::Cargo,
113        }
114    }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub(crate) enum CargoRunState {
119    Idle,
120    Running,
121    RunningPending,
122}
123
124#[derive(Debug, Copy, Clone)]
125pub(crate) enum PublishMode {
126    CancelRunning,
127    QueueIfRunning,
128}
129
130fn invalid_option(name: &str) -> jsonrpc::Error {
131    jsonrpc::Error {
132        code: jsonrpc::ErrorCode::InvalidParams,
133        message: format!("Invalid value for option \"{name}\"").into(),
134        data: None,
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139enum CargoFeatures {
140    /// use `--all-features`
141    All,
142    /// pass the feature list as `--features=...`
143    List(Vec<String>),
144}
145
146impl Default for CargoFeatures {
147    fn default() -> Self {
148        Self::List(vec![])
149    }
150}
151
152impl CargoFeatures {
153    fn from_json_value(value: &Value) -> jsonrpc::Result<Self> {
154        match value {
155            Value::Null => Ok(Self::List(vec![])),
156            Value::String(str) if str == "all" => Ok(Self::All),
157            Value::Array(values) => {
158                let features = values
159                    .iter()
160                    .map(|item| {
161                        item.as_str()
162                            .map(|s| s.to_string())
163                            .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))
164                    })
165                    .collect::<jsonrpc::Result<Vec<_>>>()?;
166
167                Ok(Self::List(features))
168            }
169            _ => Err(jsonrpc::Error {
170                code: jsonrpc::ErrorCode::InvalidParams,
171                message: "features must be a list of strings or the string \"all\"".into(),
172                data: None,
173            }),
174        }
175    }
176}
177
178#[derive(Debug)]
179pub(crate) struct CargoOptions {
180    // "check" or "clippy"
181    pub(crate) command: String,
182    pub(crate) features: CargoFeatures,
183    // `-p crate_name`
184    pub(crate) package: Option<String>,
185    pub(crate) all_targets: bool,
186    pub(crate) no_default_features: bool,
187    // Extra arguments which do not have a nice wrapper
188    pub(crate) extra_command_args: Vec<String>,
189    pub(crate) env: Vec<(String, String)>,
190    pub(crate) publish_mode: PublishMode,
191    // Interval at which we refresh (send) cargo diagnostics we have so far
192    // None means wait until the cargo command is fully done
193    pub(crate) refresh_interval_seconds: Option<Duration>,
194    /// User override: when `Some(true)`, always emit children as separate
195    /// diagnostics instead of related information, regardless of client
196    /// capability. When `None`, follow the client advertisement.
197    pub(crate) separate_child_diagnostics: Option<bool>,
198    pub(crate) check_on_save: bool,
199    pub(crate) clear_diagnostics_on_check: bool,
200    /// Live-as-you-type diagnostics. When true, the server mirrors the
201    /// workspace into a hardlinked shadow under
202    /// `target/bacon-ls-live/shadow/`, replaces dirty buffers in the shadow
203    /// on `did_change`, and runs cargo against the shadow with a separate
204    /// target dir. Off by default.
205    pub(crate) update_on_insert: bool,
206    /// Quiet period after the most recent `did_change` before the live
207    /// cargo run is triggered. Coalesces bursts of keystrokes into a single
208    /// run.
209    pub(crate) update_on_insert_debounce: Duration,
210}
211
212impl CargoOptions {
213    pub(crate) fn build_command_args(&self) -> Vec<String> {
214        let mut args = vec![self.command.clone()];
215        args.push("--message-format=json-diagnostic-rendered-ansi".to_string());
216
217        match &self.features {
218            CargoFeatures::All => {
219                args.push("--all-features".to_string());
220            }
221            CargoFeatures::List(features) if !features.is_empty() => {
222                args.push("--features".to_string());
223                let mut features_list = String::new();
224                for feature in features[..features.len() - 1].iter() {
225                    features_list += feature;
226                    features_list += ",";
227                }
228                features_list += &features[features.len() - 1];
229                args.push(features_list);
230            }
231            _ => {}
232        }
233
234        if let Some(pkg) = self.package.clone() {
235            args.push("-p".to_string());
236            args.push(pkg);
237        }
238
239        if self.all_targets {
240            args.push("--all-targets".to_string());
241        }
242
243        if self.no_default_features {
244            args.push("--no-default-features".to_string());
245        }
246
247        for arg in self.extra_command_args.iter().cloned() {
248            args.push(arg);
249        }
250
251        args
252    }
253
254    pub(crate) fn update_from_json_obj(&mut self, cargo_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
255        if let Some(value) = cargo_obj.get("command") {
256            self.command = value.as_str().ok_or_else(|| invalid_option("command"))?.to_string();
257        }
258
259        if let Some(value) = cargo_obj.get("features") {
260            self.features = CargoFeatures::from_json_value(value)?;
261        }
262
263        if let Some(value) = cargo_obj.get("package").filter(|v| !v.is_null()) {
264            self.package = Some(value.as_str().ok_or_else(|| invalid_option("package"))?.to_string());
265        }
266
267        if let Some(value) = cargo_obj.get("allTargets") {
268            self.all_targets = value.as_bool().ok_or_else(|| invalid_option("allTargets"))?;
269        }
270
271        if let Some(value) = cargo_obj.get("noDefaultFeatures") {
272            self.no_default_features = value.as_bool().ok_or_else(|| invalid_option("noDefaultFeatures"))?;
273        }
274
275        if let Some(value) = cargo_obj.get("extraArgs") {
276            self.extra_command_args = value
277                .as_array()
278                .ok_or_else(|| invalid_option("extraArgs"))?
279                .iter()
280                .map(|item| {
281                    item.as_str()
282                        .map(|s| s.to_string())
283                        .ok_or_else(|| invalid_option("extraArgs"))
284                })
285                .collect::<jsonrpc::Result<Vec<_>>>()?;
286        }
287
288        if let Some(value) = cargo_obj.get("env") {
289            self.env = value
290                .as_object()
291                .ok_or_else(|| invalid_option("env"))?
292                .iter()
293                .map(|(k, v)| {
294                    let val = v.as_str().ok_or_else(|| invalid_option("env"))?;
295                    Ok((k.clone(), val.to_string()))
296                })
297                .collect::<jsonrpc::Result<Vec<_>>>()?;
298        }
299
300        if let Some(value) = cargo_obj.get("cancelRunning") {
301            let cancel = value.as_bool().ok_or_else(|| invalid_option("cancelRunning"))?;
302            self.publish_mode = if cancel {
303                PublishMode::CancelRunning
304            } else {
305                PublishMode::QueueIfRunning
306            };
307        }
308
309        if let Some(value) = cargo_obj.get("refreshIntervalSeconds") {
310            if value.is_null() {
311                self.refresh_interval_seconds = None;
312            } else {
313                let seconds = value.as_i64().ok_or_else(|| invalid_option("refreshIntervalSeconds"))?;
314                if seconds < 0 {
315                    self.refresh_interval_seconds = None;
316                } else {
317                    self.refresh_interval_seconds = Some(Duration::from_secs(seconds as u64));
318                }
319            }
320        }
321
322        if let Some(value) = cargo_obj.get("separateChildDiagnostics") {
323            self.separate_child_diagnostics = if value.is_null() {
324                None
325            } else {
326                Some(
327                    value
328                        .as_bool()
329                        .ok_or_else(|| invalid_option("separateChildDiagnostics"))?,
330                )
331            };
332        }
333
334        if let Some(value) = cargo_obj.get("checkOnSave") {
335            self.check_on_save = value.as_bool().ok_or_else(|| invalid_option("checkOnSave"))?;
336        }
337
338        if let Some(value) = cargo_obj.get("clearDiagnosticsOnCheck") {
339            self.clear_diagnostics_on_check = value
340                .as_bool()
341                .ok_or_else(|| invalid_option("clearDiagnosticsOnCheck"))?;
342        }
343
344        if let Some(value) = cargo_obj.get("updateOnInsertDebounceMillis") {
345            let millis = value
346                .as_u64()
347                .ok_or_else(|| invalid_option("updateOnInsertDebounceMillis"))?;
348            self.update_on_insert_debounce = Duration::from_millis(millis);
349        }
350
351        Ok(())
352    }
353
354    pub(crate) fn reset(&mut self) {
355        *self = Self::default();
356    }
357}
358
359impl Default for CargoOptions {
360    fn default() -> Self {
361        Self {
362            env: Vec::new(),
363            publish_mode: PublishMode::CancelRunning,
364            command: "check".to_string(),
365            features: CargoFeatures::default(),
366            all_targets: false,
367            extra_command_args: vec![],
368            package: None,
369            refresh_interval_seconds: Some(Duration::from_secs(1)),
370            separate_child_diagnostics: None,
371            check_on_save: true,
372            clear_diagnostics_on_check: false,
373            update_on_insert: false,
374            update_on_insert_debounce: Duration::from_millis(500),
375            no_default_features: false,
376        }
377    }
378}
379
380#[derive(Debug)]
381pub(crate) struct BaconOptions {
382    pub(crate) locations_file: String,
383    pub(crate) run_in_background: bool,
384    pub(crate) run_in_background_command: String,
385    pub(crate) run_in_background_command_args: String,
386    pub(crate) validate_preferences: bool,
387    pub(crate) create_preferences_file: bool,
388    pub(crate) synchronize_all_open_files_wait: Duration,
389    pub(crate) update_on_save: bool,
390    pub(crate) update_on_save_wait: Duration,
391}
392
393impl BaconOptions {
394    pub(crate) fn update_from_json_obj(&mut self, bacon_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
395        if let Some(value) = bacon_obj.get("locationsFile") {
396            self.locations_file = value
397                .as_str()
398                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
399                .to_string();
400        }
401        if let Some(value) = bacon_obj.get("runInBackground") {
402            self.run_in_background = value
403                .as_bool()
404                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
405        }
406        if let Some(value) = bacon_obj.get("runInBackgroundCommand") {
407            self.run_in_background_command = value
408                .as_str()
409                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
410                .to_string();
411        }
412        if let Some(value) = bacon_obj.get("runInBackgroundCommandArguments") {
413            self.run_in_background_command_args = value
414                .as_str()
415                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
416                .to_string();
417        }
418        if let Some(value) = bacon_obj.get("validatePreferences") {
419            self.validate_preferences = value
420                .as_bool()
421                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
422        }
423        if let Some(value) = bacon_obj.get("createPreferencesFile") {
424            self.create_preferences_file = value
425                .as_bool()
426                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
427        }
428        if let Some(value) = bacon_obj.get("synchronizeAllOpenFilesWaitMillis") {
429            self.synchronize_all_open_files_wait = Duration::from_millis(
430                value
431                    .as_u64()
432                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
433            );
434        }
435        if let Some(value) = bacon_obj.get("updateOnSave") {
436            self.update_on_save = value
437                .as_bool()
438                .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
439        }
440        if let Some(value) = bacon_obj.get("updateOnSaveWaitMillis") {
441            self.update_on_save_wait = Duration::from_millis(
442                value
443                    .as_u64()
444                    .ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
445            );
446        }
447
448        Ok(())
449    }
450
451    pub fn reset(&mut self) {
452        *self = Self::default();
453    }
454}
455
456impl Default for BaconOptions {
457    fn default() -> Self {
458        Self {
459            locations_file: LOCATIONS_FILE.to_string(),
460            run_in_background: true,
461            run_in_background_command: BACON_BACKGROUND_COMMAND.to_string(),
462            run_in_background_command_args: BACON_BACKGROUND_COMMAND_ARGS.to_string(),
463            validate_preferences: true,
464            create_preferences_file: true,
465            synchronize_all_open_files_wait: Duration::from_millis(2000),
466            update_on_save: true,
467            update_on_save_wait: Duration::from_millis(1000),
468        }
469    }
470}
471
472/// Per-invocation overrides used to redirect a cargo run from the real
473/// workspace into the hardlinked shadow workspace for live diagnostics.
474#[derive(Debug)]
475pub(crate) struct LiveCheckContext {
476    pub(crate) shadow_root: PathBuf,
477    pub(crate) shadow_target_dir: PathBuf,
478    pub(crate) real_root: PathBuf,
479}
480
481#[derive(Debug)]
482pub(crate) struct CargoRuntime {
483    cancel_token: CancellationToken,
484    run_state: CargoRunState,
485    files_with_diags: HashSet<Uri>,
486    diagnostics_version: i32,
487    build_folder: PathBuf,
488    // Timestamp of the most recent publish_cargo_diagnostics invocation.
489    // Used by did_open to avoid kicking off a redundant run when one was
490    // just triggered (e.g. the initial run from `initialized` immediately
491    // followed by the client's first `didOpen`).
492    last_run_started: Option<Instant>,
493    /// Hardlinked shadow of the workspace used for live "as you type"
494    /// diagnostics. None until the first did_change with `update_on_insert`
495    /// enabled — building it eagerly at backend init would block startup on
496    /// large workspaces for users who never trigger live mode.
497    pub(crate) shadow: Option<ShadowWorkspace>,
498    /// File URIs that currently have a dirty buffer overlaid in the shadow.
499    /// On did_save / did_close we restore each entry to a hardlink so the
500    /// next live run reads the on-disk version.
501    pub(crate) dirty_files: HashSet<Uri>,
502    /// Pending debounced live-cargo trigger. Each `did_change` cancels the
503    /// prior handle and schedules a new one so only the last keystroke fires
504    /// a check.
505    pub(crate) live_debounce: Option<JoinHandle<()>>,
506}
507
508impl Default for CargoRuntime {
509    fn default() -> Self {
510        Self {
511            cancel_token: CancellationToken::new(),
512            run_state: CargoRunState::Idle,
513            files_with_diags: HashSet::new(),
514            diagnostics_version: 0,
515            build_folder: PathBuf::new(),
516            last_run_started: None,
517            shadow: None,
518            dirty_files: HashSet::new(),
519            live_debounce: None,
520        }
521    }
522}
523
524#[derive(Debug)]
525pub(crate) struct BaconRuntime {
526    pub(crate) shutdown_token: CancellationToken,
527    pub(crate) open_files: HashSet<Uri>,
528    // Some(..) if we have to run bacon in the background ourselves
529    pub(crate) command_handle: Option<JoinHandle<()>>,
530    pub(crate) sync_files_handle: JoinHandle<()>,
531    // Monotonic counter stamped onto each publishDiagnostics call so clients
532    // can discard stale results if publishes arrive out of order.
533    pub(crate) diagnostics_version: i32,
534}
535
536#[derive(Debug, Default)]
537struct State {
538    project_root: Option<PathBuf>,
539    workspace_folders: Option<Vec<WorkspaceFolder>>,
540    diagnostics_data_supported: bool,
541    related_information_supported: bool,
542    backend: Option<BackendRuntime>,
543    /// Set by `initialize()` from `initialization_options.cargo.updateOnInsert`.
544    /// We need this at initialize-time to advertise a `Full` text-document
545    /// sync capability, because dynamic `client/registerCapability` for
546    /// `textDocument/didChange` after `initialized` doesn't reliably retrofit
547    /// already-attached buffers (Neovim, in particular, ignores it). A
548    /// statically-advertised capability is honored at attach.
549    init_update_on_insert: bool,
550}
551
552#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
553pub(crate) struct CorrectionEdit {
554    pub(crate) range: Range,
555    pub(crate) new_text: String,
556}
557
558// A single logical fix can require several disjoint byte-range edits. For
559// example, removing `Compact` from `use …::{Compact, FmtSpan}` produces three
560// edits: remove `{`, remove `Compact, `, remove `}`, leaving `use …::FmtSpan`.
561// All edits must be applied atomically so the file stays valid.
562#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
563pub(crate) struct Correction {
564    pub(crate) label: String,
565    pub(crate) edits: Vec<CorrectionEdit>,
566}
567
568impl Correction {
569    pub(crate) fn from_single(range: Range, new_text: &str) -> Self {
570        let label = if new_text.is_empty() {
571            "Remove".to_string()
572        } else {
573            format!("Replace with: {new_text}")
574        };
575        Self {
576            label,
577            edits: vec![CorrectionEdit {
578                range,
579                new_text: new_text.to_string(),
580            }],
581        }
582    }
583
584    pub(crate) fn from_multi(edits: Vec<CorrectionEdit>) -> Self {
585        let label = match edits.iter().find(|e| !e.new_text.is_empty()) {
586            None => "Remove".to_string(),
587            Some(e) => format!("Replace with: {}", e.new_text),
588        };
589        Self { label, edits }
590    }
591}
592
593#[derive(Debug, serde::Serialize, serde::Deserialize)]
594struct DiagnosticData {
595    corrections: Vec<Correction>,
596}
597
598#[derive(Debug, Clone)]
599pub struct BaconLs {
600    client: Arc<Client>,
601    state: Arc<RwLock<State>>,
602}
603
604impl BaconLs {
605    fn new(client: Client) -> Self {
606        Self {
607            client: Arc::new(client),
608            state: Arc::new(RwLock::new(State::default())),
609        }
610    }
611
612    fn configure_tracing(log_level: Option<String>, log_path: Option<&Path>) {
613        // Configure logging to file.
614        let level = log_level.unwrap_or_else(|| env::var("RUST_LOG").unwrap_or("off".to_string()));
615        if level == "off" {
616            return;
617        }
618        let default_path = PathBuf::from(format!("{PKG_NAME}.log"));
619        let log_path = log_path.unwrap_or(&default_path);
620        let file = match std::fs::OpenOptions::new()
621            .create(true)
622            .write(true)
623            .truncate(true)
624            .open(log_path)
625        {
626            Ok(file) => file,
627            Err(e) => {
628                // stdin/stdout are the LSP jsonrpc pipes; stderr is usually
629                // captured by the client's trace window. One line there is the
630                // best we can do to tell the user why logging is silent.
631                eprintln!(
632                    "{PKG_NAME}: could not open log file {}: {e} (tracing disabled)",
633                    log_path.display()
634                );
635                return;
636            }
637        };
638        // try_init: tests may install the subscriber more than once across the
639        // process lifetime (cargo runs them in a single binary). Don't panic
640        // if a global subscriber is already set — the first one wins.
641        let _ = tracing_subscriber::fmt()
642            .with_env_filter(level)
643            .with_writer(file)
644            .with_thread_names(true)
645            .with_span_events(FmtSpan::CLOSE)
646            .with_target(true)
647            .with_file(true)
648            .with_line_number(true)
649            .try_init();
650    }
651
652    /// Run the LSP server.
653    pub async fn serve() {
654        Self::configure_tracing(None, None);
655        // Lock stdin / stdout.
656        let stdin = tokio::io::stdin();
657        let stdout = tokio::io::stdout();
658        // Start the service.
659        let (service, socket) = LspService::new(Self::new);
660        Server::new(stdin, stdout, socket).serve(service).await;
661        // Force the process to terminate instead of waiting for the tokio
662        // runtime to drain. Some background tasks (bacon subprocess readers,
663        // file watchers) can linger past the `exit` notification; if the
664        // process doesn't die promptly, `:LspRestart` in Neovim gives up
665        // before starting a fresh instance.
666        std::process::exit(0);
667    }
668
669    async fn find_git_root_directory(path: &Path) -> Option<PathBuf> {
670        let output = tokio::process::Command::new("git")
671            .arg("-C")
672            .arg(path)
673            .arg("rev-parse")
674            .arg("--show-toplevel")
675            .output()
676            .await
677            .ok()?;
678
679        if output.status.success() {
680            String::from_utf8(output.stdout).ok().map(|v| PathBuf::from(v.trim()))
681        } else {
682            None
683        }
684    }
685
686    fn detect_backend(values: &Map<String, Value>) -> Result<BackendChoice, String> {
687        if let Some(value) = values.get("backend") {
688            let backend = value.as_str().ok_or("'backend' must be a string")?;
689            match backend {
690                "cargo" => Ok(BackendChoice::Cargo),
691                "bacon" => Ok(BackendChoice::Bacon),
692                other => Err(format!("Invalid backend value '{other}'. Must be 'cargo' or 'bacon'.")),
693            }
694        } else {
695            let has_cargo = values.get("cargo").and_then(|v| v.as_object()).is_some();
696            let has_bacon = values.get("bacon").and_then(|v| v.as_object()).is_some();
697            match (has_cargo, has_bacon) {
698                (true, true) => Err(
699                    "Both 'cargo' and 'bacon' config sections present without a 'backend' key. \
700                     Set 'backend' to 'cargo' or 'bacon'."
701                        .to_string(),
702                ),
703                (_, true) => Ok(BackendChoice::Bacon),
704                _ => Ok(BackendChoice::Cargo),
705            }
706        }
707    }
708
709    async fn pull_configuration(&self) {
710        tracing::debug!("pull_configuration");
711
712        let configuration_fut = self.client.configuration(vec![ls_types::ConfigurationItem {
713            scope_uri: None,
714            section: Some("bacon_ls".to_string()),
715        }]);
716        // A client that never answers `workspace/configuration` (e.g. one
717        // mid-teardown) would otherwise keep this await alive forever, which
718        // in turn pins the `initialized` future inside the server loop and
719        // blocks a clean shutdown.
720        let response = match tokio::time::timeout(std::time::Duration::from_secs(5), configuration_fut).await {
721            Ok(Ok(response)) => response,
722            Ok(Err(e)) => {
723                tracing::error!("failed to pull configuration: {e}");
724                return;
725            }
726            Err(_) => {
727                tracing::warn!("workspace/configuration request timed out; proceeding with defaults");
728                return;
729            }
730        };
731
732        let Some(settings) = response.into_iter().next() else {
733            tracing::warn!("empty configuration response from client");
734            return;
735        };
736
737        tracing::trace!("pulled configuration: {settings:#?}");
738        self.adapt_to_settings(&settings).await;
739    }
740
741    async fn adapt_to_settings(&self, settings: &Value) {
742        let mut state = self.state.write().await;
743        let Some(values) = settings.as_object() else {
744            tracing::warn!("configuration is not a JSON object");
745            return;
746        };
747
748        if state.backend.is_none() {
749            let backend_choice = match Self::detect_backend(values) {
750                Ok(choice) => {
751                    tracing::info!(backend = ?choice, "backend detected");
752                    choice
753                }
754                Err(msg) => {
755                    tracing::error!("{msg}");
756                    self.client.show_message(MessageType::ERROR, &msg).await;
757                    return;
758                }
759            };
760
761            match backend_choice {
762                BackendChoice::Bacon => {
763                    let mut config = BaconOptions::default();
764                    if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
765                        && let Err(e) = config.update_from_json_obj(bacon_obj)
766                    {
767                        tracing::error!("invalid bacon configuration: {e}");
768                        self.client
769                            .show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
770                            .await;
771                    }
772
773                    if config.validate_preferences {
774                        if let Err(e) = Bacon::validate_preferences(
775                            &config.run_in_background_command,
776                            config.create_preferences_file,
777                        )
778                        .await
779                        {
780                            tracing::error!("{e}");
781                            self.client.show_message(MessageType::ERROR, e).await;
782                        }
783                    } else {
784                        tracing::warn!("skipping validation of bacon preferences, validateBaconPreferences is false");
785                    }
786
787                    let proj_root = state.project_root.clone();
788                    let shutdown_token = CancellationToken::new();
789                    let command_handle = if config.run_in_background {
790                        let mut current_dir = None;
791                        if let Ok(cwd) = env::current_dir() {
792                            current_dir = Self::find_git_root_directory(&cwd).await;
793                            if let Some(dir) = &current_dir {
794                                if !dir.join("Cargo.toml").exists() {
795                                    current_dir = proj_root;
796                                }
797                            } else {
798                                current_dir = proj_root;
799                            }
800                        }
801
802                        match Bacon::run_in_background(
803                            &config.run_in_background_command,
804                            &config.run_in_background_command_args,
805                            current_dir.as_ref(),
806                            shutdown_token.clone(),
807                        )
808                        .await
809                        {
810                            Ok(command) => {
811                                tracing::info!("bacon was started successfully and is running in the background");
812                                Some(command)
813                            }
814                            Err(e) => {
815                                tracing::error!("{e}");
816                                self.client.show_message(MessageType::ERROR, e).await;
817                                None
818                            }
819                        }
820                    } else {
821                        tracing::warn!("skipping background bacon startup, runBaconInBackground is false");
822                        None
823                    };
824
825                    let task_state = self.state.clone();
826                    let task_client = self.client.clone();
827                    state.backend = Some(BackendRuntime::Bacon {
828                        config,
829                        runtime: BaconRuntime {
830                            shutdown_token,
831                            open_files: HashSet::new(),
832                            command_handle,
833                            sync_files_handle: tokio::task::spawn(Self::synchronize_diagnostics(
834                                task_state,
835                                task_client,
836                            )),
837                            diagnostics_version: 0,
838                        },
839                    });
840                    tracing::info!("bacon backend initialized");
841                }
842                BackendChoice::Cargo => {
843                    let mut config = CargoOptions::default();
844                    // `update_on_insert` is sourced exclusively from
845                    // `initialization_options.cargo.updateOnInsert` (read in
846                    // `initialize` and stashed on `State`). The static
847                    // `textDocument/didChange` capability has to be decided
848                    // before workspace settings even arrive, so the runtime
849                    // gate has to come from the same place.
850                    if state.init_update_on_insert {
851                        config.update_on_insert = true;
852                    }
853                    if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
854                        && let Err(e) = config.update_from_json_obj(cargo_obj)
855                    {
856                        tracing::error!("invalid cargo configuration: {e}");
857                        self.client
858                            .show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
859                            .await;
860                    }
861                    if let Err(e) = Self::init_cargo_backend(&mut state, config) {
862                        tracing::error!("{e}");
863                        drop(state);
864                        self.client.show_message(MessageType::ERROR, e).await;
865                        return;
866                    }
867                    drop(state);
868                }
869            }
870        } else {
871            let current_choice = match &state.backend {
872                Some(BackendRuntime::Bacon { .. }) => BackendChoice::Bacon,
873                Some(BackendRuntime::Cargo { .. }) => BackendChoice::Cargo,
874                None => unreachable!("backend is Some in this branch"),
875            };
876            let desired = match Self::detect_backend(values) {
877                Ok(choice) => choice,
878                Err(err) => {
879                    tracing::error!("invalid backend configuration on reload: {err}");
880                    self.client.show_message(MessageType::ERROR, &err).await;
881                    return;
882                }
883            };
884
885            if desired != current_choice {
886                let msg = "Backend cannot be changed while the server is running. \
887                           Restart the server to switch backends.";
888                tracing::error!("{msg}");
889                self.client.show_message(MessageType::ERROR, msg).await;
890                return;
891            }
892
893            let project_root = state.project_root.clone();
894            let init_update_on_insert = state.init_update_on_insert;
895            match &mut state.backend {
896                Some(BackendRuntime::Cargo { config, runtime }) => {
897                    config.reset();
898                    if init_update_on_insert {
899                        config.update_on_insert = true;
900                    }
901                    if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
902                        && let Err(e) = config.update_from_json_obj(cargo_obj)
903                    {
904                        tracing::error!("invalid cargo configuration: {e}");
905                        self.client
906                            .show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
907                            .await;
908                    }
909                    if let Some(root) = project_root {
910                        runtime.build_folder = root;
911                    }
912                    tracing::debug!("cargo configuration updated");
913                }
914                Some(BackendRuntime::Bacon { config, .. }) => {
915                    config.reset();
916                    if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
917                        && let Err(e) = config.update_from_json_obj(bacon_obj)
918                    {
919                        tracing::error!("invalid bacon configuration: {e}");
920                        self.client
921                            .show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
922                            .await;
923                    }
924                    tracing::debug!("bacon configuration updated");
925                }
926                None => unreachable!("backend is Some in this branch"),
927            }
928        }
929    }
930
931    fn init_cargo_backend(state: &mut RwLockWriteGuard<'_, State>, config: CargoOptions) -> Result<(), String> {
932        let build_folder = match &state.project_root {
933            Some(root) => root.clone(),
934            None => match env::current_dir() {
935                Ok(cwd) => {
936                    tracing::warn!(
937                        "no Cargo project root detected; falling back to current working directory: {}",
938                        cwd.display()
939                    );
940                    cwd
941                }
942                Err(e) => {
943                    return Err(format!(
944                        "cargo backend cannot start: no project root detected and current working \
945                         directory is unavailable ({e}). Open a folder containing a Cargo.toml and \
946                         restart the server."
947                    ));
948                }
949            },
950        };
951        let runtime = CargoRuntime {
952            build_folder,
953            ..CargoRuntime::default()
954        };
955        tracing::info!(build_folder = ?runtime.build_folder, "cargo backend initialized");
956        state.backend = Some(BackendRuntime::Cargo { config, runtime });
957        Ok(())
958    }
959
960    /// Trigger a save-time cargo run against the real workspace.
961    async fn publish_cargo_diagnostics(&self) {
962        self.publish_cargo_diagnostics_inner(None).await;
963    }
964
965    /// Trigger a live "as you type" cargo run against the hardlinked shadow
966    /// workspace. Builds the shadow on first call. Returns silently if
967    /// `update_on_insert` isn't on or the shadow can't be built.
968    pub(crate) async fn publish_cargo_diagnostics_live(&self) {
969        let live_on = {
970            let state = self.state.read().await;
971            matches!(
972                &state.backend,
973                Some(BackendRuntime::Cargo { config, .. }) if config.update_on_insert
974            )
975        };
976        if !live_on {
977            return;
978        }
979        let Some(shadow) = self.ensure_shadow_built().await else {
980            return;
981        };
982        let ctx = LiveCheckContext {
983            shadow_root: shadow.shadow_root().to_path_buf(),
984            shadow_target_dir: shadow.target_dir().to_path_buf(),
985            real_root: shadow.real_root().to_path_buf(),
986        };
987        self.publish_cargo_diagnostics_inner(Some(&ctx)).await;
988    }
989
990    async fn publish_cargo_diagnostics_inner(&self, live: Option<&LiveCheckContext>) {
991        tracing::info!(live = live.is_some(), "starting cargo diagnostics run");
992        let mut guard = self.state.write().await;
993        let project_root = guard.project_root.clone();
994        let related_information_supported = guard.related_information_supported;
995
996        let Some(BackendRuntime::Cargo { config, runtime }) = &mut guard.backend else {
997            return;
998        };
999        let use_related_information = !config
1000            .separate_child_diagnostics
1001            .unwrap_or(!related_information_supported);
1002        let cargo_command = config.command.clone();
1003        let mut cargo_env = config.env.clone();
1004        let mut cmd_args = config.build_command_args();
1005        let publish_mode = config.publish_mode;
1006        let clear_diagnostics_on_check = config.clear_diagnostics_on_check;
1007        let build_folder = match live {
1008            Some(ctx) => {
1009                cmd_args.push(format!("--target-dir={}", ctx.shadow_target_dir.display()));
1010                // `--remap-path-prefix` makes rustc emit diagnostic spans with
1011                // the real workspace path in place of the shadow path, so the
1012                // editor opens the user's source file instead of a target/ copy.
1013                let rustflags = format!(
1014                    "--remap-path-prefix={}={}",
1015                    ctx.shadow_root.display(),
1016                    ctx.real_root.display()
1017                );
1018                // Honor any RUSTFLAGS the user already set in their config.
1019                if let Some(slot) = cargo_env.iter_mut().find(|(k, _)| k == "RUSTFLAGS") {
1020                    slot.1.push(' ');
1021                    slot.1.push_str(&rustflags);
1022                } else {
1023                    cargo_env.push(("RUSTFLAGS".to_string(), rustflags));
1024                }
1025                ctx.shadow_root.clone()
1026            }
1027            None => runtime.build_folder.clone(),
1028        };
1029        runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
1030        runtime.last_run_started = Some(Instant::now());
1031        let version = runtime.diagnostics_version;
1032        let refresh_interval = config.refresh_interval_seconds;
1033
1034        let cancel_token = match publish_mode {
1035            PublishMode::CancelRunning => {
1036                runtime.cancel_token.cancel();
1037                runtime.cancel_token = CancellationToken::new();
1038                runtime.cancel_token.clone()
1039            }
1040            PublishMode::QueueIfRunning => match runtime.run_state {
1041                CargoRunState::Running | CargoRunState::RunningPending => {
1042                    runtime.run_state = CargoRunState::RunningPending;
1043                    tracing::debug!("cargo already running, marking pending");
1044                    drop(guard);
1045                    return;
1046                }
1047                CargoRunState::Idle => {
1048                    runtime.run_state = CargoRunState::Running;
1049                    runtime.cancel_token.clone()
1050                }
1051            },
1052        };
1053
1054        // Drain the URIs we need to clear into a local Vec, then drop the
1055        // state lock BEFORE doing any LSP IO. Holding the write guard across
1056        // awaited publishes blocks every other handler (did_open, did_close,
1057        // codeAction, …) for the duration of the round-trips.
1058        let files_to_clear: Vec<Uri> = if clear_diagnostics_on_check {
1059            runtime.files_with_diags.drain().collect()
1060        } else {
1061            Vec::new()
1062        };
1063
1064        drop(guard);
1065
1066        for file in files_to_clear {
1067            self.client.publish_diagnostics(file, vec![], Some(version)).await;
1068        }
1069
1070        let token = ProgressToken::Number(version);
1071        let progress = self
1072            .client
1073            .progress(token, "checking")
1074            .with_message(format!("cargo {cargo_command}"))
1075            .with_percentage(0)
1076            .begin()
1077            .await;
1078
1079        let (tx, rx) = flume::unbounded();
1080
1081        let cargo_future = Cargo::cargo_diagnostics(
1082            cmd_args,
1083            &cargo_env,
1084            project_root.as_ref(),
1085            &build_folder,
1086            use_related_information,
1087            &progress,
1088            tx,
1089        );
1090
1091        let consumer_client = self.client.clone();
1092        let diagnostic_consumer = async move {
1093            // Per-URI bucket: the diagnostics to publish, a `seen` set keyed by
1094            // (range, severity, message) for O(1) dedup, and a dirty flag for
1095            // partial publishes during the cargo run.
1096            let mut diagnostics_map = HashMap::<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>::new();
1097
1098            enum AccumulateResult {
1099                Closed,
1100                NewDiagnostic,
1101                Duplicate,
1102            }
1103
1104            fn accumulate_diagnostics(
1105                recv_result: Result<(Uri, Diagnostic), RecvError>,
1106                diagnostics_map: &mut HashMap<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>,
1107            ) -> AccumulateResult {
1108                let Ok((url, diagnostic)) = recv_result else {
1109                    return AccumulateResult::Closed;
1110                };
1111                let (diagnostics, seen, dirty) = diagnostics_map.entry(url).or_default();
1112                if seen.insert(diag_key(&diagnostic)) {
1113                    diagnostics.push(diagnostic);
1114                    *dirty = true;
1115                    AccumulateResult::NewDiagnostic
1116                } else {
1117                    AccumulateResult::Duplicate
1118                }
1119            }
1120
1121            if let Some(refresh_interval) = refresh_interval {
1122                // The very first diagnostic of a run is published immediately
1123                // — waiting up to `refresh_interval` for the editor to show
1124                // *something* is the most user-visible source of latency. After
1125                // the first publish, subsequent diagnostics accumulate and are
1126                // flushed every `refresh_interval`.
1127                let mut first_published = false;
1128                let mut t = std::time::Instant::now();
1129                loop {
1130                    let mut got_new = false;
1131                    tokio::select! {
1132                        result = rx.recv_async() => {
1133                            match accumulate_diagnostics(result, &mut diagnostics_map) {
1134                                AccumulateResult::Closed => break,
1135                                AccumulateResult::NewDiagnostic => got_new = true,
1136                                AccumulateResult::Duplicate => {}
1137                            }
1138                        }
1139                        _ = tokio::time::sleep_until(tokio::time::Instant::from_std(t + refresh_interval)) => {}
1140                    }
1141
1142                    let publish_first = got_new && !first_published;
1143                    if publish_first || t.elapsed() >= refresh_interval {
1144                        for (url, (diagnostics, _seen, dirty)) in diagnostics_map.iter_mut() {
1145                            if *dirty {
1146                                consumer_client
1147                                    .publish_diagnostics(url.clone(), diagnostics.clone(), Some(version))
1148                                    .await;
1149                                *dirty = false;
1150                            }
1151                        }
1152                        if publish_first {
1153                            tracing::debug!("first diagnostic published; switching to refresh-interval cadence");
1154                            first_published = true;
1155                        }
1156                        t = std::time::Instant::now();
1157                    }
1158                }
1159            } else {
1160                loop {
1161                    if matches!(
1162                        accumulate_diagnostics(rx.recv_async().await, &mut diagnostics_map),
1163                        AccumulateResult::Closed
1164                    ) {
1165                        break;
1166                    }
1167                }
1168            }
1169
1170            diagnostics_map
1171        };
1172
1173        let consumer_handle = tokio::spawn(diagnostic_consumer);
1174
1175        let result = tokio::select! {
1176            result = cargo_future => {
1177                result.map(|_| false)
1178            },
1179            () = cancel_token.cancelled() => {
1180                tracing::info!("cargo run cancelled by newer request");
1181                Ok(true)
1182            }
1183        };
1184
1185        let was_cancelled = match result {
1186            Ok(t) => t,
1187            Err(error) => {
1188                // We know there wont be any diagnostics as they way we detect cargo errors is
1189                // if it exists with non 0 exit code and no diagnostics were found
1190                tracing::error!(?error, "error building diagnostics");
1191                progress.finish().await;
1192                let _ = consumer_handle.await;
1193                self.client.log_message(MessageType::ERROR, format!("{error}")).await;
1194                self.client.show_message(MessageType::ERROR, format!("{error}")).await;
1195                return;
1196            }
1197        };
1198
1199        if was_cancelled {
1200            // The newer run that triggered cancellation owns publishing. Touching
1201            // files_with_diags or publishing partial results here would race with
1202            // it and could push stale diagnostics on top of correct ones.
1203            let _ = consumer_handle.await;
1204            progress.finish_with_message("cancelled by user").await;
1205            return;
1206        }
1207
1208        tracing::info!("cargo run finished, collecting diagnostics");
1209
1210        let mut diagnostics = match consumer_handle.await {
1211            Ok(d) => d,
1212            Err(error) => {
1213                tracing::error!(?error, "diagnostics fetching task panicked");
1214                progress.finish().await;
1215                self.client.log_message(MessageType::ERROR, format!("{error}")).await;
1216                self.client.show_message(MessageType::ERROR, format!("{error}")).await;
1217                return;
1218            }
1219        };
1220
1221        let mut state = self.state.write().await;
1222        let Some(BackendRuntime::Cargo {
1223            config,
1224            runtime: cargo_rt,
1225        }) = &mut state.backend
1226        else {
1227            // This should be impossible to land here, if we do there a logic error
1228            tracing::error!("backend changed during cargo run");
1229            return;
1230        };
1231        let publish_mode = config.publish_mode;
1232
1233        // In CancelRunning mode a newer run may have started after our cargo
1234        // process finished but before we reached this point. If so our results
1235        // are stale — skip publishing so we don't overwrite the newer run's
1236        // output with old data.
1237        if let PublishMode::CancelRunning = publish_mode
1238            && version != cargo_rt.diagnostics_version
1239        {
1240            tracing::info!(
1241                version,
1242                current = cargo_rt.diagnostics_version,
1243                "skipping stale publish"
1244            );
1245            progress.finish_with_message("superseded by newer run").await;
1246            return;
1247        }
1248
1249        for file in cargo_rt.files_with_diags.drain() {
1250            // Add empty diagnostics so that it get cleared later
1251            let _ = diagnostics.entry(file).or_insert((vec![], HashSet::new(), true));
1252        }
1253
1254        let mut num_warnings = 0;
1255        let mut num_errors = 0;
1256        for (uri, (diagnostics, _seen, is_dirty)) in diagnostics.into_iter() {
1257            tracing::debug!(uri = uri.to_string(), "sent {} cargo diagnostics", diagnostics.len());
1258            for diagnostic in &diagnostics {
1259                match diagnostic.severity {
1260                    Some(DiagnosticSeverity::ERROR) => num_errors += 1,
1261                    Some(DiagnosticSeverity::WARNING) => num_warnings += 1,
1262                    Some(_) | None => {}
1263                }
1264            }
1265            if !diagnostics.is_empty() {
1266                let _ = cargo_rt.files_with_diags.insert(uri.clone());
1267            }
1268            if is_dirty {
1269                self.client.publish_diagnostics(uri, diagnostics, Some(version)).await;
1270            }
1271        }
1272        let message = format!("done, errors: {num_errors}, warnings: {num_warnings}");
1273        progress.finish_with_message(message).await;
1274
1275        if let PublishMode::QueueIfRunning = publish_mode {
1276            match cargo_rt.run_state {
1277                CargoRunState::RunningPending => {
1278                    cargo_rt.run_state = CargoRunState::Idle;
1279                    drop(state);
1280                    tracing::info!("re-running cargo after queued request");
1281                    Box::pin(self.publish_cargo_diagnostics()).await;
1282                }
1283                _ => {
1284                    cargo_rt.run_state = CargoRunState::Idle;
1285                    drop(state);
1286                }
1287            }
1288        }
1289    }
1290
1291    /// Lazy-build (or fetch) the live shadow workspace. Returns `None` if the
1292    /// project root isn't known or the build fails — callers should treat
1293    /// that as "skip this live update", not as a hard error.
1294    pub(crate) async fn ensure_shadow_built(&self) -> Option<ShadowWorkspace> {
1295        // Fast path: shadow already built.
1296        {
1297            let state = self.state.read().await;
1298            if let Some(BackendRuntime::Cargo { runtime, .. }) = &state.backend
1299                && let Some(shadow) = &runtime.shadow
1300            {
1301                return Some(shadow.clone());
1302            }
1303        }
1304
1305        let project_root = {
1306            let state = self.state.read().await;
1307            state.project_root.clone()
1308        };
1309        let Some(root) = project_root else {
1310            tracing::warn!("updateOnInsert: no project root; cannot build live shadow");
1311            return None;
1312        };
1313
1314        tracing::info!(root = ?root, "updateOnInsert: building live shadow workspace");
1315        // Surface this to the user — it's a one-time, multi-second cost
1316        // (tree walk + hardlink fan-out + cold cargo target dir) and
1317        // without a heads-up they'd just see the editor go quiet on the
1318        // first keystroke.
1319        self.client
1320            .show_message(
1321                MessageType::INFO,
1322                "bacon-ls: building live diagnostics shadow workspace (first run only)…",
1323            )
1324            .await;
1325        let shadow = match ShadowWorkspace::build(root).await {
1326            Ok(s) => s,
1327            Err(e) => {
1328                tracing::error!("updateOnInsert: failed to build shadow: {e}");
1329                self.client
1330                    .show_message(
1331                        MessageType::ERROR,
1332                        format!("bacon-ls: failed to build live shadow workspace: {e}"),
1333                    )
1334                    .await;
1335                return None;
1336            }
1337        };
1338
1339        // Stash; if a parallel did_change raced us and built one too, ours
1340        // overwrites — both reflect the same on-disk tree.
1341        let mut state = self.state.write().await;
1342        if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend {
1343            runtime.shadow = Some(shadow.clone());
1344        }
1345        drop(state);
1346        // Quieter signal that the shadow is ready — goes to the LSP trace
1347        // pane rather than popping a second toast.
1348        self.client
1349            .log_message(
1350                MessageType::INFO,
1351                "bacon-ls: live diagnostics shadow ready; subsequent edits will be checked as you type.",
1352            )
1353            .await;
1354        Some(shadow)
1355    }
1356
1357    /// Apply a dirty buffer (from `did_change`) to the shadow workspace.
1358    /// Tracks the URI in `dirty_files` so we can revert it later via
1359    /// `restore_shadow_link_if_dirty` on `did_save` / `did_close`.
1360    pub(crate) async fn live_update_dirty(&self, uri: Uri, content: String) {
1361        let Some(real_path_cow) = uri.to_file_path() else {
1362            tracing::warn!(uri = uri.as_str(), "updateOnInsert: did_change uri is not a file path");
1363            return;
1364        };
1365        let real_path = real_path_cow.into_owned();
1366
1367        let Some(shadow) = self.ensure_shadow_built().await else {
1368            tracing::warn!("updateOnInsert: shadow workspace not available; skipping live update");
1369            return;
1370        };
1371        if let Err(e) = shadow.write_dirty(&real_path, &content).await {
1372            tracing::warn!(path = ?real_path, ?e, "updateOnInsert: shadow write failed (file outside workspace?)");
1373            return;
1374        }
1375
1376        let debounce = {
1377            let mut state = self.state.write().await;
1378            let Some(BackendRuntime::Cargo { config, runtime }) = &mut state.backend else {
1379                return;
1380            };
1381            runtime.dirty_files.insert(uri.clone());
1382            config.update_on_insert_debounce
1383        };
1384
1385        tracing::info!(
1386            uri = uri.as_str(),
1387            debounce_ms = debounce.as_millis() as u64,
1388            "updateOnInsert: shadow updated, scheduling live cargo run"
1389        );
1390        self.schedule_live_run(debounce).await;
1391    }
1392
1393    /// Schedule (or reschedule) a live cargo run to fire after `delay` of
1394    /// idle time. Cancels any previously-scheduled live trigger so a burst of
1395    /// keystrokes coalesces into a single run.
1396    pub(crate) async fn schedule_live_run(&self, delay: Duration) {
1397        let mut state = self.state.write().await;
1398        let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else {
1399            return;
1400        };
1401        if let Some(prev) = runtime.live_debounce.take() {
1402            prev.abort();
1403        }
1404        let bacon = self.clone();
1405        runtime.live_debounce = Some(tokio::spawn(async move {
1406            tokio::time::sleep(delay).await;
1407            // The sleep is over: from here this task IS the live cargo run,
1408            // not a pending trigger. Drop our own handle before starting the
1409            // run so a later schedule_live_run / cancel_live_debounce can no
1410            // longer abort() us mid-run — aborting a running task kills the
1411            // cargo child and orphans its progress token (a `begin` with no
1412            // `end`), which leaves the client's "checking" status spinning
1413            // forever. In-flight runs are instead superseded by the
1414            // CancelRunning publish mode, which finishes the token properly.
1415            //
1416            // The take() is best-effort: if a newer trigger already replaced
1417            // our handle (and aborted us) while we held nothing, we die at
1418            // this .await before touching anything. If we win the race and
1419            // null out a newer handle, the worst case is one extra cargo run
1420            // that CancelRunning immediately supersedes — never a leak.
1421            {
1422                let mut state = bacon.state.write().await;
1423                if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend {
1424                    let _ = runtime.live_debounce.take();
1425                }
1426            }
1427            bacon.publish_cargo_diagnostics_live().await;
1428        }));
1429    }
1430
1431    /// Cancel any pending debounced live trigger. Called on `did_save` so
1432    /// the on-save cargo run (against the real workspace) is the canonical
1433    /// one and a soon-to-be-stale live run doesn't race it.
1434    pub(crate) async fn cancel_live_debounce(&self) {
1435        let mut state = self.state.write().await;
1436        if let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend
1437            && let Some(handle) = runtime.live_debounce.take()
1438        {
1439            handle.abort();
1440        }
1441    }
1442
1443    /// On `did_save` / `did_close`, replace the (possibly dirty) shadow file
1444    /// with the on-disk version, and forget the URI. `discard` selects the
1445    /// did_close semantics: restore by copy (fresh mtime, so cargo actually
1446    /// re-checks the reverted content instead of replaying the dirty build's
1447    /// cached warnings) rather than by hardlink.
1448    /// Returns true when the file actually had a dirty override to restore.
1449    pub(crate) async fn restore_shadow_link_if_dirty(&self, uri: &Uri, discard: bool) -> bool {
1450        let (shadow, real_path) = {
1451            let mut state = self.state.write().await;
1452            let Some(BackendRuntime::Cargo { runtime, .. }) = &mut state.backend else {
1453                return false;
1454            };
1455            if !runtime.dirty_files.remove(uri) {
1456                return false;
1457            }
1458            let Some(shadow) = runtime.shadow.clone() else {
1459                return false;
1460            };
1461            let Some(path_cow) = uri.to_file_path() else {
1462                return false;
1463            };
1464            (shadow, path_cow.into_owned())
1465        };
1466        let restored = if discard {
1467            shadow.restore_copy(&real_path).await
1468        } else {
1469            shadow.restore_link(&real_path).await
1470        };
1471        if let Err(e) = restored {
1472            tracing::warn!(path = ?real_path, ?e, "updateOnInsert: failed to restore shadow link");
1473        }
1474        true
1475    }
1476
1477    async fn publish_bacon_diagnostics(&self, uri: &Uri) {
1478        let mut guard = self.state.write().await;
1479        let workspace_folders = guard.workspace_folders.clone();
1480
1481        let Some(BackendRuntime::Bacon { config, runtime }) = &mut guard.backend else {
1482            return;
1483        };
1484        tracing::info!(uri = uri.to_string(), "publish bacon diagnostics");
1485        let locations_file_name = config.locations_file.clone();
1486        runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
1487        let version = runtime.diagnostics_version;
1488        drop(guard);
1489        Bacon::publish_diagnostics(
1490            &self.client,
1491            uri,
1492            &locations_file_name,
1493            workspace_folders.as_deref(),
1494            version,
1495        )
1496        .await;
1497    }
1498
1499    async fn synchronize_diagnostics(state: Arc<RwLock<State>>, client: Arc<Client>) {
1500        Bacon::synchronize_diagnostics(state, client).await;
1501    }
1502}
1503
1504#[cfg(test)]
1505mod tests {
1506    use super::*;
1507
1508    #[test]
1509    fn test_can_configure_tracing() {
1510        // Direct the test's log file into a tempdir so we don't clobber the
1511        // developer's `bacon-ls.log` in the workspace root (which is what
1512        // `cargo run` / a live editor session writes to).
1513        let tmp = tempfile::tempdir().expect("tempdir");
1514        let log_path = tmp.path().join("bacon-ls.log");
1515        BaconLs::configure_tracing(Some("info".to_string()), Some(&log_path));
1516    }
1517
1518    #[test]
1519    fn test_path_to_file_uri_plain_ascii() {
1520        let uri = path_to_file_uri("/home/me/src/lib.rs");
1521        assert_eq!(uri, "file:///home/me/src/lib.rs");
1522        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1523        assert_eq!(parsed.path().as_str(), "/home/me/src/lib.rs");
1524    }
1525
1526    #[test]
1527    fn test_path_to_file_uri_escapes_space_and_hash_and_percent() {
1528        let uri = path_to_file_uri("/home/me/My Projects/tests#1/file%.rs");
1529        assert_eq!(uri, "file:///home/me/My%20Projects/tests%231/file%25.rs");
1530        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1531        // Uri preserves the encoded form on the wire; clients are responsible
1532        // for decoding. We only need to confirm the parse succeeds.
1533        assert_eq!(parsed.path().as_str(), "/home/me/My%20Projects/tests%231/file%25.rs");
1534    }
1535
1536    #[test]
1537    fn test_path_to_file_uri_preserves_path_separators() {
1538        // The `/` separator must NOT be encoded, or clients can't recognize
1539        // segment structure.
1540        let uri = path_to_file_uri("/a/b/c");
1541        assert_eq!(uri, "file:///a/b/c");
1542    }
1543
1544    #[test]
1545    fn test_path_to_file_uri_relative_path_preserves_segments() {
1546        // Cargo emits relative paths (e.g. "src/lib.rs") in JSON output. The
1547        // current `deserialize_url` hack turns those into URIs with the first
1548        // segment as "host" — percent-encoding must not break that.
1549        let uri = path_to_file_uri("src/lib.rs");
1550        assert_eq!(uri, "file://src/lib.rs");
1551        let parsed = uri.parse::<Uri>().expect("must parse as Uri");
1552        assert_eq!(
1553            parsed.authority().map(|a| a.host().to_string()),
1554            Some("src".to_string())
1555        );
1556        assert_eq!(parsed.path().as_str(), "/lib.rs");
1557    }
1558
1559    #[test]
1560    fn test_cancel_mode_replaces_token() {
1561        let original = CancellationToken::new();
1562        let token = original.clone();
1563        token.cancel();
1564        assert!(original.is_cancelled());
1565        let new_token = CancellationToken::new();
1566        assert!(!new_token.is_cancelled());
1567    }
1568
1569    #[test]
1570    fn test_detect_backend_explicit_cargo() {
1571        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo"}"#).unwrap();
1572        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1573    }
1574
1575    #[test]
1576    fn test_detect_backend_explicit_bacon() {
1577        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "bacon"}"#).unwrap();
1578        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
1579    }
1580
1581    #[test]
1582    fn test_detect_backend_invalid_value() {
1583        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "invalid"}"#).unwrap();
1584        assert!(BaconLs::detect_backend(&values).is_err());
1585    }
1586
1587    #[test]
1588    fn test_detect_backend_infer_from_cargo_key() {
1589        let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {"command": "check"}}"#).unwrap();
1590        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1591    }
1592
1593    #[test]
1594    fn test_detect_backend_infer_from_bacon_key() {
1595        let values: Map<String, Value> =
1596            serde_json::from_str(r#"{"bacon": {"locationsFile": ".bacon-locations"}}"#).unwrap();
1597        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
1598    }
1599
1600    #[test]
1601    fn test_detect_backend_both_keys_error() {
1602        let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {}, "bacon": {}}"#).unwrap();
1603        assert!(BaconLs::detect_backend(&values).is_err());
1604    }
1605
1606    #[test]
1607    fn test_detect_backend_no_keys_defaults_to_cargo() {
1608        let values: Map<String, Value> = serde_json::from_str(r#"{}"#).unwrap();
1609        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1610    }
1611
1612    #[test]
1613    fn test_detect_backend_explicit_overrides_keys() {
1614        let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo", "bacon": {}}"#).unwrap();
1615        assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
1616    }
1617
1618    #[test]
1619    fn test_cargo_options_build_args_default() {
1620        let args = CargoOptions::default().build_command_args();
1621        assert_eq!(args, vec!["check", "--message-format=json-diagnostic-rendered-ansi"]);
1622    }
1623
1624    #[test]
1625    fn test_cargo_options_build_args_with_features() {
1626        let opts = CargoOptions {
1627            features: CargoFeatures::List(vec!["a".into(), "b".into(), "c".into()]),
1628            ..CargoOptions::default()
1629        };
1630        let args = opts.build_command_args();
1631        assert_eq!(
1632            args,
1633            vec![
1634                "check",
1635                "--message-format=json-diagnostic-rendered-ansi",
1636                "--features",
1637                "a,b,c"
1638            ]
1639        );
1640    }
1641
1642    #[test]
1643    fn test_cargo_options_build_args_single_feature() {
1644        let opts = CargoOptions {
1645            features: CargoFeatures::List(vec!["only".into()]),
1646            ..CargoOptions::default()
1647        };
1648        let args = opts.build_command_args();
1649        assert_eq!(
1650            args,
1651            vec![
1652                "check",
1653                "--message-format=json-diagnostic-rendered-ansi",
1654                "--features",
1655                "only"
1656            ]
1657        );
1658    }
1659
1660    #[test]
1661    fn test_cargo_options_build_args_with_all_features() {
1662        let opts = CargoOptions {
1663            features: CargoFeatures::All,
1664            ..CargoOptions::default()
1665        };
1666        let args = opts.build_command_args();
1667        assert_eq!(
1668            args,
1669            vec![
1670                "check",
1671                "--message-format=json-diagnostic-rendered-ansi",
1672                "--all-features",
1673            ]
1674        );
1675    }
1676
1677    #[test]
1678    fn test_cargo_options_build_args_with_package_and_extras() {
1679        let opts = CargoOptions {
1680            command: "clippy".into(),
1681            package: Some("my-crate".into()),
1682            extra_command_args: vec!["--workspace".into(), "--all-targets".into()],
1683            ..CargoOptions::default()
1684        };
1685        let args = opts.build_command_args();
1686        assert_eq!(
1687            args,
1688            vec![
1689                "clippy",
1690                "--message-format=json-diagnostic-rendered-ansi",
1691                "-p",
1692                "my-crate",
1693                "--workspace",
1694                "--all-targets",
1695            ]
1696        );
1697    }
1698
1699    #[test]
1700    fn test_cargo_options_update_from_json_full_roundtrip() {
1701        let mut opts = CargoOptions::default();
1702        let json = serde_json::json!({
1703            "command": "clippy",
1704            "features": ["a", "b"],
1705            "package": "pkg",
1706            "extraArgs": ["--workspace"],
1707            "env": {"RUST_LOG": "trace"},
1708            "cancelRunning": false,
1709            "refreshIntervalSeconds": 10,
1710            "separateChildDiagnostics": true,
1711            "checkOnSave": false,
1712            "clearDiagnosticsOnCheck": true,
1713            "updateOnInsertDebounceMillis": 250,
1714        });
1715        let obj = json.as_object().unwrap();
1716        opts.update_from_json_obj(obj).expect("should parse");
1717        assert_eq!(opts.command, "clippy");
1718        assert_eq!(
1719            opts.features,
1720            CargoFeatures::List(vec!["a".to_string(), "b".to_string()])
1721        );
1722        assert_eq!(opts.package.as_deref(), Some("pkg"));
1723        assert_eq!(opts.extra_command_args, vec!["--workspace".to_string()]);
1724        assert_eq!(opts.env, vec![("RUST_LOG".into(), "trace".into())]);
1725        assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
1726        assert_eq!(opts.refresh_interval_seconds, Some(Duration::from_secs(10)));
1727        assert_eq!(opts.separate_child_diagnostics, Some(true));
1728        assert!(!opts.check_on_save);
1729        assert!(opts.clear_diagnostics_on_check);
1730        assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(250));
1731    }
1732
1733    #[test]
1734    fn test_cargo_options_update_on_insert_defaults_off() {
1735        let opts = CargoOptions::default();
1736        assert!(!opts.update_on_insert);
1737        assert_eq!(opts.update_on_insert_debounce, Duration::from_millis(500));
1738    }
1739
1740    #[test]
1741    fn test_cargo_options_update_on_insert_debounce_rejects_negative() {
1742        let mut opts = CargoOptions::default();
1743        let json = serde_json::json!({"updateOnInsertDebounceMillis": -50});
1744        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1745    }
1746
1747    #[test]
1748    fn test_cargo_options_update_from_json_refresh_null_means_no_partial() {
1749        let mut opts = CargoOptions::default();
1750        let json = serde_json::json!({"refreshIntervalSeconds": null});
1751        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1752        assert_eq!(opts.refresh_interval_seconds, None);
1753    }
1754
1755    #[test]
1756    fn test_cargo_options_update_from_json_refresh_negative_means_no_partial() {
1757        let mut opts = CargoOptions::default();
1758        let json = serde_json::json!({"refreshIntervalSeconds": -1});
1759        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1760        assert_eq!(opts.refresh_interval_seconds, None);
1761    }
1762
1763    #[test]
1764    fn test_cargo_options_update_from_json_rejects_wrong_type() {
1765        let mut opts = CargoOptions::default();
1766        let json = serde_json::json!({"command": 42});
1767        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1768    }
1769
1770    #[test]
1771    fn test_cargo_options_update_from_json_partial_leaves_others_unchanged() {
1772        let mut opts = CargoOptions {
1773            command: "clippy".into(),
1774            ..CargoOptions::default()
1775        };
1776        let json = serde_json::json!({"checkOnSave": false});
1777        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1778        assert_eq!(opts.command, "clippy");
1779        assert!(!opts.check_on_save);
1780    }
1781
1782    #[test]
1783    fn test_cargo_options_reset_restores_defaults() {
1784        let mut opts = CargoOptions {
1785            command: "clippy".into(),
1786            features: CargoFeatures::List(vec!["foo".into()]),
1787            check_on_save: false,
1788            ..CargoOptions::default()
1789        };
1790        opts.reset();
1791        let defaults = CargoOptions::default();
1792        assert_eq!(opts.command, defaults.command);
1793        assert_eq!(opts.features, defaults.features);
1794        assert_eq!(opts.check_on_save, defaults.check_on_save);
1795    }
1796
1797    #[test]
1798    fn test_bacon_options_update_from_json_full_roundtrip() {
1799        let mut opts = BaconOptions::default();
1800        let json = serde_json::json!({
1801            "locationsFile": "custom.locations",
1802            "runInBackground": false,
1803            "runInBackgroundCommand": "/usr/local/bin/bacon",
1804            "runInBackgroundCommandArguments": "--headless -j custom",
1805            "validatePreferences": false,
1806            "createPreferencesFile": false,
1807            "synchronizeAllOpenFilesWaitMillis": 500,
1808            "updateOnSave": false,
1809            "updateOnSaveWaitMillis": 250,
1810        });
1811        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1812        assert_eq!(opts.locations_file, "custom.locations");
1813        assert!(!opts.run_in_background);
1814        assert_eq!(opts.run_in_background_command, "/usr/local/bin/bacon");
1815        assert_eq!(opts.run_in_background_command_args, "--headless -j custom");
1816        assert!(!opts.validate_preferences);
1817        assert!(!opts.create_preferences_file);
1818        assert_eq!(opts.synchronize_all_open_files_wait, Duration::from_millis(500));
1819        assert!(!opts.update_on_save);
1820        assert_eq!(opts.update_on_save_wait, Duration::from_millis(250));
1821    }
1822
1823    #[test]
1824    fn test_bacon_options_update_from_json_rejects_wrong_type() {
1825        let mut opts = BaconOptions::default();
1826        let json = serde_json::json!({"runInBackground": "yes"});
1827        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1828    }
1829
1830    #[test]
1831    fn test_bacon_options_reset_restores_defaults() {
1832        let mut opts = BaconOptions {
1833            run_in_background: false,
1834            locations_file: "foo".into(),
1835            ..BaconOptions::default()
1836        };
1837        opts.reset();
1838        let defaults = BaconOptions::default();
1839        assert_eq!(opts.run_in_background, defaults.run_in_background);
1840        assert_eq!(opts.locations_file, defaults.locations_file);
1841    }
1842
1843    #[test]
1844    fn test_correction_from_single_empty_is_remove() {
1845        let range = Range::default();
1846        let c = Correction::from_single(range, "");
1847        assert_eq!(c.label, "Remove");
1848        assert_eq!(c.edits.len(), 1);
1849        assert_eq!(c.edits[0].new_text, "");
1850    }
1851
1852    #[test]
1853    fn test_correction_from_single_nonempty_is_replace() {
1854        let range = Range::default();
1855        let c = Correction::from_single(range, "foo");
1856        assert_eq!(c.label, "Replace with: foo");
1857        assert_eq!(c.edits.len(), 1);
1858    }
1859
1860    #[test]
1861    fn test_correction_from_multi_all_empty_is_remove() {
1862        let edits = vec![
1863            CorrectionEdit {
1864                range: Range::default(),
1865                new_text: "".into(),
1866            },
1867            CorrectionEdit {
1868                range: Range::default(),
1869                new_text: "".into(),
1870            },
1871        ];
1872        let c = Correction::from_multi(edits);
1873        assert_eq!(c.label, "Remove");
1874        assert_eq!(c.edits.len(), 2);
1875    }
1876
1877    #[test]
1878    fn test_correction_from_multi_labels_by_first_nonempty() {
1879        let edits = vec![
1880            CorrectionEdit {
1881                range: Range::default(),
1882                new_text: "".into(),
1883            },
1884            CorrectionEdit {
1885                range: Range::default(),
1886                new_text: "new".into(),
1887            },
1888        ];
1889        let c = Correction::from_multi(edits);
1890        assert_eq!(c.label, "Replace with: new");
1891    }
1892
1893    #[test]
1894    fn test_severity_tag_distinguishes_levels() {
1895        assert_eq!(severity_tag(None), 0);
1896        assert_eq!(severity_tag(Some(DiagnosticSeverity::ERROR)), 1);
1897        assert_eq!(severity_tag(Some(DiagnosticSeverity::WARNING)), 2);
1898        assert_eq!(severity_tag(Some(DiagnosticSeverity::INFORMATION)), 3);
1899        assert_eq!(severity_tag(Some(DiagnosticSeverity::HINT)), 4);
1900        // All four constants must hash to distinct tags or dedup will fold
1901        // legitimately-different diagnostics together.
1902        let tags = [
1903            severity_tag(Some(DiagnosticSeverity::ERROR)),
1904            severity_tag(Some(DiagnosticSeverity::WARNING)),
1905            severity_tag(Some(DiagnosticSeverity::INFORMATION)),
1906            severity_tag(Some(DiagnosticSeverity::HINT)),
1907        ];
1908        let unique: HashSet<_> = tags.iter().collect();
1909        assert_eq!(unique.len(), tags.len());
1910    }
1911
1912    #[test]
1913    fn test_diag_key_collides_for_equal_diagnostics() {
1914        let a = Diagnostic {
1915            range: Range::default(),
1916            severity: Some(DiagnosticSeverity::ERROR),
1917            message: "hi".into(),
1918            ..Diagnostic::default()
1919        };
1920        let b = a.clone();
1921        assert_eq!(diag_key(&a), diag_key(&b));
1922    }
1923
1924    #[test]
1925    fn test_diag_key_differs_when_message_differs() {
1926        let mut a = Diagnostic {
1927            range: Range::default(),
1928            severity: Some(DiagnosticSeverity::ERROR),
1929            message: "first".into(),
1930            ..Diagnostic::default()
1931        };
1932        let b = a.clone();
1933        a.message = "second".into();
1934        assert_ne!(diag_key(&a), diag_key(&b));
1935    }
1936
1937    #[test]
1938    fn test_path_to_file_uri_empty_path() {
1939        // Empty path yields the trivial `file://` URI. Useful guard against
1940        // future regressions in the encoding helper when fed degenerate input.
1941        assert_eq!(path_to_file_uri(""), "file://");
1942    }
1943
1944    #[test]
1945    fn test_correction_from_single_label_replaces_with_text() {
1946        let c = Correction::from_single(Range::default(), "x");
1947        assert_eq!(c.label, "Replace with: x");
1948        assert_eq!(c.edits.len(), 1);
1949        assert_eq!(c.edits[0].new_text, "x");
1950    }
1951
1952    #[test]
1953    fn test_correction_from_multi_empty_edits_is_remove() {
1954        let c = Correction::from_multi(vec![]);
1955        assert_eq!(c.label, "Remove");
1956        assert!(c.edits.is_empty());
1957    }
1958
1959    #[test]
1960    fn test_cargo_options_env_roundtrip_preserves_order_in_serde_iteration() {
1961        // serde_json::Map preserves insertion order. We rely on that for
1962        // reproducible env propagation into cargo.
1963        let mut opts = CargoOptions::default();
1964        let json = serde_json::json!({
1965            "env": {"A": "1", "B": "2", "C": "3"}
1966        });
1967        opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
1968        assert_eq!(opts.env.len(), 3);
1969        let keys: Vec<_> = opts.env.iter().map(|(k, _)| k.as_str()).collect();
1970        assert_eq!(keys, vec!["A", "B", "C"]);
1971    }
1972
1973    #[test]
1974    fn test_cargo_options_update_rejects_non_object_env() {
1975        let mut opts = CargoOptions::default();
1976        let json = serde_json::json!({"env": ["A=1"]});
1977        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1978    }
1979
1980    #[test]
1981    fn test_cargo_options_update_rejects_non_string_env_value() {
1982        let mut opts = CargoOptions::default();
1983        let json = serde_json::json!({"env": {"A": 1}});
1984        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1985    }
1986
1987    #[test]
1988    fn test_cargo_options_update_rejects_non_string_feature_item() {
1989        let mut opts = CargoOptions::default();
1990        let json = serde_json::json!({"features": ["a", 2, "c"]});
1991        assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
1992    }
1993
1994    #[test]
1995    fn test_cargo_options_publish_mode_toggle_via_cancel_running() {
1996        let mut opts = CargoOptions::default();
1997        // Default is CancelRunning.
1998        assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
1999        opts.update_from_json_obj(serde_json::json!({"cancelRunning": false}).as_object().unwrap())
2000            .unwrap();
2001        assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
2002        opts.update_from_json_obj(serde_json::json!({"cancelRunning": true}).as_object().unwrap())
2003            .unwrap();
2004        assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
2005    }
2006
2007    #[test]
2008    fn test_cargo_options_separate_child_diagnostics_can_unset() {
2009        let mut opts = CargoOptions {
2010            separate_child_diagnostics: Some(true),
2011            ..CargoOptions::default()
2012        };
2013        // `as_bool()` on a non-bool returns None — and we feed that through
2014        // unchanged, so a `null` (or anything non-bool) clears the override.
2015        opts.update_from_json_obj(
2016            serde_json::json!({"separateChildDiagnostics": null})
2017                .as_object()
2018                .unwrap(),
2019        )
2020        .unwrap();
2021        assert_eq!(opts.separate_child_diagnostics, None);
2022    }
2023
2024    #[tokio::test]
2025    async fn test_find_git_root_directory_returns_none_outside_git() {
2026        let tmp = tempfile::TempDir::new().unwrap();
2027        let root = BaconLs::find_git_root_directory(tmp.path()).await;
2028        assert_eq!(root, None);
2029    }
2030
2031    #[tokio::test]
2032    async fn test_find_git_root_directory_finds_top_of_repo() {
2033        // `git -C <subdir> rev-parse --show-toplevel` should resolve to the
2034        // crate's own repo root regardless of which subdirectory we point at.
2035        let crate_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
2036        let src = crate_root.join("src");
2037        let from_subdir = BaconLs::find_git_root_directory(&src).await;
2038        assert!(from_subdir.is_some(), "src/ is inside a git repo");
2039        let from_root = BaconLs::find_git_root_directory(crate_root).await.unwrap();
2040        // Both lookups should resolve to the same toplevel.
2041        assert_eq!(from_subdir.unwrap(), from_root);
2042    }
2043
2044    #[test]
2045    fn test_init_cargo_backend_uses_existing_project_root() {
2046        let tmp = tempfile::TempDir::new().unwrap();
2047        let root = tmp.path().to_path_buf();
2048        let mut state = State {
2049            project_root: Some(root.clone()),
2050            ..State::default()
2051        };
2052        // Normally we'd hold an RwLockWriteGuard, but for this unit test we
2053        // adapt the API by going through a real lock.
2054        let lock = RwLock::new(std::mem::take(&mut state));
2055        let mut guard = lock.try_write().unwrap();
2056        BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
2057            .expect("init should succeed with explicit project root");
2058        match &guard.backend {
2059            Some(BackendRuntime::Cargo { runtime, .. }) => {
2060                assert_eq!(runtime.build_folder, root);
2061                assert_eq!(runtime.run_state, CargoRunState::Idle);
2062                assert_eq!(runtime.diagnostics_version, 0);
2063            }
2064            other => panic!("expected Cargo backend, got {other:?}"),
2065        }
2066    }
2067
2068    #[test]
2069    fn test_init_cargo_backend_falls_back_to_cwd_when_no_project_root() {
2070        let mut state = State::default();
2071        let lock = RwLock::new(std::mem::take(&mut state));
2072        let mut guard = lock.try_write().unwrap();
2073        BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
2074            .expect("init should fall back to CWD when project root is unset");
2075        match &guard.backend {
2076            Some(BackendRuntime::Cargo { runtime, .. }) => {
2077                let cwd = std::env::current_dir().unwrap();
2078                assert_eq!(runtime.build_folder, cwd, "should fall back to CWD");
2079            }
2080            other => panic!("expected Cargo backend, got {other:?}"),
2081        }
2082    }
2083
2084    #[test]
2085    fn test_cargo_options_build_args_with_env_does_not_leak_into_args() {
2086        // Sanity: env values are not added as command-line args.
2087        let opts = CargoOptions {
2088            env: vec![("A".into(), "1".into())],
2089            ..CargoOptions::default()
2090        };
2091        let args = opts.build_command_args();
2092        assert!(args.iter().all(|a| !a.contains("A=1") && !a.contains("=1")));
2093    }
2094}