Skip to main content

mcpls_core/bridge/
state.rs

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