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