Skip to main content

mcpls_core/bridge/
state.rs

1//! Document state management.
2//!
3//! Tracks open documents and their versions for LSP synchronization.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex as StdMutex};
8use std::time::{Duration, SystemTime};
9
10use lsp_types::{
11    DidChangeTextDocumentParams, DidOpenTextDocumentParams, TextDocumentContentChangeEvent,
12    TextDocumentItem, Uri, VersionedTextDocumentIdentifier,
13};
14use tokio::fs;
15use tokio::io::AsyncReadExt;
16use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
17use tokio::time::Instant;
18use url::Url;
19
20use super::lock_std;
21use crate::config::ServerId;
22use crate::error::{Error, Result};
23use crate::lsp::LspClient;
24
25/// Debounce window for re-reading a file's content when its mtime is not yet
26/// [`mtime_settled`]. The stat itself is never debounced -- only this
27/// (comparatively expensive) content re-read is rate-limited, so a burst of
28/// calls against a genuinely changed file still resyncs on the first stat
29/// that observes the new `(mtime, size)`.
30///
31/// This only bounds the *stable-but-unsettled* case: the same `(mtime,
32/// size)` observed repeatedly while that mtime is still within
33/// [`MTIME_GRANULARITY`] of "now". A file whose `(mtime, size)` changes on
34/// every stat is never debounced at all -- each such call already disagrees
35/// with the cached snapshot, so it always takes the immediate re-read path
36/// regardless of how recently the last one happened.
37const DISK_CHECK_DEBOUNCE: Duration = Duration::from_millis(250);
38
39/// Filesystem mtime granularity margin: covers FAT/exFAT (2s) and is a safe
40/// superset of HFS+/ext3/APFS (1s or finer). An mtime observed more recently
41/// than this cannot be trusted to distinguish "unchanged" from "rewritten
42/// within the same tick", so such entries are re-verified by content compare
43/// instead of by stat alone -- this is what closes the racy-rewrite gap.
44const MTIME_GRANULARITY: Duration = Duration::from_secs(2);
45
46/// Returns whether `mtime` is old enough, relative to `read_at`, that a write
47/// landing after `read_at` could not have preserved it.
48///
49/// `read_at` must be captured *before* the filesystem is stat'd (not after any
50/// subsequent read), otherwise a write racing the read itself could produce a
51/// new mtime that still appears "settled" against a later timestamp.
52fn mtime_settled(mtime: Option<SystemTime>, read_at: SystemTime) -> bool {
53    mtime.is_some_and(|m| {
54        m.checked_add(MTIME_GRANULARITY)
55            .is_some_and(|t| t <= read_at)
56    })
57}
58
59/// A snapshot of a document's on-disk filesystem state, captured the last
60/// time its content was actually read and compared.
61///
62/// [`DocumentTracker::ensure_open`] stats the file on every call; when the
63/// stat matches this snapshot and [`Self::mtime_settled`] holds, the cached
64/// content is trusted without touching the file's bytes again. This is what
65/// keeps the common "file unchanged" path cheap while still detecting
66/// external edits (git checkout/stash, formatters, the MCP host's own
67/// edits) made outside mcpls.
68#[derive(Debug, Clone, Copy)]
69pub struct DiskSync {
70    /// Last observed modification time, or `None` if the filesystem or
71    /// platform does not report one (in which case the entry is never
72    /// treated as settled, forcing a content re-read outside the debounce
73    /// window).
74    pub mtime: Option<SystemTime>,
75    /// Last observed file size in bytes.
76    pub size: u64,
77    /// Whether `mtime` was already old enough, relative to when it was
78    /// observed, that a same-tick rewrite could not have preserved it.
79    pub mtime_settled: bool,
80    /// When the file's content was last actually re-read and compared.
81    ///
82    /// Used only to debounce the content re-read on a racy (not-yet-settled)
83    /// entry; deliberately excluded from equality so two otherwise-identical
84    /// snapshots don't compare unequal merely because they were checked at
85    /// different instants.
86    pub content_checked_at: Instant,
87}
88
89impl PartialEq for DiskSync {
90    fn eq(&self, other: &Self) -> bool {
91        self.mtime == other.mtime
92            && self.size == other.size
93            && self.mtime_settled == other.mtime_settled
94    }
95}
96
97impl Eq for DiskSync {}
98
99/// State of a single document.
100///
101/// All fields are private. `DocumentTracker::open` (via `Self::new`)
102/// establishes the initial state: `version` starts at 1, `disk` provenance
103/// starts `None`, and no server is recorded as synced. From there, every
104/// mutation goes through a dedicated method (`apply_local_edit`,
105/// `commit_reload`, `set_disk`, `mark_synced`, `forget_server`) rather than a
106/// partial field write, so within a single tracked lifetime `version` (see
107/// [`Self::version`]) only increases. This does not cover re-opening: calling
108/// `DocumentTracker::open` again for an already-tracked path unconditionally
109/// replaces the entry, resetting `version` to 1 and clearing `synced` -- see
110/// that method's docs.
111///
112/// The `disk` provenance invariant: `None` means the content's on-disk
113/// provenance is unknown (it came from an in-memory `open`/`update` call, not
114/// a verified disk read), so `ensure_open` must always re-verify by content
115/// compare rather than trusting a stat match. `DiskSync`'s hand-written
116/// `PartialEq` excludes `content_checked_at` (see that field's doc comment),
117/// and that exclusion propagates here: two `DocumentState`s can compare
118/// equal via this struct's derived `PartialEq`/`Eq` despite having been
119/// disk-verified at different instants. This is intentional --
120/// `content_checked_at` is a debounce timer, not part of a document's
121/// logical state.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct DocumentState {
124    uri: Uri,
125    language_id: String,
126    version: i32,
127    content: String,
128    disk: Option<DiskSync>,
129    synced: HashMap<ServerId, i32>,
130}
131
132impl DocumentState {
133    /// Creates a new document state at version 1, with unknown disk
134    /// provenance and no server yet recorded as synced.
135    fn new(uri: Uri, language_id: String, content: String) -> Self {
136        Self {
137            uri,
138            language_id,
139            version: 1,
140            content,
141            disk: None,
142            synced: HashMap::new(),
143        }
144    }
145
146    /// Document URI.
147    #[must_use]
148    pub const fn uri(&self) -> &Uri {
149        &self.uri
150    }
151
152    /// Language identifier.
153    #[must_use]
154    pub fn language_id(&self) -> &str {
155        &self.language_id
156    }
157
158    /// Document version. Monotonically increasing: every mutation that
159    /// changes `content` (`apply_local_edit`, `commit_reload`) also bumps
160    /// this, and never decreases it.
161    #[must_use]
162    pub const fn version(&self) -> i32 {
163        self.version
164    }
165
166    /// Document content.
167    #[must_use]
168    pub fn content(&self) -> &str {
169        &self.content
170    }
171
172    /// Filesystem snapshot as of the last time `content` was read from disk.
173    /// See the struct-level docs for the meaning of `None`.
174    const fn disk(&self) -> Option<DiskSync> {
175        self.disk
176    }
177
178    /// Last document version pushed to `server` via `didOpen`/`didChange`,
179    /// or `None` if `server` has never seen this document.
180    ///
181    /// A single document can be synced to multiple servers (e.g. hover
182    /// routed to one server, diagnostics to another for the same language),
183    /// each needing its own `didOpen`/`didChange` history -- a server absent
184    /// from this map has never seen the document and must receive
185    /// `didOpen`, not `didChange`, on its next `ensure_open` call.
186    #[must_use]
187    pub fn synced_version(&self, server: &ServerId) -> Option<i32> {
188        self.synced.get(server).copied()
189    }
190
191    /// Whether no server has ever synced this document.
192    fn has_never_synced(&self) -> bool {
193        self.synced.is_empty()
194    }
195
196    /// Applies a local (non-disk) edit: bumps `version`, replaces `content`,
197    /// and clears `disk` provenance, since the new content did not come from
198    /// a verified disk read. Returns the new version.
199    fn apply_local_edit(&mut self, content: String) -> i32 {
200        self.version += 1;
201        self.content = content;
202        self.disk = None;
203        self.version
204    }
205
206    /// Commits a disk-verified reload: sets `version`, `content`, and `disk`
207    /// together. `version` must be no less than the current version,
208    /// preserving the monotonicity invariant. (Not strictly greater: the
209    /// caller computes `version` via `saturating_add`, which can legitimately
210    /// clamp to the current value at `i32::MAX`.)
211    fn commit_reload(&mut self, version: i32, content: String, snap: Option<DiskSync>) {
212        debug_assert!(
213            version >= self.version,
214            "document version must be monotonically increasing"
215        );
216        self.version = version;
217        self.content = content;
218        self.disk = snap;
219    }
220
221    /// Sets the disk snapshot without changing `content` or `version`.
222    const fn set_disk(&mut self, snap: DiskSync) {
223        self.disk = Some(snap);
224    }
225
226    /// Records that `server` has synced up to `version`.
227    fn mark_synced(&mut self, server: ServerId, version: i32) {
228        self.synced.insert(server, version);
229    }
230
231    /// Forgets `server`'s sync history for this document.
232    fn forget_server(&mut self, server: &ServerId) {
233        self.synced.remove(server);
234    }
235}
236
237/// Default value for [`ResourceLimits::max_documents`], also used as the
238/// TOML default for `workspace.max_documents` (`config::default_max_documents`).
239pub const DEFAULT_MAX_DOCUMENTS: usize = 100;
240
241/// Default value for [`ResourceLimits::max_file_size`] (10MB), also used as
242/// the TOML default for `workspace.max_file_size` (`config::default_max_file_size`).
243pub const DEFAULT_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
244
245/// Resource limits for document tracking.
246#[derive(Debug, Clone, Copy)]
247pub struct ResourceLimits {
248    /// Maximum number of open documents (0 = unlimited).
249    pub max_documents: usize,
250    /// Maximum file size in bytes (0 = unlimited).
251    pub max_file_size: u64,
252}
253
254impl Default for ResourceLimits {
255    fn default() -> Self {
256        Self {
257            max_documents: DEFAULT_MAX_DOCUMENTS,
258            max_file_size: DEFAULT_MAX_FILE_SIZE,
259        }
260    }
261}
262
263/// Tracks document state across the workspace.
264///
265/// Every method takes `&self`: the document map and the per-path locks used
266/// by [`Self::ensure_open`] are both interior-mutable, so a single tracker
267/// can be shared behind a plain `Arc<DocumentTracker>` with no outer lock.
268/// See [`Self::ensure_open`] for the concurrency contract this maintains.
269#[derive(Debug)]
270pub struct DocumentTracker {
271    /// Open documents by file path. Locked only for the short, synchronous
272    /// section that touches it — never held across an `await`.
273    documents: StdMutex<HashMap<PathBuf, DocumentState>>,
274    /// Per-path locks serializing [`Self::ensure_open`] calls for the same
275    /// path, so calls for different paths never wait on each other. See
276    /// `lock_path` for how entries are created and evicted.
277    path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
278    /// Per-server sync generation, bumped by [`Self::forget_server`].
279    ///
280    /// `ensure_open` captures a server's generation before doing any I/O and
281    /// only commits its `synced` update if the generation is unchanged when
282    /// it finishes -- see [`Self::forget_server`]'s docs for the race this
283    /// closes. Absent from the map is equivalent to generation `0`.
284    generations: StdMutex<HashMap<ServerId, u64>>,
285    /// Resource limits for tracking.
286    limits: ResourceLimits,
287    /// Custom file extension to language ID mappings.
288    extension_map: HashMap<String, String>,
289}
290
291impl DocumentTracker {
292    /// Create a new document tracker with custom limits and extension mappings.
293    #[must_use]
294    pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
295        Self {
296            documents: StdMutex::new(HashMap::new()),
297            path_locks: StdMutex::new(HashMap::new()),
298            generations: StdMutex::new(HashMap::new()),
299            limits,
300            extension_map,
301        }
302    }
303
304    /// Check if a document is currently open.
305    #[must_use]
306    pub fn is_open(&self, path: &Path) -> bool {
307        lock_std(&self.documents).contains_key(path)
308    }
309
310    /// Get a clone of the state of an open document.
311    #[must_use]
312    pub fn get(&self, path: &Path) -> Option<DocumentState> {
313        lock_std(&self.documents).get(path).cloned()
314    }
315
316    /// Text of the 0-based `line`'th line of `path`'s currently tracked
317    /// content, or `None` if the document is not open or has no such line.
318    ///
319    /// Reads the in-memory content mcpls already sent the server via
320    /// `didOpen`/`didChange` -- cheaper than a disk read (no I/O, no
321    /// re-scanning the whole file) and more correct when disk and server
322    /// state have diverged (e.g. an edit not yet flushed to disk).
323    #[must_use]
324    pub fn line_text(&self, path: &Path, line: u32) -> Option<String> {
325        lock_std(&self.documents)
326            .get(path)?
327            .content
328            .lines()
329            .nth(line as usize)
330            .map(str::to_string)
331    }
332
333    /// Get the number of open documents.
334    #[must_use]
335    pub fn len(&self) -> usize {
336        lock_std(&self.documents).len()
337    }
338
339    /// Check if there are no open documents.
340    #[must_use]
341    pub fn is_empty(&self) -> bool {
342        lock_std(&self.documents).is_empty()
343    }
344
345    /// Open a document and track its state.
346    ///
347    /// Returns the document URI for use in LSP requests.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if:
352    /// - Document limit is exceeded
353    /// - File size limit is exceeded
354    pub fn open(&self, path: PathBuf, content: String) -> Result<Uri> {
355        self.check_file_size(content.len() as u64)?;
356
357        let uri = path_to_uri(&path)?;
358        let language_id = detect_language(&path, &self.extension_map);
359
360        let state = DocumentState::new(uri.clone(), language_id, content);
361
362        // Check document limit and insert under a single lock acquisition so
363        // two concurrent `open` calls for different new paths can't both
364        // pass the check and jointly exceed the limit by one. Dropped
365        // explicitly right after the insert rather than at function return.
366        let mut documents = lock_std(&self.documents);
367        if self.limits.max_documents > 0 && documents.len() >= self.limits.max_documents {
368            return Err(Error::DocumentLimitExceeded {
369                current: documents.len(),
370                max: self.limits.max_documents,
371            });
372        }
373        documents.insert(path, state);
374        drop(documents);
375        Ok(uri)
376    }
377
378    /// Update a document's content and increment its version.
379    ///
380    /// Returns `None` if the document is not open. The updated content has no
381    /// known disk provenance, so the next `ensure_open` call on this path
382    /// will always re-verify by content compare rather than trusting a stat.
383    // TODO(critic): `update`/`open` may race a concurrent `ensure_open` for
384    // the same path; see #304 review.
385    pub fn update(&self, path: &Path, content: String) -> Option<i32> {
386        lock_std(&self.documents)
387            .get_mut(path)
388            .map(|state| state.apply_local_edit(content))
389    }
390
391    /// Returns an error if `size` exceeds the configured file size limit.
392    const fn check_file_size(&self, size: u64) -> Result<()> {
393        if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
394            return Err(Error::FileSizeLimitExceeded {
395                size,
396                max: self.limits.max_file_size,
397            });
398        }
399        Ok(())
400    }
401
402    /// Sets the disk snapshot for an already-tracked document.
403    ///
404    /// A no-op if the path is no longer tracked; every call site runs under
405    /// the per-path lock for the whole `ensure_open` call, so this should
406    /// not happen in practice, but it avoids an `unwrap`/`expect` on the
407    /// lookup.
408    fn set_disk(&self, path: &Path, snap: DiskSync) {
409        if let Some(st) = lock_std(&self.documents).get_mut(path) {
410            st.set_disk(snap);
411        }
412    }
413
414    /// Close a document and remove it from tracking.
415    ///
416    /// Returns the document state if it was open.
417    pub fn close(&self, path: &Path) -> Option<DocumentState> {
418        lock_std(&self.documents).remove(path)
419    }
420
421    /// Close all documents.
422    pub fn close_all(&self) -> Vec<DocumentState> {
423        lock_std(&self.documents)
424            .drain()
425            .map(|(_, state)| state)
426            .collect()
427    }
428
429    /// Snapshot of the filesystem paths of all currently open documents.
430    pub fn open_paths(&self) -> Vec<PathBuf> {
431        lock_std(&self.documents).keys().cloned().collect()
432    }
433
434    /// Forget `server`'s last-synced version for every currently open
435    /// document, so the next `ensure_open` call sends `didOpen` again
436    /// instead of `didChange`.
437    ///
438    /// Called after `server` is respawned: the fresh process has no memory
439    /// of any document the old one had open, so this tracker's per-server
440    /// sync history for it must be forgotten too, or `ensure_open` would
441    /// wrongly send `didChange` for a document the new process never saw.
442    ///
443    /// Also bumps `server`'s sync generation. Clearing `synced` alone is not
444    /// enough: a call already in flight against the old (dead) connection
445    /// when this runs can still have its `didOpen`/`didChange` notify
446    /// "succeed" (`LspClient::notify` only enqueues onto a channel -- a dead
447    /// process is not observed by the send itself), and would otherwise
448    /// re-insert a stale entry after this method has already cleared it.
449    /// `ensure_open` captures the generation before starting and discards
450    /// its `synced` write if the generation moved in the meantime, closing
451    /// that race regardless of exactly when the notify "succeeds".
452    pub fn forget_server(&self, server: &ServerId) {
453        *lock_std(&self.generations)
454            .entry(server.clone())
455            .or_insert(0) += 1;
456        for state in lock_std(&self.documents).values_mut() {
457            state.forget_server(server);
458        }
459    }
460
461    /// Current sync generation for `server` (see [`Self::forget_server`]).
462    fn generation(&self, server: &ServerId) -> u64 {
463        lock_std(&self.generations)
464            .get(server)
465            .copied()
466            .unwrap_or(0)
467    }
468
469    /// Acquire the per-path lock used by [`Self::ensure_open`], creating its
470    /// entry on first use.
471    ///
472    /// The map of per-path locks (`path_locks`) is itself locked only for
473    /// the map lookup/insert/remove — never across an `await` — so acquiring
474    /// one path's lock never blocks a concurrent acquisition for a different
475    /// path. Awaiting the returned path's own lock is what actually
476    /// serializes calls for the same path.
477    ///
478    /// The returned guard evicts its `path_locks` entry when dropped, but
479    /// only if no other caller is concurrently waiting on it (see
480    /// [`PathLockGuard`]'s `Drop` impl) — otherwise the map would grow by
481    /// one entry per distinct path ever opened, for the lifetime of the
482    /// process.
483    async fn lock_path(&self, path: &Path) -> PathLockGuard<'_> {
484        let arc = {
485            let mut locks = lock_std(&self.path_locks);
486            locks
487                .entry(path.to_path_buf())
488                .or_insert_with(|| Arc::new(AsyncMutex::new(())))
489                .clone()
490        };
491        let guard = Arc::clone(&arc).lock_owned().await;
492        PathLockGuard {
493            path_locks: &self.path_locks,
494            path: path.to_path_buf(),
495            arc,
496            guard: Some(guard),
497        }
498    }
499
500    /// Ensure a document is open *for `server`*, opening it lazily if
501    /// necessary, and resynchronize it with disk and with `server` if either
502    /// has fallen behind.
503    ///
504    /// A single path can be synced to several servers independently (e.g.
505    /// hover routed to one server, diagnostics to another, for the same
506    /// language) -- this call syncs only the one server it is for. Internally
507    /// it runs in two phases:
508    ///
509    /// **Disk phase**: stats the file on every call (a cheap syscall, never
510    /// debounced) to detect external changes -- `git checkout`/`stash`,
511    /// formatters, or edits made by the MCP host itself outside mcpls -- and
512    /// re-reads its content when the stat indicates a possible change (see
513    /// `DiskSync` for the settled/debounce rules). This phase never skips
514    /// the *per-server* sync check below, even when it takes a fast path
515    /// that skips the disk read: a second server that has never seen this
516    /// document must still receive `didOpen` even if the file has not
517    /// changed since a first server was opened on it.
518    ///
519    /// **Sync phase**: compares `server`'s last-synced version (tracked via
520    /// [`DocumentState::synced_version`]) against the version decided by the disk
521    /// phase, and sends exactly one of `didOpen` (server has never seen this
522    /// document), `didChange` (server is behind), or nothing (server is
523    /// already caught up). A `didChange` is always a single full-replacement
524    /// notification (a `TextDocumentContentChangeEvent` with `range: None`,
525    /// which per the LSP spec means "this is the entire new document
526    /// content"); mcpls does not consult the server's negotiated
527    /// `TextDocumentSyncKind` (`LspClient` has no access to
528    /// `ServerCapabilities` at this layer) -- full-replacement is accepted in
529    /// practice by rust-analyzer, pyright, tsserver, gopls and clangd, but is
530    /// the first place to look if a future maintainer sees sync errors from
531    /// a new server. The document is never closed and reopened on a change,
532    /// so `get_cached_diagnostics` keeps serving the last-known diagnostics
533    /// until the server re-publishes -- there is no transient empty window.
534    ///
535    /// `st.version`/`st.content`/`st.disk`/`synced[server]` are all committed
536    /// only after the notification succeeds. A server that is never asked
537    /// again never catches up to a later edit -- which is correct, since a
538    /// server that is never asked never needs the content.
539    ///
540    /// Two cases fall outside the disk-change-detection mechanism entirely:
541    /// - A tool that restores a file with an mtime and size identical to the
542    ///   last ones observed (e.g. `tar x`, `rsync -a`, `cp -p`) is
543    ///   indistinguishable from "unchanged", however long ago that snapshot
544    ///   was taken -- not just within the racy detection window. Once a
545    ///   snapshot is `mtime_settled`, restoring its exact `(mtime, size)`
546    ///   retakes the fast path forever. Closing this would require hashing
547    ///   content on every access.
548    /// - `workspace_symbol_search` is served from the LSP server's own
549    ///   index and is unaffected by this per-document mechanism for files
550    ///   mcpls has never opened.
551    ///
552    /// # Concurrency
553    ///
554    /// Calls for the *same* `path` are serialized against each other (via
555    /// `lock_path`), so no two such calls can observe or mutate that
556    /// path's state concurrently -- this is what prevents duplicate
557    /// `didOpen`/`didChange` notifications for the same document. Calls for
558    /// *different* paths run fully concurrently: neither the per-path lock
559    /// nor the short, synchronous locks used to touch the shared document
560    /// map are ever held across this call's disk I/O or LSP notify.
561    ///
562    /// # Errors
563    ///
564    /// Returns an error if:
565    /// - The file cannot be stat'd or read from disk
566    /// - The `didOpen`/`didChange` notification fails to send
567    /// - Resource limits are exceeded
568    pub async fn ensure_open(
569        &self,
570        path: &Path,
571        server: &ServerId,
572        lsp_client: &LspClient,
573    ) -> Result<Uri> {
574        let _path_guard = self.lock_path(path).await;
575        let generation = self.generation(server);
576        let decision = self.disk_phase(path).await?;
577        self.sync_phase(path, server, lsp_client, decision, generation)
578            .await
579    }
580
581    /// Disk-verification phase of `ensure_open`: decides the version `path`
582    /// should be at, reading from disk only when necessary. Never sends any
583    /// LSP notification and never returns early in a way that would skip the
584    /// per-server sync phase -- see `ensure_open`'s docs.
585    async fn disk_phase(&self, path: &Path) -> Result<Decision> {
586        if !lock_std(&self.documents).contains_key(path) {
587            return self.disk_phase_new(path).await;
588        }
589
590        let read_at = SystemTime::now();
591        let meta = fs::metadata(path).await.map_err(|e| Error::FileIo {
592            path: path.to_path_buf(),
593            source: e,
594        })?;
595        let mtime = meta.modified().ok();
596        let size = meta.len();
597
598        // `.map(...)` extracts an owned tuple from the lookup in a single
599        // statement, so the lock releases immediately rather than staying
600        // held while `fast_path` is computed.
601        let Some((uri, current_version, fast_path)) =
602            lock_std(&self.documents).get(path).map(|st| {
603                let stat_matches = st
604                    .disk()
605                    .is_some_and(|d| d.mtime == mtime && d.size == size);
606                let fast_path = match st.disk() {
607                    Some(d) if stat_matches && d.mtime_settled => true,
608                    Some(d)
609                        if stat_matches && d.content_checked_at.elapsed() < DISK_CHECK_DEBOUNCE =>
610                    {
611                        true
612                    }
613                    _ => false,
614                };
615                (st.uri.clone(), st.version, fast_path)
616            })
617        else {
618            return Err(Error::DocumentNotFound(path.to_path_buf()));
619        };
620        if fast_path {
621            return Ok(Decision::unchanged(uri, current_version));
622        }
623
624        let (fresh, ..) = self.read_to_string_checked(path).await?;
625        let snap = DiskSync {
626            mtime,
627            size,
628            mtime_settled: mtime_settled(mtime, read_at),
629            content_checked_at: Instant::now(),
630        };
631
632        let Some(unchanged) = lock_std(&self.documents)
633            .get(path)
634            .map(|st| fresh == st.content)
635        else {
636            return Err(Error::DocumentNotFound(path.to_path_buf()));
637        };
638
639        if unchanged {
640            self.set_disk(path, snap);
641            return Ok(Decision::unchanged(uri, current_version));
642        }
643
644        Ok(Decision {
645            uri,
646            target_version: current_version.saturating_add(1),
647            fresh_content: Some(fresh),
648            snap: Some(snap),
649        })
650    }
651
652    /// Reads a not-yet-tracked file from disk and opens it in the tracker at
653    /// version 1. No server has synced it yet, so the sync phase always
654    /// sends `didOpen` regardless of which server calls next.
655    async fn disk_phase_new(&self, path: &Path) -> Result<Decision> {
656        let read_at = SystemTime::now();
657        let (content, mtime, size) = self.read_to_string_checked(path).await?;
658
659        let uri = self.open(path.to_path_buf(), content)?;
660        self.set_disk(
661            path,
662            DiskSync {
663                mtime,
664                size,
665                mtime_settled: mtime_settled(mtime, read_at),
666                content_checked_at: Instant::now(),
667            },
668        );
669
670        Ok(Decision::unchanged(uri, 1))
671    }
672
673    /// Reads `path` through a single open file handle, checking its size
674    /// against [`Self::check_file_size`] using that same handle's metadata
675    /// rather than a separately-stat'd size. Reading and size-checking
676    /// through one handle closes the TOCTOU window where an atomic replace
677    /// (e.g. a concurrent `rename`) between an earlier `metadata()` call and
678    /// a path-based read could let an oversized file bypass the pre-read
679    /// size gate.
680    ///
681    /// Returns the content along with the handle's own mtime and size, so
682    /// callers can build a [`DiskSync`] snapshot consistent with what was
683    /// actually read.
684    async fn read_to_string_checked(
685        &self,
686        path: &Path,
687    ) -> Result<(String, Option<SystemTime>, u64)> {
688        let mut file = fs::File::open(path).await.map_err(|e| Error::FileIo {
689            path: path.to_path_buf(),
690            source: e,
691        })?;
692        let meta = file.metadata().await.map_err(|e| Error::FileIo {
693            path: path.to_path_buf(),
694            source: e,
695        })?;
696        self.check_file_size(meta.len())?;
697        let mut content = String::new();
698        file.read_to_string(&mut content)
699            .await
700            .map_err(|e| Error::FileIo {
701                path: path.to_path_buf(),
702                source: e,
703            })?;
704        Ok((content, meta.modified().ok(), meta.len()))
705    }
706
707    /// Per-server sync phase of `ensure_open`: sends `didOpen`, `didChange`,
708    /// or nothing to `server` depending on its last-synced version, and
709    /// commits the outcome only after the notification succeeds.
710    ///
711    /// `generation` is `server`'s sync generation as observed by the caller
712    /// before this call started (see [`Self::forget_server`]): the
713    /// `synced` write at the end is skipped if it no longer matches,
714    /// meaning `server` was respawned while this call was in flight and its
715    /// notify -- however it turned out -- was not actually delivered to the
716    /// connection now on file for `server`.
717    async fn sync_phase(
718        &self,
719        path: &Path,
720        server: &ServerId,
721        lsp_client: &LspClient,
722        decision: Decision,
723        generation: u64,
724    ) -> Result<Uri> {
725        let Decision {
726            uri,
727            target_version,
728            fresh_content,
729            snap,
730        } = decision;
731
732        // Cheap check first: the common case (an already-synced document,
733        // which is most tool calls against a file already open elsewhere)
734        // must not pay for cloning the full document content only to
735        // discard it on the `up_to_date` return below. `.map(...)` extracts
736        // an owned value from the lookup so the lock is released at the end
737        // of this statement rather than held across the checks that follow.
738        let Some(synced_version) = lock_std(&self.documents)
739            .get(path)
740            .map(|st| st.synced_version(server))
741        else {
742            return Err(Error::DocumentNotFound(path.to_path_buf()));
743        };
744        let up_to_date = synced_version.is_some_and(|v| v >= target_version);
745        let is_first_open = synced_version.is_none();
746
747        if up_to_date {
748            return Ok(uri);
749        }
750
751        let Some((language_id, text)) = lock_std(&self.documents).get(path).map(|st| {
752            let text = fresh_content.clone().unwrap_or_else(|| st.content.clone());
753            (st.language_id.clone(), text)
754        }) else {
755            return Err(Error::DocumentNotFound(path.to_path_buf()));
756        };
757
758        let notify_result = if is_first_open {
759            lsp_client
760                .notify(
761                    "textDocument/didOpen",
762                    DidOpenTextDocumentParams {
763                        text_document: TextDocumentItem {
764                            uri: uri.clone(),
765                            language_id,
766                            version: target_version,
767                            text,
768                        },
769                    },
770                )
771                .await
772        } else {
773            lsp_client
774                .notify(
775                    "textDocument/didChange",
776                    DidChangeTextDocumentParams {
777                        text_document: VersionedTextDocumentIdentifier {
778                            uri: uri.clone(),
779                            version: target_version,
780                        },
781                        content_changes: vec![TextDocumentContentChangeEvent {
782                            range: None,
783                            range_length: None,
784                            text,
785                        }],
786                    },
787                )
788                .await
789        };
790
791        if let Err(err) = notify_result {
792            // The server never learned about this document. If no server at
793            // all has synced this path yet, leaving it tracked would
794            // permanently desync every future server from the tracker, so
795            // undo the insert and let the next call retry from scratch. If
796            // another server already synced successfully, the path stays
797            // tracked for that server's sake; this server's `synced` entry
798            // simply stays absent/stale, so its own next call retries.
799            // Two short lock scopes rather than one held across the
800            // conditional `remove`: safe because `ensure_open`'s per-path
801            // lock already serializes every caller for this path, so
802            // nothing else can observe or mutate its `synced` map between
803            // them.
804            let first_ever_sync = lock_std(&self.documents)
805                .get(path)
806                .is_some_and(DocumentState::has_never_synced);
807            if is_first_open && first_ever_sync {
808                lock_std(&self.documents).remove(path);
809            }
810            return Err(err);
811        }
812
813        // Dropped explicitly right after the commit, rather than staying
814        // alive (unused) until the function returns.
815        let mut documents = lock_std(&self.documents);
816        let Some(st) = documents.get_mut(path) else {
817            return Err(Error::DocumentNotFound(path.to_path_buf()));
818        };
819        if let Some(fresh) = fresh_content {
820            st.commit_reload(target_version, fresh, snap);
821        }
822        // Read while `documents` is still held, not before: `forget_server`
823        // bumps the generation strictly before it acquires `documents`
824        // itself (see its docs), so checking under this same lock is
825        // airtight against the TOCTOU a separate, earlier read would leave
826        // open -- either this sees the new generation and skips (in which
827        // case `forget_server` has already cleared `synced`, or is blocked
828        // waiting for *this* guard to release before it does), or it sees
829        // the old one, in which case `forget_server` cannot have started
830        // clearing yet and will correctly clear the entry this commits.
831        if self.generation(server) == generation {
832            st.mark_synced(server.clone(), target_version);
833        }
834        drop(documents);
835
836        Ok(uri)
837    }
838}
839
840/// RAII guard for the per-path lock acquired by
841/// [`DocumentTracker::lock_path`].
842///
843/// Holds an `OwnedMutexGuard` on the path's `Arc<AsyncMutex<()>>>` for as
844/// long as the guard is alive, serializing `ensure_open` calls for that
845/// path. On drop, evicts the `path_locks` map entry if (and only if) no
846/// other caller holds a clone of the same `Arc` -- see the `Drop` impl for
847/// why that check is race-free.
848struct PathLockGuard<'a> {
849    path_locks: &'a StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
850    path: PathBuf,
851    arc: Arc<AsyncMutex<()>>,
852    guard: Option<OwnedMutexGuard<()>>,
853}
854
855impl Drop for PathLockGuard<'_> {
856    fn drop(&mut self) {
857        // Unlock first so a task waiting on `arc.lock_owned()` can proceed
858        // as soon as possible, rather than also waiting on `path_locks`.
859        self.guard.take();
860
861        let mut locks = lock_std(self.path_locks);
862        // Checked only after `self.guard` -- and the extra internal `Arc`
863        // clone it held -- was already dropped above, so what's left here is:
864        // this task's own `self.arc`, the map's entry, and one more
865        // reference for every *other* task that has already looked up this
866        // same entry in `lock_path` (each holds its own clone continuously
867        // from before that lookup until its own `Drop` runs this same check)
868        // but hasn't finished dropping yet. A `strong_count` of 2 means no
869        // such task exists, so it's safe to evict; any later caller just
870        // creates a fresh entry. Leaving it forever would instead grow this
871        // map by one entry per distinct path ever opened, for the process's
872        // lifetime.
873        if Arc::strong_count(&self.arc) <= 2 {
874            locks.remove(&self.path);
875        }
876    }
877}
878
879/// Outcome of `DocumentTracker::disk_phase`: the version `ensure_open`'s
880/// caller should end up synced to, and -- only when this call detected an
881/// as-yet-uncommitted content change -- the content and disk snapshot to
882/// commit alongside it.
883struct Decision {
884    uri: Uri,
885    target_version: i32,
886    fresh_content: Option<String>,
887    snap: Option<DiskSync>,
888}
889
890impl Decision {
891    /// A decision where nothing changed on disk this call: `target_version`
892    /// is already what's committed in `DocumentState`.
893    const fn unchanged(uri: Uri, target_version: i32) -> Self {
894        Self {
895            uri,
896            target_version,
897            fresh_content: None,
898            snap: None,
899        }
900    }
901}
902
903/// Convert a file path to a URI.
904///
905/// Prefer `try_path_to_uri` on paths that come from configuration or
906/// otherwise untrusted input; this wrapper exists for the common case of an
907/// already-canonicalized path, where the conversion is not expected to fail
908/// but must still surface as an error rather than a panic to keep the
909/// `panic = "abort"` release profile safe against unforeseen inputs.
910///
911/// # Errors
912///
913/// Returns [`Error::InvalidUri`] if the path cannot be represented as a
914/// `file://` URI.
915pub fn path_to_uri(path: &Path) -> Result<Uri> {
916    try_path_to_uri(path)
917        .ok_or_else(|| Error::InvalidUri(format!("cannot convert path to URI: {}", path.display())))
918}
919
920/// Convert a file path to a URI, returning `None` if the path cannot be
921/// represented as a `file://` URI.
922///
923/// Prefer this over [`path_to_uri`] on paths that come from configuration,
924/// where a bad value should surface as an error rather than a panic.
925#[must_use]
926pub fn try_path_to_uri(path: &Path) -> Option<Uri> {
927    let uri_string = encode_rfc3986_path_chars(&file_url(path)?);
928    uri_string.parse().ok()
929}
930
931#[cfg(not(windows))]
932fn file_url(path: &Path) -> Option<Url> {
933    Url::from_file_path(path).ok()
934}
935
936#[cfg(windows)]
937fn file_url(path: &Path) -> Option<Url> {
938    match Url::from_file_path(path) {
939        Ok(file_url) => Some(file_url),
940        Err(()) if path.has_root() => windows_rooted_path_to_file_url(path),
941        Err(()) => None,
942    }
943}
944
945#[cfg(windows)]
946fn windows_rooted_path_to_file_url(path: &Path) -> Option<Url> {
947    let path_str = path.to_string_lossy();
948    let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
949    let mut file_url = Url::parse("file:///").ok()?;
950    file_url.path_segments_mut().ok()?.clear().extend(
951        stripped
952            .split(['\\', '/'])
953            .filter(|segment| !segment.is_empty()),
954    );
955    Some(file_url)
956}
957
958/// Percent-encodes the RFC 3986 §2.2 "other reserved" characters that the
959/// `url` crate's default WHATWG path percent-encode set leaves untouched:
960/// `[`, `]`, `^`, `|`. The remaining three characters in that set -- `{`,
961/// `}`, and backtick -- are already encoded by `url` on serialization, so
962/// they need no handling here; see
963/// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`.
964///
965/// Shared with [`crate::bridge::resources::make_uri`] so `lsp-diagnostics://`
966/// resource URIs get the same encoding as `file://` document URIs.
967pub(super) fn encode_rfc3986_path_chars(url: &Url) -> String {
968    let prefix = url[..url::Position::BeforePath].to_owned();
969    let encoded = url[url::Position::BeforePath..]
970        .replace('[', "%5B")
971        .replace(']', "%5D")
972        .replace('^', "%5E")
973        .replace('|', "%7C");
974    format!("{prefix}{encoded}")
975}
976
977/// Convert an LSP `file://` URI to an absolute filesystem path.
978///
979/// Returns `None` if the URI is not a valid `file://` URI, uses a non-file
980/// scheme, or contains percent-encoding that cannot map to a valid path.
981#[must_use]
982pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
983    let url = Url::parse(uri.as_str()).ok()?;
984    if url.scheme() != "file" {
985        return None;
986    }
987    // Reject authority-bearing file URIs (e.g. `file://server/share`) to
988    // avoid UNC path confusion on Windows.
989    if !url.host_str().unwrap_or("").is_empty() {
990        return None;
991    }
992    url.to_file_path().ok()
993}
994
995/// Detect the language ID from a file path.
996///
997/// Consults the extension map to determine the language ID for a file.
998/// If the extension is not found in the map, returns "plaintext".
999#[must_use]
1000pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
1001    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1002
1003    extension_map
1004        .get(extension)
1005        .cloned()
1006        .unwrap_or_else(|| "plaintext".to_string())
1007}
1008
1009#[cfg(test)]
1010#[allow(clippy::unwrap_used)]
1011mod tests {
1012    use super::*;
1013
1014    #[test]
1015    fn test_detect_language() {
1016        let mut map = HashMap::new();
1017        map.insert("rs".to_string(), "rust".to_string());
1018        map.insert("py".to_string(), "python".to_string());
1019        map.insert("ts".to_string(), "typescript".to_string());
1020
1021        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
1022        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
1023        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
1024        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
1025    }
1026
1027    #[test]
1028    fn test_document_tracker() {
1029        let mut map = HashMap::new();
1030        map.insert("rs".to_string(), "rust".to_string());
1031
1032        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1033        let path = PathBuf::from("/test/file.rs");
1034
1035        assert!(!tracker.is_open(&path));
1036
1037        tracker
1038            .open(path.clone(), "fn main() {}".to_string())
1039            .unwrap();
1040        assert!(tracker.is_open(&path));
1041        assert_eq!(tracker.len(), 1);
1042
1043        let state = tracker.get(&path).unwrap();
1044        assert_eq!(state.version(), 1);
1045        assert_eq!(state.language_id(), "rust");
1046
1047        let new_version = tracker.update(&path, "fn main() { println!() }".to_string());
1048        assert_eq!(new_version, Some(2));
1049
1050        tracker.close(&path);
1051        assert!(!tracker.is_open(&path));
1052        assert!(tracker.is_empty());
1053    }
1054
1055    /// #249: after a respawn, `forget_server` must clear only the respawned
1056    /// server's sync history so the next `ensure_open` call for it sends
1057    /// `didOpen` again -- while leaving other servers synced to the same
1058    /// document untouched (a path can be synced to more than one server,
1059    /// e.g. hover routed to one, diagnostics to another).
1060    #[test]
1061    fn test_forget_server_clears_only_that_servers_synced_version() {
1062        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1063        let path = PathBuf::from("/test/file.rs");
1064        tracker
1065            .open(path.clone(), "fn main() {}".to_string())
1066            .unwrap();
1067
1068        let respawned = ServerId::from("rust-respawned");
1069        let untouched = ServerId::from("rust-diagnostics");
1070        lock_std(&tracker.documents)
1071            .get_mut(&path)
1072            .unwrap()
1073            .synced
1074            .insert(respawned.clone(), 1);
1075        lock_std(&tracker.documents)
1076            .get_mut(&path)
1077            .unwrap()
1078            .synced
1079            .insert(untouched.clone(), 1);
1080
1081        tracker.forget_server(&respawned);
1082
1083        let state = tracker.get(&path).unwrap();
1084        assert!(state.synced_version(&respawned).is_none());
1085        assert!(state.synced_version(&untouched).is_some());
1086    }
1087
1088    /// #249 S1 regression: a `sync_phase` call that captured `server`'s
1089    /// generation *before* a concurrent `forget_server` bumped it must not
1090    /// commit its `synced` write, even though its notification against the
1091    /// now-superseded connection reports success (`fake_lsp_client`'s `cat`
1092    /// backend always accepts writes, standing in for the window where a
1093    /// server's process has already died but its message loop has not yet
1094    /// observed that). Without this, a document synced against the old
1095    /// (crashed) process would be wrongly marked as already open on the
1096    /// respawned one, permanently desyncing it.
1097    #[tokio::test]
1098    async fn test_sync_phase_skips_commit_when_generation_is_stale() {
1099        let dir = TempDir::new().unwrap();
1100        let path = dir.path().join("race.rs");
1101        std::fs::write(&path, "fn main() {}").unwrap();
1102        set_mtime(&path, settled_past());
1103
1104        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1105        let server = ServerId::from("rust");
1106        let generation_before_respawn = 0; // fresh tracker: generation starts at 0
1107
1108        // A respawn happens "concurrently" with the in-flight call that
1109        // captured the generation above before this ran.
1110        tracker.forget_server(&server);
1111
1112        let (stale_client, _guard) = fake_lsp_client();
1113        let decision = tracker.disk_phase(&path).await.unwrap();
1114        tracker
1115            .sync_phase(
1116                &path,
1117                &server,
1118                &stale_client,
1119                decision,
1120                generation_before_respawn,
1121            )
1122            .await
1123            .unwrap();
1124
1125        let state = tracker.get(&path).unwrap();
1126        assert!(
1127            state.synced_version(&server).is_none(),
1128            "a sync_phase call that captured a stale generation must not \
1129             commit `synced`, even though its notify against the \
1130             superseded connection succeeded"
1131        );
1132    }
1133
1134    /// Companion to the regression above: the ordinary, non-racing path
1135    /// (`ensure_open` capturing and committing against the *current*
1136    /// generation) must still work -- the generation check must not
1137    /// suppress a legitimate commit.
1138    #[tokio::test]
1139    async fn test_ensure_open_commits_when_generation_is_current() {
1140        let dir = TempDir::new().unwrap();
1141        let path = dir.path().join("no_race.rs");
1142        std::fs::write(&path, "fn main() {}").unwrap();
1143
1144        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1145        let server = ServerId::from("rust");
1146        let (client, _guard) = fake_lsp_client();
1147
1148        tracker.ensure_open(&path, &server, &client).await.unwrap();
1149
1150        let state = tracker.get(&path).unwrap();
1151        assert_eq!(state.synced_version(&server), Some(1));
1152    }
1153
1154    #[test]
1155    fn test_document_limit() {
1156        let limits = ResourceLimits {
1157            max_documents: 2,
1158            max_file_size: 100,
1159        };
1160        let mut map = HashMap::new();
1161        map.insert("rs".to_string(), "rust".to_string());
1162
1163        let tracker = DocumentTracker::new(limits, map);
1164
1165        // First two documents should succeed
1166        tracker
1167            .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
1168            .unwrap();
1169        tracker
1170            .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
1171            .unwrap();
1172
1173        // Third should fail
1174        let result = tracker.open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string());
1175        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
1176    }
1177
1178    #[test]
1179    fn test_file_size_limit() {
1180        let limits = ResourceLimits {
1181            max_documents: 10,
1182            max_file_size: 10,
1183        };
1184        let mut map = HashMap::new();
1185        map.insert("rs".to_string(), "rust".to_string());
1186
1187        let tracker = DocumentTracker::new(limits, map);
1188
1189        // Small file should succeed
1190        tracker
1191            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
1192            .unwrap();
1193
1194        // Large file should fail
1195        let large_content = "x".repeat(100);
1196        let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
1197        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
1198    }
1199
1200    #[test]
1201    fn test_resource_limits_default() {
1202        let limits = ResourceLimits::default();
1203        assert_eq!(limits.max_documents, 100);
1204        assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
1205    }
1206
1207    #[test]
1208    fn test_resource_limits_custom() {
1209        let limits = ResourceLimits {
1210            max_documents: 50,
1211            max_file_size: 5 * 1024 * 1024,
1212        };
1213        assert_eq!(limits.max_documents, 50);
1214        assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
1215    }
1216
1217    #[test]
1218    fn test_resource_limits_zero_unlimited() {
1219        let limits = ResourceLimits {
1220            max_documents: 0,
1221            max_file_size: 0,
1222        };
1223        let mut map = HashMap::new();
1224        map.insert("rs".to_string(), "rust".to_string());
1225
1226        let tracker = DocumentTracker::new(limits, map);
1227
1228        // Should allow many documents when limit is 0
1229        for i in 0..200 {
1230            tracker
1231                .open(
1232                    PathBuf::from(format!("/test/file{i}.rs")),
1233                    "content".to_string(),
1234                )
1235                .unwrap();
1236        }
1237        assert_eq!(tracker.len(), 200);
1238
1239        // Should allow large files when limit is 0
1240        let huge_content = "x".repeat(100_000_000);
1241        tracker
1242            .open(PathBuf::from("/test/huge.rs"), huge_content)
1243            .unwrap();
1244    }
1245
1246    #[test]
1247    fn test_document_state_clone() {
1248        let state = DocumentState {
1249            uri: "file:///test.rs".parse().unwrap(),
1250            language_id: "rust".to_string(),
1251            version: 5,
1252            content: "fn main() {}".to_string(),
1253            disk: None,
1254            synced: HashMap::new(),
1255        };
1256
1257        #[allow(clippy::redundant_clone)]
1258        let cloned = state.clone();
1259        assert_eq!(cloned.uri(), state.uri());
1260        assert_eq!(cloned.language_id(), state.language_id());
1261        assert_eq!(cloned.version(), 5);
1262        assert_eq!(cloned.content(), state.content());
1263    }
1264
1265    #[test]
1266    fn test_update_nonexistent_document() {
1267        let map = HashMap::new();
1268        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1269        let path = PathBuf::from("/test/nonexistent.rs");
1270
1271        let version = tracker.update(&path, "new content".to_string());
1272        assert_eq!(
1273            version, None,
1274            "Updating non-existent document should return None"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_close_nonexistent_document() {
1280        let map = HashMap::new();
1281        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1282        let path = PathBuf::from("/test/nonexistent.rs");
1283
1284        let state = tracker.close(&path);
1285        assert_eq!(
1286            state, None,
1287            "Closing non-existent document should return None"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_close_all_documents() {
1293        let mut map = HashMap::new();
1294        map.insert("rs".to_string(), "rust".to_string());
1295
1296        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1297
1298        tracker
1299            .open(PathBuf::from("/test/file1.rs"), "content1".to_string())
1300            .unwrap();
1301        tracker
1302            .open(PathBuf::from("/test/file2.rs"), "content2".to_string())
1303            .unwrap();
1304        tracker
1305            .open(PathBuf::from("/test/file3.rs"), "content3".to_string())
1306            .unwrap();
1307
1308        assert_eq!(tracker.len(), 3);
1309
1310        let closed = tracker.close_all();
1311        assert_eq!(closed.len(), 3);
1312        assert!(tracker.is_empty());
1313    }
1314
1315    #[test]
1316    fn test_get_nonexistent_document() {
1317        let map = HashMap::new();
1318        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1319        let path = PathBuf::from("/test/nonexistent.rs");
1320
1321        let state = tracker.get(&path);
1322        assert!(
1323            state.is_none(),
1324            "Getting non-existent document should return None"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_document_version_increments() {
1330        let mut map = HashMap::new();
1331        map.insert("rs".to_string(), "rust".to_string());
1332
1333        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1334        let path = PathBuf::from("/test/versioned.rs");
1335
1336        tracker.open(path.clone(), "v1".to_string()).unwrap();
1337        assert_eq!(tracker.get(&path).unwrap().version(), 1);
1338
1339        tracker.update(&path, "v2".to_string());
1340        assert_eq!(tracker.get(&path).unwrap().version(), 2);
1341
1342        tracker.update(&path, "v3".to_string());
1343        assert_eq!(tracker.get(&path).unwrap().version(), 3);
1344
1345        tracker.update(&path, "v4".to_string());
1346        assert_eq!(tracker.get(&path).unwrap().version(), 4);
1347    }
1348
1349    #[test]
1350    #[allow(clippy::too_many_lines)]
1351    fn test_detect_language_all_extensions() {
1352        let mut map = HashMap::new();
1353        map.insert("rs".to_string(), "rust".to_string());
1354        map.insert("py".to_string(), "python".to_string());
1355        map.insert("pyw".to_string(), "python".to_string());
1356        map.insert("pyi".to_string(), "python".to_string());
1357        map.insert("js".to_string(), "javascript".to_string());
1358        map.insert("mjs".to_string(), "javascript".to_string());
1359        map.insert("cjs".to_string(), "javascript".to_string());
1360        map.insert("ts".to_string(), "typescript".to_string());
1361        map.insert("mts".to_string(), "typescript".to_string());
1362        map.insert("cts".to_string(), "typescript".to_string());
1363        map.insert("tsx".to_string(), "typescriptreact".to_string());
1364        map.insert("jsx".to_string(), "javascriptreact".to_string());
1365        map.insert("go".to_string(), "go".to_string());
1366        map.insert("c".to_string(), "c".to_string());
1367        map.insert("h".to_string(), "c".to_string());
1368        map.insert("cpp".to_string(), "cpp".to_string());
1369        map.insert("cc".to_string(), "cpp".to_string());
1370        map.insert("cxx".to_string(), "cpp".to_string());
1371        map.insert("hpp".to_string(), "cpp".to_string());
1372        map.insert("hh".to_string(), "cpp".to_string());
1373        map.insert("hxx".to_string(), "cpp".to_string());
1374        map.insert("java".to_string(), "java".to_string());
1375        map.insert("rb".to_string(), "ruby".to_string());
1376        map.insert("php".to_string(), "php".to_string());
1377        map.insert("swift".to_string(), "swift".to_string());
1378        map.insert("kt".to_string(), "kotlin".to_string());
1379        map.insert("kts".to_string(), "kotlin".to_string());
1380        map.insert("scala".to_string(), "scala".to_string());
1381        map.insert("sc".to_string(), "scala".to_string());
1382        map.insert("zig".to_string(), "zig".to_string());
1383        map.insert("lua".to_string(), "lua".to_string());
1384        map.insert("sh".to_string(), "shellscript".to_string());
1385        map.insert("bash".to_string(), "shellscript".to_string());
1386        map.insert("zsh".to_string(), "shellscript".to_string());
1387        map.insert("json".to_string(), "json".to_string());
1388        map.insert("toml".to_string(), "toml".to_string());
1389        map.insert("yaml".to_string(), "yaml".to_string());
1390        map.insert("yml".to_string(), "yaml".to_string());
1391        map.insert("xml".to_string(), "xml".to_string());
1392        map.insert("html".to_string(), "html".to_string());
1393        map.insert("htm".to_string(), "html".to_string());
1394        map.insert("css".to_string(), "css".to_string());
1395        map.insert("scss".to_string(), "scss".to_string());
1396        map.insert("less".to_string(), "less".to_string());
1397        map.insert("md".to_string(), "markdown".to_string());
1398        map.insert("markdown".to_string(), "markdown".to_string());
1399
1400        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
1401        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
1402        assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
1403        assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
1404        assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
1405        assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
1406        assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
1407        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
1408        assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
1409        assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
1410        assert_eq!(
1411            detect_language(Path::new("component.tsx"), &map),
1412            "typescriptreact"
1413        );
1414        assert_eq!(
1415            detect_language(Path::new("component.jsx"), &map),
1416            "javascriptreact"
1417        );
1418        assert_eq!(detect_language(Path::new("main.go"), &map), "go");
1419        assert_eq!(detect_language(Path::new("main.c"), &map), "c");
1420        assert_eq!(detect_language(Path::new("header.h"), &map), "c");
1421        assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
1422        assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
1423        assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
1424        assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
1425        assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
1426        assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
1427        assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
1428        assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
1429        assert_eq!(detect_language(Path::new("index.php"), &map), "php");
1430        assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
1431        assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
1432        assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
1433        assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
1434        assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
1435        assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
1436        assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
1437        assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
1438        assert_eq!(
1439            detect_language(Path::new("script.bash"), &map),
1440            "shellscript"
1441        );
1442        assert_eq!(
1443            detect_language(Path::new("script.zsh"), &map),
1444            "shellscript"
1445        );
1446        assert_eq!(detect_language(Path::new("data.json"), &map), "json");
1447        assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
1448        assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
1449        assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
1450        assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
1451        assert_eq!(detect_language(Path::new("index.html"), &map), "html");
1452        assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
1453        assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
1454        assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
1455        assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
1456        assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
1457        assert_eq!(
1458            detect_language(Path::new("README.markdown"), &map),
1459            "markdown"
1460        );
1461        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
1462        assert_eq!(
1463            detect_language(Path::new("no_extension"), &map),
1464            "plaintext"
1465        );
1466    }
1467
1468    #[test]
1469    fn test_path_to_uri_unix() {
1470        #[cfg(not(windows))]
1471        {
1472            let path = Path::new("/home/user/project/main.rs");
1473            let uri = path_to_uri(path).unwrap();
1474            assert!(
1475                uri.as_str()
1476                    .starts_with("file:///home/user/project/main.rs")
1477            );
1478        }
1479    }
1480
1481    #[test]
1482    fn test_path_to_uri_with_special_chars() {
1483        let path = Path::new("/home/user/project-test/main.rs");
1484        let uri = path_to_uri(path).unwrap();
1485        assert!(uri.as_str().starts_with("file://"));
1486        assert!(uri.as_str().contains("project-test"));
1487    }
1488
1489    #[test]
1490    fn test_path_to_uri_percent_encodes_reserved_chars() {
1491        #[cfg(windows)]
1492        let path = Path::new(r"C:\home\user\routes\api\[...]^|.ts");
1493        #[cfg(not(windows))]
1494        let path = Path::new("/home/user/routes/api/[...]^|.ts");
1495
1496        let uri = path_to_uri(path).unwrap();
1497
1498        #[cfg(windows)]
1499        let expected = "file:///C:/home/user/routes/api/%5B...%5D%5E%7C.ts";
1500        #[cfg(not(windows))]
1501        let expected = "file:///home/user/routes/api/%5B...%5D%5E%7C.ts";
1502
1503        assert_eq!(uri.as_str(), expected);
1504        assert_eq!(
1505            uri_to_path(&uri).as_deref(),
1506            Some(path),
1507            "encoded file URI should round-trip to the original path"
1508        );
1509    }
1510
1511    #[test]
1512    fn test_try_path_to_uri_returns_none_for_relative_path() {
1513        assert_eq!(try_path_to_uri(Path::new("relative/file.ts")), None);
1514    }
1515
1516    /// #234 regression: `path_to_uri` must surface a conversion failure as
1517    /// `Err`, not panic -- the whole point of the fix was making this path
1518    /// testable instead of aborting the process.
1519    #[test]
1520    fn test_path_to_uri_returns_err_for_relative_path() {
1521        let err = path_to_uri(Path::new("relative/file.ts")).unwrap_err();
1522        assert!(matches!(err, Error::InvalidUri(_)));
1523    }
1524
1525    #[cfg(windows)]
1526    #[test]
1527    fn test_try_path_to_uri_encodes_synthetic_windows_root() {
1528        let uri = try_path_to_uri(Path::new("/home/user/#work %23")).unwrap();
1529
1530        assert_eq!(uri.as_str(), "file:///home/user/%23work%20%2523");
1531    }
1532
1533    #[test]
1534    fn test_path_to_uri_percent_encodes_reserved_chars_in_short_path() {
1535        // Regression: reserved chars near the URI start must still be encoded.
1536        #[cfg(windows)]
1537        let path = Path::new(r"C:\[a].ts");
1538        #[cfg(not(windows))]
1539        let path = Path::new("/[a].ts");
1540
1541        let uri = path_to_uri(path).unwrap();
1542
1543        assert!(
1544            uri.as_str().ends_with("%5Ba%5D.ts"),
1545            "short path should percent-encode reserved chars, got {}",
1546            uri.as_str()
1547        );
1548        assert_eq!(uri_to_path(&uri).as_deref(), Some(path));
1549    }
1550
1551    #[test]
1552    fn test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars() {
1553        // RFC 3986 §2.2 "other reserved" characters. The `url` crate already
1554        // percent-encodes `{`, `}`, and backtick when serializing; `[`, `]`,
1555        // `^`, `|` are handled explicitly by `encode_rfc3986_path_chars`.
1556        #[cfg(windows)]
1557        let path = Path::new(r"C:\home\user\test[]^|{}`.ts");
1558        #[cfg(not(windows))]
1559        let path = Path::new("/home/user/test[]^|{}`.ts");
1560
1561        let uri = try_path_to_uri(path).unwrap();
1562        let uri_str = uri.as_str();
1563
1564        for (raw, encoded) in [
1565            ('[', "%5B"),
1566            (']', "%5D"),
1567            ('^', "%5E"),
1568            ('|', "%7C"),
1569            ('{', "%7B"),
1570            ('}', "%7D"),
1571            ('`', "%60"),
1572        ] {
1573            assert!(
1574                uri_str.contains(encoded),
1575                "expected {raw:?} to be percent-encoded as {encoded} in {uri_str}"
1576            );
1577        }
1578        assert!(
1579            !uri_str.contains(['[', ']', '^', '|', '{', '}', '`']),
1580            "no raw reserved characters should remain in {uri_str}"
1581        );
1582    }
1583
1584    #[test]
1585    fn test_document_tracker_concurrent_operations() {
1586        let mut map = HashMap::new();
1587        map.insert("rs".to_string(), "rust".to_string());
1588
1589        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1590        let path1 = PathBuf::from("/test/file1.rs");
1591        let path2 = PathBuf::from("/test/file2.rs");
1592
1593        tracker.open(path1.clone(), "content1".to_string()).unwrap();
1594        tracker.open(path2.clone(), "content2".to_string()).unwrap();
1595
1596        assert_eq!(tracker.len(), 2);
1597        assert!(tracker.is_open(&path1));
1598        assert!(tracker.is_open(&path2));
1599
1600        tracker.update(&path1, "new content1".to_string());
1601        assert_eq!(tracker.get(&path1).unwrap().content(), "new content1");
1602        assert_eq!(tracker.get(&path2).unwrap().content(), "content2");
1603
1604        tracker.close(&path1);
1605        assert_eq!(tracker.len(), 1);
1606        assert!(!tracker.is_open(&path1));
1607        assert!(tracker.is_open(&path2));
1608    }
1609
1610    #[test]
1611    fn test_empty_content() {
1612        let mut map = HashMap::new();
1613        map.insert("rs".to_string(), "rust".to_string());
1614
1615        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1616        let path = PathBuf::from("/test/empty.rs");
1617
1618        tracker.open(path.clone(), String::new()).unwrap();
1619        assert!(tracker.is_open(&path));
1620        assert_eq!(tracker.get(&path).unwrap().content(), "");
1621    }
1622
1623    #[test]
1624    fn test_unicode_content() {
1625        let mut map = HashMap::new();
1626        map.insert("rs".to_string(), "rust".to_string());
1627
1628        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1629        let path = PathBuf::from("/test/unicode.rs");
1630        let content = "fn テスト() { println!(\"こんにちは\"); }";
1631
1632        tracker.open(path.clone(), content.to_string()).unwrap();
1633        assert_eq!(tracker.get(&path).unwrap().content(), content);
1634    }
1635
1636    #[test]
1637    fn test_document_limit_exact_boundary() {
1638        let limits = ResourceLimits {
1639            max_documents: 5,
1640            max_file_size: 1000,
1641        };
1642        let mut map = HashMap::new();
1643        map.insert("rs".to_string(), "rust".to_string());
1644
1645        let tracker = DocumentTracker::new(limits, map);
1646
1647        for i in 0..5 {
1648            tracker
1649                .open(
1650                    PathBuf::from(format!("/test/file{i}.rs")),
1651                    "content".to_string(),
1652                )
1653                .unwrap();
1654        }
1655
1656        assert_eq!(tracker.len(), 5);
1657
1658        let result = tracker.open(PathBuf::from("/test/file6.rs"), "content".to_string());
1659        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
1660    }
1661
1662    #[test]
1663    fn test_file_size_exact_boundary() {
1664        let limits = ResourceLimits {
1665            max_documents: 10,
1666            max_file_size: 100,
1667        };
1668        let mut map = HashMap::new();
1669        map.insert("rs".to_string(), "rust".to_string());
1670
1671        let tracker = DocumentTracker::new(limits, map);
1672
1673        let exact_size_content = "x".repeat(100);
1674        tracker
1675            .open(PathBuf::from("/test/exact.rs"), exact_size_content)
1676            .unwrap();
1677
1678        let over_size_content = "x".repeat(101);
1679        let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
1680        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
1681    }
1682
1683    #[test]
1684    fn test_detect_language_with_custom_extension() {
1685        let mut map = HashMap::new();
1686        map.insert("nu".to_string(), "nushell".to_string());
1687
1688        assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
1689
1690        let empty_map = HashMap::new();
1691        assert_eq!(
1692            detect_language(Path::new("script.nu"), &empty_map),
1693            "plaintext"
1694        );
1695    }
1696
1697    #[test]
1698    fn test_detect_language_custom_overrides_default() {
1699        let mut custom_map = HashMap::new();
1700        custom_map.insert("rs".to_string(), "custom-rust".to_string());
1701
1702        assert_eq!(
1703            detect_language(Path::new("main.rs"), &custom_map),
1704            "custom-rust"
1705        );
1706
1707        let mut default_map = HashMap::new();
1708        default_map.insert("rs".to_string(), "rust".to_string());
1709
1710        assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
1711    }
1712
1713    #[test]
1714    fn test_detect_language_fallback_to_plaintext() {
1715        let mut map = HashMap::new();
1716        map.insert("nu".to_string(), "nushell".to_string());
1717
1718        // .rs not in custom map, should return plaintext
1719        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
1720    }
1721
1722    #[test]
1723    fn test_detect_language_empty_map() {
1724        let map = HashMap::new();
1725        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
1726    }
1727
1728    #[test]
1729    fn test_document_tracker_with_extensions() {
1730        let mut map = HashMap::new();
1731        map.insert("nu".to_string(), "nushell".to_string());
1732
1733        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1734
1735        let path = PathBuf::from("/test/script.nu");
1736        tracker
1737            .open(path.clone(), "# nushell script".to_string())
1738            .unwrap();
1739
1740        let state = tracker.get(&path).unwrap();
1741        assert_eq!(state.language_id(), "nushell");
1742    }
1743
1744    #[test]
1745    fn test_document_tracker_uses_provided_map() {
1746        let mut map = HashMap::new();
1747        map.insert("rs".to_string(), "rust".to_string());
1748
1749        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1750        let path = PathBuf::from("/test/main.rs");
1751        tracker
1752            .open(path.clone(), "fn main() {}".to_string())
1753            .unwrap();
1754
1755        let state = tracker.get(&path).unwrap();
1756        assert_eq!(state.language_id(), "rust");
1757    }
1758
1759    #[test]
1760    fn test_multiple_extensions_same_language() {
1761        let mut map = HashMap::new();
1762        map.insert("cpp".to_string(), "c++".to_string());
1763        map.insert("cc".to_string(), "c++".to_string());
1764        map.insert("cxx".to_string(), "c++".to_string());
1765
1766        assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
1767        assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
1768        assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
1769    }
1770
1771    #[test]
1772    fn test_case_sensitive_extensions() {
1773        let mut map = HashMap::new();
1774        map.insert("NU".to_string(), "nushell".to_string());
1775
1776        // Lowercase .nu should not match uppercase "NU" in map
1777        assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
1778    }
1779
1780    // ------------------------------------------------------------------
1781    // uri_to_path
1782    // ------------------------------------------------------------------
1783
1784    #[cfg(unix)]
1785    #[test]
1786    fn test_uri_to_path_file_scheme() {
1787        let uri: Uri = "file:///home/user/main.rs".parse().unwrap();
1788        let path = uri_to_path(&uri).unwrap();
1789        assert_eq!(path, PathBuf::from("/home/user/main.rs"));
1790    }
1791
1792    #[test]
1793    fn test_uri_to_path_non_file_scheme_returns_none() {
1794        let uri: Uri = "https://example.com/file.rs".parse().unwrap();
1795        assert!(uri_to_path(&uri).is_none());
1796    }
1797
1798    #[test]
1799    fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
1800        // Custom scheme must not be decoded by uri_to_path.
1801        let uri: Uri = "lsp-diagnostics:///home/user/main.rs".parse().unwrap();
1802        assert!(uri_to_path(&uri).is_none());
1803    }
1804
1805    #[test]
1806    fn test_uri_to_path_with_authority_returns_none() {
1807        // Authority-bearing file URIs must be rejected (UNC path defence).
1808        // lsp_types::Uri may or may not accept this string; either way
1809        // uri_to_path should return None.
1810        let result = "file://server/share/path.rs"
1811            .parse::<Uri>()
1812            .ok()
1813            .and_then(|u| uri_to_path(&u));
1814        assert!(result.is_none());
1815    }
1816
1817    // ------------------------------------------------------------------
1818    // open_paths
1819    // ------------------------------------------------------------------
1820
1821    #[test]
1822    fn test_open_paths_empty_tracker() {
1823        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1824        assert_eq!(tracker.open_paths().len(), 0);
1825    }
1826
1827    #[test]
1828    fn test_open_paths_populated_tracker() {
1829        let mut map = HashMap::new();
1830        map.insert("rs".to_string(), "rust".to_string());
1831        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1832        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
1833        tracker.open(PathBuf::from("/b.rs"), String::new()).unwrap();
1834        let mut paths = tracker.open_paths();
1835        paths.sort();
1836        assert_eq!(paths, [PathBuf::from("/a.rs"), PathBuf::from("/b.rs")]);
1837    }
1838
1839    #[test]
1840    fn test_open_paths_after_close() {
1841        let mut map = HashMap::new();
1842        map.insert("rs".to_string(), "rust".to_string());
1843        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1844        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
1845        tracker.close(Path::new("/a.rs"));
1846        assert_eq!(tracker.open_paths().len(), 0);
1847    }
1848
1849    // ------------------------------------------------------------------
1850    // ensure_open resync (issue #102)
1851    // ------------------------------------------------------------------
1852
1853    use std::process::Stdio;
1854
1855    use tempfile::TempDir;
1856    use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
1857    use tokio::process::{Child, ChildStdin, ChildStdout, Command};
1858
1859    use crate::config::LspServerConfig;
1860    use crate::lsp::LspTransport;
1861
1862    /// Holds both fake-transport child processes alive for a test.
1863    ///
1864    /// The read-half's stdin is deliberately never written to, so its `cat`
1865    /// process never sees EOF on input, never exits, and its stdout (which
1866    /// backs the transport's `receive()`) never closes -- `receive()` pends
1867    /// forever instead of observing EOF and tearing down the client's
1868    /// message loop. Using `echo` here instead would exit immediately and
1869    /// break every subsequent `notify()` call.
1870    ///
1871    /// `write_stdout` is the write-half's own stdout: since `cat` echoes
1872    /// whatever mcpls writes to its stdin, reading this back is how a test
1873    /// observes the actual framed JSON-RPC bytes sent to the "server".
1874    struct FakeServer {
1875        _write_half: Child,
1876        _read_half: Child,
1877        _read_half_stdin: ChildStdin,
1878        write_stdout: ChildStdout,
1879    }
1880
1881    /// Builds an `LspClient` backed by two `cat` child processes so
1882    /// `notify()` succeeds without a real language server.
1883    fn fake_lsp_client() -> (LspClient, FakeServer) {
1884        let mut write_half = Command::new("cat")
1885            .stdin(Stdio::piped())
1886            .stdout(Stdio::piped())
1887            .kill_on_drop(true)
1888            .spawn()
1889            .unwrap();
1890        let write_stdin = write_half.stdin.take().unwrap();
1891        let write_stdout = write_half.stdout.take().unwrap();
1892
1893        let mut read_half = Command::new("cat")
1894            .stdin(Stdio::piped())
1895            .stdout(Stdio::piped())
1896            .kill_on_drop(true)
1897            .spawn()
1898            .unwrap();
1899        let read_stdout = read_half.stdout.take().unwrap();
1900        let read_stdin = read_half.stdin.take().unwrap();
1901
1902        let transport = LspTransport::new(write_stdin, read_stdout);
1903        let client = LspClient::from_transport(LspServerConfig::rust_analyzer(), transport);
1904
1905        (
1906            client,
1907            FakeServer {
1908                _write_half: write_half,
1909                _read_half: read_half,
1910                _read_half_stdin: read_stdin,
1911                write_stdout,
1912            },
1913        )
1914    }
1915
1916    /// Backdates or forwards a file's mtime for deterministic disk-sync tests.
1917    ///
1918    /// Opened with `write(true)` rather than [`std::fs::File::open`]: on
1919    /// Windows, `set_modified` needs a handle with write access, and a
1920    /// read-only handle fails with `PermissionDenied` (Unix's
1921    /// `utimensat`-based implementation has no such requirement, which is
1922    /// why a read-only handle works there).
1923    fn set_mtime(path: &Path, time: SystemTime) {
1924        let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1925        file.set_modified(time).unwrap();
1926    }
1927
1928    fn settled_past() -> SystemTime {
1929        SystemTime::now() - Duration::from_secs(10)
1930    }
1931
1932    /// Reads one `Content-Length`-framed JSON-RPC message off `reader`.
1933    ///
1934    /// `reader` must be reused across calls (not recreated per message):
1935    /// a fresh `BufReader` would silently drop any bytes of a later message
1936    /// it over-read into its internal buffer while parsing an earlier one.
1937    async fn read_framed_message(reader: &mut BufReader<&mut ChildStdout>) -> serde_json::Value {
1938        let mut content_length = None;
1939        let mut line = String::new();
1940        loop {
1941            line.clear();
1942            reader.read_line(&mut line).await.unwrap();
1943            if line == "\r\n" || line == "\n" {
1944                break;
1945            }
1946            if let Some((key, value)) = line.trim_end().split_once(':')
1947                && key.trim().eq_ignore_ascii_case("content-length")
1948            {
1949                content_length = Some(value.trim().parse::<usize>().unwrap());
1950            }
1951        }
1952        let mut buf = vec![0u8; content_length.unwrap()];
1953        reader.read_exact(&mut buf).await.unwrap();
1954        serde_json::from_slice(&buf).unwrap()
1955    }
1956
1957    #[test]
1958    fn test_mtime_settled_boundary() {
1959        let read_at = SystemTime::now();
1960        assert!(!mtime_settled(None, read_at), "no mtime is never settled");
1961        assert!(
1962            mtime_settled(Some(read_at - Duration::from_secs(3)), read_at),
1963            "3s older than read_at is past the 2s granularity margin"
1964        );
1965        assert!(
1966            !mtime_settled(Some(read_at - Duration::from_secs(1)), read_at),
1967            "1s older than read_at is within the 2s granularity margin"
1968        );
1969        assert!(
1970            !mtime_settled(Some(read_at + Duration::from_secs(10)), read_at),
1971            "an mtime after read_at is never settled"
1972        );
1973    }
1974
1975    #[tokio::test]
1976    async fn test_ensure_open_unchanged_file_is_fast_path() {
1977        let dir = TempDir::new().unwrap();
1978        let path = dir.path().join("a.rs");
1979        std::fs::write(&path, "fn main() {}").unwrap();
1980        set_mtime(&path, settled_past());
1981
1982        let (client, _server) = fake_lsp_client();
1983        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1984
1985        let uri1 = tracker
1986            .ensure_open(&path, &ServerId::from("rust"), &client)
1987            .await
1988            .unwrap();
1989        assert_eq!(tracker.get(&path).unwrap().version(), 1);
1990
1991        let uri2 = tracker
1992            .ensure_open(&path, &ServerId::from("rust"), &client)
1993            .await
1994            .unwrap();
1995        assert_eq!(uri1, uri2);
1996        assert_eq!(tracker.get(&path).unwrap().version(), 1);
1997        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
1998    }
1999
2000    #[tokio::test]
2001    async fn test_ensure_open_resyncs_on_size_change() {
2002        let dir = TempDir::new().unwrap();
2003        let path = dir.path().join("a.rs");
2004        std::fs::write(&path, "fn main() {}").unwrap();
2005        set_mtime(&path, settled_past());
2006
2007        let (client, _server) = fake_lsp_client();
2008        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2009        tracker
2010            .ensure_open(&path, &ServerId::from("rust"), &client)
2011            .await
2012            .unwrap();
2013
2014        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
2015        set_mtime(&path, settled_past());
2016
2017        tracker
2018            .ensure_open(&path, &ServerId::from("rust"), &client)
2019            .await
2020            .unwrap();
2021        let state = tracker.get(&path).unwrap();
2022        assert_eq!(state.version(), 2);
2023        assert_eq!(state.content(), "fn main() { println!(\"hi\"); }");
2024    }
2025
2026    #[tokio::test(start_paused = true)]
2027    async fn test_ensure_open_regression_102_103_racy_same_size_rewrite() {
2028        let dir = TempDir::new().unwrap();
2029        let path = dir.path().join("a.rs");
2030        std::fs::write(&path, "AAAA").unwrap();
2031        // Leave the mtime at "now" (racy) rather than backdating it.
2032
2033        let (client, _server) = fake_lsp_client();
2034        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2035        tracker
2036            .ensure_open(&path, &ServerId::from("rust"), &client)
2037            .await
2038            .unwrap();
2039        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2040
2041        // Same-length rewrite with the mtime forced back to the recorded
2042        // value -- exactly the same-tick rewrite issue #102/#103 missed.
2043        std::fs::write(&path, "BBBB").unwrap();
2044        set_mtime(&path, original_mtime);
2045
2046        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2047
2048        tracker
2049            .ensure_open(&path, &ServerId::from("rust"), &client)
2050            .await
2051            .unwrap();
2052        let state = tracker.get(&path).unwrap();
2053        assert_eq!(
2054            state.version(),
2055            2,
2056            "must resync despite identical (mtime, size)"
2057        );
2058        assert_eq!(state.content(), "BBBB");
2059    }
2060
2061    #[tokio::test(start_paused = true)]
2062    async fn test_ensure_open_regression_102_103_settled_mtime_is_the_documented_limit() {
2063        let dir = TempDir::new().unwrap();
2064        let path = dir.path().join("a.rs");
2065        std::fs::write(&path, "AAAA").unwrap();
2066        set_mtime(&path, settled_past());
2067
2068        let (client, _server) = fake_lsp_client();
2069        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2070        tracker
2071            .ensure_open(&path, &ServerId::from("rust"), &client)
2072            .await
2073            .unwrap();
2074        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2075
2076        // Same-length rewrite restoring an already-settled mtime: this is
2077        // the documented residual limitation (e.g. `tar x`, `rsync -a`),
2078        // not a bug -- it is out of reach without hashing on every access.
2079        std::fs::write(&path, "BBBB").unwrap();
2080        set_mtime(&path, original_mtime);
2081
2082        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2083
2084        tracker
2085            .ensure_open(&path, &ServerId::from("rust"), &client)
2086            .await
2087            .unwrap();
2088        let state = tracker.get(&path).unwrap();
2089        assert_eq!(state.version(), 1, "documented limitation: fast path taken");
2090        assert_eq!(state.content(), "AAAA");
2091    }
2092
2093    #[tokio::test(start_paused = true)]
2094    async fn test_ensure_open_stat_is_never_debounced() {
2095        let dir = TempDir::new().unwrap();
2096        let path = dir.path().join("a.rs");
2097        std::fs::write(&path, "AAAA").unwrap();
2098        set_mtime(&path, settled_past());
2099
2100        let (client, _server) = fake_lsp_client();
2101        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2102        tracker
2103            .ensure_open(&path, &ServerId::from("rust"), &client)
2104            .await
2105            .unwrap();
2106
2107        // Different-size rewrite with no time advance at all: must resync
2108        // immediately, proving the debounce never gates the stat itself.
2109        std::fs::write(&path, "BBBBBBBB").unwrap();
2110        tracker
2111            .ensure_open(&path, &ServerId::from("rust"), &client)
2112            .await
2113            .unwrap();
2114
2115        let state = tracker.get(&path).unwrap();
2116        assert_eq!(state.version(), 2);
2117        assert_eq!(state.content(), "BBBBBBBB");
2118    }
2119
2120    #[tokio::test(start_paused = true)]
2121    async fn test_ensure_open_debounce_gates_reread_only() {
2122        let dir = TempDir::new().unwrap();
2123        let path = dir.path().join("a.rs");
2124        std::fs::write(&path, "AAAA").unwrap();
2125        // Racy: leave the mtime at "now".
2126
2127        let (client, _server) = fake_lsp_client();
2128        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2129        tracker
2130            .ensure_open(&path, &ServerId::from("rust"), &client)
2131            .await
2132            .unwrap();
2133        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2134
2135        std::fs::write(&path, "BBBB").unwrap(); // same size
2136        set_mtime(&path, original_mtime); // stat matches, entry stays racy
2137
2138        // Inside the debounce window: the re-read is gated, cache wins.
2139        tracker
2140            .ensure_open(&path, &ServerId::from("rust"), &client)
2141            .await
2142            .unwrap();
2143        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2144
2145        tokio::time::advance(Duration::from_millis(300)).await;
2146        tracker
2147            .ensure_open(&path, &ServerId::from("rust"), &client)
2148            .await
2149            .unwrap();
2150        let state = tracker.get(&path).unwrap();
2151        assert_eq!(state.version(), 2);
2152        assert_eq!(state.content(), "BBBB");
2153    }
2154
2155    #[tokio::test]
2156    async fn test_ensure_open_deleted_file_errors_state_untouched() {
2157        let dir = TempDir::new().unwrap();
2158        let path = dir.path().join("a.rs");
2159        std::fs::write(&path, "fn main() {}").unwrap();
2160        set_mtime(&path, settled_past());
2161
2162        let (client, _server) = fake_lsp_client();
2163        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2164        tracker
2165            .ensure_open(&path, &ServerId::from("rust"), &client)
2166            .await
2167            .unwrap();
2168
2169        std::fs::remove_file(&path).unwrap();
2170
2171        let result = tracker
2172            .ensure_open(&path, &ServerId::from("rust"), &client)
2173            .await;
2174        assert!(matches!(result, Err(Error::FileIo { .. })));
2175        assert!(tracker.is_open(&path));
2176        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2177        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
2178    }
2179
2180    #[tokio::test]
2181    async fn test_ensure_open_grows_past_limit_errors_state_intact() {
2182        let dir = TempDir::new().unwrap();
2183        let path = dir.path().join("a.rs");
2184        std::fs::write(&path, "small").unwrap();
2185        set_mtime(&path, settled_past());
2186
2187        let limits = ResourceLimits {
2188            max_documents: 10,
2189            max_file_size: 10,
2190        };
2191        let (client, _server) = fake_lsp_client();
2192        let tracker = DocumentTracker::new(limits, HashMap::new());
2193        tracker
2194            .ensure_open(&path, &ServerId::from("rust"), &client)
2195            .await
2196            .unwrap();
2197
2198        std::fs::write(&path, "x".repeat(100)).unwrap();
2199
2200        let result = tracker
2201            .ensure_open(&path, &ServerId::from("rust"), &client)
2202            .await;
2203        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
2204        assert_eq!(tracker.get(&path).unwrap().content(), "small");
2205        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2206    }
2207
2208    #[tokio::test]
2209    async fn test_ensure_open_resync_at_document_capacity() {
2210        let dir = TempDir::new().unwrap();
2211        let path = dir.path().join("a.rs");
2212        std::fs::write(&path, "AAAA").unwrap();
2213        set_mtime(&path, settled_past());
2214
2215        let limits = ResourceLimits {
2216            max_documents: 1,
2217            max_file_size: 0,
2218        };
2219        let (client, _server) = fake_lsp_client();
2220        let tracker = DocumentTracker::new(limits, HashMap::new());
2221        tracker
2222            .ensure_open(&path, &ServerId::from("rust"), &client)
2223            .await
2224            .unwrap();
2225        assert_eq!(tracker.len(), 1);
2226
2227        std::fs::write(&path, "BBBBBBBB").unwrap();
2228        let result = tracker
2229            .ensure_open(&path, &ServerId::from("rust"), &client)
2230            .await;
2231        assert!(
2232            result.is_ok(),
2233            "resync must not re-run the doc-count check on an already-tracked path"
2234        );
2235        assert_eq!(tracker.len(), 1);
2236        assert_eq!(tracker.get(&path).unwrap().version(), 2);
2237    }
2238
2239    #[tokio::test]
2240    async fn test_update_clears_disk_provenance() {
2241        let dir = TempDir::new().unwrap();
2242        let path = dir.path().join("a.rs");
2243        std::fs::write(&path, "fn main() {}").unwrap();
2244        set_mtime(&path, settled_past());
2245
2246        let (client, _server) = fake_lsp_client();
2247        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2248        tracker
2249            .ensure_open(&path, &ServerId::from("rust"), &client)
2250            .await
2251            .unwrap();
2252        assert!(tracker.get(&path).unwrap().disk.is_some());
2253
2254        tracker.update(&path, "fn main() { updated(); }".to_string());
2255        assert!(
2256            tracker.get(&path).unwrap().disk.is_none(),
2257            "update() must clear disk provenance so the next ensure_open re-verifies by content"
2258        );
2259    }
2260
2261    #[tokio::test]
2262    async fn test_first_open_self_heals_when_did_open_notify_fails() {
2263        let dir = TempDir::new().unwrap();
2264        let path = dir.path().join("a.rs");
2265        std::fs::write(&path, "fn main() {}").unwrap();
2266
2267        let (client, _server) = fake_lsp_client();
2268        // A clone shares the same command channel. Shutting down the
2269        // original (which owns the receiver task) blocks until the
2270        // background message loop has fully exited and dropped that
2271        // channel's receiver -- so the clone's next `notify()` fails
2272        // deterministically, with no race against process teardown.
2273        let notify_will_fail = client.clone();
2274        client.shutdown().await.unwrap();
2275
2276        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2277        let result = tracker
2278            .ensure_open(&path, &ServerId::from("rust"), &notify_will_fail)
2279            .await;
2280
2281        assert!(result.is_err(), "notify failure must propagate as an error");
2282        assert!(
2283            !tracker.is_open(&path),
2284            "a failed didOpen must not leave the document tracked, or the server \
2285             and tracker would stay permanently desynced"
2286        );
2287    }
2288
2289    #[tokio::test]
2290    async fn test_resync_sends_didchange_with_full_replacement_over_the_wire() {
2291        let dir = TempDir::new().unwrap();
2292        let path = dir.path().join("a.rs");
2293        std::fs::write(&path, "fn main() {}").unwrap();
2294        set_mtime(&path, settled_past());
2295
2296        let (client, mut server) = fake_lsp_client();
2297        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2298        tracker
2299            .ensure_open(&path, &ServerId::from("rust"), &client)
2300            .await
2301            .unwrap();
2302
2303        let mut wire = BufReader::new(&mut server.write_stdout);
2304        let opened = read_framed_message(&mut wire).await;
2305        assert_eq!(opened["method"], "textDocument/didOpen");
2306
2307        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
2308        set_mtime(&path, settled_past());
2309        tracker
2310            .ensure_open(&path, &ServerId::from("rust"), &client)
2311            .await
2312            .unwrap();
2313
2314        let changed = read_framed_message(&mut wire).await;
2315        assert_eq!(changed["method"], "textDocument/didChange");
2316        let params = &changed["params"];
2317        assert_eq!(params["textDocument"]["version"], 2);
2318        let change = &params["contentChanges"][0];
2319        assert!(
2320            change.get("range").is_none(),
2321            "range must be omitted, not null, for a full-replacement change"
2322        );
2323        assert!(
2324            change.get("rangeLength").is_none(),
2325            "rangeLength must be omitted, not null, for a full-replacement change"
2326        );
2327        assert_eq!(change["text"], "fn main() { println!(\"hi\"); }");
2328    }
2329
2330    /// Regression for #174 §7.1: a second server must receive `didOpen` even
2331    /// when the file has not changed since a first server was opened on it --
2332    /// the disk-phase fast path only skips the disk read, never the
2333    /// per-server sync decision. Exercises the settled-mtime fast path.
2334    #[tokio::test]
2335    async fn test_ensure_open_second_server_gets_didopen_no_disk_change() {
2336        let dir = TempDir::new().unwrap();
2337        let path = dir.path().join("a.rs");
2338        std::fs::write(&path, "fn main() {}").unwrap();
2339        set_mtime(&path, settled_past());
2340
2341        let (client_a, mut server_a) = fake_lsp_client();
2342        let (client_b, mut server_b) = fake_lsp_client();
2343        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2344
2345        let id_a = ServerId::from("server-a");
2346        let id_b = ServerId::from("server-b");
2347
2348        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
2349        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
2350        let opened_a = read_framed_message(&mut wire_a).await;
2351        assert_eq!(opened_a["method"], "textDocument/didOpen");
2352
2353        // No disk change between calls: server B's ensure_open must still
2354        // take the disk-phase fast path (settled mtime) but still send B its
2355        // own didOpen.
2356        tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
2357        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
2358        let opened_b = read_framed_message(&mut wire_b).await;
2359        assert_eq!(opened_b["method"], "textDocument/didOpen");
2360        assert_eq!(opened_b["params"]["textDocument"]["version"], 1);
2361        assert_eq!(opened_b["params"]["textDocument"]["text"], "fn main() {}");
2362    }
2363
2364    /// Same as above but through the unchanged-content re-read path (racy,
2365    /// unsettled mtime past the debounce window, forcing a real content
2366    /// compare) rather than the settled-mtime fast path.
2367    #[tokio::test(start_paused = true)]
2368    async fn test_ensure_open_second_server_gets_didopen_unchanged_content_path() {
2369        let dir = TempDir::new().unwrap();
2370        let path = dir.path().join("a.rs");
2371        std::fs::write(&path, "fn main() {}").unwrap();
2372        // Leave mtime racy (unsettled) rather than backdating it.
2373
2374        let (client_a, _server_a) = fake_lsp_client();
2375        let (client_b, mut server_b) = fake_lsp_client();
2376        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2377
2378        tracker
2379            .ensure_open(&path, &ServerId::from("server-a"), &client_a)
2380            .await
2381            .unwrap();
2382
2383        // Past the debounce window: server B's call must genuinely re-read
2384        // and compare content rather than taking either fast-path leg.
2385        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2386
2387        tracker
2388            .ensure_open(&path, &ServerId::from("server-b"), &client_b)
2389            .await
2390            .unwrap();
2391        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
2392        let opened_b = read_framed_message(&mut wire_b).await;
2393        assert_eq!(opened_b["method"], "textDocument/didOpen");
2394    }
2395
2396    /// Regression for #174 §6.2/§12: `prepare_call_hierarchy` and
2397    /// `incoming_calls`/`outgoing_calls` must resolve to the same server, since
2398    /// only `prepare` calls `ensure_open` -- pinned here at the tracker level
2399    /// by asserting a second `ensure_open` for the same server is a no-op
2400    /// once synced, so a caller that reuses the same `ServerId` for both
2401    /// calls never double-opens.
2402    #[tokio::test]
2403    async fn test_ensure_open_same_server_twice_sends_nothing_second_time() {
2404        let dir = TempDir::new().unwrap();
2405        let path = dir.path().join("a.rs");
2406        std::fs::write(&path, "fn main() {}").unwrap();
2407        set_mtime(&path, settled_past());
2408
2409        let (client, mut server) = fake_lsp_client();
2410        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2411        let id = ServerId::from("rust");
2412
2413        tracker.ensure_open(&path, &id, &client).await.unwrap();
2414        tracker.ensure_open(&path, &id, &client).await.unwrap();
2415
2416        let mut wire = BufReader::new(&mut server.write_stdout);
2417        let opened = read_framed_message(&mut wire).await;
2418        assert_eq!(opened["method"], "textDocument/didOpen");
2419        assert_eq!(
2420            tracker.get(&path).unwrap().synced_version(&id),
2421            Some(1),
2422            "second call for the same server must not re-open or re-change"
2423        );
2424    }
2425
2426    /// Regression for #174 §7.2/S6: a failing `didChange` for one server must
2427    /// leave that server's `synced` entry untouched (self-heals on retry)
2428    /// without disturbing another server that already synced successfully.
2429    #[tokio::test]
2430    async fn test_sync_phase_failed_didchange_does_not_disturb_other_server() {
2431        let dir = TempDir::new().unwrap();
2432        let path = dir.path().join("a.rs");
2433        std::fs::write(&path, "fn main() {}").unwrap();
2434        set_mtime(&path, settled_past());
2435
2436        let (client_a, _server_a) = fake_lsp_client();
2437        let (client_b, _server_b) = fake_lsp_client();
2438        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2439        let id_a = ServerId::from("server-a");
2440        let id_b = ServerId::from("server-b");
2441
2442        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
2443        tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
2444
2445        // Shut down B's client so its next notify fails, then change the file
2446        // so both servers have version 2 to catch up to.
2447        let client_b_will_fail = client_b.clone();
2448        client_b.shutdown().await.unwrap();
2449
2450        std::fs::write(&path, "fn main() { updated(); }").unwrap();
2451        set_mtime(&path, settled_past());
2452
2453        let result = tracker.ensure_open(&path, &id_b, &client_b_will_fail).await;
2454        assert!(result.is_err(), "B's didChange must fail and propagate");
2455
2456        // No commit happens before a successful notify: content, version and
2457        // both servers' `synced` entries all stay exactly as they were
2458        // before this call, so the next attempt retries from the same
2459        // starting point rather than drifting the tracker out of sync with
2460        // what was actually acknowledged over the wire.
2461        assert!(tracker.is_open(&path));
2462        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
2463        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2464        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_a), Some(1));
2465        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_b), Some(1));
2466
2467        // A's next call must independently detect the disk change (B's
2468        // failure did not consume it) and successfully advance both the
2469        // shared content/version and its own synced entry.
2470        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
2471        assert_eq!(
2472            tracker.get(&path).unwrap().content(),
2473            "fn main() { updated(); }"
2474        );
2475        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_a), Some(2));
2476        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_b), Some(1));
2477    }
2478
2479    // ------------------------------------------------------------------
2480    // ensure_open concurrency (issue #227)
2481    // ------------------------------------------------------------------
2482
2483    /// Regression for #227: `ensure_open` for one path must not block
2484    /// `ensure_open` for an unrelated path, even while the first call is
2485    /// stuck inside its own disk I/O.
2486    ///
2487    /// Simulated with a FIFO rather than a timing assumption: opening it for
2488    /// read blocks deterministically until a writer connects, so path A's
2489    /// `ensure_open` is guaranteed to still be in progress when path B's
2490    /// runs. Under the old design (a single lock spanning all of
2491    /// `ensure_open`, including disk I/O), path B would hang until path A's
2492    /// FIFO is unblocked below; the per-path lock added here must let it
2493    /// through immediately instead.
2494    #[cfg(unix)]
2495    #[tokio::test]
2496    async fn test_ensure_open_different_paths_do_not_serialize() {
2497        let dir = TempDir::new().unwrap();
2498        let path_a = dir.path().join("a.rs");
2499        let path_b = dir.path().join("b.rs");
2500
2501        std::fs::write(&path_b, "fn b() {}").unwrap();
2502        set_mtime(&path_b, settled_past());
2503
2504        let status = std::process::Command::new("mkfifo")
2505            .arg(&path_a)
2506            .status()
2507            .unwrap();
2508        assert!(status.success(), "mkfifo must succeed to set up this test");
2509
2510        let (client_a, _server_a) = fake_lsp_client();
2511        let (client_b, _server_b) = fake_lsp_client();
2512        let tracker = Arc::new(DocumentTracker::new(
2513            ResourceLimits::default(),
2514            HashMap::new(),
2515        ));
2516
2517        // Spawned so it can genuinely block on the FIFO's open() while the
2518        // rest of this test proceeds concurrently on the same runtime.
2519        let tracker_for_a = Arc::clone(&tracker);
2520        let path_a_for_task = path_a.clone();
2521        let handle_a = tokio::spawn(async move {
2522            tracker_for_a
2523                .ensure_open(&path_a_for_task, &ServerId::from("server-a"), &client_a)
2524                .await
2525        });
2526
2527        // Give the spawned task a chance to actually reach the FIFO's
2528        // blocking open() before racing it against path B below.
2529        tokio::time::sleep(Duration::from_millis(200)).await;
2530
2531        // A `timeout` error here means path B is blocked by path A's stuck
2532        // ensure_open -- the exact regression #227 fixes.
2533        tokio::time::timeout(
2534            Duration::from_secs(5),
2535            tracker.ensure_open(&path_b, &ServerId::from("server-b"), &client_b),
2536        )
2537        .await
2538        .unwrap()
2539        .unwrap();
2540
2541        // Unblock A: opening the FIFO for writing lets its open() proceed,
2542        // and closing the write end (at the end of this call) delivers EOF
2543        // to the read it's waiting to finish.
2544        let path_a_writer = path_a.clone();
2545        tokio::task::spawn_blocking(move || {
2546            std::fs::write(path_a_writer, "fn a() {}").unwrap();
2547        })
2548        .await
2549        .unwrap();
2550
2551        handle_a.await.unwrap().unwrap();
2552        assert_eq!(tracker.get(&path_a).unwrap().content(), "fn a() {}");
2553    }
2554
2555    /// Regression for #227: N concurrent `ensure_open` calls for the same
2556    /// path and the same server must still collapse into exactly one
2557    /// `didOpen` -- the per-path lock introduced to let different paths run
2558    /// concurrently must not weaken the existing same-path serialization
2559    /// that prevents duplicate opens.
2560    #[tokio::test]
2561    async fn test_ensure_open_concurrent_same_path_single_didopen() {
2562        let dir = TempDir::new().unwrap();
2563        let path = dir.path().join("a.rs");
2564        std::fs::write(&path, "fn main() {}").unwrap();
2565        set_mtime(&path, settled_past());
2566
2567        let (client, mut server) = fake_lsp_client();
2568        let tracker = Arc::new(DocumentTracker::new(
2569            ResourceLimits::default(),
2570            HashMap::new(),
2571        ));
2572        let id = ServerId::from("rust");
2573
2574        let mut handles = Vec::new();
2575        for _ in 0..8 {
2576            let tracker = Arc::clone(&tracker);
2577            let client = client.clone();
2578            let path = path.clone();
2579            let id = id.clone();
2580            handles.push(tokio::spawn(async move {
2581                tracker.ensure_open(&path, &id, &client).await
2582            }));
2583        }
2584        for handle in handles {
2585            handle.await.unwrap().unwrap();
2586        }
2587
2588        let mut wire = BufReader::new(&mut server.write_stdout);
2589        let opened = read_framed_message(&mut wire).await;
2590        assert_eq!(opened["method"], "textDocument/didOpen");
2591
2592        // No further notification should have been queued -- proves the 8
2593        // concurrent callers collapsed into exactly one `didOpen`.
2594        let extra =
2595            tokio::time::timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
2596        assert!(
2597            extra.is_err(),
2598            "expected no additional notification after the single didOpen"
2599        );
2600
2601        assert_eq!(tracker.get(&path).unwrap().synced_version(&id), Some(1));
2602        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2603    }
2604
2605    /// Regression for #227: `lock_path`'s guard must evict its `path_locks`
2606    /// entry once no caller is left waiting on it, or the map grows by one
2607    /// entry per distinct path ever opened for the lifetime of the process.
2608    /// Exercises three concurrent distinct paths (not just the two used in
2609    /// `test_ensure_open_different_paths_do_not_serialize`) to rule out an
2610    /// eviction bug that only manifests with more than two live entries.
2611    #[tokio::test]
2612    async fn test_ensure_open_path_locks_evicted_after_completion() {
2613        let dir = TempDir::new().unwrap();
2614        let paths: Vec<_> = ["a.rs", "b.rs", "c.rs"]
2615            .iter()
2616            .map(|name| dir.path().join(name))
2617            .collect();
2618        for path in &paths {
2619            std::fs::write(path, "fn f() {}").unwrap();
2620            set_mtime(path, settled_past());
2621        }
2622
2623        let tracker = Arc::new(DocumentTracker::new(
2624            ResourceLimits::default(),
2625            HashMap::new(),
2626        ));
2627        let id = ServerId::from("rust");
2628
2629        let mut handles = Vec::new();
2630        let mut servers = Vec::new();
2631        for path in paths.clone() {
2632            let tracker = Arc::clone(&tracker);
2633            let (client, server) = fake_lsp_client();
2634            servers.push(server);
2635            let id = id.clone();
2636            handles.push(tokio::spawn(async move {
2637                tracker.ensure_open(&path, &id, &client).await
2638            }));
2639        }
2640        for handle in handles {
2641            handle.await.unwrap().unwrap();
2642        }
2643        drop(servers);
2644
2645        assert!(
2646            lock_std(&tracker.path_locks).is_empty(),
2647            "path_locks must be fully evicted once every ensure_open call \
2648             for every path has completed, otherwise the map grows \
2649             unbounded for the lifetime of the process"
2650        );
2651    }
2652}