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