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, HashSet};
6use std::num::NonZeroU32;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Arc, Mutex as StdMutex};
10use std::time::{Duration, SystemTime};
11
12use lsp_types::{
13    DidChangeTextDocumentNotification, DidChangeTextDocumentParams,
14    DidCloseTextDocumentNotification, DidCloseTextDocumentParams, DidOpenTextDocumentNotification,
15    DidOpenTextDocumentParams, TextDocumentContentChangeEvent, TextDocumentItem, Uri,
16    VersionedTextDocumentIdentifier,
17};
18use serde::{Deserialize, Serialize};
19use tokio::fs;
20use tokio::io::{AsyncBufReadExt, AsyncReadExt};
21use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
22use tokio::time::Instant;
23use url::Url;
24
25use super::notifications::DocumentStamp;
26use super::{WorkspacePath, WorkspaceRoots};
27use crate::config::{DocumentLimit, LanguageId, LanguageMap, ServerId, SizeLimit};
28use crate::error::{BackgroundTask, Error, Result};
29use crate::lsp::LspClient;
30use crate::util::{BoundedUtf8Error, RegularFile, SizeExceeded, check_bounded_utf8, lock_std};
31
32/// Debounce window for re-reading a file's content when its mtime is not yet
33/// [`mtime_settled`]. The stat itself is never debounced -- only this
34/// (comparatively expensive) content re-read is rate-limited, so a burst of
35/// calls against a genuinely changed file still resyncs on the first stat
36/// that observes the new `(mtime, size)`.
37///
38/// This only bounds the *stable-but-unsettled* case: the same `(mtime,
39/// size)` observed repeatedly while that mtime is still within
40/// [`MTIME_GRANULARITY`] of "now". A file whose `(mtime, size)` changes on
41/// every stat is never debounced at all -- each such call already disagrees
42/// with the cached snapshot, so it always takes the immediate re-read path
43/// regardless of how recently the last one happened.
44const DISK_CHECK_DEBOUNCE: Duration = Duration::from_millis(250);
45
46/// Filesystem mtime granularity margin: covers FAT/exFAT (2s) and is a safe
47/// superset of HFS+/ext3/APFS (1s or finer). An mtime observed more recently
48/// than this cannot be trusted to distinguish "unchanged" from "rewritten
49/// within the same tick", so such entries are re-verified by content compare
50/// instead of by stat alone -- this is what closes the racy-rewrite gap.
51const MTIME_GRANULARITY: Duration = Duration::from_secs(2);
52
53/// Content up to this size is indexed inline; larger content goes to the
54/// blocking pool.
55const INLINE_INDEX_MAX_BYTES: usize = 1024 * 1024;
56
57/// Lines between two consecutive [`DocumentText`] checkpoints.
58const LINE_CHECKPOINT_STRIDE: usize = 64;
59
60/// Whether `byte` ends a line under the LSP 3.17 line model.
61const fn is_line_terminator(byte: u8) -> bool {
62    matches!(byte, b'\n' | b'\r')
63}
64
65/// Byte offset where the line starting at `start` ends (its terminator, or
66/// `content.len()` for the last line), and where the next line starts, or
67/// `None` for the last line. `\r\n` is one terminator.
68fn line_bounds(content: &str, start: usize) -> (usize, Option<usize>) {
69    let bytes = content.as_bytes();
70    let Some(offset) = bytes
71        .get(start..)
72        .and_then(|rest| rest.iter().position(|&b| is_line_terminator(b)))
73    else {
74        return (content.len(), None);
75    };
76    let end = start.saturating_add(offset);
77    let next = end.saturating_add(1);
78    let crlf = bytes.get(end) == Some(&b'\r') && bytes.get(next) == Some(&b'\n');
79    (end, Some(next.saturating_add(usize::from(crlf))))
80}
81
82/// A document's text together with sparse line checkpoints, so a line lookup
83/// scans at most one checkpoint stride rather than the whole document (#488).
84///
85/// Lines follow the LSP 3.17 line model, the same one
86/// [`DocumentTracker::read_line_checked`] applies to disk reads: a line ends
87/// at `\n`, `\r\n` or a lone `\r`, the terminator is not part of the line,
88/// and the empty line after a final terminator (or line 0 of empty content)
89/// exists. Servers that split only on `\n`/`\r\n` (reportedly rust-analyzer,
90/// gopls and clangd) disagree with this on files containing a lone `\r`;
91/// positions after such a `\r` are then converted against the wrong line
92/// text.
93///
94/// Content and checkpoints are built together and never mutated, so the index
95/// cannot go stale. Memory overhead is one `usize` per stride lines, at most
96/// `len / 8` bytes at the default stride even for content made only of empty
97/// lines.
98#[derive(Clone)]
99pub(super) struct DocumentText {
100    content: String,
101    digest: ContentDigest,
102    stride: usize,
103    /// `checkpoints[k]` is the byte offset where line `k * stride` starts.
104    checkpoints: Box<[usize]>,
105    /// Number of lines, saturating at `u32::MAX`; at least 1, since empty
106    /// content has line 0.
107    line_count: NonZeroU32,
108}
109
110impl std::fmt::Debug for DocumentText {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("DocumentText")
113            .field("len", &self.content.len())
114            .field("checkpoints", &self.checkpoints.len())
115            .finish_non_exhaustive()
116    }
117}
118
119impl DocumentText {
120    /// Indexes `content` at the default stride.
121    pub(super) fn new(content: String) -> Self {
122        Self::with_stride(content, LINE_CHECKPOINT_STRIDE)
123    }
124
125    /// Indexes `content`, with a checkpoint every `stride` lines (`stride`
126    /// is clamped to at least 1).
127    fn with_stride(content: String, stride: usize) -> Self {
128        let stride = stride.max(1);
129        let mut checkpoints = vec![0];
130        let mut start = 0;
131        let mut line = 0usize;
132        while let (_, Some(next)) = line_bounds(&content, start) {
133            start = next;
134            line = line.saturating_add(1);
135            if line.is_multiple_of(stride) {
136                checkpoints.push(start);
137            }
138        }
139        let line_count = u32::try_from(line)
140            .ok()
141            .and_then(|last| last.checked_add(1))
142            .and_then(NonZeroU32::new)
143            .unwrap_or(NonZeroU32::MAX);
144        Self {
145            digest: ContentDigest::of(&content),
146            content,
147            stride,
148            checkpoints: checkpoints.into_boxed_slice(),
149            line_count,
150        }
151    }
152
153    /// The digest of the full text, computed once with the line index.
154    pub(super) const fn digest(&self) -> ContentDigest {
155        self.digest
156    }
157
158    /// As [`Self::new`], but indexes content above [`INLINE_INDEX_MAX_BYTES`]
159    /// on the blocking pool so a large file does not stall the runtime.
160    async fn build(content: String) -> Result<Self> {
161        if content.len() <= INLINE_INDEX_MAX_BYTES {
162            return Ok(Self::new(content));
163        }
164        tokio::task::spawn_blocking(move || Self::new(content))
165            .await
166            .map_err(|e| Error::Io(std::io::Error::other(e)))
167    }
168
169    /// The full text.
170    pub(super) fn as_str(&self) -> &str {
171        &self.content
172    }
173
174    /// The 1-based number of the last line, which is also the line count.
175    pub(super) const fn last_line(&self) -> NonZeroU32 {
176        self.line_count
177    }
178
179    /// The 0-based `n`'th line without its terminator, or `None` if there is
180    /// no such line. Scans at most `stride - 1` terminators from the nearest
181    /// checkpoint plus the target line itself.
182    pub(super) fn line(&self, n: u32) -> Option<&str> {
183        let start = self.line_start(n)?;
184        let (end, _) = line_bounds(&self.content, start);
185        Some(&self.content[start..end])
186    }
187
188    fn line_start(&self, n: u32) -> Option<usize> {
189        let n = n as usize;
190        let mut start = *self.checkpoints.get(n.checked_div(self.stride)?)?;
191        for _ in 0..n.checked_rem(self.stride)? {
192            start = line_bounds(&self.content, start).1?;
193        }
194        Some(start)
195    }
196
197    /// The lines from the 0-based `n`'th on, without terminators; empty if
198    /// there is no such line.
199    pub(super) fn lines_from(&self, n: u32) -> impl Iterator<Item = &str> {
200        let mut next = self.line_start(n);
201        std::iter::from_fn(move || {
202            let start = next?;
203            let (end, after) = line_bounds(&self.content, start);
204            next = after;
205            self.content.get(start..end)
206        })
207    }
208}
209
210impl PartialEq for DocumentText {
211    fn eq(&self, other: &Self) -> bool {
212        self.content == other.content
213    }
214}
215
216impl Eq for DocumentText {}
217
218/// Returns whether `mtime` is old enough, relative to `read_at`, that a write
219/// landing after `read_at` could not have preserved it.
220///
221/// `read_at` must be captured *before* the filesystem is stat'd (not after any
222/// subsequent read), otherwise a write racing the read itself could produce a
223/// new mtime that still appears "settled" against a later timestamp.
224fn mtime_settled(mtime: Option<SystemTime>, read_at: SystemTime) -> bool {
225    mtime.is_some_and(|m| {
226        m.checked_add(MTIME_GRANULARITY)
227            .is_some_and(|t| t <= read_at)
228    })
229}
230
231/// A snapshot of a document's on-disk filesystem state, captured the last
232/// time its content was actually read and compared.
233///
234/// [`DocumentTracker::ensure_open`] stats the file on every call; when the
235/// stat matches this snapshot and [`Self::mtime_settled`] holds, the cached
236/// content is trusted without touching the file's bytes again. This is what
237/// keeps the common "file unchanged" path cheap while still detecting
238/// external edits (git checkout/stash, formatters, the MCP host's own
239/// edits) made outside mcpls.
240#[derive(Debug, Clone, Copy)]
241pub struct DiskSync {
242    /// Last observed modification time, or `None` if the filesystem or
243    /// platform does not report one (in which case the entry is never
244    /// treated as settled, forcing a content re-read outside the debounce
245    /// window).
246    pub mtime: Option<SystemTime>,
247    /// Last observed file size in bytes.
248    pub size: u64,
249    /// Whether `mtime` was already old enough, relative to when it was
250    /// observed, that a same-tick rewrite could not have preserved it.
251    pub mtime_settled: bool,
252    /// When the file's content was last actually re-read and compared.
253    ///
254    /// Used only to debounce the content re-read on a racy (not-yet-settled)
255    /// entry; deliberately excluded from equality so two otherwise-identical
256    /// snapshots don't compare unequal merely because they were checked at
257    /// different instants.
258    pub content_checked_at: Instant,
259}
260
261impl PartialEq for DiskSync {
262    fn eq(&self, other: &Self) -> bool {
263        self.mtime == other.mtime
264            && self.size == other.size
265            && self.mtime_settled == other.mtime_settled
266    }
267}
268
269impl Eq for DiskSync {}
270
271/// Version of a tracked document as sent to language servers in
272/// `didOpen`/`didChange`.
273///
274/// A distinct type so a document version is never confused with another
275/// integer, such as an opening counter.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
277#[serde(transparent)]
278pub struct DocumentVersion(i32);
279
280impl DocumentVersion {
281    /// The version a document starts at when first opened.
282    pub const FIRST: Self = Self(1);
283
284    /// Wraps the version number a server reported or was told.
285    #[must_use]
286    pub const fn new(version: i32) -> Self {
287        Self(version)
288    }
289
290    /// The version number as the LSP wire carries it.
291    #[must_use]
292    pub const fn get(self) -> i32 {
293        self.0
294    }
295
296    /// The version after this one, clamped at `i32::MAX`.
297    #[must_use]
298    pub(crate) const fn next(self) -> Self {
299        Self(self.0.saturating_add(1))
300    }
301}
302
303/// A digest of a document's text, so two states of a document can be told
304/// to hold the same content without keeping or comparing the text.
305///
306/// Equal texts have equal digests; the reverse holds up to hash collisions,
307/// which the sweep tolerates (a missed `didSave` until the next edit).
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub struct ContentDigest(u64);
310
311impl ContentDigest {
312    pub(crate) fn of(text: &str) -> Self {
313        use std::hash::{DefaultHasher, Hash as _, Hasher as _};
314
315        let mut hasher = DefaultHasher::new();
316        text.hash(&mut hasher);
317        Self(hasher.finish())
318    }
319}
320
321/// What the tracker knows about the state of a document a server was told
322/// about, read under one lock.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum SweepState {
325    /// The server was told the document's current version.
326    Current {
327        /// The opening and version the server has.
328        stamp: DocumentStamp,
329        /// The digest of that version's text.
330        digest: ContentDigest,
331    },
332    /// The document moved past the version the server was told.
333    Lagging {
334        /// The opening and version the server has.
335        stamp: DocumentStamp,
336    },
337}
338
339impl SweepState {
340    /// The opening and version the server was told.
341    pub(crate) const fn stamp(self) -> DocumentStamp {
342        match self {
343            Self::Current { stamp, .. } | Self::Lagging { stamp } => stamp,
344        }
345    }
346
347    /// Whether both states hold the same text: only two [`Self::Current`]
348    /// states with equal digests do, a state whose text is unknown compares as
349    /// different from everything.
350    pub(crate) fn same_text(self, other: Self) -> bool {
351        matches!(
352            (self, other),
353            (Self::Current { digest: a, .. }, Self::Current { digest: b, .. }) if a == b
354        )
355    }
356}
357
358/// Which opening of a path a tracked document belongs to; a reopened path
359/// gets a new one, so a cached result of an earlier opening is recognizably
360/// stale.
361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct Opening(u64);
363
364/// State of a single document.
365///
366/// All fields are private. `DocumentTracker::open` (via `Self::new`)
367/// establishes the initial state: `version` starts at 1, `disk` provenance
368/// starts `None`, and no server is recorded as synced. From there, every
369/// mutation goes through a dedicated method (`commit_reload`, `set_disk`,
370/// `mark_synced`, `forget_server`) rather than a
371/// partial field write, so within a single tracked lifetime `version` (see
372/// [`Self::version`]) only increases. This does not cover re-opening: calling
373/// `DocumentTracker::open` again for an already-tracked path unconditionally
374/// replaces the entry, resetting `version` to 1 and clearing `synced` -- see
375/// that method's docs.
376///
377/// The `disk` provenance invariant: `None` means the content's on-disk
378/// provenance is unknown (it came from an in-memory `open` call, not a
379/// verified disk read), so `ensure_open` must always re-verify by content
380/// compare rather than trusting a stat match. `DiskSync`'s hand-written
381/// `PartialEq` excludes `content_checked_at` (see that field's doc comment),
382/// and that exclusion propagates here: two `DocumentState`s can compare
383/// equal via this struct's own hand-written `PartialEq`/`Eq` (below) despite
384/// having been disk-verified at different instants. This is intentional --
385/// `content_checked_at` is a debounce timer, not part of a document's
386/// logical state. `last_accessed` (also excluded, for the same reason) is
387/// likewise not logical state, just an LRU-eviction timestamp (#495).
388#[derive(Debug, Clone)]
389pub(super) struct DocumentState {
390    uri: Uri,
391    language_id: LanguageId,
392    version: DocumentVersion,
393    text: DocumentText,
394    disk: Option<DiskSync>,
395    synced: HashMap<ServerId, DocumentVersion>,
396    opened: Opening,
397    /// When this document was last accessed via `ensure_open`
398    /// (`Self::touch`), used to pick the least-recently-used entry when
399    /// `DocumentTracker::open` must evict to stay under
400    /// `ResourceLimits::max_documents` (#495).
401    last_accessed: Instant,
402}
403
404impl PartialEq for DocumentState {
405    fn eq(&self, other: &Self) -> bool {
406        // Destructured (rather than plain field access) so a future new
407        // field fails to compile here until it's deliberately included or
408        // excluded -- unlike a derived impl, hand-written equality gets no
409        // such reminder for free.
410        let Self {
411            uri,
412            language_id,
413            version,
414            text,
415            disk,
416            synced,
417            opened: _,
418            last_accessed: _,
419        } = self;
420        *uri == other.uri
421            && *language_id == other.language_id
422            && *version == other.version
423            && *text == other.text
424            && *disk == other.disk
425            && *synced == other.synced
426    }
427}
428
429impl Eq for DocumentState {}
430
431impl DocumentState {
432    /// Creates a new document state at version 1, with unknown disk
433    /// provenance and no server yet recorded as synced.
434    fn new(uri: Uri, language_id: LanguageId, text: DocumentText, opened: Opening) -> Self {
435        Self {
436            uri,
437            language_id,
438            version: DocumentVersion::FIRST,
439            text,
440            disk: None,
441            synced: HashMap::new(),
442            opened,
443            last_accessed: Instant::now(),
444        }
445    }
446
447    /// Marks this document as just accessed, for LRU eviction ordering under
448    /// `ResourceLimits::max_documents` (#495).
449    fn touch(&mut self) {
450        self.last_accessed = Instant::now();
451    }
452
453    /// Document URI.
454    #[must_use]
455    #[cfg(test)]
456    pub(crate) const fn uri(&self) -> &Uri {
457        &self.uri
458    }
459
460    /// Language identifier.
461    #[must_use]
462    #[cfg(test)]
463    pub(crate) const fn language_id(&self) -> &LanguageId {
464        &self.language_id
465    }
466
467    /// Document version. Monotonically increasing: every mutation that
468    /// changes `content` (`commit_reload`) also bumps
469    /// this, and never decreases it.
470    #[must_use]
471    #[cfg(test)]
472    pub(crate) const fn version(&self) -> DocumentVersion {
473        self.version
474    }
475
476    /// Document content.
477    #[must_use]
478    #[cfg(test)]
479    pub(crate) fn content(&self) -> &str {
480        self.text.as_str()
481    }
482
483    /// Filesystem snapshot as of the last time `content` was read from disk.
484    /// See the struct-level docs for the meaning of `None`.
485    const fn disk(&self) -> Option<DiskSync> {
486        self.disk
487    }
488
489    /// Last document version pushed to `server` via `didOpen`/`didChange`,
490    /// or `None` if `server` has never seen this document.
491    ///
492    /// A single document can be synced to multiple servers (e.g. hover
493    /// routed to one server, diagnostics to another for the same language),
494    /// each needing its own `didOpen`/`didChange` history -- a server absent
495    /// from this map has never seen the document and must receive
496    /// `didOpen`, not `didChange`, on its next `ensure_open` call.
497    #[must_use]
498    pub(crate) fn synced_version(&self, server: &ServerId) -> Option<DocumentVersion> {
499        self.synced.get(server).copied()
500    }
501
502    /// Whether no server has ever synced this document.
503    fn has_never_synced(&self) -> bool {
504        self.synced.is_empty()
505    }
506
507    /// Commits a disk-verified reload: sets `version`, `content`, and `disk`
508    /// together. `version` must be no less than the current version,
509    /// preserving the monotonicity invariant. (Not strictly greater: the
510    /// caller computes `version` via `saturating_add`, which can legitimately
511    /// clamp to the current value at `i32::MAX`.)
512    fn commit_reload(
513        &mut self,
514        version: DocumentVersion,
515        text: DocumentText,
516        snap: Option<DiskSync>,
517    ) {
518        debug_assert!(
519            version >= self.version,
520            "document version must be monotonically increasing"
521        );
522        self.version = version;
523        self.text = text;
524        self.disk = snap;
525    }
526
527    /// Sets the disk snapshot without changing `content` or `version`.
528    const fn set_disk(&mut self, snap: DiskSync) {
529        self.disk = Some(snap);
530    }
531
532    /// Records that `server` has synced up to `version`.
533    fn mark_synced(&mut self, server: ServerId, version: DocumentVersion) {
534        self.synced.insert(server, version);
535    }
536
537    /// Forgets `server`'s sync history for this document.
538    fn forget_server(&mut self, server: &ServerId) {
539        self.synced.remove(server);
540    }
541}
542
543/// What a `textDocument/didOpen` carries.
544struct OpenedDocument {
545    uri: Uri,
546    language_id: LanguageId,
547    text: String,
548    version: DocumentVersion,
549}
550
551/// Resource limits for document tracking.
552#[derive(Debug, Clone, Copy)]
553pub struct ResourceLimits {
554    /// Maximum number of open documents.
555    pub max_documents: DocumentLimit,
556    /// Maximum file size in bytes.
557    pub max_file_size: SizeLimit,
558}
559
560impl Default for ResourceLimits {
561    fn default() -> Self {
562        Self {
563            max_documents: DocumentLimit::DEFAULT,
564            max_file_size: SizeLimit::DEFAULT,
565        }
566    }
567}
568
569/// Nominal charge for a [`DocumentTracker::read_line_checked`] call whose
570/// [`DocumentTracker::open_checked`] failed (path doesn't exist, isn't a
571/// regular file, or already exceeds `max_file_size`) -- zero bytes were
572/// actually scanned, but charging a literal `0` would let a response naming
573/// many nonexistent paths (a routine, non-attacker-controlled LSP server
574/// behavior -- e.g. rust-analyzer's stdlib locations without `rust-src`
575/// installed) repeat that cheap-but-nonzero syscall for free against a
576/// per-response I/O budget (see #474's budget-bypass follow-up). Small
577/// enough to have no material effect on a legitimate response's budget
578/// (~10,000 failed opens before exhausting [`SizeLimit::DEFAULT`]'s
579/// worth of budget on their own), while still bounding the failed-open
580/// amplification to the same order of magnitude as other count caps in this
581/// crate.
582pub const OPEN_FAILURE_CHARGE_BYTES: u64 = 4096;
583
584/// Outcome of [`DocumentTracker::read_line_checked`]: the requested line
585/// (`None` if the file has fewer lines, doesn't exist, or otherwise
586/// resolved to no usable text), plus the bytes to charge a caller
587/// tracking its own I/O budget across many calls (see `EncodingCtx`'s
588/// per-response disk-read budget, #474) -- not always a literal count of
589/// bytes scanned (see [`OPEN_FAILURE_CHARGE_BYTES`]), but always safe to
590/// charge as such. Charge this rather than assuming cost is proportional
591/// to `text`'s own length -- most of the cost is the lines skipped before
592/// it.
593#[derive(Debug, Clone)]
594pub struct LineRead {
595    /// The requested line's text, or `None` if the file has no such line.
596    pub(crate) text: Option<String>,
597    /// Bytes to charge against a caller's I/O budget for this call; see
598    /// this type's own doc for when this isn't a literal scanned-byte count.
599    pub(crate) bytes_read: u64,
600}
601
602/// A `textDocument/didClose` still owed after `DocumentTracker::open`'s LRU
603/// eviction removed a document (#495): the servers that had it open and the
604/// URI to close.
605///
606/// `DocumentTracker` has no access to any server's [`LspClient`] -- that
607/// registry lives one layer up, in `Translator` -- so it cannot send the
608/// close itself. Debts are kept per `(path, server)`: a server that later
609/// re-opens the path settles its own debt first (close, then open, see
610/// `DocumentTracker::sync_phase`), while the remaining servers' debts stay
611/// pending for [`DocumentTracker::try_claim_pending_close`].
612#[derive(Debug)]
613struct PendingClose {
614    uri: Uri,
615    servers: HashSet<ServerId>,
616}
617
618/// Exclusive claim on one path's owed `didClose` notifications, returned by
619/// [`DocumentTracker::try_claim_pending_close`].
620///
621/// Holds the path's lock for as long as it lives, so any later `didOpen` for
622/// the path through [`DocumentTracker::ensure_open`] is ordered after the
623/// closes. Drop it as soon as the notifications are sent.
624///
625/// A server whose close was not marked [`Self::settle`]d when the claim is
626/// dropped (the sending future was cancelled or timed out) goes back to owing
627/// it, so the tracker never forgets a close the server did not receive.
628#[derive(Debug)]
629#[must_use = "dropping the claim releases the path lock; send the closes first"]
630pub struct PendingCloseClaim<'a> {
631    /// Filesystem path of the evicted document.
632    pub(crate) path: PathBuf,
633    /// URI of the evicted document, as sent to any server that had it open.
634    pub(crate) uri: Uri,
635    /// Servers that still need a `textDocument/didClose` for `uri`.
636    servers: Vec<ServerId>,
637    tracker: &'a DocumentTracker,
638    /// Servers not yet settled, with the generation observed at claim time.
639    unsettled: Vec<(ServerId, SyncGeneration)>,
640    _path_guard: PathLockGuard<'a>,
641}
642
643impl PendingCloseClaim<'_> {
644    /// Servers that still need a `textDocument/didClose` for the claim's URI.
645    pub(crate) fn servers(&self) -> impl Iterator<Item = &ServerId> {
646        self.servers.iter()
647    }
648
649    /// Marks `server`'s close as dealt with: sent, or given up on.
650    pub(crate) fn settle(&mut self, server: &ServerId) {
651        self.unsettled.retain(|(unsettled, _)| unsettled != server);
652    }
653}
654
655impl Drop for PendingCloseClaim<'_> {
656    fn drop(&mut self) {
657        for (server, generation) in self.unsettled.drain(..) {
658            self.tracker
659                .restore_pending_close(&self.path, &self.uri, &server, generation);
660        }
661    }
662}
663
664/// How many times a server's sync history was forgotten, so a sync that began
665/// before a respawn recognizes that its result is stale.
666#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
667struct SyncGeneration(u64);
668
669impl SyncGeneration {
670    const fn next(self) -> Self {
671        Self(self.0.saturating_add(1))
672    }
673}
674
675/// Whether a tracked document has a given line.
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum LinePresence {
678    /// The document is not tracked, so its extent is unknown.
679    Untracked,
680    /// The document has the line.
681    Present,
682    /// The document is tracked and ends before the line.
683    Beyond {
684        /// The 1-based number of the document's last line.
685        last_line: NonZeroU32,
686    },
687}
688
689/// Whether a file is currently open through [`DocumentTracker`].
690///
691/// # Examples
692///
693/// ```
694/// use mcpls_core::bridge::{DocumentOpenState, Translator};
695///
696/// let translator = Translator::new();
697/// assert_eq!(
698///     translator.open_state(std::path::Path::new("/ws/a.rs")),
699///     DocumentOpenState::NotOpen
700/// );
701/// ```
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub enum DocumentOpenState {
704    /// The document is tracked as open.
705    Open,
706    /// The document is not tracked.
707    NotOpen,
708}
709
710/// Tracks document state across the workspace.
711///
712/// Every method takes `&self`: the document map and the per-path locks used
713/// by [`Self::ensure_open`] are both interior-mutable, so a single tracker
714/// can be shared behind a plain `Arc<DocumentTracker>` with no outer lock.
715/// See [`Self::ensure_open`] for the concurrency contract this maintains.
716#[derive(Debug)]
717pub struct DocumentTracker {
718    /// Open documents by file path. Locked only for the short, synchronous
719    /// section that touches it — never held across an `await`.
720    documents: StdMutex<HashMap<PathBuf, DocumentState>>,
721    /// Per-path locks serializing [`Self::ensure_open`] calls for the same
722    /// path, so calls for different paths never wait on each other. See
723    /// `lock_path` for how entries are created and evicted.
724    ///
725    /// Also excluded from `Self::open`'s LRU eviction (#495): a path is
726    /// present here for the whole duration of any `ensure_open` call against
727    /// it (`lock_path`'s guard is held across it). That window ends before
728    /// the caller's own LSP round-trip, which `in_flight` covers.
729    path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
730    /// Refcount of [`InFlightGuard`]s per path: documents a handler is still
731    /// using across its LSP round-trip, which [`Self::open`]'s LRU eviction
732    /// must not remove (#503). Shared with each guard so it can release
733    /// without borrowing the tracker.
734    in_flight: InFlightMap,
735    /// Per-server sync generation, bumped by [`Self::forget_server`].
736    ///
737    /// `ensure_open` captures a server's generation before doing any I/O and
738    /// only commits its `synced` update if the generation is unchanged when
739    /// it finishes -- see [`Self::forget_server`]'s docs for the race this
740    /// closes. Absent from the map is equivalent to the default generation.
741    generations: StdMutex<HashMap<ServerId, SyncGeneration>>,
742    /// Resource limits for tracking.
743    limits: ResourceLimits,
744    /// File extension and name to language ID mappings.
745    language_map: LanguageMap,
746    /// `didClose` notifications owed after `Self::open`'s LRU eviction (#495),
747    /// per path and server. See [`PendingClose`].
748    ///
749    /// Lock order: `documents` before `pending_closes`, never the reverse.
750    /// Under a path's lock, the servers pending for that path and the
751    /// servers synced to it never overlap.
752    pending_closes: StdMutex<HashMap<PathBuf, PendingClose>>,
753    /// Source of [`DocumentState::opened`].
754    next_opening: AtomicU64,
755}
756
757impl DocumentTracker {
758    /// Create a new document tracker with custom limits and language mappings.
759    #[must_use]
760    pub fn new(limits: ResourceLimits, language_map: impl Into<LanguageMap>) -> Self {
761        Self {
762            documents: StdMutex::new(HashMap::new()),
763            path_locks: StdMutex::new(HashMap::new()),
764            in_flight: Arc::default(),
765            generations: StdMutex::new(HashMap::new()),
766            limits,
767            language_map: language_map.into(),
768            pending_closes: StdMutex::new(HashMap::new()),
769            next_opening: AtomicU64::new(0),
770        }
771    }
772
773    /// The configured resource limits.
774    #[must_use]
775    pub(crate) const fn limits(&self) -> ResourceLimits {
776        self.limits
777    }
778
779    /// Marks `path` as in use by a handler until the returned guard drops, so
780    /// [`Self::open`]'s LRU eviction skips it (#503).
781    ///
782    /// Take the guard *before* [`Self::ensure_open`] and keep it alive across
783    /// the LSP round-trip that follows; guards for the same path stack, and
784    /// the path is evictable again only once all of them have dropped.
785    pub(crate) fn mark_in_flight(&self, path: &Path) -> InFlightGuard {
786        let mut in_flight = lock_std(&self.in_flight);
787        let count = in_flight.entry(path.to_path_buf()).or_insert(0);
788        *count = count.saturating_add(1);
789        drop(in_flight);
790        InFlightGuard {
791            in_flight: Arc::clone(&self.in_flight),
792            path: path.to_path_buf(),
793        }
794    }
795
796    /// Number of live [`InFlightGuard`]s for `path`.
797    #[cfg(test)]
798    pub(crate) fn in_flight_count(&self, path: &Path) -> usize {
799        lock_std(&self.in_flight).get(path).copied().unwrap_or(0)
800    }
801
802    /// Last document version of `path` that `server` was told about, or
803    /// `None` when `path` is not tracked or `server` never saw it.
804    #[must_use]
805    pub(crate) fn synced_version(&self, path: &Path, server: &ServerId) -> Option<DocumentVersion> {
806        lock_std(&self.documents).get(path)?.synced_version(server)
807    }
808
809    /// The opening and version of `path` that `server` was last told about,
810    /// read under one lock so the pair never mixes two openings; `None` when
811    /// `path` is not tracked or `server` never saw it.
812    #[must_use]
813    pub(crate) fn stamp(&self, path: &Path, server: &ServerId) -> Option<DocumentStamp> {
814        let documents = lock_std(&self.documents);
815        let stamp = documents.get(path).and_then(|state| {
816            Some(DocumentStamp::new(
817                Some(state.opened),
818                state.synced_version(server)?,
819            ))
820        });
821        drop(documents);
822        stamp
823    }
824
825    /// As [`Self::stamp`], also telling whether the version `server` has is the
826    /// document's current one, and if so the digest of its text.
827    #[must_use]
828    pub(crate) fn sweep_state(&self, path: &Path, server: &ServerId) -> Option<SweepState> {
829        let documents = lock_std(&self.documents);
830        let sweep_state = documents.get(path).and_then(|state| {
831            let synced = state.synced_version(server)?;
832            let stamp = DocumentStamp::new(Some(state.opened), synced);
833            Some(if synced == state.version {
834                SweepState::Current {
835                    stamp,
836                    digest: state.text.digest(),
837                }
838            } else {
839                SweepState::Lagging { stamp }
840            })
841        });
842        drop(documents);
843        sweep_state
844    }
845
846    /// Which opening of `path` is tracked; `None` when it is not tracked.
847    #[must_use]
848    pub(crate) fn opening(&self, path: &Path) -> Option<Opening> {
849        lock_std(&self.documents)
850            .get(path)
851            .map(|state| state.opened)
852    }
853
854    /// Overwrites `server`'s synced version of `path`, to simulate a resync
855    /// landing while a request is in flight.
856    #[cfg(test)]
857    pub(crate) fn set_synced_version_for_test(&self, path: &Path, server: &ServerId, version: i32) {
858        if let Some(state) = lock_std(&self.documents).get_mut(path) {
859            state.mark_synced(server.clone(), DocumentVersion::new(version));
860        }
861    }
862
863    /// Whether a document is currently open.
864    #[must_use]
865    pub fn open_state(&self, path: &Path) -> DocumentOpenState {
866        if lock_std(&self.documents).contains_key(path) {
867            DocumentOpenState::Open
868        } else {
869            DocumentOpenState::NotOpen
870        }
871    }
872
873    /// Get a clone of the state of an open document.
874    #[must_use]
875    #[cfg(test)]
876    pub(super) fn get(&self, path: &Path) -> Option<DocumentState> {
877        lock_std(&self.documents).get(path).cloned()
878    }
879
880    /// Text of the 0-based `line`'th line of `path`'s currently tracked
881    /// content, or `None` if the document is not open or has no such line.
882    ///
883    /// Reads the in-memory content mcpls already sent the server via
884    /// `didOpen`/`didChange` -- cheaper than a disk read (no I/O, no
885    /// re-scanning the whole file) and more correct when disk and server
886    /// state have diverged (e.g. an edit not yet flushed to disk).
887    #[must_use]
888    pub fn line_text(&self, path: &Path, line: u32) -> Option<String> {
889        let documents = lock_std(&self.documents);
890        documents.get(path)?.text.line(line).map(str::to_string)
891    }
892
893    /// The content of `path` as last sent to a server, or `None` if the
894    /// document is not open.
895    #[must_use]
896    pub(crate) fn text(&self, path: &Path) -> Option<String> {
897        lock_std(&self.documents)
898            .get(path)
899            .map(|state| state.text.as_str().to_owned())
900    }
901
902    /// Whether `path`'s tracked content has a 0-based `line`'th line.
903    ///
904    /// Checked under the documents lock without copying any text.
905    #[must_use]
906    pub(crate) fn line_presence(&self, path: &Path, line: u32) -> LinePresence {
907        match lock_std(&self.documents).get(path) {
908            None => LinePresence::Untracked,
909            Some(document) if document.text.line(line).is_some() => LinePresence::Present,
910            Some(document) => LinePresence::Beyond {
911                last_line: document.text.last_line(),
912            },
913        }
914    }
915
916    /// Up to `max_lines` of `path`'s tracked lines from the 0-based
917    /// `start_line` on, joined with `\n` (lines carry no terminator, so the
918    /// join is unambiguous); `None` if the document is not open or has no such
919    /// line. Copied out in one lock acquisition so callers scan without
920    /// holding the documents mutex.
921    #[must_use]
922    pub(crate) fn line_window(
923        &self,
924        path: &Path,
925        start_line: u32,
926        max_lines: usize,
927    ) -> Option<String> {
928        let documents = lock_std(&self.documents);
929        let mut lines = documents
930            .get(path)?
931            .text
932            .lines_from(start_line)
933            .take(max_lines);
934        let mut window = lines.next()?.to_string();
935        for line in lines {
936            window.push('\n');
937            window.push_str(line);
938        }
939        drop(documents);
940        Some(window)
941    }
942
943    /// Get the number of open documents.
944    #[must_use]
945    #[cfg(test)]
946    pub(crate) fn len(&self) -> usize {
947        lock_std(&self.documents).len()
948    }
949
950    /// Check if there are no open documents.
951    #[must_use]
952    #[cfg(test)]
953    pub(crate) fn is_empty(&self) -> bool {
954        lock_std(&self.documents).is_empty()
955    }
956
957    /// Open a document and track its state.
958    ///
959    /// Returns the document URI for use in LSP requests.
960    ///
961    /// When `max_documents` would otherwise be exceeded, evicts the
962    /// least-recently-used tracked document that both has no
963    /// `ensure_open` call currently in flight against it and is
964    /// disk-verified (see `evict_lru`) to make room, rather than failing
965    /// outright (#495) -- the servers that had the evicted document open are
966    /// recorded as owed a `didClose` (see [`PendingClose`]). Only falls back
967    /// to [`Error::DocumentLimitExceeded`] when no tracked document meets
968    /// both conditions, so none is safe to evict.
969    ///
970    /// The owed closes are bounded by `max_documents` times the number of
971    /// servers: evicting the same path again merges into its existing entry.
972    /// `Translator` flushes them after every `ensure_open` that could have
973    /// triggered eviction.
974    ///
975    /// The caller must hold `lock_path` for `path` (as `ensure_open` does):
976    /// without it, a concurrent `ensure_open` for the same path could
977    /// interleave with the insert below and lose its disk snapshot.
978    ///
979    /// A document a handler is still using across its LSP round-trip (see
980    /// `mark_in_flight`) is never evicted, so a concurrent `open` cannot take
981    /// it away mid-request even at a very small `max_documents` (#503).
982    ///
983    /// # Errors
984    ///
985    /// Returns an error if:
986    /// - Document limit is exceeded and no document is evictable
987    /// - File size limit is exceeded
988    #[cfg(test)]
989    pub(crate) fn open(&self, path: PathBuf, content: String) -> Result<Uri> {
990        self.open_text(path, DocumentText::new(content))
991    }
992
993    /// As [`Self::open`], for content already indexed -- lets async callers
994    /// build the [`DocumentText`] off the runtime thread.
995    fn open_text(&self, path: PathBuf, text: DocumentText) -> Result<Uri> {
996        self.check_file_size(text.as_str().len() as u64)?;
997
998        let uri = path_to_uri(&path)?;
999        let language_id = self.language_map.detect(&path);
1000
1001        let opened = Opening(self.next_opening.fetch_add(1, Ordering::Relaxed));
1002        let state = DocumentState::new(uri.clone(), language_id, text, opened);
1003
1004        // Check document limit and insert under a single lock acquisition so
1005        // two concurrent `open` calls for different new paths can't both
1006        // pass the check and jointly exceed the limit by one. Dropped
1007        // explicitly right after the insert rather than at function return.
1008        //
1009        // Skipped entirely when `path` is already tracked: re-opening an
1010        // existing path (`insert` below overwrites its entry in place, not
1011        // growing the map) never needs room made for it -- checking the
1012        // limit anyway would needlessly evict some unrelated victim (or, if
1013        // `path` itself were picked as the LRU candidate, evict and then
1014        // immediately re-insert it, queuing a spurious `didClose`).
1015        let mut documents = lock_std(&self.documents);
1016        if let Some(max) = self.limits.max_documents.get()
1017            && documents.len() >= max.get()
1018            && !documents.contains_key(&path)
1019        {
1020            let Some((evicted_path, evicted_state)) =
1021                Self::evict_lru(&mut documents, &self.path_locks, &self.in_flight)
1022            else {
1023                return Err(Error::DocumentLimitExceeded {
1024                    current: documents.len(),
1025                    max,
1026                });
1027            };
1028            self.record_pending_close(evicted_path, evicted_state);
1029        }
1030        documents.insert(path, state);
1031        drop(documents);
1032        Ok(uri)
1033    }
1034
1035    /// Records that every server `evicted` had synced is owed a `didClose`,
1036    /// merging with any debts already pending for `path`.
1037    fn record_pending_close(&self, path: PathBuf, evicted: DocumentState) {
1038        if evicted.synced.is_empty() {
1039            return;
1040        }
1041        lock_std(&self.pending_closes)
1042            .entry(path)
1043            .or_insert_with(|| PendingClose {
1044                uri: evicted.uri,
1045                servers: HashSet::new(),
1046            })
1047            .servers
1048            .extend(evicted.synced.into_keys());
1049    }
1050
1051    /// Removes and returns the least-recently-used entry in `documents` that
1052    /// is unlocked (not in `path_locks`, #495), not held by an
1053    /// [`InFlightGuard`] (#503), and disk-verified -- see below for why that
1054    /// last condition is required.
1055    ///
1056    /// A candidate whose `disk()` is `None` is skipped: its in-memory
1057    /// `content` has not been read-back-verified against disk (e.g. it was
1058    /// just `open`ed and `ensure_open` has not yet recorded a snapshot), so
1059    /// evicting it could discard content mcpls has no other record of --
1060    /// unlike a disk-verified candidate, whose evicted content is
1061    /// reproducible by re-reading the file.
1062    ///
1063    /// Returns `None` if every tracked document is currently locked, in
1064    /// flight, or not disk-verified, in which case the caller must not evict
1065    /// anything.
1066    fn evict_lru(
1067        documents: &mut HashMap<PathBuf, DocumentState>,
1068        path_locks: &StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
1069        in_flight: &InFlightMap,
1070    ) -> Option<(PathBuf, DocumentState)> {
1071        let mut busy = lock_std(path_locks)
1072            .keys()
1073            .cloned()
1074            .collect::<std::collections::HashSet<_>>();
1075        busy.extend(lock_std(in_flight).keys().cloned());
1076        let lru_path = documents
1077            .iter()
1078            .filter(|(path, state)| !busy.contains(path.as_path()) && state.disk().is_some())
1079            .min_by_key(|(_, state)| state.last_accessed)
1080            .map(|(path, _)| path.clone())?;
1081        documents.remove(&lru_path).map(|state| (lru_path, state))
1082    }
1083
1084    /// Returns an error if `size` exceeds the configured file size limit.
1085    const fn check_file_size(&self, size: u64) -> Result<()> {
1086        match SizeExceeded::check_limit(size, self.limits.max_file_size) {
1087            Ok(()) => Ok(()),
1088            Err(exceeded) => Err(Error::FileSizeLimitExceeded(exceeded)),
1089        }
1090    }
1091
1092    /// Sets the disk snapshot for an already-tracked document.
1093    ///
1094    /// A no-op if the path is no longer tracked; every call site runs under
1095    /// the per-path lock for the whole `ensure_open` call, so this should
1096    /// not happen in practice, but it avoids an `unwrap`/`expect` on the
1097    /// lookup.
1098    fn set_disk(&self, path: &Path, snap: DiskSync) {
1099        if let Some(st) = lock_std(&self.documents).get_mut(path) {
1100            st.set_disk(snap);
1101        }
1102    }
1103
1104    /// Close a document and remove it from tracking.
1105    ///
1106    /// Returns the document state if it was open.
1107    #[cfg(test)]
1108    pub(super) fn close(&self, path: &Path) -> Option<DocumentState> {
1109        lock_std(&self.documents).remove(path)
1110    }
1111
1112    /// Snapshot of the filesystem paths of all currently open documents.
1113    pub fn open_paths(&self) -> Vec<PathBuf> {
1114        lock_std(&self.documents).keys().cloned().collect()
1115    }
1116
1117    /// The filesystem paths of all currently open documents, the most
1118    /// recently used first (a document is used by an [`Access::Use`] resync).
1119    pub(crate) fn open_paths_by_recency(&self) -> Vec<PathBuf> {
1120        let mut paths: Vec<(PathBuf, Instant)> = lock_std(&self.documents)
1121            .iter()
1122            .map(|(path, state)| (path.clone(), state.last_accessed))
1123            .collect();
1124        paths.sort_unstable_by(|(_, a), (_, b)| b.cmp(a));
1125        paths.into_iter().map(|(path, _)| path).collect()
1126    }
1127
1128    /// Forget `server`'s last-synced version for every currently open
1129    /// document, so the next `ensure_open` call sends `didOpen` again
1130    /// instead of `didChange`.
1131    ///
1132    /// Called after `server` is respawned: the fresh process has no memory
1133    /// of any document the old one had open, so this tracker's per-server
1134    /// sync history for it must be forgotten too, or `ensure_open` would
1135    /// wrongly send `didChange` for a document the new process never saw.
1136    ///
1137    /// Also bumps `server`'s sync generation. Clearing `synced` alone is not
1138    /// enough: a call already in flight against the old (dead) connection
1139    /// when this runs can still have its `didOpen`/`didChange` notify
1140    /// "succeed" (`LspClient::notify` only enqueues onto a channel -- a dead
1141    /// process is not observed by the send itself), and would otherwise
1142    /// re-insert a stale entry after this method has already cleared it.
1143    /// `ensure_open` captures the generation before starting and discards
1144    /// its `synced` write if the generation moved in the meantime, closing
1145    /// that race regardless of exactly when the notify "succeeds".
1146    pub fn forget_server(&self, server: &ServerId) {
1147        let mut generations = lock_std(&self.generations);
1148        let generation = generations.entry(server.clone()).or_default();
1149        *generation = generation.next();
1150        drop(generations);
1151        for state in lock_std(&self.documents).values_mut() {
1152            state.forget_server(server);
1153        }
1154        // The respawned process never had these documents open, so owes no close.
1155        lock_std(&self.pending_closes).retain(|_, pending| {
1156            pending.servers.remove(server);
1157            !pending.servers.is_empty()
1158        });
1159    }
1160
1161    /// How many tracked documents `server`'s current process has been sent.
1162    pub(crate) fn synced_document_count(&self, server: &ServerId) -> usize {
1163        lock_std(&self.documents)
1164            .values()
1165            .filter(|state| state.synced_version(server).is_some())
1166            .count()
1167    }
1168
1169    /// Whether `server`'s sync history was ever forgotten (restart or respawn).
1170    pub(crate) fn sync_history_forgotten(&self, server: &ServerId) -> bool {
1171        self.generation(server) != SyncGeneration::default()
1172    }
1173
1174    /// Current sync generation for `server` (see [`Self::forget_server`]).
1175    fn generation(&self, server: &ServerId) -> SyncGeneration {
1176        lock_std(&self.generations)
1177            .get(server)
1178            .copied()
1179            .unwrap_or_default()
1180    }
1181
1182    /// Acquire the per-path lock used by [`Self::ensure_open`], creating its
1183    /// entry on first use.
1184    ///
1185    /// The map of per-path locks (`path_locks`) is itself locked only for
1186    /// the map lookup/insert/remove — never across an `await` — so acquiring
1187    /// one path's lock never blocks a concurrent acquisition for a different
1188    /// path. Awaiting the returned path's own lock is what actually
1189    /// serializes calls for the same path.
1190    ///
1191    /// The returned guard evicts its `path_locks` entry when dropped, but
1192    /// only if no other caller is concurrently waiting on it (see
1193    /// [`PathLockGuard`]'s `Drop` impl) — otherwise the map would grow by
1194    /// one entry per distinct path ever opened, for the lifetime of the
1195    /// process.
1196    async fn lock_path(&self, path: &Path) -> PathLockGuard<'_> {
1197        let arc = {
1198            let mut locks = lock_std(&self.path_locks);
1199            locks
1200                .entry(path.to_path_buf())
1201                .or_insert_with(|| Arc::new(AsyncMutex::new(())))
1202                .clone()
1203        };
1204        let guard = Arc::clone(&arc).lock_owned().await;
1205        PathLockGuard {
1206            path_locks: &self.path_locks,
1207            path: path.to_path_buf(),
1208            arc,
1209            guard: Some(guard),
1210        }
1211    }
1212
1213    /// As [`Self::lock_path`], but returns `None` instead of waiting when
1214    /// another caller holds `path`'s lock.
1215    fn try_lock_path(&self, path: &Path) -> Option<PathLockGuard<'_>> {
1216        let arc = {
1217            let mut locks = lock_std(&self.path_locks);
1218            locks
1219                .entry(path.to_path_buf())
1220                .or_insert_with(|| Arc::new(AsyncMutex::new(())))
1221                .clone()
1222        };
1223        if let Ok(guard) = Arc::clone(&arc).try_lock_owned() {
1224            return Some(PathLockGuard {
1225                path_locks: &self.path_locks,
1226                path: path.to_path_buf(),
1227                arc,
1228                guard: Some(guard),
1229            });
1230        }
1231        // Mirrors `PathLockGuard`'s eviction rule: our `arc` and the map's
1232        // entry are the only references left when nobody holds or awaits it.
1233        let mut locks = lock_std(&self.path_locks);
1234        if Arc::strong_count(&arc) <= 2 {
1235            locks.remove(path);
1236        }
1237        None
1238    }
1239
1240    /// Paths with a `didClose` still owed after LRU eviction, as a snapshot
1241    /// for [`Self::try_claim_pending_close`].
1242    pub(crate) fn pending_close_paths(&self) -> Vec<PathBuf> {
1243        lock_std(&self.pending_closes).keys().cloned().collect()
1244    }
1245
1246    /// Claims `path`'s owed `didClose` notifications, or `None` if there are
1247    /// none for it or another caller currently holds the path's lock.
1248    ///
1249    /// Never waits on a path lock: a busy path is left pending for a later
1250    /// call, and its own `ensure_open` settles the debt of the server it syncs
1251    /// anyway. The returned claim holds the path lock, so a concurrent
1252    /// `ensure_open` for the path waits until the closes have been sent --
1253    /// claim, notify and drop one path at a time so a wedged server cannot
1254    /// stall unrelated paths.
1255    ///
1256    /// Servers that have meanwhile synced the path are filtered out: a close
1257    /// for them would undo a live open.
1258    pub(crate) fn try_claim_pending_close(&self, path: &Path) -> Option<PendingCloseClaim<'_>> {
1259        let path_guard = self.try_lock_path(path)?;
1260        let synced: HashSet<ServerId> = lock_std(&self.documents)
1261            .get(path)
1262            .map_or_default(|st| st.synced.keys().cloned().collect());
1263        let pending = lock_std(&self.pending_closes).remove(path)?;
1264        let servers: Vec<ServerId> = pending.servers.difference(&synced).cloned().collect();
1265        if servers.is_empty() {
1266            return None;
1267        }
1268        let unsettled = servers
1269            .iter()
1270            .map(|server| (server.clone(), self.generation(server)))
1271            .collect();
1272        Some(PendingCloseClaim {
1273            path: path.to_path_buf(),
1274            uri: pending.uri,
1275            servers,
1276            tracker: self,
1277            unsettled,
1278            _path_guard: path_guard,
1279        })
1280    }
1281
1282    /// Whether `server` still owes a `didClose` for `path`. Must be called
1283    /// under `path`'s lock.
1284    fn owes_pending_close(&self, path: &Path, server: &ServerId) -> bool {
1285        lock_std(&self.pending_closes)
1286            .get(path)
1287            .is_some_and(|pending| pending.servers.contains(server))
1288    }
1289
1290    /// Removes `server`'s owed close for `path`, returning whether there was
1291    /// one. Must be called under `path`'s lock.
1292    fn take_pending_close(&self, path: &Path, server: &ServerId) -> bool {
1293        let mut pending_closes = lock_std(&self.pending_closes);
1294        let Some(pending) = pending_closes.get_mut(path) else {
1295            return false;
1296        };
1297        let owed = pending.servers.remove(server);
1298        if pending.servers.is_empty() {
1299            pending_closes.remove(path);
1300        }
1301        owed
1302    }
1303
1304    /// Re-records `server`'s owed close for `path` after its `didClose`
1305    /// failed to send. Must be called under `path`'s lock.
1306    ///
1307    /// Skipped when `server` was forgotten since `generation` was observed: a
1308    /// respawned process never had the path open, so it owes no close. The
1309    /// check runs under the `pending_closes` lock, which `forget_server`'s
1310    /// purge also takes after bumping the generation, so one of the two
1311    /// always wins.
1312    fn restore_pending_close(
1313        &self,
1314        path: &Path,
1315        uri: &Uri,
1316        server: &ServerId,
1317        generation: SyncGeneration,
1318    ) {
1319        let mut pending_closes = lock_std(&self.pending_closes);
1320        if self.generation(server) != generation {
1321            return;
1322        }
1323        pending_closes
1324            .entry(path.to_path_buf())
1325            .or_insert_with(|| PendingClose {
1326                uri: uri.clone(),
1327                servers: HashSet::new(),
1328            })
1329            .servers
1330            .insert(server.clone());
1331    }
1332
1333    /// Ensure a document is open *for `server`*, opening it lazily if
1334    /// necessary, and resynchronize it with disk and with `server` if either
1335    /// has fallen behind.
1336    ///
1337    /// A single path can be synced to several servers independently (e.g.
1338    /// hover routed to one server, diagnostics to another, for the same
1339    /// language) -- this call syncs only the one server it is for. Internally
1340    /// it runs in two phases:
1341    ///
1342    /// **Disk phase**: stats the file on every call (a cheap syscall, never
1343    /// debounced) to detect external changes -- `git checkout`/`stash`,
1344    /// formatters, or edits made by the MCP host itself outside mcpls -- and
1345    /// re-reads its content when the stat indicates a possible change (see
1346    /// `DiskSync` for the settled/debounce rules). This phase never skips
1347    /// the *per-server* sync check below, even when it takes a fast path
1348    /// that skips the disk read: a second server that has never seen this
1349    /// document must still receive `didOpen` even if the file has not
1350    /// changed since a first server was opened on it.
1351    ///
1352    /// **Sync phase**: compares `server`'s last-synced version (tracked via
1353    /// `DocumentState::synced_version`) against the version decided by the disk
1354    /// phase, and sends exactly one of `didOpen` (server has never seen this
1355    /// document), `didChange` (server is behind), or nothing (server is
1356    /// already caught up). A `didChange` is always a single full-replacement
1357    /// notification (a `TextDocumentContentChangeEvent` with `range: None`,
1358    /// which per the LSP spec means "this is the entire new document
1359    /// content"); mcpls does not consult the server's negotiated
1360    /// `TextDocumentSyncKind` (`LspClient` has no access to
1361    /// `ServerCapabilities` at this layer) -- full-replacement is accepted in
1362    /// practice by rust-analyzer, pyright, tsserver, gopls and clangd, but is
1363    /// the first place to look if a future maintainer sees sync errors from
1364    /// a new server. The document is never closed and reopened on a change,
1365    /// so `get_cached_diagnostics` keeps serving the last-known diagnostics
1366    /// until the server re-publishes -- there is no transient empty window.
1367    ///
1368    /// `st.version`/`st.text`/`st.disk`/`synced[server]` are all committed
1369    /// only after the notification succeeds. A server that is never asked
1370    /// again never catches up to a later edit -- which is correct, since a
1371    /// server that is never asked never needs the content.
1372    ///
1373    /// Two cases fall outside the disk-change-detection mechanism entirely:
1374    /// - A tool that restores a file with an mtime and size identical to the
1375    ///   last ones observed (e.g. `tar x`, `rsync -a`, `cp -p`) is
1376    ///   indistinguishable from "unchanged", however long ago that snapshot
1377    ///   was taken -- not just within the racy detection window. Once a
1378    ///   snapshot is `mtime_settled`, restoring its exact `(mtime, size)`
1379    ///   retakes the fast path forever. Closing this would require hashing
1380    ///   content on every access.
1381    /// - `workspace_symbol_search` is served from the LSP server's own
1382    ///   index and is unaffected by this per-document mechanism for files
1383    ///   mcpls has never opened.
1384    ///
1385    /// # Concurrency
1386    ///
1387    /// Calls for the *same* `path` are serialized against each other (via
1388    /// `lock_path`), so no two such calls can observe or mutate that
1389    /// path's state concurrently -- this is what prevents duplicate
1390    /// `didOpen`/`didChange` notifications for the same document. Calls for
1391    /// *different* paths run fully concurrently: neither the per-path lock
1392    /// nor the short, synchronous locks used to touch the shared document
1393    /// map are ever held across this call's disk I/O or LSP notify.
1394    ///
1395    /// # Errors
1396    ///
1397    /// Returns an error if:
1398    /// - The file cannot be stat'd or read from disk
1399    /// - The opened file does not lie under the request's roots
1400    ///   ([`Error::PathOutsideWorkspace`])
1401    /// - The request is an [`Access::Probe`] for a path the tracker does not
1402    ///   hold ([`Error::DocumentNotFound`])
1403    /// - The `didOpen`/`didChange` notification fails to send
1404    /// - Resource limits are exceeded
1405    pub async fn ensure_open(&self, request: OpenRequest<'_>) -> Result<Uri> {
1406        let path = request.path.as_path();
1407        let _path_guard = self.lock_path(path).await;
1408        let generation = self.generation(request.server);
1409        let decision = self.disk_phase(request.gate(), request.access).await?;
1410        self.sync_phase(path, request.server, request.client, decision, generation)
1411            .await
1412    }
1413
1414    /// Disk-verification phase of `ensure_open`: decides the version `path`
1415    /// should be at, reading from disk only when necessary. Never sends any
1416    /// LSP notification and never returns early in a way that would skip the
1417    /// per-server sync phase -- see `ensure_open`'s docs.
1418    async fn disk_phase(&self, gate: OpenGate<'_>, access: Access) -> Result<Decision> {
1419        let path = gate.path();
1420        if !lock_std(&self.documents).contains_key(path) {
1421            if access == Access::Probe {
1422                return Err(Error::DocumentNotFound(path.to_path_buf()));
1423            }
1424            return self.disk_phase_new(gate).await;
1425        }
1426
1427        let read_at = SystemTime::now();
1428        let meta = fs::metadata(path).await.map_err(|e| Error::FileIo {
1429            path: path.to_path_buf(),
1430            source: e,
1431        })?;
1432        let mtime = meta.modified().ok();
1433        let size = meta.len();
1434
1435        // `.map(...)` extracts an owned tuple from the lookup in a single
1436        // statement, so the lock releases immediately rather than staying
1437        // held while `fast_path` is computed. `get_mut` (rather than `get`)
1438        // so this same lookup can also `touch` the entry for LRU eviction
1439        // ordering (#495) -- every `ensure_open` call for an already-tracked
1440        // document reaches here, whether or not it ends up taking the fast
1441        // path below.
1442        let Some((uri, current_version, fast_path)) =
1443            lock_std(&self.documents).get_mut(path).map(|st| {
1444                if access == Access::Use {
1445                    st.touch();
1446                }
1447                let stat_matches = st
1448                    .disk()
1449                    .is_some_and(|d| d.mtime == mtime && d.size == size);
1450                let fast_path = match st.disk() {
1451                    Some(d) if stat_matches && d.mtime_settled => true,
1452                    Some(d)
1453                        if stat_matches && d.content_checked_at.elapsed() < DISK_CHECK_DEBOUNCE =>
1454                    {
1455                        true
1456                    }
1457                    _ => false,
1458                };
1459                (st.uri.clone(), st.version, fast_path)
1460            })
1461        else {
1462            return Err(Error::DocumentNotFound(path.to_path_buf()));
1463        };
1464        if fast_path {
1465            return Ok(Decision::unchanged(uri, current_version));
1466        }
1467
1468        let (fresh, ..) = self.read_to_string_checked(gate).await?;
1469        let snap = DiskSync {
1470            mtime,
1471            size,
1472            mtime_settled: mtime_settled(mtime, read_at),
1473            content_checked_at: Instant::now(),
1474        };
1475
1476        let Some(unchanged) = lock_std(&self.documents)
1477            .get(path)
1478            .map(|st| fresh == st.text.as_str())
1479        else {
1480            return Err(Error::DocumentNotFound(path.to_path_buf()));
1481        };
1482
1483        if unchanged {
1484            self.set_disk(path, snap);
1485            return Ok(Decision::unchanged(uri, current_version));
1486        }
1487
1488        Ok(Decision {
1489            uri,
1490            target_version: current_version.next(),
1491            fresh_content: Some(DocumentText::build(fresh).await?),
1492            snap: Some(snap),
1493        })
1494    }
1495
1496    /// Reads a not-yet-tracked file from disk and opens it in the tracker at
1497    /// version 1. No server has synced it yet, so the sync phase always
1498    /// sends `didOpen` regardless of which server calls next.
1499    async fn disk_phase_new(&self, gate: OpenGate<'_>) -> Result<Decision> {
1500        let path = gate.path();
1501        let read_at = SystemTime::now();
1502        let (content, mtime, size) = self.read_to_string_checked(gate).await?;
1503
1504        let uri = self.open_text(path.to_path_buf(), DocumentText::build(content).await?)?;
1505        self.set_disk(
1506            path,
1507            DiskSync {
1508                mtime,
1509                size,
1510                mtime_settled: mtime_settled(mtime, read_at),
1511                content_checked_at: Instant::now(),
1512            },
1513        );
1514
1515        Ok(Decision::unchanged(uri, DocumentVersion::FIRST))
1516    }
1517
1518    /// Opens `path` for reading and verifies, via that same open handle's
1519    /// metadata, that it is a regular file within [`Self::check_file_size`]'s
1520    /// limit -- never a separately-stat'd path, which would let an atomic
1521    /// replace (e.g. a concurrent `rename`) between the check and the open
1522    /// swap in something else entirely.
1523    ///
1524    /// The open runs on the blocking pool through [`RegularFile::open`], which
1525    /// owns the non-blocking FIFO and Windows device-name protections (see
1526    /// #418 and #442) shared with the config loader. A [`OpenGate::Contained`]
1527    /// open goes through [`WorkspaceRoots::open_contained`] instead, which also
1528    /// verifies the opened file against the roots (#783).
1529    async fn open_checked(&self, gate: OpenGate<'_>) -> Result<(fs::File, std::fs::Metadata)> {
1530        let opened = match gate {
1531            OpenGate::Contained { roots, path } => {
1532                let (roots, path) = (roots.clone(), path.clone());
1533                tokio::task::spawn_blocking(move || roots.open_contained(&path))
1534            }
1535            OpenGate::Ungated(path) => {
1536                let owned = path.to_path_buf();
1537                tokio::task::spawn_blocking(move || {
1538                    RegularFile::open(&owned).map_err(|error| error.into_error(&owned))
1539                })
1540            }
1541        }
1542        .await
1543        .map_err(|source| Error::TaskFailed {
1544            task: BackgroundTask::FileOpen,
1545            source,
1546        })?;
1547        let (file, meta) = opened?.into_parts();
1548        self.check_file_size(meta.len())?;
1549        Ok((fs::File::from_std(file), meta))
1550    }
1551
1552    /// Reads `file`'s content as UTF-8, bounded to one byte past
1553    /// [`Self::check_file_size`]'s limit regardless of the already-checked
1554    /// stat result -- defense in depth against the file growing between the
1555    /// stat (in [`Self::open_checked`]) and this read completing (see #418).
1556    /// A read that reaches the bound is reported as oversized even though
1557    /// the earlier stat passed, since the file grew past what was verified.
1558    ///
1559    /// `size_hint` is the size [`Self::open_checked`] already observed via
1560    /// `stat`, used only to preallocate the read buffer and avoid
1561    /// reallocation growth on the common (non-racing) path -- it is never
1562    /// trusted for the size check itself, which is always re-derived from
1563    /// the bytes actually read.
1564    async fn read_string_bounded(
1565        &self,
1566        path: &Path,
1567        mut file: fs::File,
1568        size_hint: u64,
1569    ) -> Result<String> {
1570        let max = self.limits.max_file_size;
1571        let cap = max.read_cap();
1572        let mut buf = Vec::with_capacity(usize::try_from(size_hint.min(cap)).unwrap_or(0));
1573        let io_err = |e: std::io::Error| Error::FileIo {
1574            path: path.to_path_buf(),
1575            source: e,
1576        };
1577
1578        (&mut file)
1579            .take(cap)
1580            .read_to_end(&mut buf)
1581            .await
1582            .map_err(io_err)?;
1583        check_bounded_utf8(buf, max).map_err(|e| match e {
1584            BoundedUtf8Error::TooLarge(exceeded) => Error::FileSizeLimitExceeded(exceeded),
1585            BoundedUtf8Error::InvalidUtf8(e) => {
1586                io_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
1587            }
1588        })
1589    }
1590
1591    /// Reads `path` through a single open file handle, checking its size and
1592    /// type via [`Self::open_checked`] and bounding the read via
1593    /// [`Self::read_string_bounded`].
1594    ///
1595    /// Returns the content along with the handle's own mtime and size, so
1596    /// callers can build a [`DiskSync`] snapshot consistent with what was
1597    /// actually read.
1598    async fn read_to_string_checked(
1599        &self,
1600        gate: OpenGate<'_>,
1601    ) -> Result<(String, Option<SystemTime>, u64)> {
1602        let path = gate.path();
1603        let (file, meta) = self.open_checked(gate).await?;
1604        let mtime = meta.modified().ok();
1605        let size = meta.len();
1606        let content = self.read_string_bounded(path, file, size).await?;
1607        Ok((content, mtime, size))
1608    }
1609
1610    /// Reads only the 0-based `line`'th line of `path` from disk, applying
1611    /// the same regular-file and [`Self::check_file_size`] checks as a
1612    /// tracked document's disk read (see [`Self::read_to_string_checked`]),
1613    /// but stopping as soon as `line` is found rather than buffering the
1614    /// whole file just to discard everything past one line (see #474).
1615    ///
1616    /// For a document not tracked by this tracker at all -- e.g. one
1617    /// resolved only for encoding-conversion purposes, never opened for LSP
1618    /// sync -- there is otherwise no size or file-type gate on the path at
1619    /// all (see #427). Callers that only need best-effort text (falling back
1620    /// to `None` on any error) should treat every error here that way rather
1621    /// than surfacing it.
1622    ///
1623    /// [`LineRead::text`] is `None` if `path` doesn't resolve to an
1624    /// existing, readable regular file at all (see [`Self::open_checked`]),
1625    /// if `path` has fewer than `line + 1` lines, if the line's bytes are
1626    /// not valid UTF-8, or if `budget` (or `max_file_size`) was exhausted
1627    /// before a complete line could be read -- [`LineRead::bytes_read`] is
1628    /// populated in every one of these cases (see below), never silently
1629    /// dropped via an `Err` with no byte count. Lines follow the same rule
1630    /// as [`Self::line_text`] (see [`DocumentText`]): they end at `\n`,
1631    /// `\r\n` or a lone `\r`, and the empty line right after a final
1632    /// terminator (or line 0 of an empty file) is `Some("")`.
1633    ///
1634    /// `budget` bounds this call's own read on top of
1635    /// [`SizeLimit::read_cap`] of `max_file_size`: the actual cap
1636    /// used is `min(max_file_size.read_cap(), budget + 1)`, enforced
1637    /// by wrapping the file handle itself in [`AsyncReadExt::take`] rather
1638    /// than checked after the fact -- so this call physically cannot scan
1639    /// more than one byte past `budget`, regardless of how large
1640    /// `max_file_size` is configured (including `max_file_size = 0`,
1641    /// meaning unlimited). The `+ 1` is the same disambiguation slack
1642    /// `SizeLimit::read_cap` already applies to `max_file_size`: without it, a
1643    /// read whose remaining budget exactly equals its target line's byte
1644    /// length (no trailing newline) is indistinguishable from one
1645    /// genuinely truncated by the cap. A caller enforcing its own I/O
1646    /// budget across many calls (see `EncodingCtx`'s per-response
1647    /// disk-read budget, #474) passes its remaining allowance here and
1648    /// charges exactly [`LineRead::bytes_read`] afterward -- always
1649    /// available, on every outcome, so the budget can never be bypassed by
1650    /// triggering a failure mid-scan, and never overshoots by more than
1651    /// this one byte of slack.
1652    ///
1653    /// [`Self::open_checked`] failing (path doesn't exist, isn't a regular
1654    /// file, or already exceeds `max_file_size` at stat time) is reported
1655    /// the same way, charging [`OPEN_FAILURE_CHARGE_BYTES`] rather than a
1656    /// literal `0` -- zero bytes were actually scanned, but an LSP server
1657    /// routinely names paths that don't exist locally (e.g. rust-analyzer's
1658    /// `file:///rustc/<hash>/library/...` without `rust-src` installed),
1659    /// and a literal `0` would let a response naming many such paths repeat
1660    /// this cheap-but-nonzero syscall for free against the per-response
1661    /// budget (see #474's budget-bypass follow-up). A real mid-read I/O
1662    /// error (rare, not attacker-controlled by response content) is the one
1663    /// case that still returns a genuine `Err` with no byte count.
1664    ///
1665    /// Also closes #427/#418's TOCTOU margin without a dedicated error: if
1666    /// `path` grows past `max_file_size` (or past `budget`) between
1667    /// [`Self::open_checked`]'s stat and this read completing, the capped
1668    /// take-adapter simply runs out mid-line, which this method detects
1669    /// (`buf` doesn't end in the expected `\n`) and reports as `None` rather
1670    /// than returning a truncated line as if it were complete.
1671    pub(crate) async fn read_line_checked(
1672        &self,
1673        path: &Path,
1674        line: u32,
1675        budget: u64,
1676    ) -> Result<LineRead> {
1677        let Ok((file, _meta)) = self.open_checked(OpenGate::Ungated(path)).await else {
1678            return Ok(LineRead {
1679                text: None,
1680                bytes_read: OPEN_FAILURE_CHARGE_BYTES,
1681            });
1682        };
1683        let max = self.limits.max_file_size;
1684        // `+1` slack on `budget`, same trick `SizeLimit::read_cap` already
1685        // applies to `max_file_size`: without it, a read whose remaining
1686        // budget exactly equals its target line's byte length (no trailing
1687        // newline) is indistinguishable from one truncated by the cap, and
1688        // was misreported as truncated (see #474's correctness-gate fix).
1689        let cap = max.read_cap().min(budget.saturating_add(1));
1690        let mut reader = tokio::io::BufReader::new(file.take(cap));
1691        let io_err = |e: std::io::Error| Error::FileIo {
1692            path: path.to_path_buf(),
1693            source: e,
1694        };
1695
1696        read_nth_line(&mut reader, line, cap).await.map_err(io_err)
1697    }
1698
1699    /// Enqueues `didOpen` for `server`, preceded by the `didClose` it still
1700    /// owes for this path, if any.
1701    ///
1702    /// Cancel-safe: the only await reserves queue room, and nothing is taken
1703    /// or sent until the room is held. From there the debt is cleared and both
1704    /// notifications are enqueued with no await in between, so a drop either
1705    /// leaves the debt owed and sends nothing, or leaves it settled with both
1706    /// sent.
1707    async fn send_open(
1708        &self,
1709        path: &Path,
1710        server: &ServerId,
1711        lsp_client: &LspClient,
1712        document: OpenedDocument,
1713    ) -> Result<()> {
1714        let OpenedDocument {
1715            uri,
1716            language_id,
1717            text,
1718            version,
1719        } = document;
1720        let open = DidOpenTextDocumentParams {
1721            text_document: TextDocumentItem {
1722                uri: uri.clone(),
1723                language_id: String::from(language_id).into(),
1724                version: version.get(),
1725                text,
1726            },
1727        };
1728        if !self.owes_pending_close(path, server) {
1729            return lsp_client
1730                .notify_typed::<DidOpenTextDocumentNotification>(open)
1731                .await;
1732        }
1733        let open = LspClient::outbound_notification::<DidOpenTextDocumentNotification>(open)?;
1734        let close = LspClient::outbound_notification::<DidCloseTextDocumentNotification>(
1735            DidCloseTextDocumentParams {
1736                text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
1737            },
1738        )?;
1739        let (close_permit, open_permit) = lsp_client.reserve_notification_pair().await?;
1740        if self.take_pending_close(path, server) {
1741            close_permit.send(close);
1742        }
1743        open_permit.send(open);
1744        Ok(())
1745    }
1746
1747    /// Enqueues a full-replacement `didChange`. Cancel-safe: see
1748    /// [`Self::send_open`].
1749    async fn send_change(
1750        lsp_client: &LspClient,
1751        uri: &Uri,
1752        text: String,
1753        version: DocumentVersion,
1754    ) -> Result<()> {
1755        lsp_client
1756            .notify_typed::<DidChangeTextDocumentNotification>(DidChangeTextDocumentParams {
1757                text_document: VersionedTextDocumentIdentifier {
1758                    version: version.get(),
1759                    text_document_identifier: lsp_types::TextDocumentIdentifier {
1760                        uri: uri.clone(),
1761                    },
1762                },
1763                content_changes: vec![
1764                    TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
1765                        lsp_types::TextDocumentContentChangeWholeDocument { text },
1766                    ),
1767                ],
1768            })
1769            .await
1770    }
1771
1772    /// Per-server sync phase of `ensure_open`: sends `didOpen`, `didChange`,
1773    /// or nothing to `server` depending on its last-synced version, and
1774    /// commits the outcome only after the notification succeeds.
1775    ///
1776    /// `generation` is `server`'s sync generation as observed by the caller
1777    /// before this call started (see [`Self::forget_server`]): the
1778    /// `synced` write at the end is skipped if it no longer matches,
1779    /// meaning `server` was respawned while this call was in flight and its
1780    /// notify -- however it turned out -- was not actually delivered to the
1781    /// connection now on file for `server`.
1782    async fn sync_phase(
1783        &self,
1784        path: &Path,
1785        server: &ServerId,
1786        lsp_client: &LspClient,
1787        decision: Decision,
1788        generation: SyncGeneration,
1789    ) -> Result<Uri> {
1790        let Decision {
1791            uri,
1792            target_version,
1793            fresh_content,
1794            snap,
1795        } = decision;
1796
1797        // Cheap check first: the common case (an already-synced document,
1798        // which is most tool calls against a file already open elsewhere)
1799        // must not pay for cloning the full document content only to
1800        // discard it on the `up_to_date` return below. `.map(...)` extracts
1801        // an owned value from the lookup so the lock is released at the end
1802        // of this statement rather than held across the checks that follow.
1803        let Some(synced_version) = lock_std(&self.documents)
1804            .get(path)
1805            .map(|st| st.synced_version(server))
1806        else {
1807            return Err(Error::DocumentNotFound(path.to_path_buf()));
1808        };
1809        let up_to_date = synced_version.is_some_and(|v| v >= target_version);
1810        let is_first_open = synced_version.is_none();
1811
1812        if up_to_date {
1813            return Ok(uri);
1814        }
1815
1816        let Some((language_id, text)) = lock_std(&self.documents).get(path).map(|st| {
1817            let text = fresh_content
1818                .as_ref()
1819                .unwrap_or(&st.text)
1820                .as_str()
1821                .to_owned();
1822            (st.language_id.clone(), text)
1823        }) else {
1824            return Err(Error::DocumentNotFound(path.to_path_buf()));
1825        };
1826
1827        let notify_result = if is_first_open {
1828            let document = OpenedDocument {
1829                uri: uri.clone(),
1830                language_id,
1831                text,
1832                version: target_version,
1833            };
1834            self.send_open(path, server, lsp_client, document).await
1835        } else {
1836            Self::send_change(lsp_client, &uri, text, target_version).await
1837        };
1838
1839        if let Err(err) = notify_result {
1840            // The server never learned about this document. If no server at
1841            // all has synced this path yet, leaving it tracked would
1842            // permanently desync every future server from the tracker, so
1843            // undo the insert and let the next call retry from scratch. If
1844            // another server already synced successfully, the path stays
1845            // tracked for that server's sake; this server's `synced` entry
1846            // simply stays absent/stale, so its own next call retries.
1847            // Two short lock scopes rather than one held across the
1848            // conditional `remove`: safe because `ensure_open`'s per-path
1849            // lock already serializes every caller for this path, so
1850            // nothing else can observe or mutate its `synced` map between
1851            // them.
1852            let first_ever_sync = lock_std(&self.documents)
1853                .get(path)
1854                .is_some_and(DocumentState::has_never_synced);
1855            if is_first_open && first_ever_sync {
1856                lock_std(&self.documents).remove(path);
1857            }
1858            return Err(err);
1859        }
1860
1861        // Dropped explicitly right after the commit, rather than staying
1862        // alive (unused) until the function returns.
1863        let mut documents = lock_std(&self.documents);
1864        let Some(st) = documents.get_mut(path) else {
1865            return Err(Error::DocumentNotFound(path.to_path_buf()));
1866        };
1867        if let Some(fresh) = fresh_content {
1868            st.commit_reload(target_version, fresh, snap);
1869        }
1870        // Read while `documents` is still held, not before: `forget_server`
1871        // bumps the generation strictly before it acquires `documents`
1872        // itself (see its docs), so checking under this same lock is
1873        // airtight against the TOCTOU a separate, earlier read would leave
1874        // open -- either this sees the new generation and skips (in which
1875        // case `forget_server` has already cleared `synced`, or is blocked
1876        // waiting for *this* guard to release before it does), or it sees
1877        // the old one, in which case `forget_server` cannot have started
1878        // clearing yet and will correctly clear the entry this commits.
1879        if self.generation(server) == generation {
1880            st.mark_synced(server.clone(), target_version);
1881        }
1882        drop(documents);
1883
1884        Ok(uri)
1885    }
1886}
1887
1888/// Reads the 0-based `line`'th line from `reader` under the [`DocumentText`]
1889/// line model, scanning the buffered chunks directly so skipped lines are never
1890/// copied.
1891///
1892/// `cap` is the byte limit the caller imposed on `reader` (a `take` adapter):
1893/// a read that consumed `cap` bytes without finishing is reported as `None`
1894/// rather than returning a line the cap may have cut. A `\r` ending a skipped
1895/// line at a chunk boundary is remembered, so a `\n` opening the next chunk
1896/// still counts as part of the same `\r\n` terminator.
1897#[allow(
1898    clippy::arithmetic_side_effects,
1899    clippy::indexing_slicing,
1900    reason = "every index derives from `position()` over the same chunk and stays within `chunk.len()`"
1901)]
1902async fn read_nth_line<R>(reader: &mut R, line: u32, cap: u64) -> std::io::Result<LineRead>
1903where
1904    R: tokio::io::AsyncBufRead + Unpin,
1905{
1906    let mut buf = Vec::new();
1907    let mut bytes_read: u64 = 0;
1908    let mut current_line = 0u32;
1909    let mut after_cr = false;
1910    loop {
1911        let chunk = reader.fill_buf().await?;
1912        if chunk.is_empty() {
1913            let reached_line = current_line == line && bytes_read < cap;
1914            return Ok(LineRead {
1915                text: reached_line.then(|| String::from_utf8(buf).ok()).flatten(),
1916                bytes_read,
1917            });
1918        }
1919
1920        let mut i = usize::from(after_cr && chunk[0] == b'\n');
1921        after_cr = false;
1922        let mut finished = false;
1923        while i < chunk.len() {
1924            let rest = &chunk[i..];
1925            let Some(offset) = rest.iter().position(|&b| is_line_terminator(b)) else {
1926                if current_line == line {
1927                    buf.extend_from_slice(rest);
1928                }
1929                i = chunk.len();
1930                break;
1931            };
1932            let terminator = i + offset;
1933            if current_line == line {
1934                buf.extend_from_slice(&chunk[i..terminator]);
1935                i = terminator + 1;
1936                finished = true;
1937                break;
1938            }
1939            current_line += 1;
1940            i = terminator + 1;
1941            if chunk[terminator] == b'\r' {
1942                match chunk.get(i) {
1943                    Some(b'\n') => i += 1,
1944                    Some(_) => {}
1945                    None => after_cr = true,
1946                }
1947            }
1948        }
1949
1950        bytes_read += i as u64;
1951        reader.consume(i);
1952        if finished {
1953            return Ok(LineRead {
1954                text: String::from_utf8(buf).ok(),
1955                bytes_read,
1956            });
1957        }
1958    }
1959}
1960
1961/// Per-path count of live [`InFlightGuard`]s.
1962type InFlightMap = Arc<StdMutex<HashMap<PathBuf, usize>>>;
1963
1964/// RAII marker that keeps a document out of [`DocumentTracker::open`]'s LRU
1965/// eviction for as long as it is alive (#503).
1966///
1967/// Obtained from [`DocumentTracker::mark_in_flight`]. Releases on drop, so
1968/// every exit path -- including errors and cancelled futures -- unprotects
1969/// the path again.
1970#[derive(Debug)]
1971#[must_use = "dropping the guard immediately makes the document evictable again"]
1972pub struct InFlightGuard {
1973    in_flight: InFlightMap,
1974    path: PathBuf,
1975}
1976
1977impl Drop for InFlightGuard {
1978    fn drop(&mut self) {
1979        let mut in_flight = lock_std(&self.in_flight);
1980        if let Some(count) = in_flight.get_mut(&self.path) {
1981            *count = count.saturating_sub(1);
1982            if *count == 0 {
1983                in_flight.remove(&self.path);
1984            }
1985        }
1986    }
1987}
1988
1989/// RAII guard for the per-path lock acquired by
1990/// [`DocumentTracker::lock_path`].
1991///
1992/// Holds an `OwnedMutexGuard` on the path's `Arc<AsyncMutex<()>>>` for as
1993/// long as the guard is alive, serializing `ensure_open` calls for that
1994/// path. On drop, evicts the `path_locks` map entry if (and only if) no
1995/// other caller holds a clone of the same `Arc` -- see the `Drop` impl for
1996/// why that check is race-free.
1997#[derive(Debug)]
1998struct PathLockGuard<'a> {
1999    path_locks: &'a StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
2000    path: PathBuf,
2001    arc: Arc<AsyncMutex<()>>,
2002    guard: Option<OwnedMutexGuard<()>>,
2003}
2004
2005impl Drop for PathLockGuard<'_> {
2006    fn drop(&mut self) {
2007        // Unlock first so a task waiting on `arc.lock_owned()` can proceed
2008        // as soon as possible, rather than also waiting on `path_locks`.
2009        self.guard.take();
2010
2011        let mut locks = lock_std(self.path_locks);
2012        // Checked only after `self.guard` -- and the extra internal `Arc`
2013        // clone it held -- was already dropped above, so what's left here is:
2014        // this task's own `self.arc`, the map's entry, and one more
2015        // reference for every *other* task that has already looked up this
2016        // same entry in `lock_path` (each holds its own clone continuously
2017        // from before that lookup until its own `Drop` runs this same check)
2018        // but hasn't finished dropping yet. A `strong_count` of 2 means no
2019        // such task exists, so it's safe to evict; any later caller just
2020        // creates a fresh entry. Leaving it forever would instead grow this
2021        // map by one entry per distinct path ever opened, for the process's
2022        // lifetime.
2023        if Arc::strong_count(&self.arc) <= 2 {
2024            locks.remove(&self.path);
2025        }
2026    }
2027}
2028
2029#[cfg(test)]
2030impl DocumentTracker {
2031    /// Opens `path` as a tool call would, with the parent directory as the
2032    /// only workspace root.
2033    pub(crate) async fn ensure_open_for_test(
2034        &self,
2035        path: &Path,
2036        server: &ServerId,
2037        client: &LspClient,
2038    ) -> Result<Uri> {
2039        self.ensure_open_as_for_test(path, server, client, Access::Use)
2040            .await
2041    }
2042
2043    /// As [`Self::ensure_open_for_test`], with the given `access`.
2044    pub(crate) async fn ensure_open_as_for_test(
2045        &self,
2046        path: &Path,
2047        server: &ServerId,
2048        client: &LspClient,
2049        access: Access,
2050    ) -> Result<Uri> {
2051        let parent = path.parent().unwrap_or(path);
2052        let mut roots = vec![parent.to_path_buf()];
2053        // Both spellings: a temp dir may be a symlink or an 8.3 short name, and
2054        // the verification compares the opened file's real path or the pinned
2055        // ancestors with the roots.
2056        if let Ok(canonical) = dunce::canonicalize(parent)
2057            && canonical != parent
2058        {
2059            roots.push(canonical);
2060        }
2061        self.ensure_open(OpenRequest::new(
2062            &WorkspacePath::for_test(path.to_path_buf()),
2063            &WorkspaceRoots::for_test(roots, Vec::new()),
2064            server,
2065            client,
2066            access,
2067        ))
2068        .await
2069    }
2070}
2071
2072/// Whether a resync counts as the document's use for LRU eviction.
2073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2074pub enum Access {
2075    /// A tool call or a subscribed file's sweep: the document is touched, so
2076    /// it becomes the most recently used.
2077    Use,
2078    /// A background resync of a file nobody asked for: the LRU order is left
2079    /// as tool calls made it, and an untracked path is never opened.
2080    Probe,
2081}
2082
2083/// What [`DocumentTracker::ensure_open`] needs to bring one document to the
2084/// server's side.
2085///
2086/// `path` was validated by `roots`; the file is opened and the opened handle
2087/// is verified against `roots` again, so a symlink swapped in after the
2088/// validation is refused (#783).
2089#[derive(Debug, Clone, Copy)]
2090pub struct OpenRequest<'a> {
2091    path: &'a WorkspacePath,
2092    roots: &'a WorkspaceRoots,
2093    server: &'a ServerId,
2094    client: &'a LspClient,
2095    access: Access,
2096}
2097
2098impl<'a> OpenRequest<'a> {
2099    /// A request to open `path`, validated against `roots`, for `server`
2100    /// over `client`; `access` says whether it touches the document's LRU
2101    /// position.
2102    #[must_use]
2103    pub const fn new(
2104        path: &'a WorkspacePath,
2105        roots: &'a WorkspaceRoots,
2106        server: &'a ServerId,
2107        client: &'a LspClient,
2108        access: Access,
2109    ) -> Self {
2110        Self {
2111            path,
2112            roots,
2113            server,
2114            client,
2115            access,
2116        }
2117    }
2118
2119    const fn gate(&self) -> OpenGate<'a> {
2120        OpenGate::Contained {
2121            roots: self.roots,
2122            path: self.path,
2123        }
2124    }
2125}
2126
2127/// How a document file is opened.
2128#[derive(Clone, Copy)]
2129enum OpenGate<'a> {
2130    /// Opened and verified against the workspace roots.
2131    Contained {
2132        roots: &'a WorkspaceRoots,
2133        path: &'a WorkspacePath,
2134    },
2135    /// Opened as named: a path a server reported may legitimately lie outside
2136    /// the roots.
2137    Ungated(&'a Path),
2138}
2139
2140impl<'a> OpenGate<'a> {
2141    fn path(self) -> &'a Path {
2142        match self {
2143            Self::Contained { path, .. } => path.as_path(),
2144            Self::Ungated(path) => path,
2145        }
2146    }
2147}
2148
2149/// Outcome of `DocumentTracker::disk_phase`: the version `ensure_open`'s
2150/// caller should end up synced to, and -- only when this call detected an
2151/// as-yet-uncommitted content change -- the content and disk snapshot to
2152/// commit alongside it.
2153struct Decision {
2154    uri: Uri,
2155    target_version: DocumentVersion,
2156    fresh_content: Option<DocumentText>,
2157    snap: Option<DiskSync>,
2158}
2159
2160impl Decision {
2161    /// A decision where nothing changed on disk this call: `target_version`
2162    /// is already what's committed in `DocumentState`.
2163    const fn unchanged(uri: Uri, target_version: DocumentVersion) -> Self {
2164        Self {
2165            uri,
2166            target_version,
2167            fresh_content: None,
2168            snap: None,
2169        }
2170    }
2171}
2172
2173/// Convert a file path to a URI.
2174///
2175/// Prefer `try_path_to_uri` on paths that come from configuration or
2176/// otherwise untrusted input; this wrapper exists for the common case of an
2177/// already-canonicalized path, where the conversion is not expected to fail
2178/// but must still surface as an error rather than a panic on unforeseen
2179/// inputs.
2180///
2181/// # Errors
2182///
2183/// Returns [`Error::PathToUri`] if the path cannot be represented as a
2184/// `file://` URI.
2185pub fn path_to_uri(path: &Path) -> Result<Uri> {
2186    try_path_to_uri(path).ok_or_else(|| Error::PathToUri(path.to_path_buf()))
2187}
2188
2189/// Convert a file path to a URI, returning `None` if the path cannot be
2190/// represented as a `file://` URI.
2191///
2192/// Prefer this over [`path_to_uri`] on paths that come from configuration,
2193/// where a bad value should surface as an error rather than a panic.
2194#[must_use]
2195pub fn try_path_to_uri(path: &Path) -> Option<Uri> {
2196    let uri_string = encode_rfc3986_path_chars(&file_url(path)?);
2197    Some(Uri::from(uri_string))
2198}
2199
2200#[cfg(not(windows))]
2201pub(super) fn file_url(path: &Path) -> Option<Url> {
2202    Url::from_file_path(path).ok()
2203}
2204
2205#[cfg(windows)]
2206pub(super) fn file_url(path: &Path) -> Option<Url> {
2207    match Url::from_file_path(path) {
2208        Ok(file_url) => Some(file_url),
2209        Err(()) if path.has_root() => windows_rooted_path_to_file_url(path),
2210        Err(()) => None,
2211    }
2212}
2213
2214#[cfg(windows)]
2215fn windows_rooted_path_to_file_url(path: &Path) -> Option<Url> {
2216    let path_str = path.to_string_lossy();
2217    let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
2218    let mut file_url = Url::parse("file:///").ok()?;
2219    file_url.path_segments_mut().ok()?.clear().extend(
2220        stripped
2221            .split(['\\', '/'])
2222            .filter(|segment| !segment.is_empty()),
2223    );
2224    Some(file_url)
2225}
2226
2227/// Percent-encodes the RFC 3986 §2.2 "other reserved" characters that the
2228/// `url` crate's default WHATWG path percent-encode set leaves untouched:
2229/// `[`, `]`, `^`, `|`. The remaining three characters in that set -- `{`,
2230/// `}`, and backtick -- are already encoded by `url` on serialization, so
2231/// they need no handling here; see
2232/// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`.
2233///
2234/// Shared with [`crate::bridge::resources::make_uri`] so `lsp-diagnostics://`
2235/// resource URIs get the same encoding as `file://` document URIs.
2236pub(super) fn encode_rfc3986_path_chars(url: &Url) -> String {
2237    let prefix = url[..url::Position::BeforePath].to_owned();
2238    let encoded = url[url::Position::BeforePath..]
2239        .replace('[', "%5B")
2240        .replace(']', "%5D")
2241        .replace('^', "%5E")
2242        .replace('|', "%7C");
2243    format!("{prefix}{encoded}")
2244}
2245
2246/// Convert an LSP `file://` URI to an absolute filesystem path.
2247///
2248/// Returns `None` if the URI is not a valid `file://` URI, uses a non-file
2249/// scheme, or contains percent-encoding that cannot map to a valid path.
2250#[must_use]
2251pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
2252    let url = Url::parse(uri.as_ref()).ok()?;
2253    if url.scheme() != "file" {
2254        return None;
2255    }
2256    // Reject authority-bearing file URIs (e.g. `file://server/share`) to
2257    // avoid UNC path confusion on Windows.
2258    if !url.host_str().unwrap_or("").is_empty() {
2259        return None;
2260    }
2261    url.to_file_path().ok()
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266    use std::assert_matches;
2267
2268    use super::*;
2269    use crate::config::FileExtension;
2270
2271    fn detect_language(
2272        path: &Path,
2273        extension_map: &HashMap<FileExtension, LanguageId>,
2274    ) -> LanguageId {
2275        LanguageMap::from(extension_map.clone()).detect(path)
2276    }
2277
2278    #[test]
2279    fn test_detect_language() {
2280        let mut map = HashMap::new();
2281        map.insert(
2282            FileExtension::from_static("rs"),
2283            LanguageId::from_static("rust"),
2284        );
2285        map.insert(
2286            FileExtension::from_static("py"),
2287            LanguageId::from_static("python"),
2288        );
2289        map.insert(
2290            FileExtension::from_static("ts"),
2291            LanguageId::from_static("typescript"),
2292        );
2293
2294        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
2295        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
2296        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
2297        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
2298    }
2299
2300    #[tokio::test]
2301    async fn test_document_tracker() {
2302        let mut map = HashMap::new();
2303        map.insert(
2304            FileExtension::from_static("rs"),
2305            LanguageId::from_static("rust"),
2306        );
2307
2308        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2309        let path = PathBuf::from("/test/file.rs");
2310
2311        assert_eq!(tracker.open_state(&path), DocumentOpenState::NotOpen);
2312
2313        tracker
2314            .open(path.clone(), "fn main() {}".to_string())
2315            .unwrap();
2316        assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
2317        assert_eq!(tracker.len(), 1);
2318
2319        let state = tracker.get(&path).unwrap();
2320        assert_eq!(state.version().get(), 1);
2321        assert_eq!(state.language_id(), "rust");
2322
2323        tracker.close(&path);
2324        assert_eq!(tracker.open_state(&path), DocumentOpenState::NotOpen);
2325        assert!(tracker.is_empty());
2326    }
2327
2328    /// The pair a sweep reads is one state of one opening: reopening a path
2329    /// changes the opening in the same read, and a document the server was not
2330    /// told the latest version of is `Lagging`.
2331    #[test]
2332    fn test_stamp_and_sweep_state_read_one_opening_and_version() {
2333        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2334        let server = ServerId::from_static("rust");
2335        let path = PathBuf::from("/test/stamp.rs");
2336        tracker.open(path.clone(), "fn a() {}".to_string()).unwrap();
2337        assert_eq!(tracker.stamp(&path, &server), None);
2338        assert_eq!(tracker.sweep_state(&path, &server), None);
2339
2340        tracker.set_synced_version_for_test(&path, &server, 1);
2341        let first = tracker.stamp(&path, &server).unwrap();
2342        assert_eq!(first.opening(), tracker.opening(&path));
2343        assert_eq!(first.version(), DocumentVersion::new(1));
2344        assert_matches!(
2345            tracker.sweep_state(&path, &server),
2346            Some(SweepState::Current { stamp, .. }) if stamp == first
2347        );
2348
2349        tracker.set_synced_version_for_test(&path, &server, 0);
2350        assert_matches!(
2351            tracker.sweep_state(&path, &server),
2352            Some(SweepState::Lagging { .. })
2353        );
2354
2355        tracker.close(&path);
2356        tracker.open(path.clone(), "fn a() {}".to_string()).unwrap();
2357        tracker.set_synced_version_for_test(&path, &server, 1);
2358        let second = tracker.stamp(&path, &server).unwrap();
2359        assert_ne!(second.opening(), first.opening());
2360        assert_eq!(second.opening(), tracker.opening(&path));
2361    }
2362
2363    /// #249: after a respawn, `forget_server` must clear only the respawned
2364    /// server's sync history so the next `ensure_open` call for it sends
2365    /// `didOpen` again -- while leaving other servers synced to the same
2366    /// document untouched (a path can be synced to more than one server,
2367    /// e.g. hover routed to one, diagnostics to another).
2368    #[test]
2369    fn test_forget_server_clears_only_that_servers_synced_version() {
2370        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2371        let path = PathBuf::from("/test/file.rs");
2372        tracker
2373            .open(path.clone(), "fn main() {}".to_string())
2374            .unwrap();
2375
2376        let respawned = ServerId::from_static("rust-respawned");
2377        let untouched = ServerId::from_static("rust-diagnostics");
2378        lock_std(&tracker.documents)
2379            .get_mut(&path)
2380            .unwrap()
2381            .synced
2382            .insert(respawned.clone(), DocumentVersion::FIRST);
2383        lock_std(&tracker.documents)
2384            .get_mut(&path)
2385            .unwrap()
2386            .synced
2387            .insert(untouched.clone(), DocumentVersion::FIRST);
2388
2389        tracker.forget_server(&respawned);
2390
2391        let state = tracker.get(&path).unwrap();
2392        assert!(state.synced_version(&respawned).is_none());
2393        assert!(state.synced_version(&untouched).is_some());
2394    }
2395
2396    /// #249 S1 regression: a `sync_phase` call that captured `server`'s
2397    /// generation *before* a concurrent `forget_server` bumped it must not
2398    /// commit its `synced` write, even though its notification against the
2399    /// now-superseded connection reports success (`fake_lsp_client`'s
2400    /// `DuplexStream` peer, held alive by the test's `FakeServer`, always
2401    /// accepts writes, standing in for the window where a server's process
2402    /// has already died but its message loop has not yet observed that).
2403    /// Without this, a document synced against the old (crashed) process
2404    /// would be wrongly marked as already open on the respawned one,
2405    /// permanently desyncing it.
2406    #[tokio::test]
2407    async fn test_sync_phase_skips_commit_when_generation_is_stale() {
2408        let dir = TempDir::new().unwrap();
2409        let path = dir.path().join("race.rs");
2410        std::fs::write(&path, "fn main() {}").unwrap();
2411        set_mtime(&path, settled_past());
2412
2413        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2414        let server = ServerId::from_static("rust");
2415        let generation_before_respawn = SyncGeneration::default();
2416
2417        // A respawn happens "concurrently" with the in-flight call that
2418        // captured the generation above before this ran.
2419        tracker.forget_server(&server);
2420
2421        let (stale_client, _guard) = fake_lsp_client();
2422        let decision = tracker
2423            .disk_phase(OpenGate::Ungated(&path), Access::Use)
2424            .await
2425            .unwrap();
2426        tracker
2427            .sync_phase(
2428                &path,
2429                &server,
2430                &stale_client,
2431                decision,
2432                generation_before_respawn,
2433            )
2434            .await
2435            .unwrap();
2436
2437        let state = tracker.get(&path).unwrap();
2438        assert!(
2439            state.synced_version(&server).is_none(),
2440            "a sync_phase call that captured a stale generation must not \
2441             commit `synced`, even though its notify against the \
2442             superseded connection succeeded"
2443        );
2444    }
2445
2446    /// Companion to the regression above: the ordinary, non-racing path
2447    /// (`ensure_open` capturing and committing against the *current*
2448    /// generation) must still work -- the generation check must not
2449    /// suppress a legitimate commit.
2450    #[tokio::test]
2451    async fn test_ensure_open_commits_when_generation_is_current() {
2452        let dir = TempDir::new().unwrap();
2453        let path = dir.path().join("no_race.rs");
2454        std::fs::write(&path, "fn main() {}").unwrap();
2455
2456        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2457        let server = ServerId::from_static("rust");
2458        let (client, _guard) = fake_lsp_client();
2459
2460        tracker
2461            .ensure_open_for_test(&path, &server, &client)
2462            .await
2463            .unwrap();
2464
2465        let state = tracker.get(&path).unwrap();
2466        assert_eq!(
2467            state.synced_version(&server).map(DocumentVersion::get),
2468            Some(1)
2469        );
2470    }
2471
2472    /// #796: the coverage accessors count documents sent to the current
2473    /// server process and flag a forgotten sync history.
2474    #[tokio::test]
2475    async fn test_synced_document_count_and_forgotten_history() {
2476        let dir = TempDir::new().unwrap();
2477        let path = dir.path().join("cover.rs");
2478        std::fs::write(&path, "fn main() {}").unwrap();
2479        set_mtime(&path, settled_past());
2480
2481        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2482        let server = ServerId::from_static("rust");
2483        let other = ServerId::from_static("other");
2484        let (client, _guard) = fake_lsp_client();
2485
2486        assert_eq!(tracker.synced_document_count(&server), 0);
2487        assert!(!tracker.sync_history_forgotten(&server));
2488
2489        tracker
2490            .ensure_open_for_test(&path, &server, &client)
2491            .await
2492            .unwrap();
2493        assert_eq!(tracker.synced_document_count(&server), 1);
2494        assert_eq!(tracker.synced_document_count(&other), 0);
2495
2496        tracker.forget_server(&server);
2497        assert_eq!(tracker.synced_document_count(&server), 0);
2498        assert!(tracker.sync_history_forgotten(&server));
2499        assert!(!tracker.sync_history_forgotten(&other));
2500    }
2501
2502    /// Marks `path`'s tracked document as disk-verified, for a test that
2503    /// opens a document directly via `open` (bypassing `ensure_open`'s
2504    /// `disk_phase`, which is what normally sets this) but still needs it
2505    /// eligible for `evict_lru`'s LRU eviction -- disk-verified is a
2506    /// precondition for eviction, not just unlocked (#495 S4).
2507    fn mark_disk_verified(tracker: &DocumentTracker, path: &Path) {
2508        tracker.set_disk(
2509            path,
2510            DiskSync {
2511                mtime: None,
2512                size: 0,
2513                mtime_settled: false,
2514                content_checked_at: Instant::now(),
2515            },
2516        );
2517    }
2518
2519    /// #495: at capacity with every existing document unlocked and
2520    /// disk-verified, `open` must evict the least-recently-used one to make
2521    /// room rather than fail -- its servers are recorded as owed a
2522    /// `didClose`.
2523    #[test]
2524    fn test_document_limit_evicts_lru_instead_of_failing() {
2525        let limits = ResourceLimits {
2526            max_documents: DocumentLimit::new(2),
2527            max_file_size: SizeLimit::from_static(100),
2528        };
2529        let mut map = HashMap::new();
2530        map.insert(
2531            FileExtension::from_static("rs"),
2532            LanguageId::from_static("rust"),
2533        );
2534
2535        let tracker = DocumentTracker::new(limits, map);
2536
2537        tracker
2538            .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
2539            .unwrap();
2540        mark_disk_verified(&tracker, Path::new("/test/file1.rs"));
2541        tracker
2542            .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
2543            .unwrap();
2544        mark_disk_verified(&tracker, Path::new("/test/file2.rs"));
2545
2546        tracker
2547            .open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string())
2548            .unwrap();
2549
2550        assert_eq!(tracker.len(), 2);
2551        assert_eq!(
2552            tracker.open_state(Path::new("/test/file1.rs")),
2553            DocumentOpenState::NotOpen
2554        );
2555        assert_eq!(
2556            tracker.open_state(Path::new("/test/file2.rs")),
2557            DocumentOpenState::Open
2558        );
2559        assert_eq!(
2560            tracker.open_state(Path::new("/test/file3.rs")),
2561            DocumentOpenState::Open
2562        );
2563
2564        assert!(
2565            tracker.pending_close_paths().is_empty(),
2566            "opened directly via `open`, never synced to any server"
2567        );
2568    }
2569
2570    /// #495: `open` must fall back to `DocumentLimitExceeded` when every
2571    /// tracked document currently has an operation in flight against it
2572    /// (simulated here by inserting its `path_locks` entry directly, which
2573    /// is exactly what `evict_lru` checks for) -- evicting a locked document
2574    /// would pull it out from under that in-flight operation.
2575    #[test]
2576    fn test_document_limit_falls_back_to_error_when_only_candidate_is_locked() {
2577        let limits = ResourceLimits {
2578            max_documents: DocumentLimit::new(1),
2579            max_file_size: SizeLimit::from_static(100),
2580        };
2581        let tracker = DocumentTracker::new(limits, HashMap::new());
2582
2583        let locked_path = PathBuf::from("/test/locked.rs");
2584        tracker
2585            .open(locked_path.clone(), "fn locked() {}".to_string())
2586            .unwrap();
2587        lock_std(&tracker.path_locks).insert(locked_path.clone(), Arc::new(AsyncMutex::new(())));
2588
2589        let result = tracker.open(PathBuf::from("/test/other.rs"), "fn other() {}".to_string());
2590        assert_matches!(result, Err(Error::DocumentLimitExceeded { .. }));
2591        assert!(
2592            tracker.open_state(&locked_path) == DocumentOpenState::Open,
2593            "the locked document must not be evicted"
2594        );
2595        assert_eq!(tracker.pending_close_paths().len(), 0);
2596    }
2597
2598    /// Servers currently owed a `didClose` for `path`.
2599    fn pending_servers(tracker: &DocumentTracker, path: &Path) -> HashSet<ServerId> {
2600        lock_std(&tracker.pending_closes)
2601            .get(path)
2602            .map_or_default(|pending| pending.servers.clone())
2603    }
2604
2605    /// Opens `name` through `ensure_open` so it is disk-verified (the only
2606    /// kind `evict_lru` will consider).
2607    async fn ensure_open_disk_verified(
2608        tracker: &DocumentTracker,
2609        dir: &TempDir,
2610        name: &str,
2611    ) -> PathBuf {
2612        let path = dir.path().join(name);
2613        std::fs::write(&path, name).unwrap();
2614        set_mtime(&path, settled_past());
2615        let (client, _server) = fake_lsp_client();
2616        tracker
2617            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
2618            .await
2619            .unwrap();
2620        path
2621    }
2622
2623    /// #783: a path validated against the roots whose parent directory became
2624    /// a symlink out of them is refused when it is opened, and the file behind
2625    /// the symlink is never read.
2626    #[cfg(unix)]
2627    #[tokio::test]
2628    async fn test_ensure_open_refuses_a_path_swapped_out_of_the_roots_after_validation() {
2629        let dir = TempDir::new().unwrap();
2630        let base = dunce::canonicalize(dir.path()).unwrap();
2631        let root = base.join("ws");
2632        std::fs::create_dir_all(root.join("sub")).unwrap();
2633        std::fs::create_dir_all(base.join("out")).unwrap();
2634        let roots = WorkspaceRoots::from_paths(&[&root]).unwrap();
2635        let file = root.join("sub/a.rs");
2636        std::fs::write(&file, "inside").unwrap();
2637        let validated = roots
2638            .validate_blocking(&crate::bridge::ClientPath::try_from(file).unwrap())
2639            .unwrap();
2640        std::fs::write(base.join("out/a.rs"), "secret").unwrap();
2641        std::fs::remove_dir_all(root.join("sub")).unwrap();
2642        std::os::unix::fs::symlink(base.join("out"), root.join("sub")).unwrap();
2643        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2644        let server = ServerId::from_static("rust");
2645        let (client, _guard) = fake_lsp_client();
2646
2647        let result = tracker
2648            .ensure_open(OpenRequest::new(
2649                &validated,
2650                &roots,
2651                &server,
2652                &client,
2653                Access::Use,
2654            ))
2655            .await;
2656
2657        assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
2658        assert_eq!(tracker.open_paths().len(), 0);
2659    }
2660
2661    /// #781: a probe never opens a file the tracker does not hold, so a
2662    /// document evicted between selection and visit stays evicted.
2663    #[tokio::test]
2664    async fn test_probe_of_an_untracked_path_opens_nothing() {
2665        let dir = TempDir::new().unwrap();
2666        let path = dir.path().join("a.rs");
2667        std::fs::write(&path, "fn a() {}").unwrap();
2668        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2669        let (client, _guard) = fake_lsp_client();
2670
2671        let result = tracker
2672            .ensure_open_as_for_test(
2673                &path,
2674                &ServerId::from_static("rust"),
2675                &client,
2676                Access::Probe,
2677            )
2678            .await;
2679
2680        assert_matches!(result, Err(Error::DocumentNotFound(_)));
2681        assert_eq!(tracker.open_paths().len(), 0);
2682    }
2683
2684    /// #781: a probe resyncs a tracked document but leaves the LRU order as
2685    /// tool calls made it; a use moves it to the front.
2686    #[tokio::test]
2687    async fn test_probe_leaves_the_recency_order_and_use_changes_it() {
2688        let dir = TempDir::new().unwrap();
2689        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2690        let server = ServerId::from_static("rust");
2691        let (client, _guard) = fake_lsp_client();
2692        let (a, b) = (dir.path().join("a.rs"), dir.path().join("b.rs"));
2693        for path in [&a, &b] {
2694            std::fs::write(path, "fn f() {}").unwrap();
2695            tracker
2696                .ensure_open_for_test(path, &server, &client)
2697                .await
2698                .unwrap();
2699            tokio::time::sleep(Duration::from_millis(10)).await;
2700        }
2701        assert_eq!(tracker.open_paths_by_recency(), [b.clone(), a.clone()]);
2702
2703        tracker
2704            .ensure_open_as_for_test(&a, &server, &client, Access::Probe)
2705            .await
2706            .unwrap();
2707        assert_eq!(tracker.open_paths_by_recency(), [b.clone(), a.clone()]);
2708
2709        tracker
2710            .ensure_open_for_test(&a, &server, &client)
2711            .await
2712            .unwrap();
2713        assert_eq!(tracker.open_paths_by_recency(), [a, b]);
2714    }
2715
2716    /// #503: a document held by an `InFlightGuard` is never evicted -- `open`
2717    /// falls back to `DocumentLimitExceeded` -- and becomes evictable the
2718    /// moment the guard drops.
2719    #[tokio::test]
2720    async fn test_open_does_not_evict_in_flight_document_until_guard_drops() {
2721        let dir = TempDir::new().unwrap();
2722        let limits = ResourceLimits {
2723            max_documents: DocumentLimit::new(1),
2724            max_file_size: SizeLimit::UNLIMITED,
2725        };
2726        let tracker = DocumentTracker::new(limits, HashMap::new());
2727        let path_a = ensure_open_disk_verified(&tracker, &dir, "a.rs").await;
2728
2729        let guard = tracker.mark_in_flight(&path_a);
2730        let path_b = dir.path().join("b.rs");
2731        std::fs::write(&path_b, "BBBB").unwrap();
2732
2733        let result = tracker.open(path_b.clone(), "BBBB".to_string());
2734        assert_matches!(result, Err(Error::DocumentLimitExceeded { .. }));
2735        assert_eq!(tracker.open_state(&path_a), DocumentOpenState::Open);
2736        assert_eq!(tracker.pending_close_paths().len(), 0);
2737
2738        drop(guard);
2739        tracker.open(path_b.clone(), "BBBB".to_string()).unwrap();
2740        assert_eq!(tracker.open_state(&path_a), DocumentOpenState::NotOpen);
2741        assert_eq!(tracker.open_state(&path_b), DocumentOpenState::Open);
2742        assert_eq!(tracker.pending_close_paths(), vec![path_a.clone()]);
2743        assert_eq!(
2744            pending_servers(&tracker, &path_a),
2745            HashSet::from([ServerId::from_static("rust")])
2746        );
2747    }
2748
2749    /// #503: guards for one path stack -- dropping one leaves the path
2750    /// protected until the last is gone, and the bookkeeping entry is removed.
2751    #[tokio::test]
2752    async fn test_in_flight_guards_are_refcounted_per_path() {
2753        let dir = TempDir::new().unwrap();
2754        let limits = ResourceLimits {
2755            max_documents: DocumentLimit::new(1),
2756            max_file_size: SizeLimit::UNLIMITED,
2757        };
2758        let tracker = DocumentTracker::new(limits, HashMap::new());
2759        let path_a = ensure_open_disk_verified(&tracker, &dir, "a.rs").await;
2760
2761        let first = tracker.mark_in_flight(&path_a);
2762        let second = tracker.mark_in_flight(&path_a);
2763        drop(first);
2764
2765        let path_b = dir.path().join("b.rs");
2766        let result = tracker.open(path_b.clone(), "BBBB".to_string());
2767        assert_matches!(
2768            result,
2769            Err(Error::DocumentLimitExceeded { .. }),
2770            "one guard still outstanding"
2771        );
2772
2773        drop(second);
2774        assert!(lock_std(&tracker.in_flight).is_empty());
2775        tracker.open(path_b, "BBBB".to_string()).unwrap();
2776        assert_eq!(tracker.open_state(&path_a), DocumentOpenState::NotOpen);
2777    }
2778
2779    /// #503: a guard held inside a future that is cancelled mid-await is
2780    /// released, so a timed-out handler cannot pin its document forever.
2781    #[tokio::test]
2782    async fn test_in_flight_guard_released_when_holding_future_is_cancelled() {
2783        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2784        let path = PathBuf::from("/test/cancelled.rs");
2785
2786        let held = async {
2787            let _guard = tracker.mark_in_flight(&path);
2788            std::future::pending::<()>().await;
2789        };
2790        let timed_out = tokio::time::timeout(Duration::from_millis(10), held).await;
2791
2792        assert!(timed_out.is_err());
2793        assert_eq!(tracker.in_flight_count(&path), 0);
2794    }
2795
2796    /// #495 S4: a document with no disk-verified snapshot (`disk()` is
2797    /// `None`) must never be evicted, even though it is unlocked.
2798    #[test]
2799    fn test_evict_lru_skips_document_without_disk_snapshot() {
2800        let limits = ResourceLimits {
2801            max_documents: DocumentLimit::new(1),
2802            max_file_size: SizeLimit::UNLIMITED,
2803        };
2804        let tracker = DocumentTracker::new(limits, HashMap::new());
2805        let first = PathBuf::from("/test/first.rs");
2806        tracker
2807            .open(first.clone(), "fn first() {}".to_string())
2808            .unwrap();
2809
2810        let result = tracker.open(
2811            PathBuf::from("/test/second.rs"),
2812            "fn second() {}".to_string(),
2813        );
2814        assert_matches!(result, Err(Error::DocumentLimitExceeded { .. }));
2815        assert!(
2816            tracker.open_state(&first) == DocumentOpenState::Open,
2817            "the not-disk-verified document must not be evicted"
2818        );
2819        assert_eq!(tracker.pending_close_paths().len(), 0);
2820    }
2821
2822    /// #495: `ensure_open` must bump a document's LRU recency (via
2823    /// `disk_phase`'s `touch`), so a document that was merely opened first
2824    /// but has since been re-accessed is not the one evicted -- eviction
2825    /// order must reflect actual usage, not just insertion order.
2826    #[tokio::test]
2827    async fn test_ensure_open_touch_changes_lru_eviction_order() {
2828        let dir = TempDir::new().unwrap();
2829        let path_a = dir.path().join("a.rs");
2830        let path_b = dir.path().join("b.rs");
2831        std::fs::write(&path_a, "AAAA").unwrap();
2832        std::fs::write(&path_b, "BBBB").unwrap();
2833        set_mtime(&path_a, settled_past());
2834        set_mtime(&path_b, settled_past());
2835
2836        let limits = ResourceLimits {
2837            max_documents: DocumentLimit::new(2),
2838            max_file_size: SizeLimit::UNLIMITED,
2839        };
2840        let (client, _server) = fake_lsp_client();
2841        let tracker = DocumentTracker::new(limits, HashMap::new());
2842        let server_id = ServerId::from_static("rust");
2843
2844        tracker
2845            .ensure_open_for_test(&path_a, &server_id, &client)
2846            .await
2847            .unwrap();
2848        tracker
2849            .ensure_open_for_test(&path_b, &server_id, &client)
2850            .await
2851            .unwrap();
2852
2853        // Re-access `a` so it becomes the more-recently-used of the two,
2854        // leaving `b` as the LRU entry despite having been opened second.
2855        tracker
2856            .ensure_open_for_test(&path_a, &server_id, &client)
2857            .await
2858            .unwrap();
2859
2860        let path_c = dir.path().join("c.rs");
2861        std::fs::write(&path_c, "CCCC").unwrap();
2862        set_mtime(&path_c, settled_past());
2863        tracker
2864            .ensure_open_for_test(&path_c, &server_id, &client)
2865            .await
2866            .unwrap();
2867
2868        assert!(
2869            tracker.open_state(&path_a) == DocumentOpenState::Open,
2870            "recently re-accessed, must survive"
2871        );
2872        assert!(
2873            tracker.open_state(&path_b) == DocumentOpenState::NotOpen,
2874            "least-recently-used, must be evicted"
2875        );
2876        assert_eq!(tracker.open_state(&path_c), DocumentOpenState::Open);
2877
2878        assert_eq!(tracker.pending_close_paths(), vec![path_b.clone()]);
2879        assert_eq!(
2880            pending_servers(&tracker, &path_b),
2881            HashSet::from([server_id])
2882        );
2883    }
2884
2885    #[test]
2886    fn test_file_size_limit() {
2887        let limits = ResourceLimits {
2888            max_documents: DocumentLimit::new(10),
2889            max_file_size: SizeLimit::from_static(10),
2890        };
2891        let mut map = HashMap::new();
2892        map.insert(
2893            FileExtension::from_static("rs"),
2894            LanguageId::from_static("rust"),
2895        );
2896
2897        let tracker = DocumentTracker::new(limits, map);
2898
2899        // Small file should succeed
2900        tracker
2901            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
2902            .unwrap();
2903
2904        // Large file should fail
2905        let large_content = "x".repeat(100);
2906        let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
2907        assert_matches!(result, Err(Error::FileSizeLimitExceeded { .. }));
2908    }
2909
2910    #[test]
2911    fn test_resource_limits_default() {
2912        let limits = ResourceLimits::default();
2913        assert_eq!(limits.max_documents, DocumentLimit::new(100));
2914        assert_eq!(
2915            limits.max_file_size,
2916            SizeLimit::from_static(10 * 1024 * 1024)
2917        );
2918    }
2919
2920    #[test]
2921    fn test_resource_limits_custom() {
2922        let limits = ResourceLimits {
2923            max_documents: DocumentLimit::new(50),
2924            max_file_size: SizeLimit::from_static(5 * 1024 * 1024),
2925        };
2926        assert_eq!(limits.max_documents, DocumentLimit::new(50));
2927        assert_eq!(
2928            limits.max_file_size,
2929            SizeLimit::from_static(5 * 1024 * 1024)
2930        );
2931    }
2932
2933    #[test]
2934    fn test_resource_limits_zero_unlimited() {
2935        let limits = ResourceLimits {
2936            max_documents: DocumentLimit::UNLIMITED,
2937            max_file_size: SizeLimit::UNLIMITED,
2938        };
2939        let mut map = HashMap::new();
2940        map.insert(
2941            FileExtension::from_static("rs"),
2942            LanguageId::from_static("rust"),
2943        );
2944
2945        let tracker = DocumentTracker::new(limits, map);
2946
2947        // Should allow many documents when limit is 0
2948        for i in 0..200 {
2949            tracker
2950                .open(
2951                    PathBuf::from(format!("/test/file{i}.rs")),
2952                    "content".to_string(),
2953                )
2954                .unwrap();
2955        }
2956        assert_eq!(tracker.len(), 200);
2957
2958        // Should allow large files when limit is 0
2959        let huge_content = "x".repeat(100_000_000);
2960        tracker
2961            .open(PathBuf::from("/test/huge.rs"), huge_content)
2962            .unwrap();
2963    }
2964
2965    #[test]
2966    fn test_document_state_clone() {
2967        let state = DocumentState {
2968            uri: Uri::from("file:///test.rs"),
2969            language_id: LanguageId::from_static("rust"),
2970            version: DocumentVersion::new(5),
2971            text: DocumentText::new("fn main() {}".to_string()),
2972            disk: None,
2973            synced: HashMap::new(),
2974            opened: Opening(0),
2975            last_accessed: Instant::now(),
2976        };
2977
2978        #[allow(clippy::redundant_clone, reason = "the test exercises `Clone` itself")]
2979        let cloned = state.clone();
2980        assert_eq!(cloned.uri(), state.uri());
2981        assert_eq!(cloned.language_id(), state.language_id());
2982        assert_eq!(cloned.version().get(), 5);
2983        assert_eq!(cloned.content(), state.content());
2984    }
2985
2986    #[test]
2987    fn test_line_presence_distinguishes_untracked_present_and_beyond() {
2988        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2989        let path = PathBuf::from("/test/lines.rs");
2990        assert_eq!(tracker.line_presence(&path, 0), LinePresence::Untracked);
2991
2992        tracker.open(path.clone(), "a\r\nb\n".to_string()).unwrap();
2993        assert_eq!(tracker.line_presence(&path, 0), LinePresence::Present);
2994        assert_eq!(tracker.line_presence(&path, 1), LinePresence::Present);
2995        assert_eq!(tracker.line_presence(&path, 2), LinePresence::Present);
2996        let beyond = LinePresence::Beyond {
2997            last_line: NonZeroU32::new(3).unwrap(),
2998        };
2999        assert_eq!(tracker.line_presence(&path, 3), beyond);
3000        assert_eq!(tracker.line_presence(&path, u32::MAX), beyond);
3001
3002        let empty = PathBuf::from("/test/empty.rs");
3003        tracker.open(empty.clone(), String::new()).unwrap();
3004        assert_eq!(tracker.line_presence(&empty, 0), LinePresence::Present);
3005        assert_eq!(
3006            tracker.line_presence(&empty, 1),
3007            LinePresence::Beyond {
3008                last_line: NonZeroU32::MIN
3009            }
3010        );
3011    }
3012
3013    #[test]
3014    fn test_close_nonexistent_document() {
3015        let map = HashMap::new();
3016        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3017        let path = PathBuf::from("/test/nonexistent.rs");
3018
3019        let state = tracker.close(&path);
3020        assert_eq!(
3021            state, None,
3022            "Closing non-existent document should return None"
3023        );
3024    }
3025
3026    #[test]
3027    fn test_get_nonexistent_document() {
3028        let map = HashMap::new();
3029        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3030        let path = PathBuf::from("/test/nonexistent.rs");
3031
3032        let state = tracker.get(&path);
3033        assert!(
3034            state.is_none(),
3035            "Getting non-existent document should return None"
3036        );
3037    }
3038
3039    #[test]
3040    #[allow(
3041        clippy::too_many_lines,
3042        reason = "a table of extension mappings; splitting would scatter it"
3043    )]
3044    fn test_detect_language_all_extensions() {
3045        let mut map = HashMap::new();
3046        map.insert(
3047            FileExtension::from_static("rs"),
3048            LanguageId::from_static("rust"),
3049        );
3050        map.insert(
3051            FileExtension::from_static("py"),
3052            LanguageId::from_static("python"),
3053        );
3054        map.insert(
3055            FileExtension::from_static("pyw"),
3056            LanguageId::from_static("python"),
3057        );
3058        map.insert(
3059            FileExtension::from_static("pyi"),
3060            LanguageId::from_static("python"),
3061        );
3062        map.insert(
3063            FileExtension::from_static("js"),
3064            LanguageId::from_static("javascript"),
3065        );
3066        map.insert(
3067            FileExtension::from_static("mjs"),
3068            LanguageId::from_static("javascript"),
3069        );
3070        map.insert(
3071            FileExtension::from_static("cjs"),
3072            LanguageId::from_static("javascript"),
3073        );
3074        map.insert(
3075            FileExtension::from_static("ts"),
3076            LanguageId::from_static("typescript"),
3077        );
3078        map.insert(
3079            FileExtension::from_static("mts"),
3080            LanguageId::from_static("typescript"),
3081        );
3082        map.insert(
3083            FileExtension::from_static("cts"),
3084            LanguageId::from_static("typescript"),
3085        );
3086        map.insert(
3087            FileExtension::from_static("tsx"),
3088            LanguageId::from_static("typescriptreact"),
3089        );
3090        map.insert(
3091            FileExtension::from_static("jsx"),
3092            LanguageId::from_static("javascriptreact"),
3093        );
3094        map.insert(
3095            FileExtension::from_static("go"),
3096            LanguageId::from_static("go"),
3097        );
3098        map.insert(
3099            FileExtension::from_static("c"),
3100            LanguageId::from_static("c"),
3101        );
3102        map.insert(
3103            FileExtension::from_static("h"),
3104            LanguageId::from_static("c"),
3105        );
3106        map.insert(
3107            FileExtension::from_static("cpp"),
3108            LanguageId::from_static("cpp"),
3109        );
3110        map.insert(
3111            FileExtension::from_static("cc"),
3112            LanguageId::from_static("cpp"),
3113        );
3114        map.insert(
3115            FileExtension::from_static("cxx"),
3116            LanguageId::from_static("cpp"),
3117        );
3118        map.insert(
3119            FileExtension::from_static("hpp"),
3120            LanguageId::from_static("cpp"),
3121        );
3122        map.insert(
3123            FileExtension::from_static("hh"),
3124            LanguageId::from_static("cpp"),
3125        );
3126        map.insert(
3127            FileExtension::from_static("hxx"),
3128            LanguageId::from_static("cpp"),
3129        );
3130        map.insert(
3131            FileExtension::from_static("java"),
3132            LanguageId::from_static("java"),
3133        );
3134        map.insert(
3135            FileExtension::from_static("rb"),
3136            LanguageId::from_static("ruby"),
3137        );
3138        map.insert(
3139            FileExtension::from_static("php"),
3140            LanguageId::from_static("php"),
3141        );
3142        map.insert(
3143            FileExtension::from_static("swift"),
3144            LanguageId::from_static("swift"),
3145        );
3146        map.insert(
3147            FileExtension::from_static("kt"),
3148            LanguageId::from_static("kotlin"),
3149        );
3150        map.insert(
3151            FileExtension::from_static("kts"),
3152            LanguageId::from_static("kotlin"),
3153        );
3154        map.insert(
3155            FileExtension::from_static("scala"),
3156            LanguageId::from_static("scala"),
3157        );
3158        map.insert(
3159            FileExtension::from_static("sc"),
3160            LanguageId::from_static("scala"),
3161        );
3162        map.insert(
3163            FileExtension::from_static("zig"),
3164            LanguageId::from_static("zig"),
3165        );
3166        map.insert(
3167            FileExtension::from_static("lua"),
3168            LanguageId::from_static("lua"),
3169        );
3170        map.insert(
3171            FileExtension::from_static("sh"),
3172            LanguageId::from_static("shellscript"),
3173        );
3174        map.insert(
3175            FileExtension::from_static("bash"),
3176            LanguageId::from_static("shellscript"),
3177        );
3178        map.insert(
3179            FileExtension::from_static("zsh"),
3180            LanguageId::from_static("shellscript"),
3181        );
3182        map.insert(
3183            FileExtension::from_static("json"),
3184            LanguageId::from_static("json"),
3185        );
3186        map.insert(
3187            FileExtension::from_static("toml"),
3188            LanguageId::from_static("toml"),
3189        );
3190        map.insert(
3191            FileExtension::from_static("yaml"),
3192            LanguageId::from_static("yaml"),
3193        );
3194        map.insert(
3195            FileExtension::from_static("yml"),
3196            LanguageId::from_static("yaml"),
3197        );
3198        map.insert(
3199            FileExtension::from_static("xml"),
3200            LanguageId::from_static("xml"),
3201        );
3202        map.insert(
3203            FileExtension::from_static("html"),
3204            LanguageId::from_static("html"),
3205        );
3206        map.insert(
3207            FileExtension::from_static("htm"),
3208            LanguageId::from_static("html"),
3209        );
3210        map.insert(
3211            FileExtension::from_static("css"),
3212            LanguageId::from_static("css"),
3213        );
3214        map.insert(
3215            FileExtension::from_static("scss"),
3216            LanguageId::from_static("scss"),
3217        );
3218        map.insert(
3219            FileExtension::from_static("less"),
3220            LanguageId::from_static("less"),
3221        );
3222        map.insert(
3223            FileExtension::from_static("md"),
3224            LanguageId::from_static("markdown"),
3225        );
3226        map.insert(
3227            FileExtension::from_static("markdown"),
3228            LanguageId::from_static("markdown"),
3229        );
3230
3231        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
3232        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
3233        assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
3234        assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
3235        assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
3236        assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
3237        assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
3238        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
3239        assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
3240        assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
3241        assert_eq!(
3242            detect_language(Path::new("component.tsx"), &map),
3243            "typescriptreact"
3244        );
3245        assert_eq!(
3246            detect_language(Path::new("component.jsx"), &map),
3247            "javascriptreact"
3248        );
3249        assert_eq!(detect_language(Path::new("main.go"), &map), "go");
3250        assert_eq!(detect_language(Path::new("main.c"), &map), "c");
3251        assert_eq!(detect_language(Path::new("header.h"), &map), "c");
3252        assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
3253        assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
3254        assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
3255        assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
3256        assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
3257        assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
3258        assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
3259        assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
3260        assert_eq!(detect_language(Path::new("index.php"), &map), "php");
3261        assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
3262        assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
3263        assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
3264        assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
3265        assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
3266        assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
3267        assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
3268        assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
3269        assert_eq!(
3270            detect_language(Path::new("script.bash"), &map),
3271            "shellscript"
3272        );
3273        assert_eq!(
3274            detect_language(Path::new("script.zsh"), &map),
3275            "shellscript"
3276        );
3277        assert_eq!(detect_language(Path::new("data.json"), &map), "json");
3278        assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
3279        assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
3280        assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
3281        assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
3282        assert_eq!(detect_language(Path::new("index.html"), &map), "html");
3283        assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
3284        assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
3285        assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
3286        assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
3287        assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
3288        assert_eq!(
3289            detect_language(Path::new("README.markdown"), &map),
3290            "markdown"
3291        );
3292        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
3293        assert_eq!(
3294            detect_language(Path::new("no_extension"), &map),
3295            "plaintext"
3296        );
3297    }
3298
3299    #[test]
3300    fn test_path_to_uri_unix() {
3301        #[cfg(not(windows))]
3302        {
3303            let path = Path::new("/home/user/project/main.rs");
3304            let uri = path_to_uri(path).unwrap();
3305            assert!(
3306                uri.as_ref()
3307                    .starts_with("file:///home/user/project/main.rs")
3308            );
3309        }
3310    }
3311
3312    #[test]
3313    fn test_path_to_uri_with_special_chars() {
3314        let path = Path::new("/home/user/project-test/main.rs");
3315        let uri = path_to_uri(path).unwrap();
3316        assert!(uri.as_ref().starts_with("file://"));
3317        assert!(uri.as_ref().contains("project-test"));
3318    }
3319
3320    #[test]
3321    fn test_path_to_uri_percent_encodes_reserved_chars() {
3322        #[cfg(windows)]
3323        let path = Path::new(r"C:\home\user\routes\api\[...]^|.ts");
3324        #[cfg(not(windows))]
3325        let path = Path::new("/home/user/routes/api/[...]^|.ts");
3326
3327        let uri = path_to_uri(path).unwrap();
3328
3329        #[cfg(windows)]
3330        let expected = "file:///C:/home/user/routes/api/%5B...%5D%5E%7C.ts";
3331        #[cfg(not(windows))]
3332        let expected = "file:///home/user/routes/api/%5B...%5D%5E%7C.ts";
3333
3334        assert_eq!(uri.as_ref(), expected);
3335        assert_eq!(
3336            uri_to_path(&uri).as_deref(),
3337            Some(path),
3338            "encoded file URI should round-trip to the original path"
3339        );
3340    }
3341
3342    #[test]
3343    fn test_try_path_to_uri_returns_none_for_relative_path() {
3344        assert_eq!(try_path_to_uri(Path::new("relative/file.ts")), None);
3345    }
3346
3347    /// #234 regression: `path_to_uri` must surface a conversion failure as
3348    /// `Err`, not panic -- the whole point of the fix was making this path
3349    /// testable instead of aborting the process.
3350    #[test]
3351    fn test_path_to_uri_returns_err_for_relative_path() {
3352        let err = path_to_uri(Path::new("relative/file.ts")).unwrap_err();
3353        assert_matches!(err, Error::PathToUri(_));
3354    }
3355
3356    #[cfg(windows)]
3357    #[test]
3358    fn test_try_path_to_uri_encodes_synthetic_windows_root() {
3359        let uri = try_path_to_uri(Path::new("/home/user/#work %23")).unwrap();
3360
3361        assert_eq!(uri.as_ref(), "file:///home/user/%23work%20%2523");
3362    }
3363
3364    /// A rooted-but-not-absolute Windows path (`\foo`, no drive/UNC prefix)
3365    /// satisfies `Path::has_root()` but not `Path::is_absolute()`.
3366    /// `file_url`'s `#[cfg(windows)]` variant deliberately falls back to
3367    /// `windows_rooted_path_to_file_url` on this exact case -- pinned here so
3368    /// a future change to `try_path_to_uri` (e.g. swapping the fallible
3369    /// `.parse()` this migration replaced for an `is_absolute()` guard)
3370    /// cannot silently narrow this without failing a test.
3371    #[cfg(windows)]
3372    #[test]
3373    fn test_try_path_to_uri_accepts_rooted_but_not_absolute_windows_path() {
3374        let path = Path::new(r"\foo");
3375        assert!(path.has_root());
3376        assert!(!path.is_absolute());
3377
3378        let uri = try_path_to_uri(path).unwrap();
3379
3380        assert_eq!(uri.as_ref(), "file:///foo");
3381    }
3382
3383    #[test]
3384    fn test_path_to_uri_percent_encodes_reserved_chars_in_short_path() {
3385        // Regression: reserved chars near the URI start must still be encoded.
3386        #[cfg(windows)]
3387        let path = Path::new(r"C:\[a].ts");
3388        #[cfg(not(windows))]
3389        let path = Path::new("/[a].ts");
3390
3391        let uri = path_to_uri(path).unwrap();
3392
3393        assert!(
3394            uri.as_ref().ends_with("%5Ba%5D.ts"),
3395            "short path should percent-encode reserved chars, got {}",
3396            uri.as_ref()
3397        );
3398        assert_eq!(uri_to_path(&uri).as_deref(), Some(path));
3399    }
3400
3401    #[test]
3402    fn test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars() {
3403        // RFC 3986 §2.2 "other reserved" characters. The `url` crate already
3404        // percent-encodes `{`, `}`, and backtick when serializing; `[`, `]`,
3405        // `^`, `|` are handled explicitly by `encode_rfc3986_path_chars`.
3406        #[cfg(windows)]
3407        let path = Path::new(r"C:\home\user\test[]^|{}`.ts");
3408        #[cfg(not(windows))]
3409        let path = Path::new("/home/user/test[]^|{}`.ts");
3410
3411        let uri = try_path_to_uri(path).unwrap();
3412        let uri_str = uri.as_ref();
3413
3414        for (raw, encoded) in [
3415            ('[', "%5B"),
3416            (']', "%5D"),
3417            ('^', "%5E"),
3418            ('|', "%7C"),
3419            ('{', "%7B"),
3420            ('}', "%7D"),
3421            ('`', "%60"),
3422        ] {
3423            assert!(
3424                uri_str.contains(encoded),
3425                "expected {raw:?} to be percent-encoded as {encoded} in {uri_str}"
3426            );
3427        }
3428        assert!(
3429            !uri_str.contains(['[', ']', '^', '|', '{', '}', '`']),
3430            "no raw reserved characters should remain in {uri_str}"
3431        );
3432    }
3433
3434    #[tokio::test]
3435    async fn test_document_tracker_concurrent_operations() {
3436        let mut map = HashMap::new();
3437        map.insert(
3438            FileExtension::from_static("rs"),
3439            LanguageId::from_static("rust"),
3440        );
3441
3442        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3443        let path1 = PathBuf::from("/test/file1.rs");
3444        let path2 = PathBuf::from("/test/file2.rs");
3445
3446        tracker.open(path1.clone(), "content1".to_string()).unwrap();
3447        tracker.open(path2.clone(), "content2".to_string()).unwrap();
3448
3449        assert_eq!(tracker.len(), 2);
3450        assert_eq!(tracker.open_state(&path1), DocumentOpenState::Open);
3451        assert_eq!(tracker.open_state(&path2), DocumentOpenState::Open);
3452
3453        assert_eq!(tracker.get(&path1).unwrap().content(), "content1");
3454        assert_eq!(tracker.get(&path2).unwrap().content(), "content2");
3455
3456        tracker.close(&path1);
3457        assert_eq!(tracker.len(), 1);
3458        assert_eq!(tracker.open_state(&path1), DocumentOpenState::NotOpen);
3459        assert_eq!(tracker.open_state(&path2), DocumentOpenState::Open);
3460    }
3461
3462    #[test]
3463    fn test_empty_content() {
3464        let mut map = HashMap::new();
3465        map.insert(
3466            FileExtension::from_static("rs"),
3467            LanguageId::from_static("rust"),
3468        );
3469
3470        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3471        let path = PathBuf::from("/test/empty.rs");
3472
3473        tracker.open(path.clone(), String::new()).unwrap();
3474        assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
3475        assert_eq!(tracker.get(&path).unwrap().content(), "");
3476    }
3477
3478    #[test]
3479    fn test_unicode_content() {
3480        let mut map = HashMap::new();
3481        map.insert(
3482            FileExtension::from_static("rs"),
3483            LanguageId::from_static("rust"),
3484        );
3485
3486        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3487        let path = PathBuf::from("/test/unicode.rs");
3488        let content = "fn テスト() { println!(\"こんにちは\"); }";
3489
3490        tracker.open(path.clone(), content.to_string()).unwrap();
3491        assert_eq!(tracker.get(&path).unwrap().content(), content);
3492    }
3493
3494    /// #495: at exactly `max_documents`, `open` must evict the LRU entry
3495    /// (here `file0`, the first opened) rather than fail, since none of the
3496    /// existing documents are locked and all are disk-verified.
3497    #[test]
3498    fn test_document_limit_exact_boundary() {
3499        let limits = ResourceLimits {
3500            max_documents: DocumentLimit::new(5),
3501            max_file_size: SizeLimit::from_static(1000),
3502        };
3503        let mut map = HashMap::new();
3504        map.insert(
3505            FileExtension::from_static("rs"),
3506            LanguageId::from_static("rust"),
3507        );
3508
3509        let tracker = DocumentTracker::new(limits, map);
3510
3511        for i in 0..5 {
3512            let path = PathBuf::from(format!("/test/file{i}.rs"));
3513            tracker.open(path.clone(), "content".to_string()).unwrap();
3514            mark_disk_verified(&tracker, &path);
3515        }
3516
3517        assert_eq!(tracker.len(), 5);
3518
3519        tracker
3520            .open(PathBuf::from("/test/file6.rs"), "content".to_string())
3521            .unwrap();
3522
3523        assert_eq!(tracker.len(), 5);
3524        assert_eq!(
3525            tracker.open_state(Path::new("/test/file0.rs")),
3526            DocumentOpenState::NotOpen
3527        );
3528        assert_eq!(
3529            tracker.open_state(Path::new("/test/file6.rs")),
3530            DocumentOpenState::Open
3531        );
3532    }
3533
3534    #[test]
3535    fn test_file_size_exact_boundary() {
3536        let limits = ResourceLimits {
3537            max_documents: DocumentLimit::new(10),
3538            max_file_size: SizeLimit::from_static(100),
3539        };
3540        let mut map = HashMap::new();
3541        map.insert(
3542            FileExtension::from_static("rs"),
3543            LanguageId::from_static("rust"),
3544        );
3545
3546        let tracker = DocumentTracker::new(limits, map);
3547
3548        let exact_size_content = "x".repeat(100);
3549        tracker
3550            .open(PathBuf::from("/test/exact.rs"), exact_size_content)
3551            .unwrap();
3552
3553        let over_size_content = "x".repeat(101);
3554        let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
3555        assert_matches!(result, Err(Error::FileSizeLimitExceeded { .. }));
3556    }
3557
3558    #[test]
3559    fn test_detect_language_with_custom_extension() {
3560        let mut map = HashMap::new();
3561        map.insert(
3562            FileExtension::from_static("nu"),
3563            LanguageId::from_static("nushell"),
3564        );
3565
3566        assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
3567
3568        let empty_map = HashMap::new();
3569        assert_eq!(
3570            detect_language(Path::new("script.nu"), &empty_map),
3571            "plaintext"
3572        );
3573    }
3574
3575    #[test]
3576    fn test_detect_language_custom_overrides_default() {
3577        let mut custom_map = HashMap::new();
3578        custom_map.insert(
3579            FileExtension::from_static("rs"),
3580            LanguageId::from_static("custom-rust"),
3581        );
3582
3583        assert_eq!(
3584            detect_language(Path::new("main.rs"), &custom_map),
3585            "custom-rust"
3586        );
3587
3588        let mut default_map = HashMap::new();
3589        default_map.insert(
3590            FileExtension::from_static("rs"),
3591            LanguageId::from_static("rust"),
3592        );
3593
3594        assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
3595    }
3596
3597    #[test]
3598    fn test_detect_language_fallback_to_plaintext() {
3599        let mut map = HashMap::new();
3600        map.insert(
3601            FileExtension::from_static("nu"),
3602            LanguageId::from_static("nushell"),
3603        );
3604
3605        // .rs not in custom map, should return plaintext
3606        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
3607    }
3608
3609    #[test]
3610    fn test_detect_language_empty_map() {
3611        let map = HashMap::new();
3612        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
3613    }
3614
3615    #[test]
3616    fn test_document_tracker_with_extensions() {
3617        let mut map = HashMap::new();
3618        map.insert(
3619            FileExtension::from_static("nu"),
3620            LanguageId::from_static("nushell"),
3621        );
3622
3623        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3624
3625        let path = PathBuf::from("/test/script.nu");
3626        tracker
3627            .open(path.clone(), "# nushell script".to_string())
3628            .unwrap();
3629
3630        let state = tracker.get(&path).unwrap();
3631        assert_eq!(state.language_id(), "nushell");
3632    }
3633
3634    #[test]
3635    fn test_document_tracker_uses_provided_map() {
3636        let mut map = HashMap::new();
3637        map.insert(
3638            FileExtension::from_static("rs"),
3639            LanguageId::from_static("rust"),
3640        );
3641
3642        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3643        let path = PathBuf::from("/test/main.rs");
3644        tracker
3645            .open(path.clone(), "fn main() {}".to_string())
3646            .unwrap();
3647
3648        let state = tracker.get(&path).unwrap();
3649        assert_eq!(state.language_id(), "rust");
3650    }
3651
3652    #[test]
3653    fn test_multiple_extensions_same_language() {
3654        let mut map = HashMap::new();
3655        map.insert(
3656            FileExtension::from_static("cpp"),
3657            LanguageId::from_static("c++"),
3658        );
3659        map.insert(
3660            FileExtension::from_static("cc"),
3661            LanguageId::from_static("c++"),
3662        );
3663        map.insert(
3664            FileExtension::from_static("cxx"),
3665            LanguageId::from_static("c++"),
3666        );
3667
3668        assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
3669        assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
3670        assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
3671    }
3672
3673    #[test]
3674    fn test_case_sensitive_extensions() {
3675        let mut map = HashMap::new();
3676        map.insert(
3677            FileExtension::from_static("NU"),
3678            LanguageId::from_static("nushell"),
3679        );
3680
3681        // Lowercase .nu should not match uppercase "NU" in map
3682        assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
3683    }
3684
3685    // ------------------------------------------------------------------
3686    // uri_to_path
3687    // ------------------------------------------------------------------
3688
3689    #[cfg(unix)]
3690    #[test]
3691    fn test_uri_to_path_file_scheme() {
3692        let uri: Uri = Uri::from("file:///home/user/main.rs");
3693        let path = uri_to_path(&uri).unwrap();
3694        assert_eq!(path, PathBuf::from("/home/user/main.rs"));
3695    }
3696
3697    #[test]
3698    fn test_uri_to_path_non_file_scheme_returns_none() {
3699        let uri: Uri = Uri::from("https://example.com/file.rs");
3700        assert!(uri_to_path(&uri).is_none());
3701    }
3702
3703    #[test]
3704    fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
3705        // Custom scheme must not be decoded by uri_to_path.
3706        let uri: Uri = Uri::from("lsp-diagnostics:///home/user/main.rs");
3707        assert!(uri_to_path(&uri).is_none());
3708    }
3709
3710    #[test]
3711    fn test_uri_to_path_with_authority_returns_none() {
3712        // Authority-bearing file URIs must be rejected (UNC path defence).
3713        // lsp_types::Uri may or may not accept this string; either way
3714        // uri_to_path should return None.
3715        let result = uri_to_path(&Uri::from("file://server/share/path.rs"));
3716        assert!(result.is_none());
3717    }
3718
3719    // ------------------------------------------------------------------
3720    // open_paths
3721    // ------------------------------------------------------------------
3722
3723    #[test]
3724    fn test_open_paths_empty_tracker() {
3725        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3726        assert_eq!(tracker.open_paths().len(), 0);
3727    }
3728
3729    #[test]
3730    fn test_open_paths_populated_tracker() {
3731        let mut map = HashMap::new();
3732        map.insert(
3733            FileExtension::from_static("rs"),
3734            LanguageId::from_static("rust"),
3735        );
3736        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3737        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
3738        tracker.open(PathBuf::from("/b.rs"), String::new()).unwrap();
3739        let mut paths = tracker.open_paths();
3740        paths.sort();
3741        assert_eq!(paths, [PathBuf::from("/a.rs"), PathBuf::from("/b.rs")]);
3742    }
3743
3744    #[test]
3745    fn test_open_paths_after_close() {
3746        let mut map = HashMap::new();
3747        map.insert(
3748            FileExtension::from_static("rs"),
3749            LanguageId::from_static("rust"),
3750        );
3751        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
3752        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
3753        tracker.close(Path::new("/a.rs"));
3754        assert_eq!(tracker.open_paths().len(), 0);
3755    }
3756
3757    // ------------------------------------------------------------------
3758    // ensure_open resync (issue #102)
3759    // ------------------------------------------------------------------
3760
3761    use tempfile::TempDir;
3762    use tokio::io::BufReader;
3763
3764    use crate::test_lsp::{fake_lsp_client, read_framed_message};
3765
3766    /// Backdates or forwards a file's mtime for deterministic disk-sync tests.
3767    ///
3768    /// Opened with `write(true)` rather than [`std::fs::File::open`]: on
3769    /// Windows, `set_modified` needs a handle with write access, and a
3770    /// read-only handle fails with `PermissionDenied` (Unix's
3771    /// `utimensat`-based implementation has no such requirement, which is
3772    /// why a read-only handle works there).
3773    fn set_mtime(path: &Path, time: SystemTime) {
3774        let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
3775        file.set_modified(time).unwrap();
3776    }
3777
3778    fn settled_past() -> SystemTime {
3779        SystemTime::now() - Duration::from_secs(10)
3780    }
3781
3782    #[test]
3783    fn test_mtime_settled_boundary() {
3784        let read_at = SystemTime::now();
3785        assert!(!mtime_settled(None, read_at), "no mtime is never settled");
3786        assert!(
3787            mtime_settled(Some(read_at - Duration::from_secs(3)), read_at),
3788            "3s older than read_at is past the 2s granularity margin"
3789        );
3790        assert!(
3791            !mtime_settled(Some(read_at - Duration::from_secs(1)), read_at),
3792            "1s older than read_at is within the 2s granularity margin"
3793        );
3794        assert!(
3795            !mtime_settled(Some(read_at + Duration::from_secs(10)), read_at),
3796            "an mtime after read_at is never settled"
3797        );
3798    }
3799
3800    #[tokio::test]
3801    async fn test_ensure_open_unchanged_file_is_fast_path() {
3802        let dir = TempDir::new().unwrap();
3803        let path = dir.path().join("a.rs");
3804        std::fs::write(&path, "fn main() {}").unwrap();
3805        set_mtime(&path, settled_past());
3806
3807        let (client, _server) = fake_lsp_client();
3808        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3809
3810        let uri1 = tracker
3811            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3812            .await
3813            .unwrap();
3814        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
3815
3816        let uri2 = tracker
3817            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3818            .await
3819            .unwrap();
3820        assert_eq!(uri1, uri2);
3821        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
3822        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
3823    }
3824
3825    #[tokio::test]
3826    async fn test_ensure_open_resyncs_on_size_change() {
3827        let dir = TempDir::new().unwrap();
3828        let path = dir.path().join("a.rs");
3829        std::fs::write(&path, "fn main() {}").unwrap();
3830        set_mtime(&path, settled_past());
3831
3832        let (client, _server) = fake_lsp_client();
3833        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3834        tracker
3835            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3836            .await
3837            .unwrap();
3838
3839        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
3840        set_mtime(&path, settled_past());
3841
3842        tracker
3843            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3844            .await
3845            .unwrap();
3846        let state = tracker.get(&path).unwrap();
3847        assert_eq!(state.version().get(), 2);
3848        assert_eq!(state.content(), "fn main() { println!(\"hi\"); }");
3849    }
3850
3851    #[tokio::test(start_paused = true)]
3852    async fn test_ensure_open_regression_102_103_racy_same_size_rewrite() {
3853        let dir = TempDir::new().unwrap();
3854        let path = dir.path().join("a.rs");
3855        std::fs::write(&path, "AAAA").unwrap();
3856        // Leave the mtime at "now" (racy) rather than backdating it.
3857
3858        let (client, _server) = fake_lsp_client();
3859        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3860        tracker
3861            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3862            .await
3863            .unwrap();
3864        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
3865
3866        // Same-length rewrite with the mtime forced back to the recorded
3867        // value -- exactly the same-tick rewrite issue #102/#103 missed.
3868        std::fs::write(&path, "BBBB").unwrap();
3869        set_mtime(&path, original_mtime);
3870
3871        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
3872
3873        tracker
3874            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3875            .await
3876            .unwrap();
3877        let state = tracker.get(&path).unwrap();
3878        assert_eq!(
3879            state.version().get(),
3880            2,
3881            "must resync despite identical (mtime, size)"
3882        );
3883        assert_eq!(state.content(), "BBBB");
3884    }
3885
3886    #[tokio::test(start_paused = true)]
3887    async fn test_ensure_open_regression_102_103_settled_mtime_is_the_documented_limit() {
3888        let dir = TempDir::new().unwrap();
3889        let path = dir.path().join("a.rs");
3890        std::fs::write(&path, "AAAA").unwrap();
3891        set_mtime(&path, settled_past());
3892
3893        let (client, _server) = fake_lsp_client();
3894        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3895        tracker
3896            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3897            .await
3898            .unwrap();
3899        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
3900
3901        // Same-length rewrite restoring an already-settled mtime: this is
3902        // the documented residual limitation (e.g. `tar x`, `rsync -a`),
3903        // not a bug -- it is out of reach without hashing on every access.
3904        std::fs::write(&path, "BBBB").unwrap();
3905        set_mtime(&path, original_mtime);
3906
3907        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
3908
3909        tracker
3910            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3911            .await
3912            .unwrap();
3913        let state = tracker.get(&path).unwrap();
3914        assert_eq!(
3915            state.version().get(),
3916            1,
3917            "documented limitation: fast path taken"
3918        );
3919        assert_eq!(state.content(), "AAAA");
3920    }
3921
3922    #[tokio::test(start_paused = true)]
3923    async fn test_ensure_open_stat_is_never_debounced() {
3924        let dir = TempDir::new().unwrap();
3925        let path = dir.path().join("a.rs");
3926        std::fs::write(&path, "AAAA").unwrap();
3927        set_mtime(&path, settled_past());
3928
3929        let (client, _server) = fake_lsp_client();
3930        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3931        tracker
3932            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3933            .await
3934            .unwrap();
3935
3936        // Different-size rewrite with no time advance at all: must resync
3937        // immediately, proving the debounce never gates the stat itself.
3938        std::fs::write(&path, "BBBBBBBB").unwrap();
3939        tracker
3940            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3941            .await
3942            .unwrap();
3943
3944        let state = tracker.get(&path).unwrap();
3945        assert_eq!(state.version().get(), 2);
3946        assert_eq!(state.content(), "BBBBBBBB");
3947    }
3948
3949    #[tokio::test(start_paused = true)]
3950    async fn test_ensure_open_debounce_gates_reread_only() {
3951        let dir = TempDir::new().unwrap();
3952        let path = dir.path().join("a.rs");
3953        std::fs::write(&path, "AAAA").unwrap();
3954        // Racy: leave the mtime at "now".
3955
3956        let (client, _server) = fake_lsp_client();
3957        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3958        tracker
3959            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3960            .await
3961            .unwrap();
3962        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
3963
3964        std::fs::write(&path, "BBBB").unwrap(); // same size
3965        set_mtime(&path, original_mtime); // stat matches, entry stays racy
3966
3967        // Inside the debounce window: the re-read is gated, cache wins.
3968        tracker
3969            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3970            .await
3971            .unwrap();
3972        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
3973
3974        tokio::time::advance(Duration::from_millis(300)).await;
3975        tracker
3976            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3977            .await
3978            .unwrap();
3979        let state = tracker.get(&path).unwrap();
3980        assert_eq!(state.version().get(), 2);
3981        assert_eq!(state.content(), "BBBB");
3982    }
3983
3984    #[tokio::test]
3985    async fn test_ensure_open_deleted_file_errors_state_untouched() {
3986        let dir = TempDir::new().unwrap();
3987        let path = dir.path().join("a.rs");
3988        std::fs::write(&path, "fn main() {}").unwrap();
3989        set_mtime(&path, settled_past());
3990
3991        let (client, _server) = fake_lsp_client();
3992        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3993        tracker
3994            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
3995            .await
3996            .unwrap();
3997
3998        std::fs::remove_file(&path).unwrap();
3999
4000        let result = tracker
4001            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4002            .await;
4003        assert_matches!(result, Err(Error::FileIo { .. }));
4004        assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
4005        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
4006        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
4007    }
4008
4009    #[tokio::test]
4010    async fn test_ensure_open_grows_past_limit_errors_state_intact() {
4011        let dir = TempDir::new().unwrap();
4012        let path = dir.path().join("a.rs");
4013        std::fs::write(&path, "small").unwrap();
4014        set_mtime(&path, settled_past());
4015
4016        let limits = ResourceLimits {
4017            max_documents: DocumentLimit::new(10),
4018            max_file_size: SizeLimit::from_static(10),
4019        };
4020        let (client, _server) = fake_lsp_client();
4021        let tracker = DocumentTracker::new(limits, HashMap::new());
4022        tracker
4023            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4024            .await
4025            .unwrap();
4026
4027        std::fs::write(&path, "x".repeat(100)).unwrap();
4028
4029        let result = tracker
4030            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4031            .await;
4032        assert_matches!(result, Err(Error::FileSizeLimitExceeded { .. }));
4033        assert_eq!(tracker.get(&path).unwrap().content(), "small");
4034        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
4035    }
4036
4037    #[tokio::test]
4038    async fn test_ensure_open_resync_at_document_capacity() {
4039        let dir = TempDir::new().unwrap();
4040        let path = dir.path().join("a.rs");
4041        std::fs::write(&path, "AAAA").unwrap();
4042        set_mtime(&path, settled_past());
4043
4044        let limits = ResourceLimits {
4045            max_documents: DocumentLimit::new(1),
4046            max_file_size: SizeLimit::UNLIMITED,
4047        };
4048        let (client, _server) = fake_lsp_client();
4049        let tracker = DocumentTracker::new(limits, HashMap::new());
4050        tracker
4051            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4052            .await
4053            .unwrap();
4054        assert_eq!(tracker.len(), 1);
4055
4056        std::fs::write(&path, "BBBBBBBB").unwrap();
4057        let result = tracker
4058            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4059            .await;
4060        assert!(
4061            result.is_ok(),
4062            "resync must not re-run the doc-count check on an already-tracked path"
4063        );
4064        assert_eq!(tracker.len(), 1);
4065        assert_eq!(tracker.get(&path).unwrap().version().get(), 2);
4066    }
4067
4068    #[tokio::test]
4069    async fn test_first_open_self_heals_when_did_open_notify_fails() {
4070        let dir = TempDir::new().unwrap();
4071        let path = dir.path().join("a.rs");
4072        std::fs::write(&path, "fn main() {}").unwrap();
4073
4074        let (client, _server) = fake_lsp_client();
4075        // A clone shares the same command channel. Shutting down the
4076        // original (which owns the receiver task) blocks until the
4077        // background message loop has fully exited and dropped that
4078        // channel's receiver -- so the clone's next `notify()` fails
4079        // deterministically, with no race against process teardown.
4080        let notify_will_fail = client.clone();
4081        client.shutdown().await.unwrap();
4082
4083        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4084        let result = tracker
4085            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &notify_will_fail)
4086            .await;
4087
4088        assert!(result.is_err(), "notify failure must propagate as an error");
4089        assert!(
4090            tracker.open_state(&path) == DocumentOpenState::NotOpen,
4091            "a failed didOpen must not leave the document tracked, or the server \
4092             and tracker would stay permanently desynced"
4093        );
4094    }
4095
4096    #[tokio::test]
4097    async fn test_resync_sends_didchange_with_full_replacement_over_the_wire() {
4098        let dir = TempDir::new().unwrap();
4099        let path = dir.path().join("a.rs");
4100        std::fs::write(&path, "fn main() {}").unwrap();
4101        set_mtime(&path, settled_past());
4102
4103        let (client, mut server) = fake_lsp_client();
4104        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4105        tracker
4106            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4107            .await
4108            .unwrap();
4109
4110        let mut wire = BufReader::new(&mut server.write_stdout);
4111        let opened = read_framed_message(&mut wire).await;
4112        assert_eq!(opened["method"], "textDocument/didOpen");
4113
4114        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
4115        set_mtime(&path, settled_past());
4116        tracker
4117            .ensure_open_for_test(&path, &ServerId::from_static("rust"), &client)
4118            .await
4119            .unwrap();
4120
4121        let changed = read_framed_message(&mut wire).await;
4122        assert_eq!(changed["method"], "textDocument/didChange");
4123        let params = &changed["params"];
4124        assert_eq!(params["textDocument"]["version"], 2);
4125        let change = &params["contentChanges"][0];
4126        assert!(
4127            change.get("range").is_none(),
4128            "range must be omitted, not null, for a full-replacement change"
4129        );
4130        assert!(
4131            change.get("rangeLength").is_none(),
4132            "rangeLength must be omitted, not null, for a full-replacement change"
4133        );
4134        assert_eq!(change["text"], "fn main() { println!(\"hi\"); }");
4135    }
4136
4137    /// Regression for #174 §7.1: a second server must receive `didOpen` even
4138    /// when the file has not changed since a first server was opened on it --
4139    /// the disk-phase fast path only skips the disk read, never the
4140    /// per-server sync decision. Exercises the settled-mtime fast path.
4141    #[tokio::test]
4142    async fn test_ensure_open_second_server_gets_didopen_no_disk_change() {
4143        let dir = TempDir::new().unwrap();
4144        let path = dir.path().join("a.rs");
4145        std::fs::write(&path, "fn main() {}").unwrap();
4146        set_mtime(&path, settled_past());
4147
4148        let (client_a, mut server_a) = fake_lsp_client();
4149        let (client_b, mut server_b) = fake_lsp_client();
4150        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4151
4152        let id_a = ServerId::from_static("server-a");
4153        let id_b = ServerId::from_static("server-b");
4154
4155        tracker
4156            .ensure_open_for_test(&path, &id_a, &client_a)
4157            .await
4158            .unwrap();
4159        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
4160        let opened_a = read_framed_message(&mut wire_a).await;
4161        assert_eq!(opened_a["method"], "textDocument/didOpen");
4162
4163        // No disk change between calls: server B's ensure_open must still
4164        // take the disk-phase fast path (settled mtime) but still send B its
4165        // own didOpen.
4166        tracker
4167            .ensure_open_for_test(&path, &id_b, &client_b)
4168            .await
4169            .unwrap();
4170        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
4171        let opened_b = read_framed_message(&mut wire_b).await;
4172        assert_eq!(opened_b["method"], "textDocument/didOpen");
4173        assert_eq!(opened_b["params"]["textDocument"]["version"], 1);
4174        assert_eq!(opened_b["params"]["textDocument"]["text"], "fn main() {}");
4175    }
4176
4177    /// Same as above but through the unchanged-content re-read path (racy,
4178    /// unsettled mtime past the debounce window, forcing a real content
4179    /// compare) rather than the settled-mtime fast path.
4180    #[tokio::test(start_paused = true)]
4181    async fn test_ensure_open_second_server_gets_didopen_unchanged_content_path() {
4182        let dir = TempDir::new().unwrap();
4183        let path = dir.path().join("a.rs");
4184        std::fs::write(&path, "fn main() {}").unwrap();
4185        // Leave mtime racy (unsettled) rather than backdating it.
4186
4187        let (client_a, _server_a) = fake_lsp_client();
4188        let (client_b, mut server_b) = fake_lsp_client();
4189        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4190
4191        tracker
4192            .ensure_open_for_test(&path, &ServerId::from_static("server-a"), &client_a)
4193            .await
4194            .unwrap();
4195
4196        // Past the debounce window: server B's call must genuinely re-read
4197        // and compare content rather than taking either fast-path leg.
4198        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
4199
4200        tracker
4201            .ensure_open_for_test(&path, &ServerId::from_static("server-b"), &client_b)
4202            .await
4203            .unwrap();
4204        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
4205        let opened_b = read_framed_message(&mut wire_b).await;
4206        assert_eq!(opened_b["method"], "textDocument/didOpen");
4207    }
4208
4209    /// Regression for #174 §6.2/§12: `prepare_call_hierarchy` and
4210    /// `incoming_calls`/`outgoing_calls` must resolve to the same server, since
4211    /// only `prepare` calls `ensure_open` -- pinned here at the tracker level
4212    /// by asserting a second `ensure_open` for the same server is a no-op
4213    /// once synced, so a caller that reuses the same `ServerId` for both
4214    /// calls never double-opens.
4215    #[tokio::test]
4216    async fn test_ensure_open_same_server_twice_sends_nothing_second_time() {
4217        let dir = TempDir::new().unwrap();
4218        let path = dir.path().join("a.rs");
4219        std::fs::write(&path, "fn main() {}").unwrap();
4220        set_mtime(&path, settled_past());
4221
4222        let (client, mut server) = fake_lsp_client();
4223        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4224        let id = ServerId::from_static("rust");
4225
4226        tracker
4227            .ensure_open_for_test(&path, &id, &client)
4228            .await
4229            .unwrap();
4230        tracker
4231            .ensure_open_for_test(&path, &id, &client)
4232            .await
4233            .unwrap();
4234
4235        let mut wire = BufReader::new(&mut server.write_stdout);
4236        let opened = read_framed_message(&mut wire).await;
4237        assert_eq!(opened["method"], "textDocument/didOpen");
4238        assert_eq!(
4239            tracker
4240                .get(&path)
4241                .unwrap()
4242                .synced_version(&id)
4243                .map(DocumentVersion::get),
4244            Some(1),
4245            "second call for the same server must not re-open or re-change"
4246        );
4247    }
4248
4249    /// Regression for #174 §7.2/S6: a failing `didChange` for one server must
4250    /// leave that server's `synced` entry untouched (self-heals on retry)
4251    /// without disturbing another server that already synced successfully.
4252    #[tokio::test]
4253    async fn test_sync_phase_failed_didchange_does_not_disturb_other_server() {
4254        let dir = TempDir::new().unwrap();
4255        let path = dir.path().join("a.rs");
4256        std::fs::write(&path, "fn main() {}").unwrap();
4257        set_mtime(&path, settled_past());
4258
4259        let (client_a, _server_a) = fake_lsp_client();
4260        let (client_b, _server_b) = fake_lsp_client();
4261        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4262        let id_a = ServerId::from_static("server-a");
4263        let id_b = ServerId::from_static("server-b");
4264
4265        tracker
4266            .ensure_open_for_test(&path, &id_a, &client_a)
4267            .await
4268            .unwrap();
4269        tracker
4270            .ensure_open_for_test(&path, &id_b, &client_b)
4271            .await
4272            .unwrap();
4273
4274        // Shut down B's client so its next notify fails, then change the file
4275        // so both servers have version 2 to catch up to.
4276        let client_b_will_fail = client_b.clone();
4277        client_b.shutdown().await.unwrap();
4278
4279        std::fs::write(&path, "fn main() { updated(); }").unwrap();
4280        set_mtime(&path, settled_past());
4281
4282        let result = tracker
4283            .ensure_open_for_test(&path, &id_b, &client_b_will_fail)
4284            .await;
4285        assert!(result.is_err(), "B's didChange must fail and propagate");
4286
4287        // No commit happens before a successful notify: content, version and
4288        // both servers' `synced` entries all stay exactly as they were
4289        // before this call, so the next attempt retries from the same
4290        // starting point rather than drifting the tracker out of sync with
4291        // what was actually acknowledged over the wire.
4292        assert_eq!(tracker.open_state(&path), DocumentOpenState::Open);
4293        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
4294        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
4295        assert_eq!(
4296            tracker
4297                .get(&path)
4298                .unwrap()
4299                .synced_version(&id_a)
4300                .map(DocumentVersion::get),
4301            Some(1)
4302        );
4303        assert_eq!(
4304            tracker
4305                .get(&path)
4306                .unwrap()
4307                .synced_version(&id_b)
4308                .map(DocumentVersion::get),
4309            Some(1)
4310        );
4311
4312        // A's next call must independently detect the disk change (B's
4313        // failure did not consume it) and successfully advance both the
4314        // shared content/version and its own synced entry.
4315        tracker
4316            .ensure_open_for_test(&path, &id_a, &client_a)
4317            .await
4318            .unwrap();
4319        assert_eq!(
4320            tracker.get(&path).unwrap().content(),
4321            "fn main() { updated(); }"
4322        );
4323        assert_eq!(
4324            tracker
4325                .get(&path)
4326                .unwrap()
4327                .synced_version(&id_a)
4328                .map(DocumentVersion::get),
4329            Some(2)
4330        );
4331        assert_eq!(
4332            tracker
4333                .get(&path)
4334                .unwrap()
4335                .synced_version(&id_b)
4336                .map(DocumentVersion::get),
4337            Some(1)
4338        );
4339    }
4340
4341    // ------------------------------------------------------------------
4342    // ensure_open concurrency (issue #227)
4343    // ------------------------------------------------------------------
4344
4345    /// Regression for #227: `ensure_open` for one path must not block
4346    /// `ensure_open` for an unrelated path, even while the first call is
4347    /// stuck inside its own disk I/O.
4348    ///
4349    /// Path A's own `ensure_open` call is genuinely parked on path A's
4350    /// per-path lock: `path_a_guard` (held via `lock_path`, the exact
4351    /// primitive `ensure_open` acquires before its disk I/O) is taken first,
4352    /// then a *real*, spawned `ensure_open(path_a)` call is raced against
4353    /// it, so the serialization point under test is inside `ensure_open`
4354    /// itself, not merely the standalone `lock_path` guard. Previously this
4355    /// used a FIFO, whose `open()` for read blocked deterministically until
4356    /// a writer connected; that is no longer usable for this purpose now
4357    /// that `open_checked` opens with `O_NONBLOCK` and rejects non-regular
4358    /// files immediately (see #418) -- a FIFO can no longer be coaxed into
4359    /// blocking `ensure_open`'s `open()` call at all. Under the old design
4360    /// (a single lock spanning all of `ensure_open`, including disk I/O),
4361    /// path B would hang until path A's lock is released below; the
4362    /// per-path lock added here must let it through immediately instead.
4363    #[tokio::test]
4364    async fn test_ensure_open_different_paths_do_not_serialize() {
4365        let dir = TempDir::new().unwrap();
4366        let path_a = dir.path().join("a.rs");
4367        let path_b = dir.path().join("b.rs");
4368
4369        std::fs::write(&path_a, "fn a() {}").unwrap();
4370        std::fs::write(&path_b, "fn b() {}").unwrap();
4371        set_mtime(&path_a, settled_past());
4372        set_mtime(&path_b, settled_past());
4373
4374        let (client_a, _server_a) = fake_lsp_client();
4375        let (client_b, _server_b) = fake_lsp_client();
4376        let tracker = Arc::new(DocumentTracker::new(
4377            ResourceLimits::default(),
4378            HashMap::new(),
4379        ));
4380
4381        let path_a_guard = tracker.lock_path(&path_a).await;
4382
4383        // Spawned so a real `ensure_open(path_a)` call is genuinely parked
4384        // on path A's lock (held by `path_a_guard` above) while path B's
4385        // call below runs.
4386        let tracker_for_a = Arc::clone(&tracker);
4387        let path_a_for_task = path_a.clone();
4388        let handle_a = tokio::spawn(async move {
4389            tracker_for_a
4390                .ensure_open_for_test(
4391                    &path_a_for_task,
4392                    &ServerId::from_static("server-a"),
4393                    &client_a,
4394                )
4395                .await
4396        });
4397
4398        // Give the spawned task a chance to actually reach and block on
4399        // path A's lock before racing path B's call against it below.
4400        tokio::time::sleep(Duration::from_millis(200)).await;
4401
4402        // A `timeout` error here means path B is blocked by path A's stuck
4403        // ensure_open -- the exact regression #227 fixes.
4404        tokio::time::timeout(
4405            Duration::from_secs(5),
4406            tracker.ensure_open_for_test(&path_b, &ServerId::from_static("server-b"), &client_b),
4407        )
4408        .await
4409        .unwrap()
4410        .unwrap();
4411
4412        drop(path_a_guard);
4413
4414        handle_a.await.unwrap().unwrap();
4415        assert_eq!(tracker.get(&path_a).unwrap().content(), "fn a() {}");
4416    }
4417
4418    /// Regression for #227: N concurrent `ensure_open` calls for the same
4419    /// path and the same server must still collapse into exactly one
4420    /// `didOpen` -- the per-path lock introduced to let different paths run
4421    /// concurrently must not weaken the existing same-path serialization
4422    /// that prevents duplicate opens.
4423    #[tokio::test]
4424    async fn test_ensure_open_concurrent_same_path_single_didopen() {
4425        let dir = TempDir::new().unwrap();
4426        let path = dir.path().join("a.rs");
4427        std::fs::write(&path, "fn main() {}").unwrap();
4428        set_mtime(&path, settled_past());
4429
4430        let (client, mut server) = fake_lsp_client();
4431        let tracker = Arc::new(DocumentTracker::new(
4432            ResourceLimits::default(),
4433            HashMap::new(),
4434        ));
4435        let id = ServerId::from_static("rust");
4436
4437        let mut handles = Vec::new();
4438        for _ in 0..8 {
4439            let tracker = Arc::clone(&tracker);
4440            let client = client.clone();
4441            let path = path.clone();
4442            let id = id.clone();
4443            handles.push(tokio::spawn(async move {
4444                tracker.ensure_open_for_test(&path, &id, &client).await
4445            }));
4446        }
4447        for handle in handles {
4448            handle.await.unwrap().unwrap();
4449        }
4450
4451        let mut wire = BufReader::new(&mut server.write_stdout);
4452        let opened = read_framed_message(&mut wire).await;
4453        assert_eq!(opened["method"], "textDocument/didOpen");
4454
4455        // No further notification should have been queued -- proves the 8
4456        // concurrent callers collapsed into exactly one `didOpen`.
4457        let extra =
4458            tokio::time::timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
4459        assert!(
4460            extra.is_err(),
4461            "expected no additional notification after the single didOpen"
4462        );
4463
4464        assert_eq!(
4465            tracker
4466                .get(&path)
4467                .unwrap()
4468                .synced_version(&id)
4469                .map(DocumentVersion::get),
4470            Some(1)
4471        );
4472        assert_eq!(tracker.get(&path).unwrap().version().get(), 1);
4473    }
4474
4475    /// Regression for #227: `lock_path`'s guard must evict its `path_locks`
4476    /// entry once no caller is left waiting on it, or the map grows by one
4477    /// entry per distinct path ever opened for the lifetime of the process.
4478    /// Exercises three concurrent distinct paths (not just the two used in
4479    /// `test_ensure_open_different_paths_do_not_serialize`) to rule out an
4480    /// eviction bug that only manifests with more than two live entries.
4481    #[tokio::test]
4482    async fn test_ensure_open_path_locks_evicted_after_completion() {
4483        let dir = TempDir::new().unwrap();
4484        let paths: Vec<_> = ["a.rs", "b.rs", "c.rs"]
4485            .iter()
4486            .map(|name| dir.path().join(name))
4487            .collect();
4488        for path in &paths {
4489            std::fs::write(path, "fn f() {}").unwrap();
4490            set_mtime(path, settled_past());
4491        }
4492
4493        let tracker = Arc::new(DocumentTracker::new(
4494            ResourceLimits::default(),
4495            HashMap::new(),
4496        ));
4497        let id = ServerId::from_static("rust");
4498
4499        let mut handles = Vec::new();
4500        let mut servers = Vec::new();
4501        for path in paths.clone() {
4502            let tracker = Arc::clone(&tracker);
4503            let (client, server) = fake_lsp_client();
4504            servers.push(server);
4505            let id = id.clone();
4506            handles.push(tokio::spawn(async move {
4507                tracker.ensure_open_for_test(&path, &id, &client).await
4508            }));
4509        }
4510        for handle in handles {
4511            handle.await.unwrap().unwrap();
4512        }
4513        drop(servers);
4514
4515        assert!(
4516            lock_std(&tracker.path_locks).is_empty(),
4517            "path_locks must be fully evicted once every ensure_open call \
4518             for every path has completed, otherwise the map grows \
4519             unbounded for the lifetime of the process"
4520        );
4521    }
4522
4523    /// Regression for #418: `read_to_string_checked` must reject a FIFO
4524    /// rather than trust its (always-zero) reported size and either hang
4525    /// reading it or return an unbounded stream of bytes.
4526    ///
4527    /// Unlike `test_ensure_open_different_paths_do_not_serialize`'s use of
4528    /// the same `mkfifo` idiom, this test needs no background writer and no
4529    /// timeout race to prove non-blocking behavior: `open_checked`'s
4530    /// `O_NONBLOCK` open is the fix under test, so a correct implementation
4531    /// returns an error immediately, with no peer ever connecting. The
4532    /// outer `timeout` is only a safety net so a regression here fails fast
4533    /// instead of hanging the test suite.
4534    #[cfg(unix)]
4535    #[tokio::test]
4536    async fn test_read_to_string_checked_rejects_fifo() {
4537        let dir = TempDir::new().unwrap();
4538        let path = dir.path().join("fifo");
4539        crate::test_lsp::make_fifo(&path);
4540
4541        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4542        // A timeout here means the fix failed and open() is still blocking
4543        // indefinitely on the FIFO -- the exact regression #418 fixes.
4544        let result = tokio::time::timeout(
4545            Duration::from_secs(5),
4546            tracker.read_to_string_checked(OpenGate::Ungated(&path)),
4547        )
4548        .await
4549        .unwrap();
4550
4551        assert_matches!(result, Err(Error::NotARegularFile(_)));
4552    }
4553
4554    /// Regression for #442: `read_to_string_checked` must reject the `NUL`
4555    /// device on Windows via `GetFileType`, not `FileType::is_file()` --
4556    /// which does not reliably classify reserved device names as
4557    /// non-regular. This is the Windows counterpart of
4558    /// `test_read_to_string_checked_rejects_fifo`; `NUL` opens immediately
4559    /// (unlike a FIFO with no writer), so the outer `timeout` here is only a
4560    /// safety net, not proof of non-blocking behavior on its own -- the
4561    /// `GetFileType` check itself is what's under test.
4562    #[cfg(windows)]
4563    #[tokio::test]
4564    async fn test_read_to_string_checked_rejects_nul_device() {
4565        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4566        let path = PathBuf::from("NUL");
4567        let result = tokio::time::timeout(
4568            Duration::from_secs(5),
4569            tracker.read_to_string_checked(OpenGate::Ungated(&path)),
4570        )
4571        .await
4572        .unwrap();
4573
4574        assert_matches!(result, Err(Error::NotARegularFile(_)));
4575    }
4576
4577    /// Boundary regression for #427/#418's shared size gate: a file of
4578    /// exactly `max_file_size` bytes must succeed through
4579    /// `read_to_string_checked` (the disk-read path `ensure_open` uses),
4580    /// and one byte more must fail as `FileSizeLimitExceeded` -- not just
4581    /// "some file well over the limit is rejected".
4582    #[tokio::test]
4583    async fn test_read_to_string_checked_size_boundary() {
4584        let dir = TempDir::new().unwrap();
4585        let path = dir.path().join("boundary.rs");
4586        let tracker = DocumentTracker::new(
4587            ResourceLimits {
4588                max_documents: DocumentLimit::new(100),
4589                max_file_size: SizeLimit::from_static(10),
4590            },
4591            HashMap::new(),
4592        );
4593
4594        std::fs::write(&path, "a".repeat(10)).unwrap();
4595        let (content, ..) = tracker
4596            .read_to_string_checked(OpenGate::Ungated(&path))
4597            .await
4598            .unwrap();
4599        assert_eq!(content.len(), 10);
4600
4601        std::fs::write(&path, "a".repeat(11)).unwrap();
4602        let result = tracker
4603            .read_to_string_checked(OpenGate::Ungated(&path))
4604            .await;
4605        assert_matches!(
4606            result,
4607            Err(Error::FileSizeLimitExceeded(SizeExceeded { size: 11, max })) if max.get() == 10
4608        );
4609    }
4610
4611    /// Regression for #474: `read_line_checked` must stop reading (and
4612    /// UTF-8-decoding) once it has the requested line, not buffer/validate
4613    /// the rest of the file. The file's second line is invalid UTF-8, which
4614    /// would fail a whole-file read (as the pre-#474 `read_checked` +
4615    /// `.lines().nth(...)` path did); reading line 0 must still succeed.
4616    #[tokio::test]
4617    async fn test_read_line_checked_does_not_read_past_target_line() {
4618        let dir = TempDir::new().unwrap();
4619        let path = dir.path().join("partial.rs");
4620        let mut content = b"hello\n".to_vec();
4621        content.extend_from_slice(&[0xFF, 0xFE]);
4622        content.push(b'\n');
4623        std::fs::write(&path, &content).unwrap();
4624
4625        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4626        let line = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
4627        assert_eq!(line.text.as_deref(), Some("hello"));
4628    }
4629
4630    /// Regression for M3: an off-by-one in `current_line` (e.g. returning
4631    /// line `N + 1` for `N`) would ship green if every test used line 0.
4632    /// Exercises a non-zero target line on a multi-line fixture.
4633    #[tokio::test]
4634    async fn test_read_line_checked_returns_requested_non_zero_line() {
4635        let dir = TempDir::new().unwrap();
4636        let path = dir.path().join("multi.rs");
4637        std::fs::write(&path, "first\nsecond\nthird\nfourth\n").unwrap();
4638
4639        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4640        assert_eq!(
4641            tracker
4642                .read_line_checked(&path, 2, u64::MAX)
4643                .await
4644                .unwrap()
4645                .text
4646                .as_deref(),
4647            Some("third")
4648        );
4649    }
4650
4651    /// `read_line_checked` must report `Ok(None)`, not an error, when `line`
4652    /// is past the file's last line -- distinguishing "file has fewer lines
4653    /// than requested" from an actual read failure.
4654    #[tokio::test]
4655    async fn test_read_line_checked_returns_none_past_last_line() {
4656        let dir = TempDir::new().unwrap();
4657        let path = dir.path().join("short.rs");
4658        std::fs::write(&path, "only one line").unwrap();
4659
4660        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4661        assert_eq!(
4662            tracker
4663                .read_line_checked(&path, 5, u64::MAX)
4664                .await
4665                .unwrap()
4666                .text,
4667            None
4668        );
4669    }
4670
4671    /// A requested line with no trailing `\n` at all (the file's only line,
4672    /// never terminated) must still be returned -- distinct from
4673    /// `test_read_line_checked_returns_none_past_last_line`, which requests a
4674    /// line number past this same kind of file instead of the line itself.
4675    #[tokio::test]
4676    async fn test_read_line_checked_reads_last_line_without_trailing_newline() {
4677        let dir = TempDir::new().unwrap();
4678        let path = dir.path().join("no_newline.rs");
4679        std::fs::write(&path, "only one line").unwrap();
4680
4681        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4682        assert_eq!(
4683            tracker
4684                .read_line_checked(&path, 0, u64::MAX)
4685                .await
4686                .unwrap()
4687                .text
4688                .as_deref(),
4689            Some("only one line")
4690        );
4691    }
4692
4693    #[tokio::test]
4694    async fn test_read_line_checked_empty_file_line_zero_is_empty_line() {
4695        let dir = TempDir::new().unwrap();
4696        let path = dir.path().join("empty.rs");
4697        std::fs::write(&path, "").unwrap();
4698
4699        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4700        assert_eq!(
4701            tracker
4702                .read_line_checked(&path, 0, u64::MAX)
4703                .await
4704                .unwrap()
4705                .text
4706                .as_deref(),
4707            Some("")
4708        );
4709        assert_eq!(
4710            tracker
4711                .read_line_checked(&path, 1, u64::MAX)
4712                .await
4713                .unwrap()
4714                .text,
4715            None
4716        );
4717    }
4718
4719    #[tokio::test]
4720    async fn test_read_line_checked_trailing_empty_line_after_final_newline() {
4721        let dir = TempDir::new().unwrap();
4722        let path = dir.path().join("trailing.rs");
4723        std::fs::write(&path, "a\n").unwrap();
4724
4725        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4726        let read = |line| tracker.read_line_checked(&path, line, u64::MAX);
4727        assert_eq!(read(1).await.unwrap().text.as_deref(), Some(""));
4728        assert_eq!(read(2).await.unwrap().text, None);
4729    }
4730
4731    #[tokio::test]
4732    async fn test_read_line_checked_cap_at_line_boundary_is_not_empty_line() {
4733        let dir = TempDir::new().unwrap();
4734        let path = dir.path().join("capped.rs");
4735        std::fs::write(&path, "abc\nrest\n").unwrap();
4736
4737        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4738        // budget 3 -> cap 4: the read stops right after "abc\n", mid-file.
4739        assert_eq!(
4740            tracker.read_line_checked(&path, 1, 3).await.unwrap().text,
4741            None
4742        );
4743    }
4744
4745    /// Pins that CRLF-terminated lines come out identical to LF-only ones.
4746    #[tokio::test]
4747    async fn test_read_line_checked_strips_crlf_line_ending() {
4748        let dir = TempDir::new().unwrap();
4749        let path = dir.path().join("crlf.rs");
4750        std::fs::write(&path, "first\r\nsecond\r\n").unwrap();
4751
4752        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4753        assert_eq!(
4754            tracker
4755                .read_line_checked(&path, 0, u64::MAX)
4756                .await
4757                .unwrap()
4758                .text
4759                .as_deref(),
4760            Some("first")
4761        );
4762        assert_eq!(
4763            tracker
4764                .read_line_checked(&path, 1, u64::MAX)
4765                .await
4766                .unwrap()
4767                .text
4768                .as_deref(),
4769            Some("second")
4770        );
4771    }
4772
4773    /// The tracker and disk readers must agree on every line, including the
4774    /// CR-stripping and trailing-empty-line edge cases -- `to_lsp` falls back
4775    /// to disk whenever the tracker misses, so a divergence would
4776    /// reintroduce a false degradation signal.
4777    #[tokio::test]
4778    async fn test_line_text_and_read_line_checked_agree_on_line_rule() {
4779        let dir = TempDir::new().unwrap();
4780        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4781
4782        let contents = [
4783            "",
4784            "abc",
4785            "abc\n",
4786            "abc\r\n",
4787            "abc\r\r\n",
4788            "abc\r",
4789            "a\n\nb",
4790            "a\r\nb\r\n",
4791            "a\rb",
4792            "a\r\r\nb",
4793            "\n\r",
4794            "\r\n\r\n",
4795        ];
4796        for (i, content) in contents.iter().enumerate() {
4797            let path = dir.path().join(format!("parity_{i}.rs"));
4798            std::fs::write(&path, content).unwrap();
4799            tracker.open(path.clone(), (*content).to_string()).unwrap();
4800            for line in 0..6 {
4801                let disk = tracker
4802                    .read_line_checked(&path, line, u64::MAX)
4803                    .await
4804                    .unwrap()
4805                    .text;
4806                assert_eq!(
4807                    tracker.line_text(&path, line),
4808                    disk,
4809                    "content {content:?}, line {line}"
4810                );
4811            }
4812        }
4813    }
4814
4815    #[test]
4816    fn test_line_text_trailing_empty_line_and_lone_cr_terminator() {
4817        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4818        let path = PathBuf::from("/test/lines.rs");
4819        tracker
4820            .open(path.clone(), "ab\r\ncd\r\r\n".to_string())
4821            .unwrap();
4822
4823        assert_eq!(tracker.line_text(&path, 0).as_deref(), Some("ab"));
4824        assert_eq!(tracker.line_text(&path, 1).as_deref(), Some("cd"));
4825        assert_eq!(tracker.line_text(&path, 2).as_deref(), Some(""));
4826        assert_eq!(tracker.line_text(&path, 3).as_deref(), Some(""));
4827        assert_eq!(tracker.line_text(&path, 4), None);
4828
4829        let empty = PathBuf::from("/test/empty.rs");
4830        tracker.open(empty.clone(), String::new()).unwrap();
4831        assert_eq!(tracker.line_text(&empty, 0).as_deref(), Some(""));
4832        assert_eq!(tracker.line_text(&empty, 1), None);
4833    }
4834
4835    /// Regression for the `SizeLimit::read_cap` off-by-one: a file whose size
4836    /// is exactly `max_file_size` must not be misreported as oversized when
4837    /// a request (for a line past the file's content) forces a full read to
4838    /// EOF. The cap is `max_file_size + 1` precisely so this exact-boundary
4839    /// case is distinguishable from a genuinely oversized file.
4840    #[tokio::test]
4841    async fn test_read_line_checked_exact_max_file_size_reads_to_eof_without_error() {
4842        let dir = TempDir::new().unwrap();
4843        let path = dir.path().join("exact.rs");
4844        let content = "a".repeat(20);
4845        std::fs::write(&path, &content).unwrap();
4846
4847        let limits = ResourceLimits {
4848            max_documents: DocumentLimit::new(100),
4849            max_file_size: SizeLimit::from_static(20),
4850        };
4851        let tracker = DocumentTracker::new(limits, HashMap::new());
4852
4853        assert_eq!(
4854            tracker
4855                .read_line_checked(&path, 0, u64::MAX)
4856                .await
4857                .unwrap()
4858                .text
4859                .as_deref(),
4860            Some(content.as_str())
4861        );
4862        assert_eq!(
4863            tracker
4864                .read_line_checked(&path, 1, u64::MAX)
4865                .await
4866                .unwrap()
4867                .text,
4868            None,
4869            "a line past an exact-max_file_size file's only line must read to EOF cleanly, not \
4870             be misreported as truncated"
4871        );
4872    }
4873
4874    /// Regression for the S1 budget-bypass fix: `budget` must physically
4875    /// bound the read (via the take-adapter), not just gate whether a read
4876    /// is attempted -- a read that starts with budget left must still stop
4877    /// at exactly that many bytes, never at the full `max_file_size`.
4878    /// Distinguishes this from `max_file_size.read_cap()` alone by
4879    /// using a `budget` far smaller than `max_file_size`.
4880    #[tokio::test]
4881    async fn test_read_line_checked_bounds_read_by_budget_not_just_max_file_size() {
4882        let dir = TempDir::new().unwrap();
4883        let path = dir.path().join("budget.rs");
4884        std::fs::write(&path, "a".repeat(1000)).unwrap();
4885
4886        let limits = ResourceLimits {
4887            max_documents: DocumentLimit::new(100),
4888            max_file_size: SizeLimit::from_static(1000),
4889        };
4890        let tracker = DocumentTracker::new(limits, HashMap::new());
4891
4892        let read = tracker.read_line_checked(&path, 0, 10).await.unwrap();
4893        assert_eq!(
4894            read.text, None,
4895            "a single line far longer than the budget must not be returned as if complete"
4896        );
4897        assert_eq!(
4898            read.bytes_read, 11,
4899            "the read must stop at exactly the budget's +1 slack (see the correctness-gate fix \
4900             below), not at max_file_size"
4901        );
4902    }
4903
4904    /// Regression for a correctness-gate finding: `cap`'s `budget` component
4905    /// needs the same `+1` disambiguation slack `SizeLimit::read_cap` already
4906    /// applies to `max_file_size` -- without it, a read whose remaining
4907    /// budget exactly equals its target line's byte length (no trailing
4908    /// newline) is indistinguishable from one genuinely truncated by the
4909    /// cap, and was misreported as truncated (`text: None`) even though the
4910    /// read fully succeeded.
4911    #[tokio::test]
4912    async fn test_read_line_checked_exact_budget_match_on_unterminated_line_not_truncated() {
4913        let dir = TempDir::new().unwrap();
4914        let path = dir.path().join("exact_budget.rs");
4915        let content = "twelve chars";
4916        std::fs::write(&path, content).unwrap();
4917
4918        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4919        let read = tracker
4920            .read_line_checked(&path, 0, content.len() as u64)
4921            .await
4922            .unwrap();
4923        assert_eq!(
4924            read.text.as_deref(),
4925            Some(content),
4926            "budget exactly matching the line's byte length must not be misreported as truncated"
4927        );
4928        assert_eq!(read.bytes_read, content.len() as u64);
4929    }
4930
4931    /// Regression for the S1 budget-bypass fix: an invalid-UTF-8 line (the
4932    /// realistic attack shape -- a `.rlib`/image/pack file under
4933    /// `max_file_size`) must still report an accurate `bytes_read` on
4934    /// `LineRead::text == None`, not lose it down an `Err` path with no byte
4935    /// count -- that loss is exactly what let a hostile response scan
4936    /// unlimited bytes while charging the per-response budget zero.
4937    #[tokio::test]
4938    async fn test_read_line_checked_reports_bytes_read_for_invalid_utf8_line() {
4939        let dir = TempDir::new().unwrap();
4940        let path = dir.path().join("invalid_utf8.rs");
4941        let mut content = vec![0xFFu8, 0xFE, 0xFD];
4942        content.push(b'\n');
4943        std::fs::write(&path, &content).unwrap();
4944
4945        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4946        let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
4947        assert_eq!(read.text, None);
4948        assert_eq!(
4949            read.bytes_read,
4950            content.len() as u64,
4951            "bytes scanned must be reported even though the line wasn't valid UTF-8"
4952        );
4953    }
4954
4955    /// Regression for the open-failure-charge fix: a path that doesn't
4956    /// exist (the realistic, non-attacker case -- e.g. an LSP server naming
4957    /// a stdlib location not present locally) must resolve to `Ok(None)`,
4958    /// not `Err`, and must charge the small nominal
4959    /// `OPEN_FAILURE_CHARGE_BYTES` amount rather than `0` (which would let
4960    /// a response repeat this for free) or the full budget (the previous
4961    /// round's regression, which zeroed the whole per-response budget on
4962    /// the very first such location).
4963    #[tokio::test]
4964    async fn test_read_line_checked_charges_nominal_amount_for_nonexistent_path() {
4965        let dir = TempDir::new().unwrap();
4966        let path = dir.path().join("does_not_exist.rs");
4967
4968        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
4969        // A nonexistent path must resolve to Ok(None), not Err.
4970        let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
4971        assert_eq!(read.text, None);
4972        assert_eq!(read.bytes_read, OPEN_FAILURE_CHARGE_BYTES);
4973    }
4974
4975    /// Independent line splitter for the oracle tests: a single left-to-right
4976    /// pass over the bytes, unlike the checkpointed lookup under test.
4977    fn naive_lines(content: &str) -> Vec<&str> {
4978        let bytes = content.as_bytes();
4979        let mut lines = Vec::new();
4980        let (mut start, mut i) = (0, 0);
4981        while i < bytes.len() {
4982            match bytes[i] {
4983                b'\n' => {
4984                    lines.push(&content[start..i]);
4985                    i += 1;
4986                    start = i;
4987                }
4988                b'\r' => {
4989                    lines.push(&content[start..i]);
4990                    i += 1;
4991                    if bytes.get(i) == Some(&b'\n') {
4992                        i += 1;
4993                    }
4994                    start = i;
4995                }
4996                _ => i += 1,
4997            }
4998        }
4999        lines.push(&content[start..]);
5000        lines
5001    }
5002
5003    /// Every string up to `max_len` characters over `{a, e-acute, CR, LF}`.
5004    fn all_strings(max_len: usize) -> Vec<String> {
5005        let alphabet = ['a', 'é', '\r', '\n'];
5006        let mut all = vec![String::new()];
5007        let mut frontier = vec![String::new()];
5008        for _ in 0..max_len {
5009            frontier = frontier
5010                .iter()
5011                .flat_map(|s| {
5012                    alphabet.iter().map(move |c| {
5013                        let mut next = s.clone();
5014                        next.push(*c);
5015                        next
5016                    })
5017                })
5018                .collect();
5019            all.extend(frontier.iter().cloned());
5020        }
5021        all
5022    }
5023
5024    /// #513/#488: `DocumentText::line` agrees with an independent splitter on
5025    /// every short string, at strides small enough that checkpoints land on
5026    /// every CR/LF pair and on the EOF boundary.
5027    #[test]
5028    fn test_document_text_line_matches_naive_splitter_exhaustively() {
5029        for content in all_strings(8) {
5030            let expected = naive_lines(&content);
5031            for stride in 1..=3 {
5032                let text = DocumentText::with_stride(content.clone(), stride);
5033                for (n, want) in expected.iter().enumerate() {
5034                    assert_eq!(
5035                        text.line(u32::try_from(n).unwrap()),
5036                        Some(*want),
5037                        "{content:?} stride {stride} line {n}"
5038                    );
5039                }
5040                assert_eq!(
5041                    text.line(u32::try_from(expected.len()).unwrap()),
5042                    None,
5043                    "{content:?} stride {stride} past the last line"
5044                );
5045            }
5046        }
5047    }
5048
5049    /// `lines_from` yields exactly the lines `line` would, from any start.
5050    #[test]
5051    fn test_document_text_lines_from_matches_line_exhaustively() {
5052        for content in all_strings(7) {
5053            let expected = naive_lines(&content);
5054            for stride in 1..=3 {
5055                let text = DocumentText::with_stride(content.clone(), stride);
5056                for start in 0..=expected.len() {
5057                    let got: Vec<&str> = text.lines_from(u32::try_from(start).unwrap()).collect();
5058                    assert_eq!(
5059                        got,
5060                        expected[start.min(expected.len())..],
5061                        "{content:?} {stride} {start}"
5062                    );
5063                }
5064            }
5065        }
5066    }
5067
5068    /// #513: the disk reader agrees with the same oracle, including with a
5069    /// one-byte buffer that splits every CRLF across two fills.
5070    #[tokio::test]
5071    async fn test_read_nth_line_matches_naive_splitter_exhaustively() {
5072        for content in all_strings(7) {
5073            let expected = naive_lines(&content);
5074            for capacity in [1, 2, 8 * 1024] {
5075                for n in 0..=expected.len() {
5076                    let mut reader =
5077                        tokio::io::BufReader::with_capacity(capacity, content.as_bytes());
5078                    let read = read_nth_line(&mut reader, u32::try_from(n).unwrap(), u64::MAX)
5079                        .await
5080                        .unwrap();
5081                    assert_eq!(
5082                        read.text.as_deref(),
5083                        expected.get(n).copied(),
5084                        "{content:?} capacity {capacity} line {n}"
5085                    );
5086                }
5087            }
5088        }
5089    }
5090
5091    /// #488 M3(a): exactly one stride of lines plus a final terminator leaves
5092    /// an empty line starting at EOF, which must still be reachable.
5093    #[test]
5094    fn test_document_text_checkpoint_at_eof_after_full_stride() {
5095        for terminator in ["\n", "\r\n", "\r"] {
5096            let content = format!("x{terminator}").repeat(LINE_CHECKPOINT_STRIDE);
5097            let text = DocumentText::new(content);
5098            let stride = u32::try_from(LINE_CHECKPOINT_STRIDE).unwrap();
5099            assert_eq!(text.line(stride - 1), Some("x"));
5100            assert_eq!(text.line(stride), Some(""), "terminator {terminator:?}");
5101            assert_eq!(text.line(stride + 1), None);
5102        }
5103    }
5104
5105    /// #488: a document spanning several checkpoint strides resolves lines on
5106    /// both sides of each checkpoint, with CRLF straddling none of them wrong.
5107    #[test]
5108    fn test_document_text_lines_across_default_stride_boundaries() {
5109        let content: String = (0..200)
5110            .map(|i| format!("line{i}\r\n"))
5111            .collect::<Vec<_>>()
5112            .concat();
5113        let text = DocumentText::new(content);
5114        for i in [0u32, 1, 63, 64, 65, 127, 128, 199] {
5115            assert_eq!(text.line(i), Some(format!("line{i}").as_str()), "line {i}");
5116        }
5117        assert_eq!(text.line(200), Some(""));
5118        assert_eq!(text.line(201), None);
5119    }
5120
5121    /// Content above the inline threshold is indexed on the blocking pool and
5122    /// yields the same lines.
5123    #[tokio::test]
5124    async fn test_document_text_build_above_inline_threshold_matches_inline() {
5125        let content = "ab\r\n".repeat(INLINE_INDEX_MAX_BYTES / 4 + 10);
5126        assert!(content.len() > INLINE_INDEX_MAX_BYTES);
5127        let built = DocumentText::build(content.clone()).await.unwrap();
5128        let inline = DocumentText::new(content);
5129        assert_eq!(built.checkpoints, inline.checkpoints);
5130        assert_eq!(built.line(1000), Some("ab"));
5131    }
5132
5133    #[test]
5134    fn test_document_text_checkpoint_memory_is_sparse() {
5135        let text = DocumentText::new("\n".repeat(6400));
5136        assert_eq!(text.checkpoints.len(), 6400 / LINE_CHECKPOINT_STRIDE + 1);
5137    }
5138
5139    /// #513: a chunk boundary between `\r` and `\n` must not count the pair as
5140    /// two terminators.
5141    #[tokio::test]
5142    async fn test_read_nth_line_crlf_split_across_fills_is_one_terminator() {
5143        let mut reader = tokio::io::BufReader::with_capacity(1, b"a\r\nb".as_slice());
5144        let read = read_nth_line(&mut reader, 1, u64::MAX).await.unwrap();
5145        assert_eq!(read.text.as_deref(), Some("b"));
5146    }
5147
5148    /// #513: the cap running out mid-line is reported as `None`, not a
5149    /// truncated line.
5150    #[tokio::test]
5151    async fn test_read_nth_line_cap_exhausted_mid_line_is_none() {
5152        let mut reader =
5153            tokio::io::BufReader::new(tokio::io::AsyncReadExt::take(b"abcdef\n".as_slice(), 3));
5154        let read = read_nth_line(&mut reader, 0, 3).await.unwrap();
5155        assert_eq!(read.text, None);
5156        assert_eq!(read.bytes_read, 3);
5157    }
5158
5159    /// Opens `name` for `servers` in turn through `ensure_open`, returning the
5160    /// path and one `(client, fake server)` pair per server.
5161    async fn open_for_servers(
5162        tracker: &DocumentTracker,
5163        dir: &TempDir,
5164        name: &str,
5165        servers: &[&str],
5166    ) -> (
5167        PathBuf,
5168        Vec<(ServerId, LspClient, crate::test_lsp::FakeServer)>,
5169    ) {
5170        let path = dir.path().join(name);
5171        std::fs::write(&path, name).unwrap();
5172        set_mtime(&path, settled_past());
5173        let mut out = Vec::new();
5174        for server in servers {
5175            let (client, fake) = fake_lsp_client();
5176            let id = ServerId::new(*server).unwrap();
5177            tracker
5178                .ensure_open_for_test(&path, &id, &client)
5179                .await
5180                .unwrap();
5181            out.push((id, client, fake));
5182        }
5183        (path, out)
5184    }
5185
5186    /// Evicts the tracker's only document by opening `other`.
5187    async fn evict_by_opening(tracker: &DocumentTracker, dir: &TempDir, other: &str) {
5188        let path = dir.path().join(other);
5189        std::fs::write(&path, other).unwrap();
5190        set_mtime(&path, settled_past());
5191        let (client, _fake) = fake_lsp_client();
5192        tracker
5193            .ensure_open_for_test(&path, &ServerId::from_static("other"), &client)
5194            .await
5195            .unwrap();
5196    }
5197
5198    fn one_document_tracker() -> DocumentTracker {
5199        DocumentTracker::new(
5200            ResourceLimits {
5201                max_documents: DocumentLimit::new(1),
5202                max_file_size: SizeLimit::UNLIMITED,
5203            },
5204            HashMap::new(),
5205        )
5206    }
5207
5208    /// #515: a path evicted while synced to A and D, then re-opened through A
5209    /// alone, sends A a close before its open, and still owes D its close.
5210    #[tokio::test]
5211    async fn test_reopen_through_one_server_settles_only_that_servers_close() {
5212        let dir = TempDir::new().unwrap();
5213        let tracker = one_document_tracker();
5214        let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
5215        evict_by_opening(&tracker, &dir, "q.rs").await;
5216        assert_eq!(
5217            pending_servers(&tracker, &path),
5218            HashSet::from([ServerId::from_static("a"), ServerId::from_static("d")])
5219        );
5220
5221        let (id_a, client_a, mut fake_a) = servers.remove(0);
5222        tracker
5223            .ensure_open_for_test(&path, &id_a, &client_a)
5224            .await
5225            .unwrap();
5226
5227        let mut wire = BufReader::new(&mut fake_a.write_stdout);
5228        assert_eq!(
5229            read_framed_message(&mut wire).await["method"],
5230            "textDocument/didOpen"
5231        );
5232        assert_eq!(
5233            read_framed_message(&mut wire).await["method"],
5234            "textDocument/didClose"
5235        );
5236        assert_eq!(
5237            read_framed_message(&mut wire).await["method"],
5238            "textDocument/didOpen"
5239        );
5240
5241        assert_eq!(
5242            pending_servers(&tracker, &path),
5243            HashSet::from([ServerId::from_static("d")])
5244        );
5245        let claim = tracker.try_claim_pending_close(&path).unwrap();
5246        assert_eq!(claim.servers, vec![ServerId::from_static("d")]);
5247    }
5248
5249    /// #515: evict then flush hands out every owed close exactly once.
5250    #[tokio::test]
5251    async fn test_claim_returns_every_owed_server_once() {
5252        let dir = TempDir::new().unwrap();
5253        let tracker = one_document_tracker();
5254        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
5255        evict_by_opening(&tracker, &dir, "q.rs").await;
5256
5257        assert_eq!(tracker.pending_close_paths(), vec![path.clone()]);
5258        let mut claim = tracker.try_claim_pending_close(&path).unwrap();
5259        assert_eq!(claim.path, path);
5260        let mut claimed = claim.servers.clone();
5261        claimed.sort_by_key(ToString::to_string);
5262        assert_eq!(
5263            claimed,
5264            vec![ServerId::from_static("a"), ServerId::from_static("d")]
5265        );
5266        for server in claimed {
5267            claim.settle(&server);
5268        }
5269        drop(claim);
5270
5271        assert_eq!(tracker.pending_close_paths().len(), 0);
5272        assert!(tracker.try_claim_pending_close(&path).is_none());
5273    }
5274
5275    /// #515 S2: a busy path is deferred, not awaited, and its debt survives.
5276    #[tokio::test]
5277    async fn test_claim_defers_busy_path_without_waiting() {
5278        let dir = TempDir::new().unwrap();
5279        let tracker = one_document_tracker();
5280        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5281        evict_by_opening(&tracker, &dir, "q.rs").await;
5282
5283        let busy = tracker.lock_path(&path).await;
5284        assert!(tracker.try_claim_pending_close(&path).is_none());
5285        assert_eq!(
5286            pending_servers(&tracker, &path),
5287            HashSet::from([ServerId::from_static("a")]),
5288            "a deferred claim must leave the debt pending"
5289        );
5290        drop(busy);
5291        assert!(tracker.try_claim_pending_close(&path).is_some());
5292        assert!(
5293            lock_std(&tracker.path_locks).is_empty(),
5294            "failed and successful claims must not leak path-lock entries"
5295        );
5296    }
5297
5298    /// #515: while a claim is alive, `ensure_open` for the path waits for it,
5299    /// so a later `didOpen` is ordered after the claimed closes.
5300    #[tokio::test]
5301    async fn test_claim_holds_path_lock_until_dropped() {
5302        let dir = TempDir::new().unwrap();
5303        let tracker = Arc::new(one_document_tracker());
5304        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5305        evict_by_opening(&tracker, &dir, "q.rs").await;
5306
5307        let claim = tracker.try_claim_pending_close(&path).unwrap();
5308        let (client, _fake) = fake_lsp_client();
5309        let reopen = {
5310            let tracker = Arc::clone(&tracker);
5311            let path = path.clone();
5312            tokio::spawn(async move {
5313                tracker
5314                    .ensure_open_for_test(&path, &ServerId::from_static("b"), &client)
5315                    .await
5316            })
5317        };
5318        tokio::time::sleep(Duration::from_millis(50)).await;
5319        assert!(!reopen.is_finished(), "ensure_open must wait for the claim");
5320        drop(claim);
5321        reopen.await.unwrap().unwrap();
5322    }
5323
5324    /// #687 FR-027: a claim dropped before its closes were sent (the flushing
5325    /// call was cancelled) puts them back, so the server is not left with a
5326    /// document the tracker has forgotten.
5327    #[tokio::test]
5328    async fn test_claim_dropped_unsettled_restores_the_owed_closes() {
5329        let dir = TempDir::new().unwrap();
5330        let tracker = one_document_tracker();
5331        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
5332        evict_by_opening(&tracker, &dir, "q.rs").await;
5333
5334        let mut claim = tracker.try_claim_pending_close(&path).unwrap();
5335        claim.settle(&ServerId::from_static("a"));
5336        drop(claim);
5337
5338        assert_eq!(
5339            pending_servers(&tracker, &path),
5340            HashSet::from([ServerId::from_static("d")]),
5341            "only the close that was never settled stays owed"
5342        );
5343    }
5344
5345    /// A server respawned while its close was claimed owes nothing any more.
5346    #[tokio::test]
5347    async fn test_claim_dropped_after_the_server_was_forgotten_restores_nothing() {
5348        let dir = TempDir::new().unwrap();
5349        let tracker = one_document_tracker();
5350        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5351        evict_by_opening(&tracker, &dir, "q.rs").await;
5352
5353        let claim = tracker.try_claim_pending_close(&path).unwrap();
5354        tracker.forget_server(&ServerId::from_static("a"));
5355        drop(claim);
5356
5357        assert!(pending_servers(&tracker, &path).is_empty());
5358    }
5359
5360    /// #687 FR-026: a sync dropped while it waits for room on the command
5361    /// queue neither sends nor settles anything, and a later call delivers the
5362    /// close and the open together.
5363    #[tokio::test]
5364    async fn test_sync_phase_dropped_while_waiting_for_queue_room_keeps_the_debt() {
5365        use crate::lsp::LspClient;
5366
5367        let dir = TempDir::new().unwrap();
5368        let tracker = one_document_tracker();
5369        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5370        evict_by_opening(&tracker, &dir, "q.rs").await;
5371        let server = ServerId::from_static("a");
5372        let (client, mut commands) =
5373            LspClient::with_command_channel(crate::config::LspServerConfig::rust_analyzer(), 2);
5374        client
5375            .notify("test/fill", serde_json::json!({}))
5376            .await
5377            .unwrap();
5378        client
5379            .notify("test/fill", serde_json::json!({}))
5380            .await
5381            .unwrap();
5382
5383        let generation = tracker.generation(&server);
5384        let decision = tracker
5385            .disk_phase(OpenGate::Ungated(&path), Access::Use)
5386            .await
5387            .unwrap();
5388        {
5389            let sync = tracker.sync_phase(&path, &server, &client, decision, generation);
5390            let mut sync = std::pin::pin!(sync);
5391            assert!(futures::poll!(sync.as_mut()).is_pending());
5392        }
5393        assert_eq!(
5394            pending_servers(&tracker, &path),
5395            HashSet::from([server.clone()]),
5396            "the abandoned sync must leave the close owed"
5397        );
5398        assert!(
5399            tracker
5400                .get(&path)
5401                .unwrap()
5402                .synced_version(&server)
5403                .is_none()
5404        );
5405
5406        assert_eq!(commands.next_method().as_deref(), Some("test/fill"));
5407        assert_eq!(commands.next_method().as_deref(), Some("test/fill"));
5408        assert_eq!(
5409            commands.next_method(),
5410            None,
5411            "the abandoned sync sent something"
5412        );
5413
5414        let generation = tracker.generation(&server);
5415        let decision = tracker
5416            .disk_phase(OpenGate::Ungated(&path), Access::Use)
5417            .await
5418            .unwrap();
5419        tracker
5420            .sync_phase(&path, &server, &client, decision, generation)
5421            .await
5422            .unwrap();
5423        let methods: Vec<_> = std::iter::from_fn(|| commands.next_method()).collect();
5424        assert_eq!(
5425            methods,
5426            ["textDocument/didClose", "textDocument/didOpen"],
5427            "the debt is settled before the open, in one step"
5428        );
5429        assert!(pending_servers(&tracker, &path).is_empty());
5430        assert!(
5431            tracker
5432                .get(&path)
5433                .unwrap()
5434                .synced_version(&server)
5435                .is_some()
5436        );
5437    }
5438
5439    /// #515: evict, re-open and evict again merges into one entry per server.
5440    #[tokio::test]
5441    async fn test_repeated_eviction_merges_pending_closes() {
5442        let dir = TempDir::new().unwrap();
5443        let tracker = one_document_tracker();
5444        let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
5445        evict_by_opening(&tracker, &dir, "q.rs").await;
5446
5447        let (id_d, client_d, _fake_d) = servers.remove(1);
5448        tracker
5449            .ensure_open_for_test(&path, &id_d, &client_d)
5450            .await
5451            .unwrap();
5452        evict_by_opening(&tracker, &dir, "r.rs").await;
5453
5454        assert_eq!(
5455            pending_servers(&tracker, &path),
5456            HashSet::from([ServerId::from_static("a"), ServerId::from_static("d")])
5457        );
5458        assert_eq!(tracker.pending_close_paths().len(), 2);
5459    }
5460
5461    /// #515: a close that fails after `forget_server` ran (respawn) must not
5462    /// re-add the forgotten server's debt.
5463    #[tokio::test]
5464    async fn test_restore_pending_close_skips_forgotten_server() {
5465        let dir = TempDir::new().unwrap();
5466        let tracker = one_document_tracker();
5467        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5468        evict_by_opening(&tracker, &dir, "q.rs").await;
5469        let server = ServerId::from_static("a");
5470        let uri = path_to_uri(&path).unwrap();
5471
5472        let generation = tracker.generation(&server);
5473        assert!(tracker.take_pending_close(&path, &server));
5474        tracker.forget_server(&server);
5475        tracker.restore_pending_close(&path, &uri, &server, generation);
5476        assert!(pending_servers(&tracker, &path).is_empty());
5477
5478        tracker.restore_pending_close(&path, &uri, &server, tracker.generation(&server));
5479        assert_eq!(pending_servers(&tracker, &path), HashSet::from([server]));
5480    }
5481
5482    /// #515: a respawned server never had the documents open, so it owes no
5483    /// close; other servers' debts stay.
5484    #[tokio::test]
5485    async fn test_forget_server_purges_its_pending_closes() {
5486        let dir = TempDir::new().unwrap();
5487        let tracker = one_document_tracker();
5488        let (path, _servers) = open_for_servers(&tracker, &dir, "p.rs", &["a", "d"]).await;
5489        evict_by_opening(&tracker, &dir, "q.rs").await;
5490
5491        tracker.forget_server(&ServerId::from_static("a"));
5492        assert_eq!(
5493            pending_servers(&tracker, &path),
5494            HashSet::from([ServerId::from_static("d")])
5495        );
5496        tracker.forget_server(&ServerId::from_static("d"));
5497        assert!(!lock_std(&tracker.pending_closes).contains_key(&path));
5498    }
5499
5500    /// #515: a failed `didClose` keeps the server's debt and surfaces the error.
5501    #[tokio::test]
5502    async fn test_failed_close_before_reopen_restores_the_debt() {
5503        let dir = TempDir::new().unwrap();
5504        let tracker = one_document_tracker();
5505        let (path, mut servers) = open_for_servers(&tracker, &dir, "p.rs", &["a"]).await;
5506        evict_by_opening(&tracker, &dir, "q.rs").await;
5507
5508        let (id_a, client_a, _fake_a) = servers.remove(0);
5509        let will_fail = client_a.clone();
5510        client_a.shutdown().await.unwrap();
5511        assert!(
5512            tracker
5513                .ensure_open_for_test(&path, &id_a, &will_fail)
5514                .await
5515                .is_err()
5516        );
5517        assert_eq!(
5518            pending_servers(&tracker, &path),
5519            HashSet::from([ServerId::from_static("a")])
5520        );
5521    }
5522}