Skip to main content

mcpls_core/bridge/
state.rs

1//! Document state management.
2//!
3//! Tracks open documents and their versions for LSP synchronization.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex as StdMutex};
8use std::time::{Duration, SystemTime};
9
10use lsp_types::{
11    DidChangeTextDocumentNotification, DidChangeTextDocumentParams,
12    DidOpenTextDocumentNotification, DidOpenTextDocumentParams, TextDocumentContentChangeEvent,
13    TextDocumentItem, Uri, VersionedTextDocumentIdentifier,
14};
15use tokio::fs;
16use tokio::io::{AsyncBufReadExt, AsyncReadExt};
17use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
18use tokio::time::Instant;
19use url::Url;
20
21use super::lock_std;
22use crate::config::ServerId;
23use crate::error::{Error, Result};
24use crate::lsp::LspClient;
25use crate::util::{BoundedReadOutcome, bounded_read_cap, check_bounded_utf8};
26
27/// Debounce window for re-reading a file's content when its mtime is not yet
28/// [`mtime_settled`]. The stat itself is never debounced -- only this
29/// (comparatively expensive) content re-read is rate-limited, so a burst of
30/// calls against a genuinely changed file still resyncs on the first stat
31/// that observes the new `(mtime, size)`.
32///
33/// This only bounds the *stable-but-unsettled* case: the same `(mtime,
34/// size)` observed repeatedly while that mtime is still within
35/// [`MTIME_GRANULARITY`] of "now". A file whose `(mtime, size)` changes on
36/// every stat is never debounced at all -- each such call already disagrees
37/// with the cached snapshot, so it always takes the immediate re-read path
38/// regardless of how recently the last one happened.
39const DISK_CHECK_DEBOUNCE: Duration = Duration::from_millis(250);
40
41/// Filesystem mtime granularity margin: covers FAT/exFAT (2s) and is a safe
42/// superset of HFS+/ext3/APFS (1s or finer). An mtime observed more recently
43/// than this cannot be trusted to distinguish "unchanged" from "rewritten
44/// within the same tick", so such entries are re-verified by content compare
45/// instead of by stat alone -- this is what closes the racy-rewrite gap.
46const MTIME_GRANULARITY: Duration = Duration::from_secs(2);
47
48/// Returns whether `mtime` is old enough, relative to `read_at`, that a write
49/// landing after `read_at` could not have preserved it.
50///
51/// `read_at` must be captured *before* the filesystem is stat'd (not after any
52/// subsequent read), otherwise a write racing the read itself could produce a
53/// new mtime that still appears "settled" against a later timestamp.
54fn mtime_settled(mtime: Option<SystemTime>, read_at: SystemTime) -> bool {
55    mtime.is_some_and(|m| {
56        m.checked_add(MTIME_GRANULARITY)
57            .is_some_and(|t| t <= read_at)
58    })
59}
60
61/// Rejects `file` unless its Win32 file type is `FILE_TYPE_DISK`, the
62/// Windows equivalent of the Unix `fstat`-based regular-file check in
63/// [`DocumentTracker::open_checked`]. `std::fs::Metadata::is_file()` alone
64/// is not a reliable rejection for every special path on Windows (e.g.
65/// reserved device names like `CON`, `COM1`, `NUL`); those can still block
66/// indefinitely on read, so this bounds the read -- not the open itself,
67/// which Win32 has no non-blocking equivalent for (see #442).
68#[cfg(windows)]
69fn check_disk_file_type(file: &fs::File, path: &Path) -> Result<()> {
70    use std::os::windows::io::AsRawHandle;
71
72    use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, GetFileType};
73
74    #[allow(unsafe_code)]
75    // SAFETY: `file` is a valid, still-open handle just obtained from open(); GetFileType's only precondition.
76    let file_type = unsafe { GetFileType(file.as_raw_handle().cast()) };
77
78    if file_type != FILE_TYPE_DISK {
79        return Err(Error::NotARegularFile(path.to_path_buf()));
80    }
81    Ok(())
82}
83
84/// A snapshot of a document's on-disk filesystem state, captured the last
85/// time its content was actually read and compared.
86///
87/// [`DocumentTracker::ensure_open`] stats the file on every call; when the
88/// stat matches this snapshot and [`Self::mtime_settled`] holds, the cached
89/// content is trusted without touching the file's bytes again. This is what
90/// keeps the common "file unchanged" path cheap while still detecting
91/// external edits (git checkout/stash, formatters, the MCP host's own
92/// edits) made outside mcpls.
93#[derive(Debug, Clone, Copy)]
94pub struct DiskSync {
95    /// Last observed modification time, or `None` if the filesystem or
96    /// platform does not report one (in which case the entry is never
97    /// treated as settled, forcing a content re-read outside the debounce
98    /// window).
99    pub mtime: Option<SystemTime>,
100    /// Last observed file size in bytes.
101    pub size: u64,
102    /// Whether `mtime` was already old enough, relative to when it was
103    /// observed, that a same-tick rewrite could not have preserved it.
104    pub mtime_settled: bool,
105    /// When the file's content was last actually re-read and compared.
106    ///
107    /// Used only to debounce the content re-read on a racy (not-yet-settled)
108    /// entry; deliberately excluded from equality so two otherwise-identical
109    /// snapshots don't compare unequal merely because they were checked at
110    /// different instants.
111    pub content_checked_at: Instant,
112}
113
114impl PartialEq for DiskSync {
115    fn eq(&self, other: &Self) -> bool {
116        self.mtime == other.mtime
117            && self.size == other.size
118            && self.mtime_settled == other.mtime_settled
119    }
120}
121
122impl Eq for DiskSync {}
123
124/// State of a single document.
125///
126/// All fields are private. `DocumentTracker::open` (via `Self::new`)
127/// establishes the initial state: `version` starts at 1, `disk` provenance
128/// starts `None`, and no server is recorded as synced. From there, every
129/// mutation goes through a dedicated method (`apply_local_edit`,
130/// `commit_reload`, `set_disk`, `mark_synced`, `forget_server`) rather than a
131/// partial field write, so within a single tracked lifetime `version` (see
132/// [`Self::version`]) only increases. This does not cover re-opening: calling
133/// `DocumentTracker::open` again for an already-tracked path unconditionally
134/// replaces the entry, resetting `version` to 1 and clearing `synced` -- see
135/// that method's docs.
136///
137/// The `disk` provenance invariant: `None` means the content's on-disk
138/// provenance is unknown (it came from an in-memory `open`/`update` call, not
139/// a verified disk read), so `ensure_open` must always re-verify by content
140/// compare rather than trusting a stat match. `DiskSync`'s hand-written
141/// `PartialEq` excludes `content_checked_at` (see that field's doc comment),
142/// and that exclusion propagates here: two `DocumentState`s can compare
143/// equal via this struct's own hand-written `PartialEq`/`Eq` (below) despite
144/// having been disk-verified at different instants. This is intentional --
145/// `content_checked_at` is a debounce timer, not part of a document's
146/// logical state. `last_accessed` (also excluded, for the same reason) is
147/// likewise not logical state, just an LRU-eviction timestamp (#495).
148#[derive(Debug, Clone)]
149pub struct DocumentState {
150    uri: Uri,
151    language_id: String,
152    version: i32,
153    content: String,
154    disk: Option<DiskSync>,
155    synced: HashMap<ServerId, i32>,
156    /// When this document was last accessed via `ensure_open`/`update`
157    /// (`Self::touch`), used to pick the least-recently-used entry when
158    /// `DocumentTracker::open` must evict to stay under
159    /// `ResourceLimits::max_documents` (#495).
160    last_accessed: Instant,
161}
162
163impl PartialEq for DocumentState {
164    fn eq(&self, other: &Self) -> bool {
165        // Destructured (rather than plain field access) so a future new
166        // field fails to compile here until it's deliberately included or
167        // excluded -- unlike a derived impl, hand-written equality gets no
168        // such reminder for free.
169        let Self {
170            uri,
171            language_id,
172            version,
173            content,
174            disk,
175            synced,
176            last_accessed: _,
177        } = self;
178        *uri == other.uri
179            && *language_id == other.language_id
180            && *version == other.version
181            && *content == other.content
182            && *disk == other.disk
183            && *synced == other.synced
184    }
185}
186
187impl Eq for DocumentState {}
188
189impl DocumentState {
190    /// Creates a new document state at version 1, with unknown disk
191    /// provenance and no server yet recorded as synced.
192    fn new(uri: Uri, language_id: String, content: String) -> Self {
193        Self {
194            uri,
195            language_id,
196            version: 1,
197            content,
198            disk: None,
199            synced: HashMap::new(),
200            last_accessed: Instant::now(),
201        }
202    }
203
204    /// Marks this document as just accessed, for LRU eviction ordering under
205    /// `ResourceLimits::max_documents` (#495).
206    fn touch(&mut self) {
207        self.last_accessed = Instant::now();
208    }
209
210    /// Document URI.
211    #[must_use]
212    pub const fn uri(&self) -> &Uri {
213        &self.uri
214    }
215
216    /// Language identifier.
217    #[must_use]
218    pub fn language_id(&self) -> &str {
219        &self.language_id
220    }
221
222    /// Document version. Monotonically increasing: every mutation that
223    /// changes `content` (`apply_local_edit`, `commit_reload`) also bumps
224    /// this, and never decreases it.
225    #[must_use]
226    pub const fn version(&self) -> i32 {
227        self.version
228    }
229
230    /// Document content.
231    #[must_use]
232    pub fn content(&self) -> &str {
233        &self.content
234    }
235
236    /// Filesystem snapshot as of the last time `content` was read from disk.
237    /// See the struct-level docs for the meaning of `None`.
238    const fn disk(&self) -> Option<DiskSync> {
239        self.disk
240    }
241
242    /// Last document version pushed to `server` via `didOpen`/`didChange`,
243    /// or `None` if `server` has never seen this document.
244    ///
245    /// A single document can be synced to multiple servers (e.g. hover
246    /// routed to one server, diagnostics to another for the same language),
247    /// each needing its own `didOpen`/`didChange` history -- a server absent
248    /// from this map has never seen the document and must receive
249    /// `didOpen`, not `didChange`, on its next `ensure_open` call.
250    #[must_use]
251    pub fn synced_version(&self, server: &ServerId) -> Option<i32> {
252        self.synced.get(server).copied()
253    }
254
255    /// Whether no server has ever synced this document.
256    fn has_never_synced(&self) -> bool {
257        self.synced.is_empty()
258    }
259
260    /// Applies a local (non-disk) edit: bumps `version`, replaces `content`,
261    /// and clears `disk` provenance, since the new content did not come from
262    /// a verified disk read. Returns the new version.
263    fn apply_local_edit(&mut self, content: String) -> i32 {
264        self.version += 1;
265        self.content = content;
266        self.disk = None;
267        self.version
268    }
269
270    /// Commits a disk-verified reload: sets `version`, `content`, and `disk`
271    /// together. `version` must be no less than the current version,
272    /// preserving the monotonicity invariant. (Not strictly greater: the
273    /// caller computes `version` via `saturating_add`, which can legitimately
274    /// clamp to the current value at `i32::MAX`.)
275    fn commit_reload(&mut self, version: i32, content: String, snap: Option<DiskSync>) {
276        debug_assert!(
277            version >= self.version,
278            "document version must be monotonically increasing"
279        );
280        self.version = version;
281        self.content = content;
282        self.disk = snap;
283    }
284
285    /// Sets the disk snapshot without changing `content` or `version`.
286    const fn set_disk(&mut self, snap: DiskSync) {
287        self.disk = Some(snap);
288    }
289
290    /// Records that `server` has synced up to `version`.
291    fn mark_synced(&mut self, server: ServerId, version: i32) {
292        self.synced.insert(server, version);
293    }
294
295    /// Forgets `server`'s sync history for this document.
296    fn forget_server(&mut self, server: &ServerId) {
297        self.synced.remove(server);
298    }
299}
300
301/// Default value for [`ResourceLimits::max_documents`], also used as the
302/// TOML default for `workspace.max_documents` (`config::default_max_documents`).
303pub const DEFAULT_MAX_DOCUMENTS: usize = 100;
304
305/// Default value for [`ResourceLimits::max_file_size`] (10MB), also used as
306/// the TOML default for `workspace.max_file_size` (`config::default_max_file_size`).
307pub const DEFAULT_MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
308
309/// Resource limits for document tracking.
310#[derive(Debug, Clone, Copy)]
311pub struct ResourceLimits {
312    /// Maximum number of open documents (0 = unlimited).
313    pub max_documents: usize,
314    /// Maximum file size in bytes (0 = unlimited).
315    pub max_file_size: u64,
316}
317
318impl Default for ResourceLimits {
319    fn default() -> Self {
320        Self {
321            max_documents: DEFAULT_MAX_DOCUMENTS,
322            max_file_size: DEFAULT_MAX_FILE_SIZE,
323        }
324    }
325}
326
327/// Nominal charge for a [`DocumentTracker::read_line_checked`] call whose
328/// [`DocumentTracker::open_checked`] failed (path doesn't exist, isn't a
329/// regular file, or already exceeds `max_file_size`) -- zero bytes were
330/// actually scanned, but charging a literal `0` would let a response naming
331/// many nonexistent paths (a routine, non-attacker-controlled LSP server
332/// behavior -- e.g. rust-analyzer's stdlib locations without `rust-src`
333/// installed) repeat that cheap-but-nonzero syscall for free against a
334/// per-response I/O budget (see #474's budget-bypass follow-up). Small
335/// enough to have no material effect on a legitimate response's budget
336/// (~10,000 failed opens before exhausting [`DEFAULT_MAX_FILE_SIZE`]'s
337/// worth of budget on their own), while still bounding the failed-open
338/// amplification to the same order of magnitude as other count caps in this
339/// crate.
340pub const OPEN_FAILURE_CHARGE_BYTES: u64 = 4096;
341
342/// Outcome of [`DocumentTracker::read_line_checked`]: the requested line
343/// (`None` if the file has fewer lines, doesn't exist, or otherwise
344/// resolved to no usable text), plus the bytes to charge a caller
345/// tracking its own I/O budget across many calls (see `EncodingCtx`'s
346/// per-response disk-read budget, #474) -- not always a literal count of
347/// bytes scanned (see [`OPEN_FAILURE_CHARGE_BYTES`]), but always safe to
348/// charge as such. Charge this rather than assuming cost is proportional
349/// to `text`'s own length -- most of the cost is the lines skipped before
350/// it.
351#[derive(Debug, Clone)]
352pub struct LineRead {
353    /// The requested line's text, or `None` if the file has no such line.
354    pub(crate) text: Option<String>,
355    /// Bytes to charge against a caller's I/O budget for this call; see
356    /// this type's own doc for when this isn't a literal scanned-byte count.
357    pub(crate) bytes_read: u64,
358}
359
360/// A document evicted by [`DocumentTracker::open`]'s LRU eviction (#495).
361///
362/// Carries the servers whose `textDocument/didOpen`/`didChange` it had
363/// received. `DocumentTracker` itself has no access to any server's
364/// [`LspClient`] --
365/// that registry lives one layer up, in `Translator` -- so it cannot send
366/// `textDocument/didClose` itself. Instead, [`DocumentTracker::take_evicted`]
367/// hands these back to a caller that does have that access, which must send
368/// each of `synced_servers` a `textDocument/didClose` for `uri`, or that
369/// server's own open-document set keeps growing even though mcpls's own
370/// tracking evicted the entry.
371#[derive(Debug, Clone)]
372pub struct EvictedDocument {
373    /// Filesystem path of the evicted document.
374    pub path: PathBuf,
375    /// URI of the evicted document, as sent to any server that had it open.
376    pub uri: Uri,
377    /// Servers that had this document open, each needing a
378    /// `textDocument/didClose` now that mcpls itself has evicted it.
379    pub synced_servers: Vec<ServerId>,
380}
381
382/// Tracks document state across the workspace.
383///
384/// Every method takes `&self`: the document map and the per-path locks used
385/// by [`Self::ensure_open`] are both interior-mutable, so a single tracker
386/// can be shared behind a plain `Arc<DocumentTracker>` with no outer lock.
387/// See [`Self::ensure_open`] for the concurrency contract this maintains.
388#[derive(Debug)]
389pub struct DocumentTracker {
390    /// Open documents by file path. Locked only for the short, synchronous
391    /// section that touches it — never held across an `await`.
392    documents: StdMutex<HashMap<PathBuf, DocumentState>>,
393    /// Per-path locks serializing [`Self::ensure_open`] calls for the same
394    /// path, so calls for different paths never wait on each other. See
395    /// `lock_path` for how entries are created and evicted.
396    ///
397    /// Also doubles as the "has an in-flight operation" signal
398    /// [`Self::open`]'s LRU eviction consults (#495): a path is present here
399    /// for the whole duration of any `ensure_open`/`update` call against it
400    /// (`lock_path`'s guard is held across both), so excluding every path
401    /// present in this map from eviction candidates is exactly "never evict
402    /// a document with an operation in flight".
403    path_locks: StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
404    /// Per-server sync generation, bumped by [`Self::forget_server`].
405    ///
406    /// `ensure_open` captures a server's generation before doing any I/O and
407    /// only commits its `synced` update if the generation is unchanged when
408    /// it finishes -- see [`Self::forget_server`]'s docs for the race this
409    /// closes. Absent from the map is equivalent to generation `0`.
410    generations: StdMutex<HashMap<ServerId, u64>>,
411    /// Resource limits for tracking.
412    limits: ResourceLimits,
413    /// Custom file extension to language ID mappings.
414    extension_map: HashMap<String, String>,
415    /// Documents evicted by [`Self::open`]'s LRU eviction, queued for
416    /// [`Self::take_evicted`] to hand to a caller that can notify their
417    /// servers (#495). See [`EvictedDocument`].
418    evicted: StdMutex<Vec<EvictedDocument>>,
419}
420
421impl DocumentTracker {
422    /// Create a new document tracker with custom limits and extension mappings.
423    #[must_use]
424    pub fn new(limits: ResourceLimits, extension_map: HashMap<String, String>) -> Self {
425        Self {
426            documents: StdMutex::new(HashMap::new()),
427            path_locks: StdMutex::new(HashMap::new()),
428            generations: StdMutex::new(HashMap::new()),
429            limits,
430            extension_map,
431            evicted: StdMutex::new(Vec::new()),
432        }
433    }
434
435    /// Drains and returns documents evicted by [`Self::open`]'s LRU eviction
436    /// since the last call (#495) -- see [`EvictedDocument`]. A caller with
437    /// access to each server's `LspClient` (i.e. `Translator`) should call
438    /// this after every `ensure_open` that could have triggered eviction and
439    /// send `textDocument/didClose` for each evicted document to each of its
440    /// `synced_servers`.
441    pub fn take_evicted(&self) -> Vec<EvictedDocument> {
442        std::mem::take(&mut lock_std(&self.evicted))
443    }
444
445    /// Check if a document is currently open.
446    #[must_use]
447    pub fn is_open(&self, path: &Path) -> bool {
448        lock_std(&self.documents).contains_key(path)
449    }
450
451    /// Get a clone of the state of an open document.
452    #[must_use]
453    pub fn get(&self, path: &Path) -> Option<DocumentState> {
454        lock_std(&self.documents).get(path).cloned()
455    }
456
457    /// Text of the 0-based `line`'th line of `path`'s currently tracked
458    /// content, or `None` if the document is not open or has no such line.
459    ///
460    /// Reads the in-memory content mcpls already sent the server via
461    /// `didOpen`/`didChange` -- cheaper than a disk read (no I/O, no
462    /// re-scanning the whole file) and more correct when disk and server
463    /// state have diverged (e.g. an edit not yet flushed to disk).
464    #[must_use]
465    pub fn line_text(&self, path: &Path, line: u32) -> Option<String> {
466        lock_std(&self.documents)
467            .get(path)?
468            .content
469            .lines()
470            .nth(line as usize)
471            .map(str::to_string)
472    }
473
474    /// Get the number of open documents.
475    #[must_use]
476    pub fn len(&self) -> usize {
477        lock_std(&self.documents).len()
478    }
479
480    /// Check if there are no open documents.
481    #[must_use]
482    pub fn is_empty(&self) -> bool {
483        lock_std(&self.documents).is_empty()
484    }
485
486    /// Open a document and track its state.
487    ///
488    /// Returns the document URI for use in LSP requests.
489    ///
490    /// When `max_documents` would otherwise be exceeded, evicts the
491    /// least-recently-used tracked document that both has no
492    /// `ensure_open`/`update` call currently in flight against it and is
493    /// disk-verified (see `evict_lru`) to make room, rather than failing
494    /// outright (#495) -- the evicted document is queued for
495    /// [`Self::take_evicted`]. Only falls back to
496    /// [`Error::DocumentLimitExceeded`] when no tracked document meets both
497    /// conditions, so none is safe to evict.
498    ///
499    /// `take_evicted`'s queue is an unbounded `Vec` that only ever grows
500    /// until drained -- `Translator` drains it after every `ensure_open`
501    /// that could have triggered eviction, but a caller that invokes this
502    /// method directly (bypassing `ensure_open`, e.g. an embedder) is
503    /// responsible for draining it too, or the queue (and every
504    /// `EvictedDocument`'s content) accumulates for the tracker's lifetime.
505    ///
506    /// Note the narrower guarantee than "no operation in flight" might
507    /// suggest: the `ensure_open`/`update` lock this checks (`path_locks`)
508    /// is released once that call returns, *before* the caller's actual LSP
509    /// round-trip for the document runs (see `path_locks`'s doc) -- a
510    /// document already past its own `ensure_open` can still be evicted
511    /// while its handler's request is in flight. Harmless at the default
512    /// `max_documents` (100): the just-prepared document is always the most
513    /// recently used, so it's never the LRU candidate. At a very small
514    /// configured limit with enough concurrent calls, two in-flight
515    /// documents could in principle evict each other mid-request.
516    ///
517    /// # Errors
518    ///
519    /// Returns an error if:
520    /// - Document limit is exceeded and no document is evictable
521    /// - File size limit is exceeded
522    pub fn open(&self, path: PathBuf, content: String) -> Result<Uri> {
523        self.check_file_size(content.len() as u64)?;
524
525        let uri = path_to_uri(&path)?;
526        let language_id = detect_language(&path, &self.extension_map);
527
528        let state = DocumentState::new(uri.clone(), language_id, content);
529
530        // Check document limit and insert under a single lock acquisition so
531        // two concurrent `open` calls for different new paths can't both
532        // pass the check and jointly exceed the limit by one. Dropped
533        // explicitly right after the insert rather than at function return.
534        //
535        // Skipped entirely when `path` is already tracked: re-opening an
536        // existing path (`insert` below overwrites its entry in place, not
537        // growing the map) never needs room made for it -- checking the
538        // limit anyway would needlessly evict some unrelated victim (or, if
539        // `path` itself were picked as the LRU candidate, evict and then
540        // immediately re-insert it, queuing a spurious `didClose`).
541        let mut documents = lock_std(&self.documents);
542        if self.limits.max_documents > 0
543            && documents.len() >= self.limits.max_documents
544            && !documents.contains_key(&path)
545        {
546            let Some((evicted_path, evicted_state)) =
547                Self::evict_lru(&mut documents, &self.path_locks)
548            else {
549                return Err(Error::DocumentLimitExceeded {
550                    current: documents.len(),
551                    max: self.limits.max_documents,
552                });
553            };
554            lock_std(&self.evicted).push(EvictedDocument {
555                path: evicted_path,
556                uri: evicted_state.uri,
557                synced_servers: evicted_state.synced.into_keys().collect(),
558            });
559        }
560        documents.insert(path, state);
561        drop(documents);
562        Ok(uri)
563    }
564
565    /// Removes and returns the least-recently-used entry in `documents` that
566    /// is both unlocked and disk-verified -- see `path_locks`'s doc for why
567    /// "present in `path_locks`" is exactly "has an `ensure_open`/`update`
568    /// operation in flight" (#495), and below for why "disk-verified" is
569    /// required too.
570    ///
571    /// A candidate whose `disk()` is `None` is skipped: that means its
572    /// in-memory `content` either has never been read-back-verified against
573    /// disk at all, or -- the concerning case -- has *diverged* from disk
574    /// via `Self::update`'s `apply_local_edit` (a local, not-yet-`didOpen`ed
575    /// edit already pushed to the server, per that method's own doc). In
576    /// either case, evicting it and later reopening the path from disk on a
577    /// future `ensure_open` would silently discard content mcpls has no
578    /// other record of -- unlike a disk-verified candidate, whose evicted
579    /// content is by definition reproducible by re-reading the file. No
580    /// in-tree caller invokes `update` today, so this is a structural guard
581    /// against a latent, not-yet-reachable data-loss shape rather than a
582    /// currently-observed bug.
583    ///
584    /// Returns `None` if every tracked document is currently locked or not
585    /// disk-verified, in which case the caller must not evict anything.
586    fn evict_lru(
587        documents: &mut HashMap<PathBuf, DocumentState>,
588        path_locks: &StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
589    ) -> Option<(PathBuf, DocumentState)> {
590        let locked = lock_std(path_locks)
591            .keys()
592            .cloned()
593            .collect::<std::collections::HashSet<_>>();
594        let lru_path = documents
595            .iter()
596            .filter(|(path, state)| !locked.contains(path.as_path()) && state.disk().is_some())
597            .min_by_key(|(_, state)| state.last_accessed)
598            .map(|(path, _)| path.clone())?;
599        documents.remove(&lru_path).map(|state| (lru_path, state))
600    }
601
602    /// Update a document's content and increment its version.
603    ///
604    /// Returns `None` if the document is not open. The updated content has no
605    /// known disk provenance, so the next `ensure_open` call on this path
606    /// will always re-verify by content compare rather than trusting a stat.
607    ///
608    /// # Concurrency
609    ///
610    /// Takes the same per-path lock as [`Self::ensure_open`] (see
611    /// `lock_path`), so this can never interleave with an `ensure_open` call
612    /// for the same path -- closing the race where `ensure_open`'s disk
613    /// phase reads a `(uri, version, disk snapshot)` under a short-lived
614    /// lock and its sync phase later commits against that now-stale
615    /// snapshot after a concurrent `update` bumped the version in between.
616    ///
617    /// **Warning**: `lock_path`'s mutex is not reentrant. Never call `update`
618    /// from a task that already holds this same path's `lock_path` guard
619    /// (e.g. from within `ensure_open`/`disk_phase`/`sync_phase`, or any
620    /// future caller nested inside one) -- doing so self-deadlocks
621    /// permanently, with no panic and no timeout to signal it.
622    pub async fn update(&self, path: &Path, content: String) -> Option<i32> {
623        let _path_guard = self.lock_path(path).await;
624        lock_std(&self.documents).get_mut(path).map(|state| {
625            state.touch();
626            state.apply_local_edit(content)
627        })
628    }
629
630    /// Returns an error if `size` exceeds the configured file size limit.
631    const fn check_file_size(&self, size: u64) -> Result<()> {
632        if self.limits.max_file_size > 0 && size > self.limits.max_file_size {
633            return Err(Error::FileSizeLimitExceeded {
634                size,
635                max: self.limits.max_file_size,
636            });
637        }
638        Ok(())
639    }
640
641    /// Sets the disk snapshot for an already-tracked document.
642    ///
643    /// A no-op if the path is no longer tracked; every call site runs under
644    /// the per-path lock for the whole `ensure_open` call, so this should
645    /// not happen in practice, but it avoids an `unwrap`/`expect` on the
646    /// lookup.
647    fn set_disk(&self, path: &Path, snap: DiskSync) {
648        if let Some(st) = lock_std(&self.documents).get_mut(path) {
649            st.set_disk(snap);
650        }
651    }
652
653    /// Close a document and remove it from tracking.
654    ///
655    /// Returns the document state if it was open.
656    pub fn close(&self, path: &Path) -> Option<DocumentState> {
657        lock_std(&self.documents).remove(path)
658    }
659
660    /// Close all documents.
661    pub fn close_all(&self) -> Vec<DocumentState> {
662        lock_std(&self.documents)
663            .drain()
664            .map(|(_, state)| state)
665            .collect()
666    }
667
668    /// Snapshot of the filesystem paths of all currently open documents.
669    pub fn open_paths(&self) -> Vec<PathBuf> {
670        lock_std(&self.documents).keys().cloned().collect()
671    }
672
673    /// Forget `server`'s last-synced version for every currently open
674    /// document, so the next `ensure_open` call sends `didOpen` again
675    /// instead of `didChange`.
676    ///
677    /// Called after `server` is respawned: the fresh process has no memory
678    /// of any document the old one had open, so this tracker's per-server
679    /// sync history for it must be forgotten too, or `ensure_open` would
680    /// wrongly send `didChange` for a document the new process never saw.
681    ///
682    /// Also bumps `server`'s sync generation. Clearing `synced` alone is not
683    /// enough: a call already in flight against the old (dead) connection
684    /// when this runs can still have its `didOpen`/`didChange` notify
685    /// "succeed" (`LspClient::notify` only enqueues onto a channel -- a dead
686    /// process is not observed by the send itself), and would otherwise
687    /// re-insert a stale entry after this method has already cleared it.
688    /// `ensure_open` captures the generation before starting and discards
689    /// its `synced` write if the generation moved in the meantime, closing
690    /// that race regardless of exactly when the notify "succeeds".
691    pub fn forget_server(&self, server: &ServerId) {
692        *lock_std(&self.generations)
693            .entry(server.clone())
694            .or_insert(0) += 1;
695        for state in lock_std(&self.documents).values_mut() {
696            state.forget_server(server);
697        }
698    }
699
700    /// Current sync generation for `server` (see [`Self::forget_server`]).
701    fn generation(&self, server: &ServerId) -> u64 {
702        lock_std(&self.generations)
703            .get(server)
704            .copied()
705            .unwrap_or(0)
706    }
707
708    /// Acquire the per-path lock used by [`Self::ensure_open`], creating its
709    /// entry on first use.
710    ///
711    /// The map of per-path locks (`path_locks`) is itself locked only for
712    /// the map lookup/insert/remove — never across an `await` — so acquiring
713    /// one path's lock never blocks a concurrent acquisition for a different
714    /// path. Awaiting the returned path's own lock is what actually
715    /// serializes calls for the same path.
716    ///
717    /// The returned guard evicts its `path_locks` entry when dropped, but
718    /// only if no other caller is concurrently waiting on it (see
719    /// [`PathLockGuard`]'s `Drop` impl) — otherwise the map would grow by
720    /// one entry per distinct path ever opened, for the lifetime of the
721    /// process.
722    async fn lock_path(&self, path: &Path) -> PathLockGuard<'_> {
723        let arc = {
724            let mut locks = lock_std(&self.path_locks);
725            locks
726                .entry(path.to_path_buf())
727                .or_insert_with(|| Arc::new(AsyncMutex::new(())))
728                .clone()
729        };
730        let guard = Arc::clone(&arc).lock_owned().await;
731        PathLockGuard {
732            path_locks: &self.path_locks,
733            path: path.to_path_buf(),
734            arc,
735            guard: Some(guard),
736        }
737    }
738
739    /// Ensure a document is open *for `server`*, opening it lazily if
740    /// necessary, and resynchronize it with disk and with `server` if either
741    /// has fallen behind.
742    ///
743    /// A single path can be synced to several servers independently (e.g.
744    /// hover routed to one server, diagnostics to another, for the same
745    /// language) -- this call syncs only the one server it is for. Internally
746    /// it runs in two phases:
747    ///
748    /// **Disk phase**: stats the file on every call (a cheap syscall, never
749    /// debounced) to detect external changes -- `git checkout`/`stash`,
750    /// formatters, or edits made by the MCP host itself outside mcpls -- and
751    /// re-reads its content when the stat indicates a possible change (see
752    /// `DiskSync` for the settled/debounce rules). This phase never skips
753    /// the *per-server* sync check below, even when it takes a fast path
754    /// that skips the disk read: a second server that has never seen this
755    /// document must still receive `didOpen` even if the file has not
756    /// changed since a first server was opened on it.
757    ///
758    /// **Sync phase**: compares `server`'s last-synced version (tracked via
759    /// [`DocumentState::synced_version`]) against the version decided by the disk
760    /// phase, and sends exactly one of `didOpen` (server has never seen this
761    /// document), `didChange` (server is behind), or nothing (server is
762    /// already caught up). A `didChange` is always a single full-replacement
763    /// notification (a `TextDocumentContentChangeEvent` with `range: None`,
764    /// which per the LSP spec means "this is the entire new document
765    /// content"); mcpls does not consult the server's negotiated
766    /// `TextDocumentSyncKind` (`LspClient` has no access to
767    /// `ServerCapabilities` at this layer) -- full-replacement is accepted in
768    /// practice by rust-analyzer, pyright, tsserver, gopls and clangd, but is
769    /// the first place to look if a future maintainer sees sync errors from
770    /// a new server. The document is never closed and reopened on a change,
771    /// so `get_cached_diagnostics` keeps serving the last-known diagnostics
772    /// until the server re-publishes -- there is no transient empty window.
773    ///
774    /// `st.version`/`st.content`/`st.disk`/`synced[server]` are all committed
775    /// only after the notification succeeds. A server that is never asked
776    /// again never catches up to a later edit -- which is correct, since a
777    /// server that is never asked never needs the content.
778    ///
779    /// Two cases fall outside the disk-change-detection mechanism entirely:
780    /// - A tool that restores a file with an mtime and size identical to the
781    ///   last ones observed (e.g. `tar x`, `rsync -a`, `cp -p`) is
782    ///   indistinguishable from "unchanged", however long ago that snapshot
783    ///   was taken -- not just within the racy detection window. Once a
784    ///   snapshot is `mtime_settled`, restoring its exact `(mtime, size)`
785    ///   retakes the fast path forever. Closing this would require hashing
786    ///   content on every access.
787    /// - `workspace_symbol_search` is served from the LSP server's own
788    ///   index and is unaffected by this per-document mechanism for files
789    ///   mcpls has never opened.
790    ///
791    /// # Concurrency
792    ///
793    /// Calls for the *same* `path` are serialized against each other (via
794    /// `lock_path`), so no two such calls can observe or mutate that
795    /// path's state concurrently -- this is what prevents duplicate
796    /// `didOpen`/`didChange` notifications for the same document. Calls for
797    /// *different* paths run fully concurrently: neither the per-path lock
798    /// nor the short, synchronous locks used to touch the shared document
799    /// map are ever held across this call's disk I/O or LSP notify.
800    ///
801    /// # Errors
802    ///
803    /// Returns an error if:
804    /// - The file cannot be stat'd or read from disk
805    /// - The `didOpen`/`didChange` notification fails to send
806    /// - Resource limits are exceeded
807    pub async fn ensure_open(
808        &self,
809        path: &Path,
810        server: &ServerId,
811        lsp_client: &LspClient,
812    ) -> Result<Uri> {
813        let _path_guard = self.lock_path(path).await;
814        let generation = self.generation(server);
815        let decision = self.disk_phase(path).await?;
816        self.sync_phase(path, server, lsp_client, decision, generation)
817            .await
818    }
819
820    /// Disk-verification phase of `ensure_open`: decides the version `path`
821    /// should be at, reading from disk only when necessary. Never sends any
822    /// LSP notification and never returns early in a way that would skip the
823    /// per-server sync phase -- see `ensure_open`'s docs.
824    async fn disk_phase(&self, path: &Path) -> Result<Decision> {
825        if !lock_std(&self.documents).contains_key(path) {
826            return self.disk_phase_new(path).await;
827        }
828
829        let read_at = SystemTime::now();
830        let meta = fs::metadata(path).await.map_err(|e| Error::FileIo {
831            path: path.to_path_buf(),
832            source: e,
833        })?;
834        let mtime = meta.modified().ok();
835        let size = meta.len();
836
837        // `.map(...)` extracts an owned tuple from the lookup in a single
838        // statement, so the lock releases immediately rather than staying
839        // held while `fast_path` is computed. `get_mut` (rather than `get`)
840        // so this same lookup can also `touch` the entry for LRU eviction
841        // ordering (#495) -- every `ensure_open` call for an already-tracked
842        // document reaches here, whether or not it ends up taking the fast
843        // path below.
844        let Some((uri, current_version, fast_path)) =
845            lock_std(&self.documents).get_mut(path).map(|st| {
846                st.touch();
847                let stat_matches = st
848                    .disk()
849                    .is_some_and(|d| d.mtime == mtime && d.size == size);
850                let fast_path = match st.disk() {
851                    Some(d) if stat_matches && d.mtime_settled => true,
852                    Some(d)
853                        if stat_matches && d.content_checked_at.elapsed() < DISK_CHECK_DEBOUNCE =>
854                    {
855                        true
856                    }
857                    _ => false,
858                };
859                (st.uri.clone(), st.version, fast_path)
860            })
861        else {
862            return Err(Error::DocumentNotFound(path.to_path_buf()));
863        };
864        if fast_path {
865            return Ok(Decision::unchanged(uri, current_version));
866        }
867
868        let (fresh, ..) = self.read_to_string_checked(path).await?;
869        let snap = DiskSync {
870            mtime,
871            size,
872            mtime_settled: mtime_settled(mtime, read_at),
873            content_checked_at: Instant::now(),
874        };
875
876        let Some(unchanged) = lock_std(&self.documents)
877            .get(path)
878            .map(|st| fresh == st.content)
879        else {
880            return Err(Error::DocumentNotFound(path.to_path_buf()));
881        };
882
883        if unchanged {
884            self.set_disk(path, snap);
885            return Ok(Decision::unchanged(uri, current_version));
886        }
887
888        Ok(Decision {
889            uri,
890            target_version: current_version.saturating_add(1),
891            fresh_content: Some(fresh),
892            snap: Some(snap),
893        })
894    }
895
896    /// Reads a not-yet-tracked file from disk and opens it in the tracker at
897    /// version 1. No server has synced it yet, so the sync phase always
898    /// sends `didOpen` regardless of which server calls next.
899    async fn disk_phase_new(&self, path: &Path) -> Result<Decision> {
900        let read_at = SystemTime::now();
901        let (content, mtime, size) = self.read_to_string_checked(path).await?;
902
903        let uri = self.open(path.to_path_buf(), content)?;
904        self.set_disk(
905            path,
906            DiskSync {
907                mtime,
908                size,
909                mtime_settled: mtime_settled(mtime, read_at),
910                content_checked_at: Instant::now(),
911            },
912        );
913
914        Ok(Decision::unchanged(uri, 1))
915    }
916
917    /// Opens `path` for reading and verifies, via that same open handle's
918    /// metadata, that it is a regular file within [`Self::check_file_size`]'s
919    /// limit -- never a separately-stat'd path, which would let an atomic
920    /// replace (e.g. a concurrent `rename`) between the check and the open
921    /// swap in something else entirely.
922    ///
923    /// On Unix the open itself uses `O_NONBLOCK`, which has no effect on
924    /// regular files but makes opening a FIFO (or other peer-waiting special
925    /// file) return immediately instead of blocking indefinitely for a
926    /// writer -- the file-type check below then rejects it. Without this,
927    /// a FIFO substituted for an expected regular file could hang the
928    /// calling task (and pin a blocking-pool thread) forever (see #418).
929    ///
930    /// **Known gap on Windows**: `CreateFileW` (what `fs::File::open` and
931    /// `OpenOptions::open` call into) has no `O_NONBLOCK` equivalent, so the
932    /// open itself can still block indefinitely on a hostile path (e.g. an
933    /// oplock held by another process, or a dead network redirector) --
934    /// Win32 offers nothing to bound that. What Windows does get is a
935    /// content-read guarantee: the open handle is checked via `GetFileType`
936    /// (see [`check_disk_file_type`]) immediately after open and before
937    /// `metadata()` or any content read, rejecting anything that is not
938    /// `FILE_TYPE_DISK` (e.g. reserved device names like `CON`, `COM1`,
939    /// `NUL`, which `FileType::is_file()` alone does not reliably reject) --
940    /// see #442. Platforms that are neither Unix nor Windows get neither
941    /// protection: a plain blocking open with no file-type check beyond
942    /// `is_file()`.
943    async fn open_checked(&self, path: &Path) -> Result<(fs::File, std::fs::Metadata)> {
944        #[cfg(unix)]
945        let opened = fs::OpenOptions::new()
946            .read(true)
947            .custom_flags(libc::O_NONBLOCK)
948            .open(path)
949            .await;
950        #[cfg(not(unix))]
951        let opened = fs::File::open(path).await;
952
953        let file = opened.map_err(|e| Error::FileIo {
954            path: path.to_path_buf(),
955            source: e,
956        })?;
957        // Must precede metadata() below: GetFileInformationByHandle may fail for non-disk handles.
958        #[cfg(windows)]
959        check_disk_file_type(&file, path)?;
960        let meta = file.metadata().await.map_err(|e| Error::FileIo {
961            path: path.to_path_buf(),
962            source: e,
963        })?;
964        if !meta.is_file() {
965            return Err(Error::NotARegularFile(path.to_path_buf()));
966        }
967        self.check_file_size(meta.len())?;
968        Ok((file, meta))
969    }
970
971    /// Reads `file`'s content as UTF-8, bounded to one byte past
972    /// [`Self::check_file_size`]'s limit regardless of the already-checked
973    /// stat result -- defense in depth against the file growing between the
974    /// stat (in [`Self::open_checked`]) and this read completing (see #418).
975    /// A read that reaches the bound is reported as oversized even though
976    /// the earlier stat passed, since the file grew past what was verified.
977    ///
978    /// `size_hint` is the size [`Self::open_checked`] already observed via
979    /// `stat`, used only to preallocate the read buffer and avoid
980    /// reallocation growth on the common (non-racing) path -- it is never
981    /// trusted for the size check itself, which is always re-derived from
982    /// the bytes actually read.
983    async fn read_string_bounded(
984        &self,
985        path: &Path,
986        mut file: fs::File,
987        size_hint: u64,
988    ) -> Result<String> {
989        let max = self.limits.max_file_size;
990        let cap = bounded_read_cap(max);
991        let mut buf = Vec::with_capacity(usize::try_from(size_hint.min(cap)).unwrap_or(0));
992        let io_err = |e: std::io::Error| Error::FileIo {
993            path: path.to_path_buf(),
994            source: e,
995        };
996
997        (&mut file)
998            .take(cap)
999            .read_to_end(&mut buf)
1000            .await
1001            .map_err(io_err)?;
1002        match check_bounded_utf8(buf, max) {
1003            BoundedReadOutcome::Ok(s) => Ok(s),
1004            BoundedReadOutcome::TooLarge { size } => {
1005                Err(Error::FileSizeLimitExceeded { size, max })
1006            }
1007            BoundedReadOutcome::InvalidUtf8(e) => Err(io_err(std::io::Error::new(
1008                std::io::ErrorKind::InvalidData,
1009                e,
1010            ))),
1011        }
1012    }
1013
1014    /// Reads `path` through a single open file handle, checking its size and
1015    /// type via [`Self::open_checked`] and bounding the read via
1016    /// [`Self::read_string_bounded`].
1017    ///
1018    /// Returns the content along with the handle's own mtime and size, so
1019    /// callers can build a [`DiskSync`] snapshot consistent with what was
1020    /// actually read.
1021    async fn read_to_string_checked(
1022        &self,
1023        path: &Path,
1024    ) -> Result<(String, Option<SystemTime>, u64)> {
1025        let (file, meta) = self.open_checked(path).await?;
1026        let mtime = meta.modified().ok();
1027        let size = meta.len();
1028        let content = self.read_string_bounded(path, file, size).await?;
1029        Ok((content, mtime, size))
1030    }
1031
1032    /// Reads only the 0-based `line`'th line of `path` from disk, applying
1033    /// the same regular-file and [`Self::check_file_size`] checks as a
1034    /// tracked document's disk read (see [`Self::read_to_string_checked`]),
1035    /// but stopping as soon as `line` is found rather than buffering the
1036    /// whole file just to discard everything past one line (see #474).
1037    ///
1038    /// For a document not tracked by this tracker at all -- e.g. one
1039    /// resolved only for encoding-conversion purposes, never opened for LSP
1040    /// sync -- there is otherwise no size or file-type gate on the path at
1041    /// all (see #427). Callers that only need best-effort text (falling back
1042    /// to `None` on any error) should treat every error here that way rather
1043    /// than surfacing it.
1044    ///
1045    /// [`LineRead::text`] is `None` if `path` doesn't resolve to an
1046    /// existing, readable regular file at all (see [`Self::open_checked`]),
1047    /// if `path` has fewer than `line + 1` lines, if the line's bytes are
1048    /// not valid UTF-8, or if `budget` (or `max_file_size`) was exhausted
1049    /// before a complete line could be read -- [`LineRead::bytes_read`] is
1050    /// populated in every one of these cases (see below), never silently
1051    /// dropped via an `Err` with no byte count. The line's trailing line
1052    /// ending is stripped to match `str::lines`'s convention exactly: a
1053    /// trailing `\n` is removed, and only then is one further trailing `\r`
1054    /// also removed (a real `\r\n` terminator) -- a final line with no
1055    /// trailing `\n` at all keeps any trailing `\r` verbatim, since it was
1056    /// never followed by a real line terminator, same as `str::lines`.
1057    ///
1058    /// `budget` bounds this call's own read on top of
1059    /// [`crate::util::bounded_read_cap`] of `max_file_size`: the actual cap
1060    /// used is `min(bounded_read_cap(max_file_size), budget + 1)`, enforced
1061    /// by wrapping the file handle itself in [`AsyncReadExt::take`] rather
1062    /// than checked after the fact -- so this call physically cannot scan
1063    /// more than one byte past `budget`, regardless of how large
1064    /// `max_file_size` is configured (including `max_file_size = 0`,
1065    /// meaning unlimited). The `+ 1` is the same disambiguation slack
1066    /// `bounded_read_cap` already applies to `max_file_size`: without it, a
1067    /// read whose remaining budget exactly equals its target line's byte
1068    /// length (no trailing newline) is indistinguishable from one
1069    /// genuinely truncated by the cap. A caller enforcing its own I/O
1070    /// budget across many calls (see `EncodingCtx`'s per-response
1071    /// disk-read budget, #474) passes its remaining allowance here and
1072    /// charges exactly [`LineRead::bytes_read`] afterward -- always
1073    /// available, on every outcome, so the budget can never be bypassed by
1074    /// triggering a failure mid-scan, and never overshoots by more than
1075    /// this one byte of slack.
1076    ///
1077    /// [`Self::open_checked`] failing (path doesn't exist, isn't a regular
1078    /// file, or already exceeds `max_file_size` at stat time) is reported
1079    /// the same way, charging [`OPEN_FAILURE_CHARGE_BYTES`] rather than a
1080    /// literal `0` -- zero bytes were actually scanned, but an LSP server
1081    /// routinely names paths that don't exist locally (e.g. rust-analyzer's
1082    /// `file:///rustc/<hash>/library/...` without `rust-src` installed),
1083    /// and a literal `0` would let a response naming many such paths repeat
1084    /// this cheap-but-nonzero syscall for free against the per-response
1085    /// budget (see #474's budget-bypass follow-up). A real mid-read I/O
1086    /// error (rare, not attacker-controlled by response content) is the one
1087    /// case that still returns a genuine `Err` with no byte count.
1088    ///
1089    /// Also closes #427/#418's TOCTOU margin without a dedicated error: if
1090    /// `path` grows past `max_file_size` (or past `budget`) between
1091    /// [`Self::open_checked`]'s stat and this read completing, the capped
1092    /// take-adapter simply runs out mid-line, which this method detects
1093    /// (`buf` doesn't end in the expected `\n`) and reports as `None` rather
1094    /// than returning a truncated line as if it were complete.
1095    pub(crate) async fn read_line_checked(
1096        &self,
1097        path: &Path,
1098        line: u32,
1099        budget: u64,
1100    ) -> Result<LineRead> {
1101        let Ok((file, _meta)) = self.open_checked(path).await else {
1102            return Ok(LineRead {
1103                text: None,
1104                bytes_read: OPEN_FAILURE_CHARGE_BYTES,
1105            });
1106        };
1107        let max = self.limits.max_file_size;
1108        // `+1` slack on `budget`, same trick `bounded_read_cap` already
1109        // applies to `max_file_size`: without it, a read whose remaining
1110        // budget exactly equals its target line's byte length (no trailing
1111        // newline) is indistinguishable from one truncated by the cap, and
1112        // was misreported as truncated (see #474's correctness-gate fix).
1113        let cap = bounded_read_cap(max).min(budget.saturating_add(1));
1114        let mut reader = tokio::io::BufReader::new(file.take(cap));
1115        let io_err = |e: std::io::Error| Error::FileIo {
1116            path: path.to_path_buf(),
1117            source: e,
1118        };
1119
1120        let mut buf = Vec::new();
1121        let mut bytes_read: u64 = 0;
1122        let mut current_line = 0u32;
1123        loop {
1124            buf.clear();
1125            let n = reader.read_until(b'\n', &mut buf).await.map_err(io_err)?;
1126            bytes_read += n as u64;
1127            if n == 0 {
1128                // No complete line left to return either way; bytes scanned
1129                // are still reported so the caller can charge them.
1130                return Ok(LineRead {
1131                    text: None,
1132                    bytes_read,
1133                });
1134            }
1135            if current_line == line {
1136                let truncated_by_cap = bytes_read >= cap && buf.last() != Some(&b'\n');
1137                if truncated_by_cap {
1138                    return Ok(LineRead {
1139                        text: None,
1140                        bytes_read,
1141                    });
1142                }
1143                if buf.last() == Some(&b'\n') {
1144                    buf.pop();
1145                    if buf.last() == Some(&b'\r') {
1146                        buf.pop();
1147                    }
1148                }
1149                return Ok(LineRead {
1150                    text: String::from_utf8(buf).ok(),
1151                    bytes_read,
1152                });
1153            }
1154            current_line += 1;
1155        }
1156    }
1157
1158    /// Per-server sync phase of `ensure_open`: sends `didOpen`, `didChange`,
1159    /// or nothing to `server` depending on its last-synced version, and
1160    /// commits the outcome only after the notification succeeds.
1161    ///
1162    /// `generation` is `server`'s sync generation as observed by the caller
1163    /// before this call started (see [`Self::forget_server`]): the
1164    /// `synced` write at the end is skipped if it no longer matches,
1165    /// meaning `server` was respawned while this call was in flight and its
1166    /// notify -- however it turned out -- was not actually delivered to the
1167    /// connection now on file for `server`.
1168    async fn sync_phase(
1169        &self,
1170        path: &Path,
1171        server: &ServerId,
1172        lsp_client: &LspClient,
1173        decision: Decision,
1174        generation: u64,
1175    ) -> Result<Uri> {
1176        let Decision {
1177            uri,
1178            target_version,
1179            fresh_content,
1180            snap,
1181        } = decision;
1182
1183        // Cheap check first: the common case (an already-synced document,
1184        // which is most tool calls against a file already open elsewhere)
1185        // must not pay for cloning the full document content only to
1186        // discard it on the `up_to_date` return below. `.map(...)` extracts
1187        // an owned value from the lookup so the lock is released at the end
1188        // of this statement rather than held across the checks that follow.
1189        let Some(synced_version) = lock_std(&self.documents)
1190            .get(path)
1191            .map(|st| st.synced_version(server))
1192        else {
1193            return Err(Error::DocumentNotFound(path.to_path_buf()));
1194        };
1195        let up_to_date = synced_version.is_some_and(|v| v >= target_version);
1196        let is_first_open = synced_version.is_none();
1197
1198        if up_to_date {
1199            return Ok(uri);
1200        }
1201
1202        let Some((language_id, text)) = lock_std(&self.documents).get(path).map(|st| {
1203            let text = fresh_content.clone().unwrap_or_else(|| st.content.clone());
1204            (st.language_id.clone(), text)
1205        }) else {
1206            return Err(Error::DocumentNotFound(path.to_path_buf()));
1207        };
1208
1209        let notify_result = if is_first_open {
1210            lsp_client
1211                .notify_typed::<DidOpenTextDocumentNotification>(DidOpenTextDocumentParams {
1212                    text_document: TextDocumentItem {
1213                        uri: uri.clone(),
1214                        language_id: language_id.into(),
1215                        version: target_version,
1216                        text,
1217                    },
1218                })
1219                .await
1220        } else {
1221            lsp_client
1222                .notify_typed::<DidChangeTextDocumentNotification>(DidChangeTextDocumentParams {
1223                    text_document: VersionedTextDocumentIdentifier {
1224                        version: target_version,
1225                        text_document_identifier: lsp_types::TextDocumentIdentifier {
1226                            uri: uri.clone(),
1227                        },
1228                    },
1229                    content_changes: vec![
1230                        TextDocumentContentChangeEvent::TextDocumentContentChangeWholeDocument(
1231                            lsp_types::TextDocumentContentChangeWholeDocument { text },
1232                        ),
1233                    ],
1234                })
1235                .await
1236        };
1237
1238        if let Err(err) = notify_result {
1239            // The server never learned about this document. If no server at
1240            // all has synced this path yet, leaving it tracked would
1241            // permanently desync every future server from the tracker, so
1242            // undo the insert and let the next call retry from scratch. If
1243            // another server already synced successfully, the path stays
1244            // tracked for that server's sake; this server's `synced` entry
1245            // simply stays absent/stale, so its own next call retries.
1246            // Two short lock scopes rather than one held across the
1247            // conditional `remove`: safe because `ensure_open`'s per-path
1248            // lock already serializes every caller for this path, so
1249            // nothing else can observe or mutate its `synced` map between
1250            // them.
1251            let first_ever_sync = lock_std(&self.documents)
1252                .get(path)
1253                .is_some_and(DocumentState::has_never_synced);
1254            if is_first_open && first_ever_sync {
1255                lock_std(&self.documents).remove(path);
1256            }
1257            return Err(err);
1258        }
1259
1260        // Dropped explicitly right after the commit, rather than staying
1261        // alive (unused) until the function returns.
1262        let mut documents = lock_std(&self.documents);
1263        let Some(st) = documents.get_mut(path) else {
1264            return Err(Error::DocumentNotFound(path.to_path_buf()));
1265        };
1266        if let Some(fresh) = fresh_content {
1267            st.commit_reload(target_version, fresh, snap);
1268        }
1269        // Read while `documents` is still held, not before: `forget_server`
1270        // bumps the generation strictly before it acquires `documents`
1271        // itself (see its docs), so checking under this same lock is
1272        // airtight against the TOCTOU a separate, earlier read would leave
1273        // open -- either this sees the new generation and skips (in which
1274        // case `forget_server` has already cleared `synced`, or is blocked
1275        // waiting for *this* guard to release before it does), or it sees
1276        // the old one, in which case `forget_server` cannot have started
1277        // clearing yet and will correctly clear the entry this commits.
1278        if self.generation(server) == generation {
1279            st.mark_synced(server.clone(), target_version);
1280        }
1281        drop(documents);
1282
1283        Ok(uri)
1284    }
1285}
1286
1287/// RAII guard for the per-path lock acquired by
1288/// [`DocumentTracker::lock_path`].
1289///
1290/// Holds an `OwnedMutexGuard` on the path's `Arc<AsyncMutex<()>>>` for as
1291/// long as the guard is alive, serializing `ensure_open` calls for that
1292/// path. On drop, evicts the `path_locks` map entry if (and only if) no
1293/// other caller holds a clone of the same `Arc` -- see the `Drop` impl for
1294/// why that check is race-free.
1295struct PathLockGuard<'a> {
1296    path_locks: &'a StdMutex<HashMap<PathBuf, Arc<AsyncMutex<()>>>>,
1297    path: PathBuf,
1298    arc: Arc<AsyncMutex<()>>,
1299    guard: Option<OwnedMutexGuard<()>>,
1300}
1301
1302impl Drop for PathLockGuard<'_> {
1303    fn drop(&mut self) {
1304        // Unlock first so a task waiting on `arc.lock_owned()` can proceed
1305        // as soon as possible, rather than also waiting on `path_locks`.
1306        self.guard.take();
1307
1308        let mut locks = lock_std(self.path_locks);
1309        // Checked only after `self.guard` -- and the extra internal `Arc`
1310        // clone it held -- was already dropped above, so what's left here is:
1311        // this task's own `self.arc`, the map's entry, and one more
1312        // reference for every *other* task that has already looked up this
1313        // same entry in `lock_path` (each holds its own clone continuously
1314        // from before that lookup until its own `Drop` runs this same check)
1315        // but hasn't finished dropping yet. A `strong_count` of 2 means no
1316        // such task exists, so it's safe to evict; any later caller just
1317        // creates a fresh entry. Leaving it forever would instead grow this
1318        // map by one entry per distinct path ever opened, for the process's
1319        // lifetime.
1320        if Arc::strong_count(&self.arc) <= 2 {
1321            locks.remove(&self.path);
1322        }
1323    }
1324}
1325
1326/// Outcome of `DocumentTracker::disk_phase`: the version `ensure_open`'s
1327/// caller should end up synced to, and -- only when this call detected an
1328/// as-yet-uncommitted content change -- the content and disk snapshot to
1329/// commit alongside it.
1330struct Decision {
1331    uri: Uri,
1332    target_version: i32,
1333    fresh_content: Option<String>,
1334    snap: Option<DiskSync>,
1335}
1336
1337impl Decision {
1338    /// A decision where nothing changed on disk this call: `target_version`
1339    /// is already what's committed in `DocumentState`.
1340    const fn unchanged(uri: Uri, target_version: i32) -> Self {
1341        Self {
1342            uri,
1343            target_version,
1344            fresh_content: None,
1345            snap: None,
1346        }
1347    }
1348}
1349
1350/// Convert a file path to a URI.
1351///
1352/// Prefer `try_path_to_uri` on paths that come from configuration or
1353/// otherwise untrusted input; this wrapper exists for the common case of an
1354/// already-canonicalized path, where the conversion is not expected to fail
1355/// but must still surface as an error rather than a panic to keep the
1356/// `panic = "abort"` release profile safe against unforeseen inputs.
1357///
1358/// # Errors
1359///
1360/// Returns [`Error::InvalidUri`] if the path cannot be represented as a
1361/// `file://` URI.
1362pub fn path_to_uri(path: &Path) -> Result<Uri> {
1363    try_path_to_uri(path)
1364        .ok_or_else(|| Error::InvalidUri(format!("cannot convert path to URI: {}", path.display())))
1365}
1366
1367/// Convert a file path to a URI, returning `None` if the path cannot be
1368/// represented as a `file://` URI.
1369///
1370/// Prefer this over [`path_to_uri`] on paths that come from configuration,
1371/// where a bad value should surface as an error rather than a panic.
1372#[must_use]
1373pub fn try_path_to_uri(path: &Path) -> Option<Uri> {
1374    let uri_string = encode_rfc3986_path_chars(&file_url(path)?);
1375    Some(Uri::from(uri_string))
1376}
1377
1378#[cfg(not(windows))]
1379fn file_url(path: &Path) -> Option<Url> {
1380    Url::from_file_path(path).ok()
1381}
1382
1383#[cfg(windows)]
1384fn file_url(path: &Path) -> Option<Url> {
1385    match Url::from_file_path(path) {
1386        Ok(file_url) => Some(file_url),
1387        Err(()) if path.has_root() => windows_rooted_path_to_file_url(path),
1388        Err(()) => None,
1389    }
1390}
1391
1392#[cfg(windows)]
1393fn windows_rooted_path_to_file_url(path: &Path) -> Option<Url> {
1394    let path_str = path.to_string_lossy();
1395    let stripped = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
1396    let mut file_url = Url::parse("file:///").ok()?;
1397    file_url.path_segments_mut().ok()?.clear().extend(
1398        stripped
1399            .split(['\\', '/'])
1400            .filter(|segment| !segment.is_empty()),
1401    );
1402    Some(file_url)
1403}
1404
1405/// Percent-encodes the RFC 3986 §2.2 "other reserved" characters that the
1406/// `url` crate's default WHATWG path percent-encode set leaves untouched:
1407/// `[`, `]`, `^`, `|`. The remaining three characters in that set -- `{`,
1408/// `}`, and backtick -- are already encoded by `url` on serialization, so
1409/// they need no handling here; see
1410/// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`.
1411///
1412/// Shared with [`crate::bridge::resources::make_uri`] so `lsp-diagnostics://`
1413/// resource URIs get the same encoding as `file://` document URIs.
1414pub(super) fn encode_rfc3986_path_chars(url: &Url) -> String {
1415    let prefix = url[..url::Position::BeforePath].to_owned();
1416    let encoded = url[url::Position::BeforePath..]
1417        .replace('[', "%5B")
1418        .replace(']', "%5D")
1419        .replace('^', "%5E")
1420        .replace('|', "%7C");
1421    format!("{prefix}{encoded}")
1422}
1423
1424/// Convert an LSP `file://` URI to an absolute filesystem path.
1425///
1426/// Returns `None` if the URI is not a valid `file://` URI, uses a non-file
1427/// scheme, or contains percent-encoding that cannot map to a valid path.
1428#[must_use]
1429pub fn uri_to_path(uri: &Uri) -> Option<PathBuf> {
1430    let url = Url::parse(uri.as_ref()).ok()?;
1431    if url.scheme() != "file" {
1432        return None;
1433    }
1434    // Reject authority-bearing file URIs (e.g. `file://server/share`) to
1435    // avoid UNC path confusion on Windows.
1436    if !url.host_str().unwrap_or("").is_empty() {
1437        return None;
1438    }
1439    url.to_file_path().ok()
1440}
1441
1442/// Detect the language ID from a file path.
1443///
1444/// Consults the extension map to determine the language ID for a file.
1445/// If the extension is not found in the map, returns "plaintext".
1446#[must_use]
1447pub fn detect_language(path: &Path, extension_map: &HashMap<String, String>) -> String {
1448    let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1449
1450    extension_map
1451        .get(extension)
1452        .cloned()
1453        .unwrap_or_else(|| "plaintext".to_string())
1454}
1455
1456#[cfg(test)]
1457#[allow(clippy::unwrap_used)]
1458mod tests {
1459    use super::*;
1460
1461    #[test]
1462    fn test_detect_language() {
1463        let mut map = HashMap::new();
1464        map.insert("rs".to_string(), "rust".to_string());
1465        map.insert("py".to_string(), "python".to_string());
1466        map.insert("ts".to_string(), "typescript".to_string());
1467
1468        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
1469        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
1470        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
1471        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
1472    }
1473
1474    #[tokio::test]
1475    async fn test_document_tracker() {
1476        let mut map = HashMap::new();
1477        map.insert("rs".to_string(), "rust".to_string());
1478
1479        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1480        let path = PathBuf::from("/test/file.rs");
1481
1482        assert!(!tracker.is_open(&path));
1483
1484        tracker
1485            .open(path.clone(), "fn main() {}".to_string())
1486            .unwrap();
1487        assert!(tracker.is_open(&path));
1488        assert_eq!(tracker.len(), 1);
1489
1490        let state = tracker.get(&path).unwrap();
1491        assert_eq!(state.version(), 1);
1492        assert_eq!(state.language_id(), "rust");
1493
1494        let new_version = tracker
1495            .update(&path, "fn main() { println!() }".to_string())
1496            .await;
1497        assert_eq!(new_version, Some(2));
1498
1499        tracker.close(&path);
1500        assert!(!tracker.is_open(&path));
1501        assert!(tracker.is_empty());
1502    }
1503
1504    /// #249: after a respawn, `forget_server` must clear only the respawned
1505    /// server's sync history so the next `ensure_open` call for it sends
1506    /// `didOpen` again -- while leaving other servers synced to the same
1507    /// document untouched (a path can be synced to more than one server,
1508    /// e.g. hover routed to one, diagnostics to another).
1509    #[test]
1510    fn test_forget_server_clears_only_that_servers_synced_version() {
1511        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1512        let path = PathBuf::from("/test/file.rs");
1513        tracker
1514            .open(path.clone(), "fn main() {}".to_string())
1515            .unwrap();
1516
1517        let respawned = ServerId::from("rust-respawned");
1518        let untouched = ServerId::from("rust-diagnostics");
1519        lock_std(&tracker.documents)
1520            .get_mut(&path)
1521            .unwrap()
1522            .synced
1523            .insert(respawned.clone(), 1);
1524        lock_std(&tracker.documents)
1525            .get_mut(&path)
1526            .unwrap()
1527            .synced
1528            .insert(untouched.clone(), 1);
1529
1530        tracker.forget_server(&respawned);
1531
1532        let state = tracker.get(&path).unwrap();
1533        assert!(state.synced_version(&respawned).is_none());
1534        assert!(state.synced_version(&untouched).is_some());
1535    }
1536
1537    /// #249 S1 regression: a `sync_phase` call that captured `server`'s
1538    /// generation *before* a concurrent `forget_server` bumped it must not
1539    /// commit its `synced` write, even though its notification against the
1540    /// now-superseded connection reports success (`fake_lsp_client`'s
1541    /// `DuplexStream` peer, held alive by the test's `FakeServer`, always
1542    /// accepts writes, standing in for the window where a server's process
1543    /// has already died but its message loop has not yet observed that).
1544    /// Without this, a document synced against the old (crashed) process
1545    /// would be wrongly marked as already open on the respawned one,
1546    /// permanently desyncing it.
1547    #[tokio::test]
1548    async fn test_sync_phase_skips_commit_when_generation_is_stale() {
1549        let dir = TempDir::new().unwrap();
1550        let path = dir.path().join("race.rs");
1551        std::fs::write(&path, "fn main() {}").unwrap();
1552        set_mtime(&path, settled_past());
1553
1554        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1555        let server = ServerId::from("rust");
1556        let generation_before_respawn = 0; // fresh tracker: generation starts at 0
1557
1558        // A respawn happens "concurrently" with the in-flight call that
1559        // captured the generation above before this ran.
1560        tracker.forget_server(&server);
1561
1562        let (stale_client, _guard) = fake_lsp_client();
1563        let decision = tracker.disk_phase(&path).await.unwrap();
1564        tracker
1565            .sync_phase(
1566                &path,
1567                &server,
1568                &stale_client,
1569                decision,
1570                generation_before_respawn,
1571            )
1572            .await
1573            .unwrap();
1574
1575        let state = tracker.get(&path).unwrap();
1576        assert!(
1577            state.synced_version(&server).is_none(),
1578            "a sync_phase call that captured a stale generation must not \
1579             commit `synced`, even though its notify against the \
1580             superseded connection succeeded"
1581        );
1582    }
1583
1584    /// Companion to the regression above: the ordinary, non-racing path
1585    /// (`ensure_open` capturing and committing against the *current*
1586    /// generation) must still work -- the generation check must not
1587    /// suppress a legitimate commit.
1588    #[tokio::test]
1589    async fn test_ensure_open_commits_when_generation_is_current() {
1590        let dir = TempDir::new().unwrap();
1591        let path = dir.path().join("no_race.rs");
1592        std::fs::write(&path, "fn main() {}").unwrap();
1593
1594        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
1595        let server = ServerId::from("rust");
1596        let (client, _guard) = fake_lsp_client();
1597
1598        tracker.ensure_open(&path, &server, &client).await.unwrap();
1599
1600        let state = tracker.get(&path).unwrap();
1601        assert_eq!(state.synced_version(&server), Some(1));
1602    }
1603
1604    /// Marks `path`'s tracked document as disk-verified, for a test that
1605    /// opens a document directly via `open` (bypassing `ensure_open`'s
1606    /// `disk_phase`, which is what normally sets this) but still needs it
1607    /// eligible for `evict_lru`'s LRU eviction -- disk-verified is a
1608    /// precondition for eviction, not just unlocked (#495 S4).
1609    fn mark_disk_verified(tracker: &DocumentTracker, path: &Path) {
1610        tracker.set_disk(
1611            path,
1612            DiskSync {
1613                mtime: None,
1614                size: 0,
1615                mtime_settled: false,
1616                content_checked_at: Instant::now(),
1617            },
1618        );
1619    }
1620
1621    /// #495: at capacity with every existing document unlocked and
1622    /// disk-verified, `open` must evict the least-recently-used one to make
1623    /// room rather than fail -- the evicted document is queued for
1624    /// `take_evicted`.
1625    #[test]
1626    fn test_document_limit_evicts_lru_instead_of_failing() {
1627        let limits = ResourceLimits {
1628            max_documents: 2,
1629            max_file_size: 100,
1630        };
1631        let mut map = HashMap::new();
1632        map.insert("rs".to_string(), "rust".to_string());
1633
1634        let tracker = DocumentTracker::new(limits, map);
1635
1636        tracker
1637            .open(PathBuf::from("/test/file1.rs"), "fn test1() {}".to_string())
1638            .unwrap();
1639        mark_disk_verified(&tracker, Path::new("/test/file1.rs"));
1640        tracker
1641            .open(PathBuf::from("/test/file2.rs"), "fn test2() {}".to_string())
1642            .unwrap();
1643        mark_disk_verified(&tracker, Path::new("/test/file2.rs"));
1644
1645        tracker
1646            .open(PathBuf::from("/test/file3.rs"), "fn test3() {}".to_string())
1647            .unwrap();
1648
1649        assert_eq!(tracker.len(), 2);
1650        assert!(!tracker.is_open(Path::new("/test/file1.rs")));
1651        assert!(tracker.is_open(Path::new("/test/file2.rs")));
1652        assert!(tracker.is_open(Path::new("/test/file3.rs")));
1653
1654        let evicted = tracker.take_evicted();
1655        assert_eq!(evicted.len(), 1);
1656        assert_eq!(evicted[0].path, PathBuf::from("/test/file1.rs"));
1657        assert!(
1658            evicted[0].synced_servers.is_empty(),
1659            "opened directly via `open`, never synced to any server"
1660        );
1661    }
1662
1663    /// #495: `open` must fall back to `DocumentLimitExceeded` when every
1664    /// tracked document currently has an operation in flight against it
1665    /// (simulated here by inserting its `path_locks` entry directly, which
1666    /// is exactly what `evict_lru` checks for) -- evicting a locked document
1667    /// would pull it out from under that in-flight operation.
1668    #[test]
1669    fn test_document_limit_falls_back_to_error_when_only_candidate_is_locked() {
1670        let limits = ResourceLimits {
1671            max_documents: 1,
1672            max_file_size: 100,
1673        };
1674        let tracker = DocumentTracker::new(limits, HashMap::new());
1675
1676        let locked_path = PathBuf::from("/test/locked.rs");
1677        tracker
1678            .open(locked_path.clone(), "fn locked() {}".to_string())
1679            .unwrap();
1680        lock_std(&tracker.path_locks).insert(locked_path.clone(), Arc::new(AsyncMutex::new(())));
1681
1682        let result = tracker.open(PathBuf::from("/test/other.rs"), "fn other() {}".to_string());
1683        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
1684        assert!(
1685            tracker.is_open(&locked_path),
1686            "the locked document must not be evicted"
1687        );
1688        assert!(tracker.take_evicted().is_empty());
1689    }
1690
1691    /// #495 S4: a document whose content has diverged from disk (via
1692    /// `update`, which clears `disk` -- see `DocumentState::apply_local_edit`)
1693    /// must never be evicted even though it is unlocked -- evicting it would
1694    /// silently discard in-memory content mcpls has no other record of. No
1695    /// in-tree caller invokes `update` today; this guards a structural,
1696    /// not-yet-reachable data-loss shape rather than a currently-observed bug.
1697    #[tokio::test]
1698    async fn test_evict_lru_skips_document_with_diverged_unsaved_content() {
1699        let dir = TempDir::new().unwrap();
1700        let path_a = dir.path().join("a.rs");
1701        std::fs::write(&path_a, "AAAA").unwrap();
1702        set_mtime(&path_a, settled_past());
1703
1704        let limits = ResourceLimits {
1705            max_documents: 1,
1706            max_file_size: 0,
1707        };
1708        let (client, _server) = fake_lsp_client();
1709        let tracker = DocumentTracker::new(limits, HashMap::new());
1710        let server_id = ServerId::from("rust");
1711
1712        tracker
1713            .ensure_open(&path_a, &server_id, &client)
1714            .await
1715            .unwrap();
1716        // Diverge from disk: an in-memory edit not yet reflected on disk.
1717        tracker
1718            .update(&path_a, "AAAA-edited".to_string())
1719            .await
1720            .unwrap();
1721
1722        let path_b = dir.path().join("b.rs");
1723        std::fs::write(&path_b, "BBBB").unwrap();
1724
1725        let result = tracker.open(path_b, "BBBB".to_string());
1726        assert!(matches!(result, Err(Error::DocumentLimitExceeded { .. })));
1727        assert!(
1728            tracker.is_open(&path_a),
1729            "the diverged, not-disk-verified document must not be evicted"
1730        );
1731        assert_eq!(tracker.get(&path_a).unwrap().content(), "AAAA-edited");
1732        assert!(tracker.take_evicted().is_empty());
1733    }
1734
1735    /// #495: `ensure_open` must bump a document's LRU recency (via
1736    /// `disk_phase`'s `touch`), so a document that was merely opened first
1737    /// but has since been re-accessed is not the one evicted -- eviction
1738    /// order must reflect actual usage, not just insertion order.
1739    #[tokio::test]
1740    async fn test_ensure_open_touch_changes_lru_eviction_order() {
1741        let dir = TempDir::new().unwrap();
1742        let path_a = dir.path().join("a.rs");
1743        let path_b = dir.path().join("b.rs");
1744        std::fs::write(&path_a, "AAAA").unwrap();
1745        std::fs::write(&path_b, "BBBB").unwrap();
1746        set_mtime(&path_a, settled_past());
1747        set_mtime(&path_b, settled_past());
1748
1749        let limits = ResourceLimits {
1750            max_documents: 2,
1751            max_file_size: 0,
1752        };
1753        let (client, _server) = fake_lsp_client();
1754        let tracker = DocumentTracker::new(limits, HashMap::new());
1755        let server_id = ServerId::from("rust");
1756
1757        tracker
1758            .ensure_open(&path_a, &server_id, &client)
1759            .await
1760            .unwrap();
1761        tracker
1762            .ensure_open(&path_b, &server_id, &client)
1763            .await
1764            .unwrap();
1765
1766        // Re-access `a` so it becomes the more-recently-used of the two,
1767        // leaving `b` as the LRU entry despite having been opened second.
1768        tracker
1769            .ensure_open(&path_a, &server_id, &client)
1770            .await
1771            .unwrap();
1772
1773        let path_c = dir.path().join("c.rs");
1774        std::fs::write(&path_c, "CCCC").unwrap();
1775        set_mtime(&path_c, settled_past());
1776        tracker
1777            .ensure_open(&path_c, &server_id, &client)
1778            .await
1779            .unwrap();
1780
1781        assert!(
1782            tracker.is_open(&path_a),
1783            "recently re-accessed, must survive"
1784        );
1785        assert!(
1786            !tracker.is_open(&path_b),
1787            "least-recently-used, must be evicted"
1788        );
1789        assert!(tracker.is_open(&path_c));
1790
1791        let evicted = tracker.take_evicted();
1792        assert_eq!(evicted.len(), 1);
1793        assert_eq!(evicted[0].path, path_b);
1794        assert_eq!(evicted[0].synced_servers, vec![server_id]);
1795    }
1796
1797    #[test]
1798    fn test_file_size_limit() {
1799        let limits = ResourceLimits {
1800            max_documents: 10,
1801            max_file_size: 10,
1802        };
1803        let mut map = HashMap::new();
1804        map.insert("rs".to_string(), "rust".to_string());
1805
1806        let tracker = DocumentTracker::new(limits, map);
1807
1808        // Small file should succeed
1809        tracker
1810            .open(PathBuf::from("/test/small.rs"), "fn f(){}".to_string())
1811            .unwrap();
1812
1813        // Large file should fail
1814        let large_content = "x".repeat(100);
1815        let result = tracker.open(PathBuf::from("/test/large.rs"), large_content);
1816        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
1817    }
1818
1819    #[test]
1820    fn test_resource_limits_default() {
1821        let limits = ResourceLimits::default();
1822        assert_eq!(limits.max_documents, 100);
1823        assert_eq!(limits.max_file_size, 10 * 1024 * 1024);
1824    }
1825
1826    #[test]
1827    fn test_resource_limits_custom() {
1828        let limits = ResourceLimits {
1829            max_documents: 50,
1830            max_file_size: 5 * 1024 * 1024,
1831        };
1832        assert_eq!(limits.max_documents, 50);
1833        assert_eq!(limits.max_file_size, 5 * 1024 * 1024);
1834    }
1835
1836    #[test]
1837    fn test_resource_limits_zero_unlimited() {
1838        let limits = ResourceLimits {
1839            max_documents: 0,
1840            max_file_size: 0,
1841        };
1842        let mut map = HashMap::new();
1843        map.insert("rs".to_string(), "rust".to_string());
1844
1845        let tracker = DocumentTracker::new(limits, map);
1846
1847        // Should allow many documents when limit is 0
1848        for i in 0..200 {
1849            tracker
1850                .open(
1851                    PathBuf::from(format!("/test/file{i}.rs")),
1852                    "content".to_string(),
1853                )
1854                .unwrap();
1855        }
1856        assert_eq!(tracker.len(), 200);
1857
1858        // Should allow large files when limit is 0
1859        let huge_content = "x".repeat(100_000_000);
1860        tracker
1861            .open(PathBuf::from("/test/huge.rs"), huge_content)
1862            .unwrap();
1863    }
1864
1865    #[test]
1866    fn test_document_state_clone() {
1867        let state = DocumentState {
1868            uri: Uri::from("file:///test.rs"),
1869            language_id: "rust".to_string(),
1870            version: 5,
1871            content: "fn main() {}".to_string(),
1872            disk: None,
1873            synced: HashMap::new(),
1874            last_accessed: Instant::now(),
1875        };
1876
1877        #[allow(clippy::redundant_clone)]
1878        let cloned = state.clone();
1879        assert_eq!(cloned.uri(), state.uri());
1880        assert_eq!(cloned.language_id(), state.language_id());
1881        assert_eq!(cloned.version(), 5);
1882        assert_eq!(cloned.content(), state.content());
1883    }
1884
1885    #[tokio::test]
1886    async fn test_update_nonexistent_document() {
1887        let map = HashMap::new();
1888        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1889        let path = PathBuf::from("/test/nonexistent.rs");
1890
1891        let version = tracker.update(&path, "new content".to_string()).await;
1892        assert_eq!(
1893            version, None,
1894            "Updating non-existent document should return None"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_close_nonexistent_document() {
1900        let map = HashMap::new();
1901        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1902        let path = PathBuf::from("/test/nonexistent.rs");
1903
1904        let state = tracker.close(&path);
1905        assert_eq!(
1906            state, None,
1907            "Closing non-existent document should return None"
1908        );
1909    }
1910
1911    #[test]
1912    fn test_close_all_documents() {
1913        let mut map = HashMap::new();
1914        map.insert("rs".to_string(), "rust".to_string());
1915
1916        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1917
1918        tracker
1919            .open(PathBuf::from("/test/file1.rs"), "content1".to_string())
1920            .unwrap();
1921        tracker
1922            .open(PathBuf::from("/test/file2.rs"), "content2".to_string())
1923            .unwrap();
1924        tracker
1925            .open(PathBuf::from("/test/file3.rs"), "content3".to_string())
1926            .unwrap();
1927
1928        assert_eq!(tracker.len(), 3);
1929
1930        let closed = tracker.close_all();
1931        assert_eq!(closed.len(), 3);
1932        assert!(tracker.is_empty());
1933    }
1934
1935    #[test]
1936    fn test_get_nonexistent_document() {
1937        let map = HashMap::new();
1938        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1939        let path = PathBuf::from("/test/nonexistent.rs");
1940
1941        let state = tracker.get(&path);
1942        assert!(
1943            state.is_none(),
1944            "Getting non-existent document should return None"
1945        );
1946    }
1947
1948    #[tokio::test]
1949    async fn test_document_version_increments() {
1950        let mut map = HashMap::new();
1951        map.insert("rs".to_string(), "rust".to_string());
1952
1953        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
1954        let path = PathBuf::from("/test/versioned.rs");
1955
1956        tracker.open(path.clone(), "v1".to_string()).unwrap();
1957        assert_eq!(tracker.get(&path).unwrap().version(), 1);
1958
1959        tracker.update(&path, "v2".to_string()).await;
1960        assert_eq!(tracker.get(&path).unwrap().version(), 2);
1961
1962        tracker.update(&path, "v3".to_string()).await;
1963        assert_eq!(tracker.get(&path).unwrap().version(), 3);
1964
1965        tracker.update(&path, "v4".to_string()).await;
1966        assert_eq!(tracker.get(&path).unwrap().version(), 4);
1967    }
1968
1969    #[test]
1970    #[allow(clippy::too_many_lines)]
1971    fn test_detect_language_all_extensions() {
1972        let mut map = HashMap::new();
1973        map.insert("rs".to_string(), "rust".to_string());
1974        map.insert("py".to_string(), "python".to_string());
1975        map.insert("pyw".to_string(), "python".to_string());
1976        map.insert("pyi".to_string(), "python".to_string());
1977        map.insert("js".to_string(), "javascript".to_string());
1978        map.insert("mjs".to_string(), "javascript".to_string());
1979        map.insert("cjs".to_string(), "javascript".to_string());
1980        map.insert("ts".to_string(), "typescript".to_string());
1981        map.insert("mts".to_string(), "typescript".to_string());
1982        map.insert("cts".to_string(), "typescript".to_string());
1983        map.insert("tsx".to_string(), "typescriptreact".to_string());
1984        map.insert("jsx".to_string(), "javascriptreact".to_string());
1985        map.insert("go".to_string(), "go".to_string());
1986        map.insert("c".to_string(), "c".to_string());
1987        map.insert("h".to_string(), "c".to_string());
1988        map.insert("cpp".to_string(), "cpp".to_string());
1989        map.insert("cc".to_string(), "cpp".to_string());
1990        map.insert("cxx".to_string(), "cpp".to_string());
1991        map.insert("hpp".to_string(), "cpp".to_string());
1992        map.insert("hh".to_string(), "cpp".to_string());
1993        map.insert("hxx".to_string(), "cpp".to_string());
1994        map.insert("java".to_string(), "java".to_string());
1995        map.insert("rb".to_string(), "ruby".to_string());
1996        map.insert("php".to_string(), "php".to_string());
1997        map.insert("swift".to_string(), "swift".to_string());
1998        map.insert("kt".to_string(), "kotlin".to_string());
1999        map.insert("kts".to_string(), "kotlin".to_string());
2000        map.insert("scala".to_string(), "scala".to_string());
2001        map.insert("sc".to_string(), "scala".to_string());
2002        map.insert("zig".to_string(), "zig".to_string());
2003        map.insert("lua".to_string(), "lua".to_string());
2004        map.insert("sh".to_string(), "shellscript".to_string());
2005        map.insert("bash".to_string(), "shellscript".to_string());
2006        map.insert("zsh".to_string(), "shellscript".to_string());
2007        map.insert("json".to_string(), "json".to_string());
2008        map.insert("toml".to_string(), "toml".to_string());
2009        map.insert("yaml".to_string(), "yaml".to_string());
2010        map.insert("yml".to_string(), "yaml".to_string());
2011        map.insert("xml".to_string(), "xml".to_string());
2012        map.insert("html".to_string(), "html".to_string());
2013        map.insert("htm".to_string(), "html".to_string());
2014        map.insert("css".to_string(), "css".to_string());
2015        map.insert("scss".to_string(), "scss".to_string());
2016        map.insert("less".to_string(), "less".to_string());
2017        map.insert("md".to_string(), "markdown".to_string());
2018        map.insert("markdown".to_string(), "markdown".to_string());
2019
2020        assert_eq!(detect_language(Path::new("main.rs"), &map), "rust");
2021        assert_eq!(detect_language(Path::new("script.py"), &map), "python");
2022        assert_eq!(detect_language(Path::new("script.pyw"), &map), "python");
2023        assert_eq!(detect_language(Path::new("script.pyi"), &map), "python");
2024        assert_eq!(detect_language(Path::new("app.js"), &map), "javascript");
2025        assert_eq!(detect_language(Path::new("app.mjs"), &map), "javascript");
2026        assert_eq!(detect_language(Path::new("app.cjs"), &map), "javascript");
2027        assert_eq!(detect_language(Path::new("app.ts"), &map), "typescript");
2028        assert_eq!(detect_language(Path::new("app.mts"), &map), "typescript");
2029        assert_eq!(detect_language(Path::new("app.cts"), &map), "typescript");
2030        assert_eq!(
2031            detect_language(Path::new("component.tsx"), &map),
2032            "typescriptreact"
2033        );
2034        assert_eq!(
2035            detect_language(Path::new("component.jsx"), &map),
2036            "javascriptreact"
2037        );
2038        assert_eq!(detect_language(Path::new("main.go"), &map), "go");
2039        assert_eq!(detect_language(Path::new("main.c"), &map), "c");
2040        assert_eq!(detect_language(Path::new("header.h"), &map), "c");
2041        assert_eq!(detect_language(Path::new("main.cpp"), &map), "cpp");
2042        assert_eq!(detect_language(Path::new("main.cc"), &map), "cpp");
2043        assert_eq!(detect_language(Path::new("main.cxx"), &map), "cpp");
2044        assert_eq!(detect_language(Path::new("header.hpp"), &map), "cpp");
2045        assert_eq!(detect_language(Path::new("header.hh"), &map), "cpp");
2046        assert_eq!(detect_language(Path::new("header.hxx"), &map), "cpp");
2047        assert_eq!(detect_language(Path::new("Main.java"), &map), "java");
2048        assert_eq!(detect_language(Path::new("script.rb"), &map), "ruby");
2049        assert_eq!(detect_language(Path::new("index.php"), &map), "php");
2050        assert_eq!(detect_language(Path::new("App.swift"), &map), "swift");
2051        assert_eq!(detect_language(Path::new("Main.kt"), &map), "kotlin");
2052        assert_eq!(detect_language(Path::new("script.kts"), &map), "kotlin");
2053        assert_eq!(detect_language(Path::new("Main.scala"), &map), "scala");
2054        assert_eq!(detect_language(Path::new("script.sc"), &map), "scala");
2055        assert_eq!(detect_language(Path::new("main.zig"), &map), "zig");
2056        assert_eq!(detect_language(Path::new("script.lua"), &map), "lua");
2057        assert_eq!(detect_language(Path::new("script.sh"), &map), "shellscript");
2058        assert_eq!(
2059            detect_language(Path::new("script.bash"), &map),
2060            "shellscript"
2061        );
2062        assert_eq!(
2063            detect_language(Path::new("script.zsh"), &map),
2064            "shellscript"
2065        );
2066        assert_eq!(detect_language(Path::new("data.json"), &map), "json");
2067        assert_eq!(detect_language(Path::new("config.toml"), &map), "toml");
2068        assert_eq!(detect_language(Path::new("config.yaml"), &map), "yaml");
2069        assert_eq!(detect_language(Path::new("config.yml"), &map), "yaml");
2070        assert_eq!(detect_language(Path::new("data.xml"), &map), "xml");
2071        assert_eq!(detect_language(Path::new("index.html"), &map), "html");
2072        assert_eq!(detect_language(Path::new("index.htm"), &map), "html");
2073        assert_eq!(detect_language(Path::new("styles.css"), &map), "css");
2074        assert_eq!(detect_language(Path::new("styles.scss"), &map), "scss");
2075        assert_eq!(detect_language(Path::new("styles.less"), &map), "less");
2076        assert_eq!(detect_language(Path::new("README.md"), &map), "markdown");
2077        assert_eq!(
2078            detect_language(Path::new("README.markdown"), &map),
2079            "markdown"
2080        );
2081        assert_eq!(detect_language(Path::new("unknown.xyz"), &map), "plaintext");
2082        assert_eq!(
2083            detect_language(Path::new("no_extension"), &map),
2084            "plaintext"
2085        );
2086    }
2087
2088    #[test]
2089    fn test_path_to_uri_unix() {
2090        #[cfg(not(windows))]
2091        {
2092            let path = Path::new("/home/user/project/main.rs");
2093            let uri = path_to_uri(path).unwrap();
2094            assert!(
2095                uri.as_ref()
2096                    .starts_with("file:///home/user/project/main.rs")
2097            );
2098        }
2099    }
2100
2101    #[test]
2102    fn test_path_to_uri_with_special_chars() {
2103        let path = Path::new("/home/user/project-test/main.rs");
2104        let uri = path_to_uri(path).unwrap();
2105        assert!(uri.as_ref().starts_with("file://"));
2106        assert!(uri.as_ref().contains("project-test"));
2107    }
2108
2109    #[test]
2110    fn test_path_to_uri_percent_encodes_reserved_chars() {
2111        #[cfg(windows)]
2112        let path = Path::new(r"C:\home\user\routes\api\[...]^|.ts");
2113        #[cfg(not(windows))]
2114        let path = Path::new("/home/user/routes/api/[...]^|.ts");
2115
2116        let uri = path_to_uri(path).unwrap();
2117
2118        #[cfg(windows)]
2119        let expected = "file:///C:/home/user/routes/api/%5B...%5D%5E%7C.ts";
2120        #[cfg(not(windows))]
2121        let expected = "file:///home/user/routes/api/%5B...%5D%5E%7C.ts";
2122
2123        assert_eq!(uri.as_ref(), expected);
2124        assert_eq!(
2125            uri_to_path(&uri).as_deref(),
2126            Some(path),
2127            "encoded file URI should round-trip to the original path"
2128        );
2129    }
2130
2131    #[test]
2132    fn test_try_path_to_uri_returns_none_for_relative_path() {
2133        assert_eq!(try_path_to_uri(Path::new("relative/file.ts")), None);
2134    }
2135
2136    /// #234 regression: `path_to_uri` must surface a conversion failure as
2137    /// `Err`, not panic -- the whole point of the fix was making this path
2138    /// testable instead of aborting the process.
2139    #[test]
2140    fn test_path_to_uri_returns_err_for_relative_path() {
2141        let err = path_to_uri(Path::new("relative/file.ts")).unwrap_err();
2142        assert!(matches!(err, Error::InvalidUri(_)));
2143    }
2144
2145    #[cfg(windows)]
2146    #[test]
2147    fn test_try_path_to_uri_encodes_synthetic_windows_root() {
2148        let uri = try_path_to_uri(Path::new("/home/user/#work %23")).unwrap();
2149
2150        assert_eq!(uri.as_ref(), "file:///home/user/%23work%20%2523");
2151    }
2152
2153    /// A rooted-but-not-absolute Windows path (`\foo`, no drive/UNC prefix)
2154    /// satisfies `Path::has_root()` but not `Path::is_absolute()`.
2155    /// `file_url`'s `#[cfg(windows)]` variant deliberately falls back to
2156    /// `windows_rooted_path_to_file_url` on this exact case -- pinned here so
2157    /// a future change to `try_path_to_uri` (e.g. swapping the fallible
2158    /// `.parse()` this migration replaced for an `is_absolute()` guard)
2159    /// cannot silently narrow this without failing a test.
2160    #[cfg(windows)]
2161    #[test]
2162    fn test_try_path_to_uri_accepts_rooted_but_not_absolute_windows_path() {
2163        let path = Path::new(r"\foo");
2164        assert!(path.has_root());
2165        assert!(!path.is_absolute());
2166
2167        let uri = try_path_to_uri(path).unwrap();
2168
2169        assert_eq!(uri.as_ref(), "file:///foo");
2170    }
2171
2172    #[test]
2173    fn test_path_to_uri_percent_encodes_reserved_chars_in_short_path() {
2174        // Regression: reserved chars near the URI start must still be encoded.
2175        #[cfg(windows)]
2176        let path = Path::new(r"C:\[a].ts");
2177        #[cfg(not(windows))]
2178        let path = Path::new("/[a].ts");
2179
2180        let uri = path_to_uri(path).unwrap();
2181
2182        assert!(
2183            uri.as_ref().ends_with("%5Ba%5D.ts"),
2184            "short path should percent-encode reserved chars, got {}",
2185            uri.as_ref()
2186        );
2187        assert_eq!(uri_to_path(&uri).as_deref(), Some(path));
2188    }
2189
2190    #[test]
2191    fn test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars() {
2192        // RFC 3986 §2.2 "other reserved" characters. The `url` crate already
2193        // percent-encodes `{`, `}`, and backtick when serializing; `[`, `]`,
2194        // `^`, `|` are handled explicitly by `encode_rfc3986_path_chars`.
2195        #[cfg(windows)]
2196        let path = Path::new(r"C:\home\user\test[]^|{}`.ts");
2197        #[cfg(not(windows))]
2198        let path = Path::new("/home/user/test[]^|{}`.ts");
2199
2200        let uri = try_path_to_uri(path).unwrap();
2201        let uri_str = uri.as_ref();
2202
2203        for (raw, encoded) in [
2204            ('[', "%5B"),
2205            (']', "%5D"),
2206            ('^', "%5E"),
2207            ('|', "%7C"),
2208            ('{', "%7B"),
2209            ('}', "%7D"),
2210            ('`', "%60"),
2211        ] {
2212            assert!(
2213                uri_str.contains(encoded),
2214                "expected {raw:?} to be percent-encoded as {encoded} in {uri_str}"
2215            );
2216        }
2217        assert!(
2218            !uri_str.contains(['[', ']', '^', '|', '{', '}', '`']),
2219            "no raw reserved characters should remain in {uri_str}"
2220        );
2221    }
2222
2223    #[tokio::test]
2224    async fn test_document_tracker_concurrent_operations() {
2225        let mut map = HashMap::new();
2226        map.insert("rs".to_string(), "rust".to_string());
2227
2228        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2229        let path1 = PathBuf::from("/test/file1.rs");
2230        let path2 = PathBuf::from("/test/file2.rs");
2231
2232        tracker.open(path1.clone(), "content1".to_string()).unwrap();
2233        tracker.open(path2.clone(), "content2".to_string()).unwrap();
2234
2235        assert_eq!(tracker.len(), 2);
2236        assert!(tracker.is_open(&path1));
2237        assert!(tracker.is_open(&path2));
2238
2239        tracker.update(&path1, "new content1".to_string()).await;
2240        assert_eq!(tracker.get(&path1).unwrap().content(), "new content1");
2241        assert_eq!(tracker.get(&path2).unwrap().content(), "content2");
2242
2243        tracker.close(&path1);
2244        assert_eq!(tracker.len(), 1);
2245        assert!(!tracker.is_open(&path1));
2246        assert!(tracker.is_open(&path2));
2247    }
2248
2249    #[test]
2250    fn test_empty_content() {
2251        let mut map = HashMap::new();
2252        map.insert("rs".to_string(), "rust".to_string());
2253
2254        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2255        let path = PathBuf::from("/test/empty.rs");
2256
2257        tracker.open(path.clone(), String::new()).unwrap();
2258        assert!(tracker.is_open(&path));
2259        assert_eq!(tracker.get(&path).unwrap().content(), "");
2260    }
2261
2262    #[test]
2263    fn test_unicode_content() {
2264        let mut map = HashMap::new();
2265        map.insert("rs".to_string(), "rust".to_string());
2266
2267        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2268        let path = PathBuf::from("/test/unicode.rs");
2269        let content = "fn テスト() { println!(\"こんにちは\"); }";
2270
2271        tracker.open(path.clone(), content.to_string()).unwrap();
2272        assert_eq!(tracker.get(&path).unwrap().content(), content);
2273    }
2274
2275    /// #495: at exactly `max_documents`, `open` must evict the LRU entry
2276    /// (here `file0`, the first opened) rather than fail, since none of the
2277    /// existing documents are locked and all are disk-verified.
2278    #[test]
2279    fn test_document_limit_exact_boundary() {
2280        let limits = ResourceLimits {
2281            max_documents: 5,
2282            max_file_size: 1000,
2283        };
2284        let mut map = HashMap::new();
2285        map.insert("rs".to_string(), "rust".to_string());
2286
2287        let tracker = DocumentTracker::new(limits, map);
2288
2289        for i in 0..5 {
2290            let path = PathBuf::from(format!("/test/file{i}.rs"));
2291            tracker.open(path.clone(), "content".to_string()).unwrap();
2292            mark_disk_verified(&tracker, &path);
2293        }
2294
2295        assert_eq!(tracker.len(), 5);
2296
2297        tracker
2298            .open(PathBuf::from("/test/file6.rs"), "content".to_string())
2299            .unwrap();
2300
2301        assert_eq!(tracker.len(), 5);
2302        assert!(!tracker.is_open(Path::new("/test/file0.rs")));
2303        assert!(tracker.is_open(Path::new("/test/file6.rs")));
2304    }
2305
2306    #[test]
2307    fn test_file_size_exact_boundary() {
2308        let limits = ResourceLimits {
2309            max_documents: 10,
2310            max_file_size: 100,
2311        };
2312        let mut map = HashMap::new();
2313        map.insert("rs".to_string(), "rust".to_string());
2314
2315        let tracker = DocumentTracker::new(limits, map);
2316
2317        let exact_size_content = "x".repeat(100);
2318        tracker
2319            .open(PathBuf::from("/test/exact.rs"), exact_size_content)
2320            .unwrap();
2321
2322        let over_size_content = "x".repeat(101);
2323        let result = tracker.open(PathBuf::from("/test/over.rs"), over_size_content);
2324        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
2325    }
2326
2327    #[test]
2328    fn test_detect_language_with_custom_extension() {
2329        let mut map = HashMap::new();
2330        map.insert("nu".to_string(), "nushell".to_string());
2331
2332        assert_eq!(detect_language(Path::new("script.nu"), &map), "nushell");
2333
2334        let empty_map = HashMap::new();
2335        assert_eq!(
2336            detect_language(Path::new("script.nu"), &empty_map),
2337            "plaintext"
2338        );
2339    }
2340
2341    #[test]
2342    fn test_detect_language_custom_overrides_default() {
2343        let mut custom_map = HashMap::new();
2344        custom_map.insert("rs".to_string(), "custom-rust".to_string());
2345
2346        assert_eq!(
2347            detect_language(Path::new("main.rs"), &custom_map),
2348            "custom-rust"
2349        );
2350
2351        let mut default_map = HashMap::new();
2352        default_map.insert("rs".to_string(), "rust".to_string());
2353
2354        assert_eq!(detect_language(Path::new("main.rs"), &default_map), "rust");
2355    }
2356
2357    #[test]
2358    fn test_detect_language_fallback_to_plaintext() {
2359        let mut map = HashMap::new();
2360        map.insert("nu".to_string(), "nushell".to_string());
2361
2362        // .rs not in custom map, should return plaintext
2363        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
2364    }
2365
2366    #[test]
2367    fn test_detect_language_empty_map() {
2368        let map = HashMap::new();
2369        assert_eq!(detect_language(Path::new("main.rs"), &map), "plaintext");
2370    }
2371
2372    #[test]
2373    fn test_document_tracker_with_extensions() {
2374        let mut map = HashMap::new();
2375        map.insert("nu".to_string(), "nushell".to_string());
2376
2377        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2378
2379        let path = PathBuf::from("/test/script.nu");
2380        tracker
2381            .open(path.clone(), "# nushell script".to_string())
2382            .unwrap();
2383
2384        let state = tracker.get(&path).unwrap();
2385        assert_eq!(state.language_id(), "nushell");
2386    }
2387
2388    #[test]
2389    fn test_document_tracker_uses_provided_map() {
2390        let mut map = HashMap::new();
2391        map.insert("rs".to_string(), "rust".to_string());
2392
2393        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2394        let path = PathBuf::from("/test/main.rs");
2395        tracker
2396            .open(path.clone(), "fn main() {}".to_string())
2397            .unwrap();
2398
2399        let state = tracker.get(&path).unwrap();
2400        assert_eq!(state.language_id(), "rust");
2401    }
2402
2403    #[test]
2404    fn test_multiple_extensions_same_language() {
2405        let mut map = HashMap::new();
2406        map.insert("cpp".to_string(), "c++".to_string());
2407        map.insert("cc".to_string(), "c++".to_string());
2408        map.insert("cxx".to_string(), "c++".to_string());
2409
2410        assert_eq!(detect_language(Path::new("main.cpp"), &map), "c++");
2411        assert_eq!(detect_language(Path::new("main.cc"), &map), "c++");
2412        assert_eq!(detect_language(Path::new("main.cxx"), &map), "c++");
2413    }
2414
2415    #[test]
2416    fn test_case_sensitive_extensions() {
2417        let mut map = HashMap::new();
2418        map.insert("NU".to_string(), "nushell".to_string());
2419
2420        // Lowercase .nu should not match uppercase "NU" in map
2421        assert_eq!(detect_language(Path::new("script.nu"), &map), "plaintext");
2422    }
2423
2424    // ------------------------------------------------------------------
2425    // uri_to_path
2426    // ------------------------------------------------------------------
2427
2428    #[cfg(unix)]
2429    #[test]
2430    fn test_uri_to_path_file_scheme() {
2431        let uri: Uri = Uri::from("file:///home/user/main.rs");
2432        let path = uri_to_path(&uri).unwrap();
2433        assert_eq!(path, PathBuf::from("/home/user/main.rs"));
2434    }
2435
2436    #[test]
2437    fn test_uri_to_path_non_file_scheme_returns_none() {
2438        let uri: Uri = Uri::from("https://example.com/file.rs");
2439        assert!(uri_to_path(&uri).is_none());
2440    }
2441
2442    #[test]
2443    fn test_uri_to_path_lsp_diagnostics_scheme_returns_none() {
2444        // Custom scheme must not be decoded by uri_to_path.
2445        let uri: Uri = Uri::from("lsp-diagnostics:///home/user/main.rs");
2446        assert!(uri_to_path(&uri).is_none());
2447    }
2448
2449    #[test]
2450    fn test_uri_to_path_with_authority_returns_none() {
2451        // Authority-bearing file URIs must be rejected (UNC path defence).
2452        // lsp_types::Uri may or may not accept this string; either way
2453        // uri_to_path should return None.
2454        let result = uri_to_path(&Uri::from("file://server/share/path.rs"));
2455        assert!(result.is_none());
2456    }
2457
2458    // ------------------------------------------------------------------
2459    // open_paths
2460    // ------------------------------------------------------------------
2461
2462    #[test]
2463    fn test_open_paths_empty_tracker() {
2464        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2465        assert_eq!(tracker.open_paths().len(), 0);
2466    }
2467
2468    #[test]
2469    fn test_open_paths_populated_tracker() {
2470        let mut map = HashMap::new();
2471        map.insert("rs".to_string(), "rust".to_string());
2472        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2473        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
2474        tracker.open(PathBuf::from("/b.rs"), String::new()).unwrap();
2475        let mut paths = tracker.open_paths();
2476        paths.sort();
2477        assert_eq!(paths, [PathBuf::from("/a.rs"), PathBuf::from("/b.rs")]);
2478    }
2479
2480    #[test]
2481    fn test_open_paths_after_close() {
2482        let mut map = HashMap::new();
2483        map.insert("rs".to_string(), "rust".to_string());
2484        let tracker = DocumentTracker::new(ResourceLimits::default(), map);
2485        tracker.open(PathBuf::from("/a.rs"), String::new()).unwrap();
2486        tracker.close(Path::new("/a.rs"));
2487        assert_eq!(tracker.open_paths().len(), 0);
2488    }
2489
2490    // ------------------------------------------------------------------
2491    // ensure_open resync (issue #102)
2492    // ------------------------------------------------------------------
2493
2494    use tempfile::TempDir;
2495    use tokio::io::BufReader;
2496
2497    use crate::test_lsp::{fake_lsp_client, read_framed_message};
2498
2499    /// Backdates or forwards a file's mtime for deterministic disk-sync tests.
2500    ///
2501    /// Opened with `write(true)` rather than [`std::fs::File::open`]: on
2502    /// Windows, `set_modified` needs a handle with write access, and a
2503    /// read-only handle fails with `PermissionDenied` (Unix's
2504    /// `utimensat`-based implementation has no such requirement, which is
2505    /// why a read-only handle works there).
2506    fn set_mtime(path: &Path, time: SystemTime) {
2507        let file = std::fs::OpenOptions::new().write(true).open(path).unwrap();
2508        file.set_modified(time).unwrap();
2509    }
2510
2511    fn settled_past() -> SystemTime {
2512        SystemTime::now() - Duration::from_secs(10)
2513    }
2514
2515    #[test]
2516    fn test_mtime_settled_boundary() {
2517        let read_at = SystemTime::now();
2518        assert!(!mtime_settled(None, read_at), "no mtime is never settled");
2519        assert!(
2520            mtime_settled(Some(read_at - Duration::from_secs(3)), read_at),
2521            "3s older than read_at is past the 2s granularity margin"
2522        );
2523        assert!(
2524            !mtime_settled(Some(read_at - Duration::from_secs(1)), read_at),
2525            "1s older than read_at is within the 2s granularity margin"
2526        );
2527        assert!(
2528            !mtime_settled(Some(read_at + Duration::from_secs(10)), read_at),
2529            "an mtime after read_at is never settled"
2530        );
2531    }
2532
2533    #[tokio::test]
2534    async fn test_ensure_open_unchanged_file_is_fast_path() {
2535        let dir = TempDir::new().unwrap();
2536        let path = dir.path().join("a.rs");
2537        std::fs::write(&path, "fn main() {}").unwrap();
2538        set_mtime(&path, settled_past());
2539
2540        let (client, _server) = fake_lsp_client();
2541        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2542
2543        let uri1 = tracker
2544            .ensure_open(&path, &ServerId::from("rust"), &client)
2545            .await
2546            .unwrap();
2547        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2548
2549        let uri2 = tracker
2550            .ensure_open(&path, &ServerId::from("rust"), &client)
2551            .await
2552            .unwrap();
2553        assert_eq!(uri1, uri2);
2554        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2555        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
2556    }
2557
2558    #[tokio::test]
2559    async fn test_ensure_open_resyncs_on_size_change() {
2560        let dir = TempDir::new().unwrap();
2561        let path = dir.path().join("a.rs");
2562        std::fs::write(&path, "fn main() {}").unwrap();
2563        set_mtime(&path, settled_past());
2564
2565        let (client, _server) = fake_lsp_client();
2566        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2567        tracker
2568            .ensure_open(&path, &ServerId::from("rust"), &client)
2569            .await
2570            .unwrap();
2571
2572        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
2573        set_mtime(&path, settled_past());
2574
2575        tracker
2576            .ensure_open(&path, &ServerId::from("rust"), &client)
2577            .await
2578            .unwrap();
2579        let state = tracker.get(&path).unwrap();
2580        assert_eq!(state.version(), 2);
2581        assert_eq!(state.content(), "fn main() { println!(\"hi\"); }");
2582    }
2583
2584    #[tokio::test(start_paused = true)]
2585    async fn test_ensure_open_regression_102_103_racy_same_size_rewrite() {
2586        let dir = TempDir::new().unwrap();
2587        let path = dir.path().join("a.rs");
2588        std::fs::write(&path, "AAAA").unwrap();
2589        // Leave the mtime at "now" (racy) rather than backdating it.
2590
2591        let (client, _server) = fake_lsp_client();
2592        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2593        tracker
2594            .ensure_open(&path, &ServerId::from("rust"), &client)
2595            .await
2596            .unwrap();
2597        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2598
2599        // Same-length rewrite with the mtime forced back to the recorded
2600        // value -- exactly the same-tick rewrite issue #102/#103 missed.
2601        std::fs::write(&path, "BBBB").unwrap();
2602        set_mtime(&path, original_mtime);
2603
2604        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2605
2606        tracker
2607            .ensure_open(&path, &ServerId::from("rust"), &client)
2608            .await
2609            .unwrap();
2610        let state = tracker.get(&path).unwrap();
2611        assert_eq!(
2612            state.version(),
2613            2,
2614            "must resync despite identical (mtime, size)"
2615        );
2616        assert_eq!(state.content(), "BBBB");
2617    }
2618
2619    #[tokio::test(start_paused = true)]
2620    async fn test_ensure_open_regression_102_103_settled_mtime_is_the_documented_limit() {
2621        let dir = TempDir::new().unwrap();
2622        let path = dir.path().join("a.rs");
2623        std::fs::write(&path, "AAAA").unwrap();
2624        set_mtime(&path, settled_past());
2625
2626        let (client, _server) = fake_lsp_client();
2627        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2628        tracker
2629            .ensure_open(&path, &ServerId::from("rust"), &client)
2630            .await
2631            .unwrap();
2632        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2633
2634        // Same-length rewrite restoring an already-settled mtime: this is
2635        // the documented residual limitation (e.g. `tar x`, `rsync -a`),
2636        // not a bug -- it is out of reach without hashing on every access.
2637        std::fs::write(&path, "BBBB").unwrap();
2638        set_mtime(&path, original_mtime);
2639
2640        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2641
2642        tracker
2643            .ensure_open(&path, &ServerId::from("rust"), &client)
2644            .await
2645            .unwrap();
2646        let state = tracker.get(&path).unwrap();
2647        assert_eq!(state.version(), 1, "documented limitation: fast path taken");
2648        assert_eq!(state.content(), "AAAA");
2649    }
2650
2651    #[tokio::test(start_paused = true)]
2652    async fn test_ensure_open_stat_is_never_debounced() {
2653        let dir = TempDir::new().unwrap();
2654        let path = dir.path().join("a.rs");
2655        std::fs::write(&path, "AAAA").unwrap();
2656        set_mtime(&path, settled_past());
2657
2658        let (client, _server) = fake_lsp_client();
2659        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2660        tracker
2661            .ensure_open(&path, &ServerId::from("rust"), &client)
2662            .await
2663            .unwrap();
2664
2665        // Different-size rewrite with no time advance at all: must resync
2666        // immediately, proving the debounce never gates the stat itself.
2667        std::fs::write(&path, "BBBBBBBB").unwrap();
2668        tracker
2669            .ensure_open(&path, &ServerId::from("rust"), &client)
2670            .await
2671            .unwrap();
2672
2673        let state = tracker.get(&path).unwrap();
2674        assert_eq!(state.version(), 2);
2675        assert_eq!(state.content(), "BBBBBBBB");
2676    }
2677
2678    #[tokio::test(start_paused = true)]
2679    async fn test_ensure_open_debounce_gates_reread_only() {
2680        let dir = TempDir::new().unwrap();
2681        let path = dir.path().join("a.rs");
2682        std::fs::write(&path, "AAAA").unwrap();
2683        // Racy: leave the mtime at "now".
2684
2685        let (client, _server) = fake_lsp_client();
2686        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2687        tracker
2688            .ensure_open(&path, &ServerId::from("rust"), &client)
2689            .await
2690            .unwrap();
2691        let original_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
2692
2693        std::fs::write(&path, "BBBB").unwrap(); // same size
2694        set_mtime(&path, original_mtime); // stat matches, entry stays racy
2695
2696        // Inside the debounce window: the re-read is gated, cache wins.
2697        tracker
2698            .ensure_open(&path, &ServerId::from("rust"), &client)
2699            .await
2700            .unwrap();
2701        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2702
2703        tokio::time::advance(Duration::from_millis(300)).await;
2704        tracker
2705            .ensure_open(&path, &ServerId::from("rust"), &client)
2706            .await
2707            .unwrap();
2708        let state = tracker.get(&path).unwrap();
2709        assert_eq!(state.version(), 2);
2710        assert_eq!(state.content(), "BBBB");
2711    }
2712
2713    #[tokio::test]
2714    async fn test_ensure_open_deleted_file_errors_state_untouched() {
2715        let dir = TempDir::new().unwrap();
2716        let path = dir.path().join("a.rs");
2717        std::fs::write(&path, "fn main() {}").unwrap();
2718        set_mtime(&path, settled_past());
2719
2720        let (client, _server) = fake_lsp_client();
2721        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2722        tracker
2723            .ensure_open(&path, &ServerId::from("rust"), &client)
2724            .await
2725            .unwrap();
2726
2727        std::fs::remove_file(&path).unwrap();
2728
2729        let result = tracker
2730            .ensure_open(&path, &ServerId::from("rust"), &client)
2731            .await;
2732        assert!(matches!(result, Err(Error::FileIo { .. })));
2733        assert!(tracker.is_open(&path));
2734        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2735        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
2736    }
2737
2738    #[tokio::test]
2739    async fn test_ensure_open_grows_past_limit_errors_state_intact() {
2740        let dir = TempDir::new().unwrap();
2741        let path = dir.path().join("a.rs");
2742        std::fs::write(&path, "small").unwrap();
2743        set_mtime(&path, settled_past());
2744
2745        let limits = ResourceLimits {
2746            max_documents: 10,
2747            max_file_size: 10,
2748        };
2749        let (client, _server) = fake_lsp_client();
2750        let tracker = DocumentTracker::new(limits, HashMap::new());
2751        tracker
2752            .ensure_open(&path, &ServerId::from("rust"), &client)
2753            .await
2754            .unwrap();
2755
2756        std::fs::write(&path, "x".repeat(100)).unwrap();
2757
2758        let result = tracker
2759            .ensure_open(&path, &ServerId::from("rust"), &client)
2760            .await;
2761        assert!(matches!(result, Err(Error::FileSizeLimitExceeded { .. })));
2762        assert_eq!(tracker.get(&path).unwrap().content(), "small");
2763        assert_eq!(tracker.get(&path).unwrap().version(), 1);
2764    }
2765
2766    #[tokio::test]
2767    async fn test_ensure_open_resync_at_document_capacity() {
2768        let dir = TempDir::new().unwrap();
2769        let path = dir.path().join("a.rs");
2770        std::fs::write(&path, "AAAA").unwrap();
2771        set_mtime(&path, settled_past());
2772
2773        let limits = ResourceLimits {
2774            max_documents: 1,
2775            max_file_size: 0,
2776        };
2777        let (client, _server) = fake_lsp_client();
2778        let tracker = DocumentTracker::new(limits, HashMap::new());
2779        tracker
2780            .ensure_open(&path, &ServerId::from("rust"), &client)
2781            .await
2782            .unwrap();
2783        assert_eq!(tracker.len(), 1);
2784
2785        std::fs::write(&path, "BBBBBBBB").unwrap();
2786        let result = tracker
2787            .ensure_open(&path, &ServerId::from("rust"), &client)
2788            .await;
2789        assert!(
2790            result.is_ok(),
2791            "resync must not re-run the doc-count check on an already-tracked path"
2792        );
2793        assert_eq!(tracker.len(), 1);
2794        assert_eq!(tracker.get(&path).unwrap().version(), 2);
2795    }
2796
2797    #[tokio::test]
2798    async fn test_update_clears_disk_provenance() {
2799        let dir = TempDir::new().unwrap();
2800        let path = dir.path().join("a.rs");
2801        std::fs::write(&path, "fn main() {}").unwrap();
2802        set_mtime(&path, settled_past());
2803
2804        let (client, _server) = fake_lsp_client();
2805        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2806        tracker
2807            .ensure_open(&path, &ServerId::from("rust"), &client)
2808            .await
2809            .unwrap();
2810        assert!(tracker.get(&path).unwrap().disk.is_some());
2811
2812        tracker
2813            .update(&path, "fn main() { updated(); }".to_string())
2814            .await;
2815        assert!(
2816            tracker.get(&path).unwrap().disk.is_none(),
2817            "update() must clear disk provenance so the next ensure_open re-verifies by content"
2818        );
2819    }
2820
2821    #[tokio::test]
2822    async fn test_first_open_self_heals_when_did_open_notify_fails() {
2823        let dir = TempDir::new().unwrap();
2824        let path = dir.path().join("a.rs");
2825        std::fs::write(&path, "fn main() {}").unwrap();
2826
2827        let (client, _server) = fake_lsp_client();
2828        // A clone shares the same command channel. Shutting down the
2829        // original (which owns the receiver task) blocks until the
2830        // background message loop has fully exited and dropped that
2831        // channel's receiver -- so the clone's next `notify()` fails
2832        // deterministically, with no race against process teardown.
2833        let notify_will_fail = client.clone();
2834        client.shutdown().await.unwrap();
2835
2836        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2837        let result = tracker
2838            .ensure_open(&path, &ServerId::from("rust"), &notify_will_fail)
2839            .await;
2840
2841        assert!(result.is_err(), "notify failure must propagate as an error");
2842        assert!(
2843            !tracker.is_open(&path),
2844            "a failed didOpen must not leave the document tracked, or the server \
2845             and tracker would stay permanently desynced"
2846        );
2847    }
2848
2849    #[tokio::test]
2850    async fn test_resync_sends_didchange_with_full_replacement_over_the_wire() {
2851        let dir = TempDir::new().unwrap();
2852        let path = dir.path().join("a.rs");
2853        std::fs::write(&path, "fn main() {}").unwrap();
2854        set_mtime(&path, settled_past());
2855
2856        let (client, mut server) = fake_lsp_client();
2857        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2858        tracker
2859            .ensure_open(&path, &ServerId::from("rust"), &client)
2860            .await
2861            .unwrap();
2862
2863        let mut wire = BufReader::new(&mut server.write_stdout);
2864        let opened = read_framed_message(&mut wire).await;
2865        assert_eq!(opened["method"], "textDocument/didOpen");
2866
2867        std::fs::write(&path, "fn main() { println!(\"hi\"); }").unwrap();
2868        set_mtime(&path, settled_past());
2869        tracker
2870            .ensure_open(&path, &ServerId::from("rust"), &client)
2871            .await
2872            .unwrap();
2873
2874        let changed = read_framed_message(&mut wire).await;
2875        assert_eq!(changed["method"], "textDocument/didChange");
2876        let params = &changed["params"];
2877        assert_eq!(params["textDocument"]["version"], 2);
2878        let change = &params["contentChanges"][0];
2879        assert!(
2880            change.get("range").is_none(),
2881            "range must be omitted, not null, for a full-replacement change"
2882        );
2883        assert!(
2884            change.get("rangeLength").is_none(),
2885            "rangeLength must be omitted, not null, for a full-replacement change"
2886        );
2887        assert_eq!(change["text"], "fn main() { println!(\"hi\"); }");
2888    }
2889
2890    /// Regression for #174 §7.1: a second server must receive `didOpen` even
2891    /// when the file has not changed since a first server was opened on it --
2892    /// the disk-phase fast path only skips the disk read, never the
2893    /// per-server sync decision. Exercises the settled-mtime fast path.
2894    #[tokio::test]
2895    async fn test_ensure_open_second_server_gets_didopen_no_disk_change() {
2896        let dir = TempDir::new().unwrap();
2897        let path = dir.path().join("a.rs");
2898        std::fs::write(&path, "fn main() {}").unwrap();
2899        set_mtime(&path, settled_past());
2900
2901        let (client_a, mut server_a) = fake_lsp_client();
2902        let (client_b, mut server_b) = fake_lsp_client();
2903        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2904
2905        let id_a = ServerId::from("server-a");
2906        let id_b = ServerId::from("server-b");
2907
2908        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
2909        let mut wire_a = BufReader::new(&mut server_a.write_stdout);
2910        let opened_a = read_framed_message(&mut wire_a).await;
2911        assert_eq!(opened_a["method"], "textDocument/didOpen");
2912
2913        // No disk change between calls: server B's ensure_open must still
2914        // take the disk-phase fast path (settled mtime) but still send B its
2915        // own didOpen.
2916        tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
2917        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
2918        let opened_b = read_framed_message(&mut wire_b).await;
2919        assert_eq!(opened_b["method"], "textDocument/didOpen");
2920        assert_eq!(opened_b["params"]["textDocument"]["version"], 1);
2921        assert_eq!(opened_b["params"]["textDocument"]["text"], "fn main() {}");
2922    }
2923
2924    /// Same as above but through the unchanged-content re-read path (racy,
2925    /// unsettled mtime past the debounce window, forcing a real content
2926    /// compare) rather than the settled-mtime fast path.
2927    #[tokio::test(start_paused = true)]
2928    async fn test_ensure_open_second_server_gets_didopen_unchanged_content_path() {
2929        let dir = TempDir::new().unwrap();
2930        let path = dir.path().join("a.rs");
2931        std::fs::write(&path, "fn main() {}").unwrap();
2932        // Leave mtime racy (unsettled) rather than backdating it.
2933
2934        let (client_a, _server_a) = fake_lsp_client();
2935        let (client_b, mut server_b) = fake_lsp_client();
2936        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2937
2938        tracker
2939            .ensure_open(&path, &ServerId::from("server-a"), &client_a)
2940            .await
2941            .unwrap();
2942
2943        // Past the debounce window: server B's call must genuinely re-read
2944        // and compare content rather than taking either fast-path leg.
2945        tokio::time::advance(DISK_CHECK_DEBOUNCE + Duration::from_millis(1)).await;
2946
2947        tracker
2948            .ensure_open(&path, &ServerId::from("server-b"), &client_b)
2949            .await
2950            .unwrap();
2951        let mut wire_b = BufReader::new(&mut server_b.write_stdout);
2952        let opened_b = read_framed_message(&mut wire_b).await;
2953        assert_eq!(opened_b["method"], "textDocument/didOpen");
2954    }
2955
2956    /// Regression for #174 §6.2/§12: `prepare_call_hierarchy` and
2957    /// `incoming_calls`/`outgoing_calls` must resolve to the same server, since
2958    /// only `prepare` calls `ensure_open` -- pinned here at the tracker level
2959    /// by asserting a second `ensure_open` for the same server is a no-op
2960    /// once synced, so a caller that reuses the same `ServerId` for both
2961    /// calls never double-opens.
2962    #[tokio::test]
2963    async fn test_ensure_open_same_server_twice_sends_nothing_second_time() {
2964        let dir = TempDir::new().unwrap();
2965        let path = dir.path().join("a.rs");
2966        std::fs::write(&path, "fn main() {}").unwrap();
2967        set_mtime(&path, settled_past());
2968
2969        let (client, mut server) = fake_lsp_client();
2970        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2971        let id = ServerId::from("rust");
2972
2973        tracker.ensure_open(&path, &id, &client).await.unwrap();
2974        tracker.ensure_open(&path, &id, &client).await.unwrap();
2975
2976        let mut wire = BufReader::new(&mut server.write_stdout);
2977        let opened = read_framed_message(&mut wire).await;
2978        assert_eq!(opened["method"], "textDocument/didOpen");
2979        assert_eq!(
2980            tracker.get(&path).unwrap().synced_version(&id),
2981            Some(1),
2982            "second call for the same server must not re-open or re-change"
2983        );
2984    }
2985
2986    /// Regression for #174 §7.2/S6: a failing `didChange` for one server must
2987    /// leave that server's `synced` entry untouched (self-heals on retry)
2988    /// without disturbing another server that already synced successfully.
2989    #[tokio::test]
2990    async fn test_sync_phase_failed_didchange_does_not_disturb_other_server() {
2991        let dir = TempDir::new().unwrap();
2992        let path = dir.path().join("a.rs");
2993        std::fs::write(&path, "fn main() {}").unwrap();
2994        set_mtime(&path, settled_past());
2995
2996        let (client_a, _server_a) = fake_lsp_client();
2997        let (client_b, _server_b) = fake_lsp_client();
2998        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
2999        let id_a = ServerId::from("server-a");
3000        let id_b = ServerId::from("server-b");
3001
3002        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
3003        tracker.ensure_open(&path, &id_b, &client_b).await.unwrap();
3004
3005        // Shut down B's client so its next notify fails, then change the file
3006        // so both servers have version 2 to catch up to.
3007        let client_b_will_fail = client_b.clone();
3008        client_b.shutdown().await.unwrap();
3009
3010        std::fs::write(&path, "fn main() { updated(); }").unwrap();
3011        set_mtime(&path, settled_past());
3012
3013        let result = tracker.ensure_open(&path, &id_b, &client_b_will_fail).await;
3014        assert!(result.is_err(), "B's didChange must fail and propagate");
3015
3016        // No commit happens before a successful notify: content, version and
3017        // both servers' `synced` entries all stay exactly as they were
3018        // before this call, so the next attempt retries from the same
3019        // starting point rather than drifting the tracker out of sync with
3020        // what was actually acknowledged over the wire.
3021        assert!(tracker.is_open(&path));
3022        assert_eq!(tracker.get(&path).unwrap().content(), "fn main() {}");
3023        assert_eq!(tracker.get(&path).unwrap().version(), 1);
3024        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_a), Some(1));
3025        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_b), Some(1));
3026
3027        // A's next call must independently detect the disk change (B's
3028        // failure did not consume it) and successfully advance both the
3029        // shared content/version and its own synced entry.
3030        tracker.ensure_open(&path, &id_a, &client_a).await.unwrap();
3031        assert_eq!(
3032            tracker.get(&path).unwrap().content(),
3033            "fn main() { updated(); }"
3034        );
3035        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_a), Some(2));
3036        assert_eq!(tracker.get(&path).unwrap().synced_version(&id_b), Some(1));
3037    }
3038
3039    // ------------------------------------------------------------------
3040    // ensure_open concurrency (issue #227)
3041    // ------------------------------------------------------------------
3042
3043    /// Regression for #227: `ensure_open` for one path must not block
3044    /// `ensure_open` for an unrelated path, even while the first call is
3045    /// stuck inside its own disk I/O.
3046    ///
3047    /// Path A's own `ensure_open` call is genuinely parked on path A's
3048    /// per-path lock: `path_a_guard` (held via `lock_path`, the exact
3049    /// primitive `ensure_open` acquires before its disk I/O) is taken first,
3050    /// then a *real*, spawned `ensure_open(path_a)` call is raced against
3051    /// it, so the serialization point under test is inside `ensure_open`
3052    /// itself, not merely the standalone `lock_path` guard. Previously this
3053    /// used a FIFO, whose `open()` for read blocked deterministically until
3054    /// a writer connected; that is no longer usable for this purpose now
3055    /// that `open_checked` opens with `O_NONBLOCK` and rejects non-regular
3056    /// files immediately (see #418) -- a FIFO can no longer be coaxed into
3057    /// blocking `ensure_open`'s `open()` call at all. Under the old design
3058    /// (a single lock spanning all of `ensure_open`, including disk I/O),
3059    /// path B would hang until path A's lock is released below; the
3060    /// per-path lock added here must let it through immediately instead.
3061    #[tokio::test]
3062    async fn test_ensure_open_different_paths_do_not_serialize() {
3063        let dir = TempDir::new().unwrap();
3064        let path_a = dir.path().join("a.rs");
3065        let path_b = dir.path().join("b.rs");
3066
3067        std::fs::write(&path_a, "fn a() {}").unwrap();
3068        std::fs::write(&path_b, "fn b() {}").unwrap();
3069        set_mtime(&path_a, settled_past());
3070        set_mtime(&path_b, settled_past());
3071
3072        let (client_a, _server_a) = fake_lsp_client();
3073        let (client_b, _server_b) = fake_lsp_client();
3074        let tracker = Arc::new(DocumentTracker::new(
3075            ResourceLimits::default(),
3076            HashMap::new(),
3077        ));
3078
3079        let path_a_guard = tracker.lock_path(&path_a).await;
3080
3081        // Spawned so a real `ensure_open(path_a)` call is genuinely parked
3082        // on path A's lock (held by `path_a_guard` above) while path B's
3083        // call below runs.
3084        let tracker_for_a = Arc::clone(&tracker);
3085        let path_a_for_task = path_a.clone();
3086        let handle_a = tokio::spawn(async move {
3087            tracker_for_a
3088                .ensure_open(&path_a_for_task, &ServerId::from("server-a"), &client_a)
3089                .await
3090        });
3091
3092        // Give the spawned task a chance to actually reach and block on
3093        // path A's lock before racing path B's call against it below.
3094        tokio::time::sleep(Duration::from_millis(200)).await;
3095
3096        // A `timeout` error here means path B is blocked by path A's stuck
3097        // ensure_open -- the exact regression #227 fixes.
3098        tokio::time::timeout(
3099            Duration::from_secs(5),
3100            tracker.ensure_open(&path_b, &ServerId::from("server-b"), &client_b),
3101        )
3102        .await
3103        .unwrap()
3104        .unwrap();
3105
3106        drop(path_a_guard);
3107
3108        handle_a.await.unwrap().unwrap();
3109        assert_eq!(tracker.get(&path_a).unwrap().content(), "fn a() {}");
3110    }
3111
3112    /// Regression for #358: `update` must serialize against a concurrent
3113    /// `ensure_open` for the *same* path via the shared per-path lock, not
3114    /// just against other `ensure_open` calls.
3115    ///
3116    /// A real, spawned `ensure_open(path)` call is genuinely parked on the
3117    /// path's lock (held via `lock_path`, the exact primitive `ensure_open`
3118    /// acquires before its disk I/O) while `update` is raced against it --
3119    /// see `test_ensure_open_different_paths_do_not_serialize` for why a
3120    /// standalone `lock_path` guard alone is not enough, and for why this
3121    /// replaced the previous FIFO-blocking idiom. Before the #358 fix,
3122    /// `update` took no per-path lock at all and would have raced straight
3123    /// through instead of blocking.
3124    #[tokio::test]
3125    async fn test_update_serializes_with_concurrent_ensure_open_same_path() {
3126        let dir = TempDir::new().unwrap();
3127        let path = dir.path().join("a.rs");
3128        std::fs::write(&path, "fn a() {}").unwrap();
3129        set_mtime(&path, settled_past());
3130
3131        let (client, _server) = fake_lsp_client();
3132        let tracker = Arc::new(DocumentTracker::new(
3133            ResourceLimits::default(),
3134            HashMap::new(),
3135        ));
3136
3137        let path_guard = tracker.lock_path(&path).await;
3138
3139        // Spawned so a real `ensure_open(path)` call is genuinely parked on
3140        // the path's lock (held by `path_guard` above) while `update` is
3141        // raced against it below.
3142        let tracker_for_open = Arc::clone(&tracker);
3143        let path_for_task = path.clone();
3144        let handle_open = tokio::spawn(async move {
3145            tracker_for_open
3146                .ensure_open(&path_for_task, &ServerId::from("rust"), &client)
3147                .await
3148        });
3149
3150        // Give the spawned task a chance to actually reach and block on the
3151        // path's lock before racing `update` against it below.
3152        tokio::time::sleep(Duration::from_millis(200)).await;
3153
3154        // A successful (non-timeout) result here would mean `update` raced
3155        // straight past `ensure_open`'s still-held per-path lock -- the
3156        // exact regression #358 fixes.
3157        let update_while_blocked = tokio::time::timeout(
3158            Duration::from_millis(300),
3159            tracker.update(&path, "raced content".to_string()),
3160        )
3161        .await;
3162        assert!(
3163            update_while_blocked.is_err(),
3164            "update() must block while ensure_open holds the per-path lock for the same path"
3165        );
3166
3167        drop(path_guard);
3168
3169        handle_open.await.unwrap().unwrap();
3170        assert_eq!(tracker.get(&path).unwrap().content(), "fn a() {}");
3171        assert_eq!(tracker.get(&path).unwrap().version(), 1);
3172
3173        // With the lock released, `update` must now proceed and observably
3174        // apply on top of `ensure_open`'s committed state.
3175        let new_version = tracker
3176            .update(&path, "fn a() { updated(); }".to_string())
3177            .await;
3178        assert_eq!(new_version, Some(2));
3179        assert_eq!(
3180            tracker.get(&path).unwrap().content(),
3181            "fn a() { updated(); }"
3182        );
3183    }
3184
3185    /// Regression for #227: N concurrent `ensure_open` calls for the same
3186    /// path and the same server must still collapse into exactly one
3187    /// `didOpen` -- the per-path lock introduced to let different paths run
3188    /// concurrently must not weaken the existing same-path serialization
3189    /// that prevents duplicate opens.
3190    #[tokio::test]
3191    async fn test_ensure_open_concurrent_same_path_single_didopen() {
3192        let dir = TempDir::new().unwrap();
3193        let path = dir.path().join("a.rs");
3194        std::fs::write(&path, "fn main() {}").unwrap();
3195        set_mtime(&path, settled_past());
3196
3197        let (client, mut server) = fake_lsp_client();
3198        let tracker = Arc::new(DocumentTracker::new(
3199            ResourceLimits::default(),
3200            HashMap::new(),
3201        ));
3202        let id = ServerId::from("rust");
3203
3204        let mut handles = Vec::new();
3205        for _ in 0..8 {
3206            let tracker = Arc::clone(&tracker);
3207            let client = client.clone();
3208            let path = path.clone();
3209            let id = id.clone();
3210            handles.push(tokio::spawn(async move {
3211                tracker.ensure_open(&path, &id, &client).await
3212            }));
3213        }
3214        for handle in handles {
3215            handle.await.unwrap().unwrap();
3216        }
3217
3218        let mut wire = BufReader::new(&mut server.write_stdout);
3219        let opened = read_framed_message(&mut wire).await;
3220        assert_eq!(opened["method"], "textDocument/didOpen");
3221
3222        // No further notification should have been queued -- proves the 8
3223        // concurrent callers collapsed into exactly one `didOpen`.
3224        let extra =
3225            tokio::time::timeout(Duration::from_millis(200), read_framed_message(&mut wire)).await;
3226        assert!(
3227            extra.is_err(),
3228            "expected no additional notification after the single didOpen"
3229        );
3230
3231        assert_eq!(tracker.get(&path).unwrap().synced_version(&id), Some(1));
3232        assert_eq!(tracker.get(&path).unwrap().version(), 1);
3233    }
3234
3235    /// Regression for #227: `lock_path`'s guard must evict its `path_locks`
3236    /// entry once no caller is left waiting on it, or the map grows by one
3237    /// entry per distinct path ever opened for the lifetime of the process.
3238    /// Exercises three concurrent distinct paths (not just the two used in
3239    /// `test_ensure_open_different_paths_do_not_serialize`) to rule out an
3240    /// eviction bug that only manifests with more than two live entries.
3241    #[tokio::test]
3242    async fn test_ensure_open_path_locks_evicted_after_completion() {
3243        let dir = TempDir::new().unwrap();
3244        let paths: Vec<_> = ["a.rs", "b.rs", "c.rs"]
3245            .iter()
3246            .map(|name| dir.path().join(name))
3247            .collect();
3248        for path in &paths {
3249            std::fs::write(path, "fn f() {}").unwrap();
3250            set_mtime(path, settled_past());
3251        }
3252
3253        let tracker = Arc::new(DocumentTracker::new(
3254            ResourceLimits::default(),
3255            HashMap::new(),
3256        ));
3257        let id = ServerId::from("rust");
3258
3259        let mut handles = Vec::new();
3260        let mut servers = Vec::new();
3261        for path in paths.clone() {
3262            let tracker = Arc::clone(&tracker);
3263            let (client, server) = fake_lsp_client();
3264            servers.push(server);
3265            let id = id.clone();
3266            handles.push(tokio::spawn(async move {
3267                tracker.ensure_open(&path, &id, &client).await
3268            }));
3269        }
3270        for handle in handles {
3271            handle.await.unwrap().unwrap();
3272        }
3273        drop(servers);
3274
3275        assert!(
3276            lock_std(&tracker.path_locks).is_empty(),
3277            "path_locks must be fully evicted once every ensure_open call \
3278             for every path has completed, otherwise the map grows \
3279             unbounded for the lifetime of the process"
3280        );
3281    }
3282
3283    /// Regression for #418: `read_to_string_checked` must reject a FIFO
3284    /// rather than trust its (always-zero) reported size and either hang
3285    /// reading it or return an unbounded stream of bytes.
3286    ///
3287    /// Unlike `test_ensure_open_different_paths_do_not_serialize`'s use of
3288    /// the same `mkfifo` idiom, this test needs no background writer and no
3289    /// timeout race to prove non-blocking behavior: `open_checked`'s
3290    /// `O_NONBLOCK` open is the fix under test, so a correct implementation
3291    /// returns an error immediately, with no peer ever connecting. The
3292    /// outer `timeout` is only a safety net so a regression here fails fast
3293    /// instead of hanging the test suite.
3294    #[cfg(unix)]
3295    #[tokio::test]
3296    async fn test_read_to_string_checked_rejects_fifo() {
3297        let dir = TempDir::new().unwrap();
3298        let path = dir.path().join("fifo");
3299        let status = std::process::Command::new("mkfifo")
3300            .arg(&path)
3301            .status()
3302            .unwrap();
3303        assert!(status.success(), "mkfifo must succeed to set up this test");
3304
3305        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3306        // A timeout here means the fix failed and open() is still blocking
3307        // indefinitely on the FIFO -- the exact regression #418 fixes.
3308        let result = tokio::time::timeout(
3309            Duration::from_secs(5),
3310            tracker.read_to_string_checked(&path),
3311        )
3312        .await
3313        .unwrap();
3314
3315        assert!(matches!(result, Err(Error::NotARegularFile(_))));
3316    }
3317
3318    /// Direct regression for #442: `check_disk_file_type` itself, isolated
3319    /// from `open_checked`'s surrounding `is_file()` check. Unlike
3320    /// `test_read_to_string_checked_rejects_nul_device` below, this fails if
3321    /// `check_disk_file_type` were ever bypassed or deleted -- both checks
3322    /// currently produce the identical `Error::NotARegularFile` variant, so
3323    /// an end-to-end test alone can't tell them apart.
3324    #[cfg(windows)]
3325    #[tokio::test]
3326    async fn test_check_disk_file_type_accepts_regular_rejects_nul() {
3327        let dir = TempDir::new().unwrap();
3328        let path = dir.path().join("regular.txt");
3329        std::fs::write(&path, "hello").unwrap();
3330
3331        let regular = fs::File::open(&path).await.unwrap();
3332        assert!(check_disk_file_type(&regular, &path).is_ok());
3333
3334        let nul_path = PathBuf::from("NUL");
3335        let nul = fs::File::open(&nul_path).await.unwrap();
3336        assert!(matches!(
3337            check_disk_file_type(&nul, &nul_path),
3338            Err(Error::NotARegularFile(_))
3339        ));
3340    }
3341
3342    /// Regression for #442: `read_to_string_checked` must reject the `NUL`
3343    /// device on Windows via `GetFileType`, not `FileType::is_file()` --
3344    /// which does not reliably classify reserved device names as
3345    /// non-regular. This is the Windows counterpart of
3346    /// `test_read_to_string_checked_rejects_fifo`; `NUL` opens immediately
3347    /// (unlike a FIFO with no writer), so the outer `timeout` here is only a
3348    /// safety net, not proof of non-blocking behavior on its own -- the
3349    /// `GetFileType` check itself is what's under test.
3350    #[cfg(windows)]
3351    #[tokio::test]
3352    async fn test_read_to_string_checked_rejects_nul_device() {
3353        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3354        let path = PathBuf::from("NUL");
3355        let result = tokio::time::timeout(
3356            Duration::from_secs(5),
3357            tracker.read_to_string_checked(&path),
3358        )
3359        .await
3360        .unwrap();
3361
3362        assert!(matches!(result, Err(Error::NotARegularFile(_))));
3363    }
3364
3365    /// Boundary regression for #427/#418's shared size gate: a file of
3366    /// exactly `max_file_size` bytes must succeed through
3367    /// `read_to_string_checked` (the disk-read path `ensure_open` uses),
3368    /// and one byte more must fail as `FileSizeLimitExceeded` -- not just
3369    /// "some file well over the limit is rejected".
3370    #[tokio::test]
3371    async fn test_read_to_string_checked_size_boundary() {
3372        let dir = TempDir::new().unwrap();
3373        let path = dir.path().join("boundary.rs");
3374        let tracker = DocumentTracker::new(
3375            ResourceLimits {
3376                max_documents: 100,
3377                max_file_size: 10,
3378            },
3379            HashMap::new(),
3380        );
3381
3382        std::fs::write(&path, "a".repeat(10)).unwrap();
3383        let (content, ..) = tracker.read_to_string_checked(&path).await.unwrap();
3384        assert_eq!(content.len(), 10);
3385
3386        std::fs::write(&path, "a".repeat(11)).unwrap();
3387        let result = tracker.read_to_string_checked(&path).await;
3388        assert!(matches!(
3389            result,
3390            Err(Error::FileSizeLimitExceeded { size: 11, max: 10 })
3391        ));
3392    }
3393
3394    /// Regression for #474: `read_line_checked` must stop reading (and
3395    /// UTF-8-decoding) once it has the requested line, not buffer/validate
3396    /// the rest of the file. The file's second line is invalid UTF-8, which
3397    /// would fail a whole-file read (as the pre-#474 `read_checked` +
3398    /// `.lines().nth(...)` path did); reading line 0 must still succeed.
3399    #[tokio::test]
3400    async fn test_read_line_checked_does_not_read_past_target_line() {
3401        let dir = TempDir::new().unwrap();
3402        let path = dir.path().join("partial.rs");
3403        let mut content = b"hello\n".to_vec();
3404        content.extend_from_slice(&[0xFF, 0xFE]);
3405        content.push(b'\n');
3406        std::fs::write(&path, &content).unwrap();
3407
3408        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3409        let line = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
3410        assert_eq!(line.text.as_deref(), Some("hello"));
3411    }
3412
3413    /// Regression for M3: an off-by-one in `current_line` (e.g. returning
3414    /// line `N + 1` for `N`) would ship green if every test used line 0.
3415    /// Exercises a non-zero target line on a multi-line fixture.
3416    #[tokio::test]
3417    async fn test_read_line_checked_returns_requested_non_zero_line() {
3418        let dir = TempDir::new().unwrap();
3419        let path = dir.path().join("multi.rs");
3420        std::fs::write(&path, "first\nsecond\nthird\nfourth\n").unwrap();
3421
3422        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3423        assert_eq!(
3424            tracker
3425                .read_line_checked(&path, 2, u64::MAX)
3426                .await
3427                .unwrap()
3428                .text
3429                .as_deref(),
3430            Some("third")
3431        );
3432    }
3433
3434    /// `read_line_checked` must report `Ok(None)`, not an error, when `line`
3435    /// is past the file's last line -- distinguishing "file has fewer lines
3436    /// than requested" from an actual read failure.
3437    #[tokio::test]
3438    async fn test_read_line_checked_returns_none_past_last_line() {
3439        let dir = TempDir::new().unwrap();
3440        let path = dir.path().join("short.rs");
3441        std::fs::write(&path, "only one line").unwrap();
3442
3443        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3444        assert_eq!(
3445            tracker
3446                .read_line_checked(&path, 5, u64::MAX)
3447                .await
3448                .unwrap()
3449                .text,
3450            None
3451        );
3452    }
3453
3454    /// A requested line with no trailing `\n` at all (the file's only line,
3455    /// never terminated) must still be returned -- distinct from
3456    /// `test_read_line_checked_returns_none_past_last_line`, which requests a
3457    /// line number past this same kind of file instead of the line itself.
3458    #[tokio::test]
3459    async fn test_read_line_checked_reads_last_line_without_trailing_newline() {
3460        let dir = TempDir::new().unwrap();
3461        let path = dir.path().join("no_newline.rs");
3462        std::fs::write(&path, "only one line").unwrap();
3463
3464        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3465        assert_eq!(
3466            tracker
3467                .read_line_checked(&path, 0, u64::MAX)
3468                .await
3469                .unwrap()
3470                .text
3471                .as_deref(),
3472            Some("only one line")
3473        );
3474    }
3475
3476    #[tokio::test]
3477    async fn test_read_line_checked_empty_file_returns_none() {
3478        let dir = TempDir::new().unwrap();
3479        let path = dir.path().join("empty.rs");
3480        std::fs::write(&path, "").unwrap();
3481
3482        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3483        assert_eq!(
3484            tracker
3485                .read_line_checked(&path, 0, u64::MAX)
3486                .await
3487                .unwrap()
3488                .text,
3489            None
3490        );
3491    }
3492
3493    /// `read_until(b'\n', ..)` splits lines on `\n` alone, so a `\r` ahead of
3494    /// it is left in `buf` until the trailing-separator strip loop removes
3495    /// it -- pins that CRLF-terminated lines come out identical to LF-only
3496    /// ones.
3497    #[tokio::test]
3498    async fn test_read_line_checked_strips_crlf_line_ending() {
3499        let dir = TempDir::new().unwrap();
3500        let path = dir.path().join("crlf.rs");
3501        std::fs::write(&path, "first\r\nsecond\r\n").unwrap();
3502
3503        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3504        assert_eq!(
3505            tracker
3506                .read_line_checked(&path, 0, u64::MAX)
3507                .await
3508                .unwrap()
3509                .text
3510                .as_deref(),
3511            Some("first")
3512        );
3513        assert_eq!(
3514            tracker
3515                .read_line_checked(&path, 1, u64::MAX)
3516                .await
3517                .unwrap()
3518                .text
3519                .as_deref(),
3520            Some("second")
3521        );
3522    }
3523
3524    /// Regression for M2: `str::lines` strips at most one trailing `\r` per
3525    /// line, not every trailing `\r`, and only when it precedes an actual
3526    /// `\n` terminator -- a final, untermined line keeps a trailing `\r`
3527    /// verbatim. Uses `str::lines` itself as the oracle on the exact inputs
3528    /// that distinguish these from a naive "strip every trailing `\r`/`\n`"
3529    /// implementation.
3530    #[tokio::test]
3531    async fn test_read_line_checked_matches_str_lines_crlf_semantics() {
3532        let dir = TempDir::new().unwrap();
3533        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3534
3535        let double_cr = "abc\r\r\n";
3536        let path_a = dir.path().join("double_cr.rs");
3537        std::fs::write(&path_a, double_cr).unwrap();
3538        assert_eq!(
3539            tracker
3540                .read_line_checked(&path_a, 0, u64::MAX)
3541                .await
3542                .unwrap()
3543                .text
3544                .as_deref(),
3545            double_cr.lines().next()
3546        );
3547
3548        let trailing_cr_no_newline = "abc\r";
3549        let path_b = dir.path().join("trailing_cr_no_newline.rs");
3550        std::fs::write(&path_b, trailing_cr_no_newline).unwrap();
3551        assert_eq!(
3552            tracker
3553                .read_line_checked(&path_b, 0, u64::MAX)
3554                .await
3555                .unwrap()
3556                .text
3557                .as_deref(),
3558            trailing_cr_no_newline.lines().next()
3559        );
3560    }
3561
3562    /// Regression for the `bounded_read_cap` off-by-one: a file whose size
3563    /// is exactly `max_file_size` must not be misreported as oversized when
3564    /// a request (for a line past the file's content) forces a full read to
3565    /// EOF. The cap is `max_file_size + 1` precisely so this exact-boundary
3566    /// case is distinguishable from a genuinely oversized file.
3567    #[tokio::test]
3568    async fn test_read_line_checked_exact_max_file_size_reads_to_eof_without_error() {
3569        let dir = TempDir::new().unwrap();
3570        let path = dir.path().join("exact.rs");
3571        let content = "a".repeat(20);
3572        std::fs::write(&path, &content).unwrap();
3573
3574        let limits = ResourceLimits {
3575            max_documents: 100,
3576            max_file_size: 20,
3577        };
3578        let tracker = DocumentTracker::new(limits, HashMap::new());
3579
3580        assert_eq!(
3581            tracker
3582                .read_line_checked(&path, 0, u64::MAX)
3583                .await
3584                .unwrap()
3585                .text
3586                .as_deref(),
3587            Some(content.as_str())
3588        );
3589        assert_eq!(
3590            tracker
3591                .read_line_checked(&path, 1, u64::MAX)
3592                .await
3593                .unwrap()
3594                .text,
3595            None,
3596            "a line past an exact-max_file_size file's only line must read to EOF cleanly, not \
3597             be misreported as truncated"
3598        );
3599    }
3600
3601    /// Regression for the S1 budget-bypass fix: `budget` must physically
3602    /// bound the read (via the take-adapter), not just gate whether a read
3603    /// is attempted -- a read that starts with budget left must still stop
3604    /// at exactly that many bytes, never at the full `max_file_size`.
3605    /// Distinguishes this from `bounded_read_cap(max_file_size)` alone by
3606    /// using a `budget` far smaller than `max_file_size`.
3607    #[tokio::test]
3608    async fn test_read_line_checked_bounds_read_by_budget_not_just_max_file_size() {
3609        let dir = TempDir::new().unwrap();
3610        let path = dir.path().join("budget.rs");
3611        std::fs::write(&path, "a".repeat(1000)).unwrap();
3612
3613        let limits = ResourceLimits {
3614            max_documents: 100,
3615            max_file_size: 1000,
3616        };
3617        let tracker = DocumentTracker::new(limits, HashMap::new());
3618
3619        let read = tracker.read_line_checked(&path, 0, 10).await.unwrap();
3620        assert_eq!(
3621            read.text, None,
3622            "a single line far longer than the budget must not be returned as if complete"
3623        );
3624        assert_eq!(
3625            read.bytes_read, 11,
3626            "the read must stop at exactly the budget's +1 slack (see the correctness-gate fix \
3627             below), not at max_file_size"
3628        );
3629    }
3630
3631    /// Regression for a correctness-gate finding: `cap`'s `budget` component
3632    /// needs the same `+1` disambiguation slack `bounded_read_cap` already
3633    /// applies to `max_file_size` -- without it, a read whose remaining
3634    /// budget exactly equals its target line's byte length (no trailing
3635    /// newline) is indistinguishable from one genuinely truncated by the
3636    /// cap, and was misreported as truncated (`text: None`) even though the
3637    /// read fully succeeded.
3638    #[tokio::test]
3639    async fn test_read_line_checked_exact_budget_match_on_unterminated_line_not_truncated() {
3640        let dir = TempDir::new().unwrap();
3641        let path = dir.path().join("exact_budget.rs");
3642        let content = "twelve chars";
3643        std::fs::write(&path, content).unwrap();
3644
3645        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3646        let read = tracker
3647            .read_line_checked(&path, 0, content.len() as u64)
3648            .await
3649            .unwrap();
3650        assert_eq!(
3651            read.text.as_deref(),
3652            Some(content),
3653            "budget exactly matching the line's byte length must not be misreported as truncated"
3654        );
3655        assert_eq!(read.bytes_read, content.len() as u64);
3656    }
3657
3658    /// Regression for the S1 budget-bypass fix: an invalid-UTF-8 line (the
3659    /// realistic attack shape -- a `.rlib`/image/pack file under
3660    /// `max_file_size`) must still report an accurate `bytes_read` on
3661    /// `LineRead::text == None`, not lose it down an `Err` path with no byte
3662    /// count -- that loss is exactly what let a hostile response scan
3663    /// unlimited bytes while charging the per-response budget zero.
3664    #[tokio::test]
3665    async fn test_read_line_checked_reports_bytes_read_for_invalid_utf8_line() {
3666        let dir = TempDir::new().unwrap();
3667        let path = dir.path().join("invalid_utf8.rs");
3668        let mut content = vec![0xFFu8, 0xFE, 0xFD];
3669        content.push(b'\n');
3670        std::fs::write(&path, &content).unwrap();
3671
3672        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3673        let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
3674        assert_eq!(read.text, None);
3675        assert_eq!(
3676            read.bytes_read,
3677            content.len() as u64,
3678            "bytes scanned must be reported even though the line wasn't valid UTF-8"
3679        );
3680    }
3681
3682    /// Regression for the open-failure-charge fix: a path that doesn't
3683    /// exist (the realistic, non-attacker case -- e.g. an LSP server naming
3684    /// a stdlib location not present locally) must resolve to `Ok(None)`,
3685    /// not `Err`, and must charge the small nominal
3686    /// `OPEN_FAILURE_CHARGE_BYTES` amount rather than `0` (which would let
3687    /// a response repeat this for free) or the full budget (the previous
3688    /// round's regression, which zeroed the whole per-response budget on
3689    /// the very first such location).
3690    #[tokio::test]
3691    async fn test_read_line_checked_charges_nominal_amount_for_nonexistent_path() {
3692        let dir = TempDir::new().unwrap();
3693        let path = dir.path().join("does_not_exist.rs");
3694
3695        let tracker = DocumentTracker::new(ResourceLimits::default(), HashMap::new());
3696        // A nonexistent path must resolve to Ok(None), not Err.
3697        let read = tracker.read_line_checked(&path, 0, u64::MAX).await.unwrap();
3698        assert_eq!(read.text, None);
3699        assert_eq!(read.bytes_read, OPEN_FAILURE_CHARGE_BYTES);
3700    }
3701}