Skip to main content

mcpls_core/bridge/
notifications.rs

1//! LSP notification storage and management.
2//!
3//! Stores diagnostics, log messages, and server messages received from LSP servers.
4
5use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
6use std::sync::Arc;
7
8use chrono::{DateTime, Utc};
9use lsp_types::{Diagnostic as LspDiagnostic, Uri};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use tracing::{debug, warn};
13
14use crate::bridge::indexing::{IndexingPolicy, IndexingReset, IndexingState, IndexingTracker};
15use crate::bridge::resources::{DiagnosticsResourceUri, PublishedDiagnosticsUri};
16use crate::bridge::{
17    DiagnosticsAvailability, DocumentTracker, DocumentVersion, Opening, uri_to_path,
18};
19use crate::config::{BoundedText, ServerId};
20use crate::util::{WarnLimiter, truncate_string};
21
22mod bounds;
23mod pulled_index;
24
25pub use bounds::BoundedDiagnostics;
26use bounds::{MAX_ENTRY_TEXT_BYTES, cap_diagnostics_entry_size};
27use pulled_index::{PulledIndex, point};
28pub use pulled_index::{ReportedSeverity, reported_code};
29
30/// Maximum number of log entries to store.
31const MAX_LOG_ENTRIES: usize = 100;
32
33/// Global budget for distinct-URI diagnostic entries, shared work-conservingly
34/// across every registered diagnostics-route server rather than claimed by
35/// one server alone.
36///
37/// Guards against unbounded growth when a spawned LSP server publishes
38/// diagnostics for an unbounded number of distinct URIs over a long-running
39/// session, matching the bounding already applied to `logs`/`messages`.
40/// Eviction only triggers once this global total is reached; it then targets
41/// whichever server most exceeds its fair share of
42/// `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count` (see
43/// [`NotificationCache::set_diagnostics_route_count`]). If no server exceeds
44/// its share, eviction falls back to the writer's own oldest entry instead
45/// -- even if the writer is itself within its share -- since it is the one
46/// whose new entry needs room; a narrower fallback further evicts from the
47/// largest other in-share server only if the writer itself has no entries
48/// yet (its very first write) and every existing server is already within
49/// its own share, since otherwise there would be nothing to evict and the
50/// aggregate cap could be exceeded (see the private `server_to_evict_from`
51/// for both fallbacks). A quieter, non-writer server that is within its fair
52/// share is otherwise never touched (#266). A single active server can
53/// still use the full budget when other registered servers are idle (#276)
54/// instead of being capped at a static equal split regardless of how much
55/// of it they actually use. Which single entry within the chosen server (or
56/// another over-share one) is actually removed is further refined by
57/// emptiness -- see the private `entry_to_evict` (#284).
58pub const MAX_DIAGNOSTIC_ENTRIES: usize = 1000;
59
60/// A `file:` URI normalized for cache lookup; only built by [`Self::of`], so a
61/// raw URI string can never be mistaken for a key.
62///
63/// On Windows, URI comparisons must be case-insensitive: the filesystem is
64/// case-insensitive and different tools (e.g. rust-analyzer vs std) may
65/// produce drive letters in different cases (`C:` vs `c:`).
66/// Lowercasing the entire URI is safe for `file://` URIs because they have
67/// no case-sensitive query or fragment components.
68#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
69pub struct DiagnosticsKey(String);
70
71impl DiagnosticsKey {
72    pub(crate) fn of(uri: &Uri) -> Self {
73        let text: &str = uri.as_ref();
74        if cfg!(windows) {
75            Self(text.to_ascii_lowercase())
76        } else {
77            Self(text.to_owned())
78        }
79    }
80}
81
82/// How a cached entry's URI relates to the canonical path of its file.
83#[derive(Debug, Clone, PartialEq, Eq)]
84enum Spelling {
85    /// The server published under the canonical spelling of the path.
86    Canonical,
87    /// The server published under a symlink alias of the file at this key.
88    Alias(DiagnosticsKey),
89}
90
91/// Where a cached diagnostics list came from.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
93enum Provenance {
94    /// Delivered by `textDocument/publishDiagnostics`.
95    Pushed,
96    /// Answered to a `textDocument/diagnostic` request.
97    Pulled,
98}
99
100/// Identifies one cache slot: a published URI and the way its diagnostics arrived.
101///
102/// A file has at most one `Pulled` slot, always under its canonical URI, next
103/// to its `Pushed` slots, so neither source erases the other.
104#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
105struct SlotKey {
106    uri: DiagnosticsKey,
107    provenance: Provenance,
108}
109
110impl SlotKey {
111    const fn pushed(uri: DiagnosticsKey) -> Self {
112        Self {
113            uri,
114            provenance: Provenance::Pushed,
115        }
116    }
117
118    const fn pulled(uri: DiagnosticsKey) -> Self {
119        Self {
120            uri,
121            provenance: Provenance::Pulled,
122        }
123    }
124}
125
126/// Orders the pulls of one server: a later request gets a greater ticket.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
128struct PullTicket(u64);
129
130/// Counts how often a server's diagnostics were cleared, so a pull answered by
131/// a process that has since been replaced can be recognized.
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133struct ClearEpoch(u64);
134
135/// A pull request's claim on the cache, issued by [`NotificationCache::begin_pull`]
136/// before the request is sent and redeemed by
137/// [`NotificationCache::store_pulled_diagnostics`].
138#[derive(Debug, Clone, Copy)]
139pub struct PullStamp {
140    ticket: PullTicket,
141    epoch: ClearEpoch,
142    version: DocumentVersion,
143}
144
145impl PullStamp {
146    /// Document version the pull was requested at.
147    pub(crate) const fn version(self) -> DocumentVersion {
148        self.version
149    }
150}
151
152/// Whether the tracker still holds the document version a pull was requested at.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum VersionCheck {
155    /// The tracked version still equals the stamped one.
156    Current,
157    /// The document was resynced to another version while the pull was in flight.
158    Moved,
159}
160
161/// Why a pull report was not stored.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum Discard {
164    /// A pull issued later already stored its report.
165    OlderTicket,
166    /// The server's diagnostics were cleared after the pull was issued.
167    ServerCleared,
168    /// The document moved to another version while the pull was in flight.
169    VersionMoved,
170    /// No entry could be evicted to make room.
171    NoRoom,
172}
173
174/// What a stored pull report did to the file's pulled slot.
175#[derive(Debug)]
176pub enum SlotChange {
177    /// The slot already held exactly this list.
178    Identical,
179    /// The slot was replaced; `before` is the file's snapshot taken just
180    /// before, so the merged views can be compared. `None` when the caller
181    /// did not ask for one (nobody is subscribed): the view is then taken to
182    /// have changed.
183    Replaced { before: Option<DiagnosticSources> },
184}
185
186/// Whether a pull write takes the snapshot a view comparison needs.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum SnapshotBefore {
189    /// Take it.
190    Take,
191    /// Skip it; nobody compares views.
192    Skip,
193}
194
195/// A capacity eviction found nothing it may remove.
196#[derive(Debug)]
197struct NoRoom {
198    /// Files whose slots were evicted before the search ran dry.
199    evicted: Vec<DiagnosticsKey>,
200}
201
202/// Result of [`NotificationCache::store_pulled_diagnostics`].
203#[derive(Debug)]
204#[must_use]
205pub enum PullWrite {
206    /// The report is now the file's pulled slot.
207    Stored {
208        slot: SlotChange,
209        /// Files whose slots were evicted to make room.
210        evicted: Vec<DiagnosticsKey>,
211    },
212    /// The report was dropped and is handed back.
213    Discarded {
214        reason: Discard,
215        /// Files whose slots were evicted before the pull was dropped.
216        evicted: Vec<DiagnosticsKey>,
217        items: BoundedDiagnostics,
218    },
219}
220
221/// Result of [`NotificationCache::write_published_diagnostics`].
222#[derive(Debug)]
223#[must_use]
224pub enum PushWrite {
225    /// The publish is now cached.
226    Stored {
227        /// Files whose slots were evicted to make room for the published one.
228        evicted: Vec<DiagnosticsKey>,
229    },
230    /// The publish was refused and nothing about the file changed: its URI
231    /// spellings are at their cap, or no entry could be evicted to make room.
232    Dropped {
233        /// Files whose slots were evicted before the publish was refused.
234        evicted: Vec<DiagnosticsKey>,
235    },
236}
237
238impl PushWrite {
239    /// Files whose slots were evicted by the write.
240    #[must_use]
241    pub fn evicted(&self) -> &[DiagnosticsKey] {
242        match self {
243            Self::Stored { evicted } | Self::Dropped { evicted } => evicted,
244        }
245    }
246}
247
248/// Whether the merged diagnostics of a file differ before and after a write.
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum ChangeOutcome {
251    /// A read would now return something else.
252    Changed,
253    /// A read would return the same diagnostics.
254    Unchanged,
255}
256
257impl ChangeOutcome {
258    /// Compares two merged views as multisets of serialized diagnostics; an
259    /// absent view differs from a present but empty one.
260    pub(crate) fn of(before: Option<&DiagnosticInfo>, after: Option<&DiagnosticInfo>) -> Self {
261        Self::compare(before, after, |d| serde_json::to_vec(d).ok())
262    }
263
264    /// [`Self::of`] over a caller-chosen serializer. A diagnostic that cannot
265    /// be serialized makes its view incomparable, which reads as a change:
266    /// the conservative side is to notify.
267    fn compare(
268        before: Option<&DiagnosticInfo>,
269        after: Option<&DiagnosticInfo>,
270        serialize: impl Fn(&LspDiagnostic) -> Option<Vec<u8>>,
271    ) -> Self {
272        let fingerprint = |info: &DiagnosticInfo| -> Option<Vec<Vec<u8>>> {
273            let mut items = info
274                .diagnostics
275                .iter()
276                .map(&serialize)
277                .collect::<Option<Vec<_>>>()?;
278            items.sort_unstable();
279            Some(items)
280        };
281        match (before, after) {
282            (None, None) => Self::Unchanged,
283            (Some(before), Some(after)) => match (fingerprint(before), fingerprint(after)) {
284                (Some(before), Some(after)) if before == after => Self::Unchanged,
285                _ => Self::Changed,
286            },
287            _ => Self::Changed,
288        }
289    }
290}
291
292/// What the document tracker says about the document a push is for.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum DocumentSync {
295    /// The server was last told this version of the document.
296    Synced(DocumentVersion),
297    /// The document is not open for the server.
298    NotOpen,
299    /// No tracker is attached, so there is nothing to compare with.
300    Unattached,
301}
302
303/// Whether a push supersedes the pulled slot of its file.
304///
305/// A push carrying a newer document version than the pull answered does. Any
306/// push does when the tracked document is no longer at the pulled version
307/// (it moved on, or a closed or evicted document restarted its version) or is
308/// not open. While the document still is at the pulled version the pull
309/// stays, whether the push carries an equal, a lower or no version: servers
310/// that push and pull one version keep both slots, and a versionless flycheck
311/// push must not erase the pulled native diagnostics (bridge/004). A document
312/// closed and reopened since the pull is handled by [`NotificationCache`]
313/// itself, since its version can coincide with the pulled one again.
314///
315/// Without a tracker ([`DocumentSync::Unattached`]) only a newer version counts.
316fn pull_is_superseded(
317    pulled: DocumentVersion,
318    pushed: Option<DocumentVersion>,
319    sync: DocumentSync,
320) -> bool {
321    match (pushed, sync) {
322        (Some(pushed), _) if pushed > pulled => true,
323        (_, DocumentSync::Unattached) => false,
324        (_, DocumentSync::NotOpen) => true,
325        (_, DocumentSync::Synced(current)) => current != pulled,
326    }
327}
328
329/// Which file's slots a capacity eviction must leave alone.
330#[derive(Debug, Clone, Copy)]
331enum Protect<'a> {
332    Nothing,
333    File(&'a DiagnosticsKey),
334}
335
336/// Indices into a pushed entry's diagnostics that a pull of the same document
337/// state (same opening, same version) reported as well.
338#[derive(Debug, Clone, Default, PartialEq, Eq)]
339struct Covered(BTreeSet<usize>);
340
341/// Most `source` values learned per server from its pull reports.
342const MAX_LEARNED_PULL_SOURCES: usize = 32;
343
344/// Longest `source` value that is learned.
345const MAX_DIAGNOSTIC_SOURCE_BYTES: usize = 128;
346
347/// The `source` of a diagnostic (`"Pylance"`, `"rustc"`), bounded so a server
348/// cannot make the cache remember an arbitrary amount of text.
349#[derive(Debug, Clone, PartialEq, Eq)]
350struct DiagnosticSource(BoundedText<MAX_DIAGNOSTIC_SOURCE_BYTES>);
351
352impl DiagnosticSource {
353    fn of(diagnostic: &LspDiagnostic) -> Option<Self> {
354        let text = diagnostic.source.as_deref()?;
355        BoundedText::new(text).ok().map(Self)
356    }
357}
358
359/// Whether a pushed entry still describes the document state the tracker last
360/// synced to the server, and what of it a pull has since answered for.
361///
362/// Computed under the cache lock from the entry, the tracker and the file's
363/// pulled slot, so a read and the notification decision behind it agree.
364#[derive(Debug, Clone, PartialEq, Eq)]
365enum Freshness {
366    /// Nothing is excluded: the entry is current, unversioned, the document is
367    /// not tracked, or no pull at the synced state answers for the file.
368    Current,
369    /// The entry is older (or from another opening) than the synced state and
370    /// the same server's pull at that state exists: the covered items are
371    /// what that pull did not repeat, so they are dropped from the merge.
372    Superseded { covered: Covered },
373}
374
375impl Freshness {
376    fn excludes(&self, index: usize) -> bool {
377        match self {
378            Self::Current => false,
379            Self::Superseded { covered } => covered.0.contains(&index),
380        }
381    }
382}
383
384/// How a cached entry's diagnostics arrived, with the bookkeeping that only
385/// exists for that way.
386#[derive(Debug, Clone, PartialEq, Eq)]
387enum Origin {
388    /// Delivered by `textDocument/publishDiagnostics`.
389    Pushed {
390        spelling: Spelling,
391        /// Document version the server published the diagnostics for, if any.
392        version: Option<DocumentVersion>,
393        /// The tracked opening of the document when the publish arrived, or
394        /// `None` without a tracker or an open document.
395        opening: Option<Opening>,
396        /// The diagnostics a pull of the same document state reported too;
397        /// reset by a new publish, grown when the pulled slot is retired.
398        covered: Covered,
399    },
400    /// Answered to a `textDocument/diagnostic` request, always for a known
401    /// document version and under the file's canonical URI.
402    Pulled {
403        /// Orders the pulls of the file's pulled slot.
404        ticket: PullTicket,
405        /// The tracked opening of the document when the report was stored, or
406        /// `None` without a tracker or an open document.
407        opening: Option<Opening>,
408        /// Document version the pull was requested at.
409        version: DocumentVersion,
410    },
411}
412
413impl Origin {
414    /// Document version the entry's diagnostics were produced for.
415    const fn version(&self) -> Option<DocumentVersion> {
416        match self {
417            Self::Pushed { version, .. } => *version,
418            Self::Pulled { version, .. } => Some(*version),
419        }
420    }
421
422    const fn provenance(&self) -> Provenance {
423        match self {
424            Self::Pushed { .. } => Provenance::Pushed,
425            Self::Pulled { .. } => Provenance::Pulled,
426        }
427    }
428}
429
430/// One cached diagnostics entry with all of its bookkeeping, so the indices
431/// derived from it are only ever touched by `insert_entry`/`take_entry`.
432#[derive(Debug)]
433struct CachedEntry {
434    /// URI the entry is stored under.
435    uri: Uri,
436    diagnostics: Vec<LspDiagnostic>,
437    /// Server that published the entry.
438    owner: ServerId,
439    /// Position in `owner`'s write order.
440    seq: u64,
441    origin: Origin,
442}
443
444impl CachedEntry {
445    /// The entry as a [`DiagnosticInfo`], with the version taken from its origin.
446    fn info(&self) -> DiagnosticInfo {
447        DiagnosticInfo {
448            uri: self.uri.clone(),
449            version: self.origin.version(),
450            diagnostics: self.diagnostics.clone(),
451        }
452    }
453
454    /// Key of the file this entry belongs to, given the entry's own `key`.
455    const fn file<'a>(&'a self, key: &'a SlotKey) -> &'a DiagnosticsKey {
456        match &self.origin {
457            Origin::Pushed {
458                spelling: Spelling::Alias(canonical),
459                ..
460            } => canonical,
461            Origin::Pushed {
462                spelling: Spelling::Canonical,
463                ..
464            }
465            | Origin::Pulled { .. } => &key.uri,
466        }
467    }
468}
469
470/// The cache key under which diagnostics for the file behind `uri` are
471/// stored, for matching [`NotificationCache::clear_server_diagnostics`]
472/// results against a subscription.
473pub fn diagnostics_cache_key(uri: &DiagnosticsResourceUri) -> Option<DiagnosticsKey> {
474    let path = crate::bridge::resources::parse_uri(uri.as_str()).ok()?;
475    let lsp_uri = crate::bridge::try_path_to_uri(path.as_path())?;
476    Some(DiagnosticsKey::of(&lsp_uri))
477}
478
479/// Maximum number of distinct published URIs (a canonical path and its
480/// symlink aliases) whose diagnostics are unioned under one file; bounds how
481/// far a misbehaving server can fan a single file out.
482const MAX_SOURCES_PER_FILE: usize = 8;
483
484/// Maximum number of server messages to store.
485const MAX_SERVER_MESSAGES: usize = 50;
486
487/// Most empty-entry removals remembered for replay on `subscriptions/listen`:
488/// as many as the cache can hold entries, so a full cache cleared at once
489/// (e.g. a server restart) loses no replayable clear.
490const MAX_RECENT_EVICTIONS: usize = MAX_DIAGNOSTIC_ENTRIES;
491
492/// Most evicted files remembered for `NotificationCache::availability`.
493const MAX_EVICTION_MARKS: usize = MAX_DIAGNOSTIC_ENTRIES;
494
495/// How long an empty-entry removal stays replayable.
496const EVICTION_REPLAY_WINDOW: std::time::Duration = std::time::Duration::from_mins(2);
497
498/// File keys of empty entries removed within `EVICTION_REPLAY_WINDOW`.
499///
500/// `at` is the source of truth; `order` is an expiry queue over it in which an
501/// item whose instant differs from `at`'s is stale (the key was re-recorded or
502/// dropped) and is skipped. Re-recording a key adds an order item, so `order`
503/// is rebuilt from `at` once it exceeds twice `at`'s size, keeping both
504/// bounded under churn on few keys. Over `MAX_RECENT_EVICTIONS` the oldest
505/// record is forgotten and logged once per window.
506#[derive(Debug, Default)]
507struct EvictionRecord {
508    at: HashMap<DiagnosticsKey, std::time::Instant>,
509    order: VecDeque<(DiagnosticsKey, std::time::Instant)>,
510    overflow_warn: WarnLimiter,
511}
512
513impl EvictionRecord {
514    fn record(&mut self, file: DiagnosticsKey, now: std::time::Instant) {
515        self.prune_expired(now);
516        self.at.insert(file.clone(), now);
517        self.order.push_back((file, now));
518        while self.at.len() > MAX_RECENT_EVICTIONS {
519            self.forget_oldest(now);
520        }
521        if self.order.len() > self.at.len().saturating_mul(2) {
522            self.compact();
523        }
524    }
525
526    fn contains(&self, key: &DiagnosticsKey, now: std::time::Instant) -> bool {
527        self.at
528            .get(key)
529            .is_some_and(|at| now.saturating_duration_since(*at) <= EVICTION_REPLAY_WINDOW)
530    }
531
532    fn is_live(&self, key: &DiagnosticsKey, at: std::time::Instant) -> bool {
533        self.at.get(key) == Some(&at)
534    }
535
536    fn prune_expired(&mut self, now: std::time::Instant) {
537        while let Some((key, at)) = self
538            .order
539            .pop_front_if(|(_, at)| now.saturating_duration_since(*at) > EVICTION_REPLAY_WINDOW)
540        {
541            if self.is_live(&key, at) {
542                self.at.remove(&key);
543            }
544        }
545    }
546
547    fn forget_oldest(&mut self, now: std::time::Instant) {
548        while let Some((key, at)) = self.order.pop_front() {
549            if self.is_live(&key, at) {
550                self.at.remove(&key);
551                self.warn_overflow(now);
552                return;
553            }
554        }
555    }
556
557    fn warn_overflow(&mut self, now: std::time::Instant) {
558        if self.overflow_warn.due(now, EVICTION_REPLAY_WINDOW) {
559            warn!(
560                "more than {MAX_RECENT_EVICTIONS} diagnostics clears evicted within {}s, \
561                 forgetting the oldest; a re-attaching listen may miss them",
562                EVICTION_REPLAY_WINDOW.as_secs()
563            );
564        }
565    }
566
567    fn compact(&mut self) {
568        let mut live: Vec<_> = self.at.iter().map(|(k, at)| (k.clone(), *at)).collect();
569        live.sort_by_key(|(_, at)| *at);
570        self.order = live.into();
571    }
572
573    #[cfg(test)]
574    fn len(&self) -> usize {
575        self.at.len()
576    }
577
578    #[cfg(test)]
579    fn is_empty(&self) -> bool {
580        self.at.is_empty()
581    }
582}
583
584/// What was cached for a file when capacity eviction removed its last entry.
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586enum EvictedContent {
587    /// Only empty lists: the file was clean.
588    Clean,
589    /// A non-empty list: what the server said is no longer known.
590    Lost,
591}
592
593#[derive(Debug)]
594struct EvictionMark {
595    owner: ServerId,
596    content: EvictedContent,
597    seq: u64,
598}
599
600/// Files whose entries capacity eviction removed, so a read can tell a clean
601/// file or lost diagnostics from a file nothing was ever published for.
602///
603/// Bounded to `MAX_EVICTION_MARKS`; once the oldest mark of a server is
604/// forgotten the server is `overflowed`, and a file without entry or mark is
605/// then reported as possibly evicted rather than as pending. A server's marks
606/// and overflow are dropped with its diagnostics (respawn), and a file's mark
607/// with its next entry. The overflow also ends with the server's next accepted
608/// write, so a file forgotten before it reads as pending again instead of
609/// possibly evicted for as long as the process runs.
610#[derive(Debug, Default)]
611struct EvictionMarks {
612    by_file: HashMap<DiagnosticsKey, EvictionMark>,
613    order: BTreeMap<u64, DiagnosticsKey>,
614    next_seq: u64,
615    overflowed: HashSet<ServerId>,
616}
617
618impl EvictionMarks {
619    fn record(&mut self, file: DiagnosticsKey, owner: ServerId, content: EvictedContent) {
620        let content = match self.by_file.get(&file) {
621            Some(previous) if previous.content == EvictedContent::Lost => EvictedContent::Lost,
622            _ => content,
623        };
624        self.forget_file(&file);
625        let seq = self.next_seq;
626        self.next_seq = self.next_seq.saturating_add(1);
627        self.order.insert(seq, file.clone());
628        self.by_file.insert(
629            file,
630            EvictionMark {
631                owner,
632                content,
633                seq,
634            },
635        );
636        while self.by_file.len() > MAX_EVICTION_MARKS {
637            let Some((_, oldest)) = self.order.pop_first() else {
638                break;
639            };
640            if let Some(mark) = self.by_file.remove(&oldest) {
641                self.overflowed.insert(mark.owner);
642            }
643        }
644    }
645
646    fn forget_file(&mut self, file: &DiagnosticsKey) {
647        if let Some(mark) = self.by_file.remove(file) {
648            self.order.remove(&mark.seq);
649        }
650    }
651
652    fn forget_server(&mut self, server: &ServerId) {
653        self.by_file.retain(|_, mark| &mark.owner != server);
654        self.order.retain(|_, file| self.by_file.contains_key(file));
655        self.overflowed.remove(server);
656    }
657
658    fn content(&self, file: &DiagnosticsKey) -> Option<EvictedContent> {
659        self.by_file.get(file).map(|mark| mark.content)
660    }
661
662    fn has_overflowed(&self, server: &ServerId) -> bool {
663        self.overflowed.contains(server)
664    }
665
666    /// Ends `server`'s overflow and returns whether it was set. Called before
667    /// the write's own evictions, which can start a new one.
668    fn clear_overflow(&mut self, server: &ServerId) -> bool {
669        self.overflowed.remove(server)
670    }
671
672    fn restore_overflow(&mut self, server: &ServerId) {
673        self.overflowed.insert(server.clone());
674    }
675}
676
677/// Borrows a diagnostic's free-form `message` as plain text, regardless of
678/// whether the server sent it as a plain string or (per LSP 3.18)
679/// `MarkupContent`.
680pub fn message_as_str(message: &lsp_types::Message) -> &str {
681    match message {
682        lsp_types::Message::String(s) => s,
683        lsp_types::Message::MarkupContent(m) => &m.value,
684    }
685}
686
687/// Information about diagnostics for a document.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct DiagnosticInfo {
690    /// URI of the document.
691    pub uri: Uri,
692    /// Document version when diagnostics were received.
693    pub version: Option<DocumentVersion>,
694    /// List of diagnostics.
695    pub diagnostics: Vec<LspDiagnostic>,
696}
697
698/// How an entry of a [`DiagnosticSources`] snapshot arrived.
699#[derive(Debug, Clone, PartialEq, Eq)]
700enum SourceOrigin {
701    /// Published under this spelling of the path.
702    Pushed(Spelling),
703    /// Answered to a pull, under the canonical spelling.
704    Pulled,
705}
706
707impl SourceOrigin {
708    const fn is_pulled(&self) -> bool {
709        matches!(self, Self::Pulled)
710    }
711}
712
713impl From<&Origin> for SourceOrigin {
714    fn from(origin: &Origin) -> Self {
715        match origin {
716            Origin::Pushed { spelling, .. } => Self::Pushed(spelling.clone()),
717            Origin::Pulled { .. } => Self::Pulled,
718        }
719    }
720}
721
722/// One cached entry of a [`DiagnosticSources`] snapshot.
723#[derive(Debug, Clone)]
724struct SourceEntry {
725    info: DiagnosticInfo,
726    origin: SourceOrigin,
727    freshness: Freshness,
728}
729
730/// Owned snapshot of the diagnostics cached for one file, possibly spread
731/// over several published URIs (a canonical path plus symlink aliases) and
732/// over a pushed and a pulled source.
733///
734/// Taken under the cache lock by [`NotificationCache::diagnostic_sources`];
735/// the clone/dedupe/sort/cap work in [`Self::merge`] then runs after the
736/// guard is dropped, so the diagnostics pump is never blocked behind it.
737#[derive(Debug, Clone)]
738pub struct DiagnosticSources {
739    requested: Uri,
740    entries: Vec<SourceEntry>,
741}
742
743impl DiagnosticSources {
744    /// Replaces the snapshot's pulled entry with `diagnostics`, for a pull
745    /// report that was answered but not stored.
746    pub(crate) fn with_pulled(
747        mut self,
748        file: &Uri,
749        version: Option<DocumentVersion>,
750        diagnostics: BoundedDiagnostics,
751    ) -> Self {
752        self.entries.retain(|entry| !entry.origin.is_pulled());
753        self.entries.push(SourceEntry {
754            info: DiagnosticInfo {
755                uri: file.clone(),
756                version,
757                diagnostics: diagnostics.0,
758            },
759            origin: SourceOrigin::Pulled,
760            freshness: Freshness::Current,
761        });
762        self
763    }
764
765    /// Collapses the sources into one entry for the requested file.
766    ///
767    /// - No source: `None`.
768    /// - One pushed source: returned unchanged, with its own `uri` and
769    ///   `version`, even when it is a symlink alias.
770    /// - Otherwise: pulled diagnostics come first and are never collapsed
771    ///   among themselves; a pushed diagnostic is dropped when it duplicates a
772    ///   pulled one (same problem, see `PulledIndex::contains_same_problem`) or an earlier
773    ///   pushed one (equal in every field). The result is ordered by range
774    ///   (stable) and re-capped to the per-entry size bound. The entry's `uri`
775    ///   is the requested one and its `version` is that of the pushed source
776    ///   published under the canonical spelling, else the pulled one's, else
777    ///   `None`.
778    ///
779    /// # Examples
780    ///
781    /// ```
782    /// use lsp_types::Uri;
783    /// use mcpls_core::bridge::NotificationCache;
784    ///
785    /// let cache = NotificationCache::new();
786    /// let uri = Uri::from("file:///workspace/main.rs".to_owned());
787    /// let sources = cache.diagnostic_sources(&uri);
788    /// assert!(sources.merge().is_none());
789    /// ```
790    #[must_use]
791    pub fn merge(self) -> Option<DiagnosticInfo> {
792        let Self { requested, entries } = self;
793        let has_pulled = entries.iter().any(|entry| entry.origin.is_pulled());
794        if entries.len() <= 1 && !has_pulled {
795            return entries.into_iter().next().map(|entry| entry.info);
796        }
797        let version = entries
798            .iter()
799            .find(|entry| {
800                entry.origin == SourceOrigin::Pushed(Spelling::Canonical)
801                    && entry.freshness == Freshness::Current
802            })
803            .and_then(|entry| entry.info.version)
804            .or_else(|| {
805                entries
806                    .iter()
807                    .find(|entry| entry.origin.is_pulled())
808                    .and_then(|entry| entry.info.version)
809            });
810
811        let (pulled, pushed): (Vec<SourceEntry>, Vec<SourceEntry>) = entries
812            .into_iter()
813            .partition(|entry| entry.origin.is_pulled());
814        let pulled: Vec<LspDiagnostic> = pulled
815            .into_iter()
816            .flat_map(|entry| entry.info.diagnostics)
817            .collect();
818        let index = PulledIndex::new(&pulled);
819        let mut seen: HashSet<Vec<u8>> = HashSet::new();
820        let mut kept_pushed = Vec::new();
821        let pushed_items = pushed.into_iter().flat_map(|entry| {
822            let SourceEntry {
823                info, freshness, ..
824            } = entry;
825            info.diagnostics
826                .into_iter()
827                .enumerate()
828                .filter(move |(position, _)| !freshness.excludes(*position))
829                .map(|(_, diagnostic)| diagnostic)
830        });
831        for diagnostic in pushed_items {
832            if index.contains_same_problem(&diagnostic) {
833                continue;
834            }
835            // A serialization error cannot happen for a `Diagnostic`; keeping
836            // the item is the safe fallback.
837            if serde_json::to_vec(&diagnostic).map_or(true, |bytes| seen.insert(bytes)) {
838                kept_pushed.push(diagnostic);
839            }
840        }
841        drop(index);
842        let mut merged = pulled;
843        merged.extend(kept_pushed);
844        merged.sort_by_key(|d| (point(d.range.start), point(d.range.end)));
845        cap_diagnostics_entry_size(&requested, &mut merged);
846
847        Some(DiagnosticInfo {
848            uri: requested,
849            version,
850            diagnostics: merged,
851        })
852    }
853}
854
855/// A log entry from the LSP server.
856#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
857pub struct LogEntry {
858    /// Log level.
859    pub level: LogLevel,
860    /// Log message.
861    pub message: String,
862    /// Timestamp when the log was received.
863    pub timestamp: DateTime<Utc>,
864}
865
866/// Log severity level.
867#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
868#[serde(rename_all = "lowercase")]
869#[schemars(inline)]
870pub enum LogLevel {
871    /// Error log level.
872    Error,
873    /// Warning log level.
874    Warning,
875    /// Info log level.
876    Info,
877    /// Debug log level.
878    Debug,
879}
880
881impl LogLevel {
882    const fn rank(self) -> u8 {
883        match self {
884            Self::Error => 0,
885            Self::Warning => 1,
886            Self::Info => 2,
887            Self::Debug => 3,
888        }
889    }
890
891    /// Whether a log of this level is at least as severe as `min`.
892    ///
893    /// # Examples
894    ///
895    /// ```
896    /// use mcpls_core::bridge::LogLevel;
897    ///
898    /// assert!(LogLevel::Error.meets(LogLevel::Warning));
899    /// assert!(!LogLevel::Debug.meets(LogLevel::Info));
900    /// assert!(LogLevel::Debug.meets(LogLevel::Debug));
901    /// ```
902    #[must_use]
903    pub const fn meets(self, min: Self) -> bool {
904        self.rank() <= min.rank()
905    }
906}
907
908impl From<lsp_types::MessageType> for LogLevel {
909    fn from(msg_type: lsp_types::MessageType) -> Self {
910        match msg_type {
911            lsp_types::MessageType::Error => Self::Error,
912            lsp_types::MessageType::Warning => Self::Warning,
913            lsp_types::MessageType::Info => Self::Info,
914            // LOG and unknown message types default to Debug
915            _ => Self::Debug,
916        }
917    }
918}
919
920/// A message from the LSP server.
921#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
922pub struct ServerMessage {
923    /// Message type.
924    pub message_type: MessageType,
925    /// Message content.
926    pub message: String,
927    /// Timestamp when the message was received.
928    pub timestamp: DateTime<Utc>,
929}
930
931/// Server message type.
932#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
933#[serde(rename_all = "lowercase")]
934pub enum MessageType {
935    /// Error message.
936    Error,
937    /// Warning message.
938    Warning,
939    /// Info message.
940    Info,
941    /// Log message.
942    Log,
943}
944
945impl From<lsp_types::MessageType> for MessageType {
946    fn from(msg_type: lsp_types::MessageType) -> Self {
947        match msg_type {
948            lsp_types::MessageType::Error => Self::Error,
949            lsp_types::MessageType::Warning => Self::Warning,
950            lsp_types::MessageType::Info => Self::Info,
951            // LOG and unknown message types default to Log
952            _ => Self::Log,
953        }
954    }
955}
956
957/// Cache for LSP server notifications.
958#[derive(Debug)]
959pub struct NotificationCache {
960    /// Diagnostics entries by published URI.
961    entries: HashMap<SlotKey, CachedEntry>,
962    /// Per-server entry keys ordered oldest-write-first, keyed by a
963    /// monotonic sequence number rather than position: a re-publish removes
964    /// its old entry by key in `O(log n)` instead of scanning for it, which a
965    /// plain `VecDeque` would require. Not independently capped per server --
966    /// only the aggregate across all servers is bounded, by
967    /// `MAX_DIAGNOSTIC_ENTRIES` -- but each server's own map length is what
968    /// eviction compares against its fair share (see
969    /// [`NotificationCache::server_to_evict_from`]) to decide which server
970    /// loses an entry once the aggregate is full, so one server's write
971    /// volume can never evict another's entries while it still has room
972    /// left in the global budget (#266, #276).
973    order: HashMap<ServerId, BTreeMap<u64, SlotKey>>,
974    /// Canonical file key -> keys of the entries (the canonical path and its
975    /// symlink aliases) whose diagnostics are unioned on read.
976    files: HashMap<DiagnosticsKey, BTreeSet<SlotKey>>,
977    /// Next sequence number to assign in `order`. Shared across every
978    /// server's order map and monotonically increasing for the cache's
979    /// lifetime; never reused, so it never collides with an older entry
980    /// still pending eviction.
981    next_diagnostic_seq: u64,
982    /// Next ticket handed to a pull by [`Self::begin_pull`].
983    next_pull_ticket: u64,
984    /// Per server, the `source` values its non-empty full pull reports carried,
985    /// so a pushed item with such a source is known to be one the server
986    /// reports through pulls too. Dropped with the server's diagnostics.
987    pull_sources: HashMap<ServerId, Vec<DiagnosticSource>>,
988    /// Per-server count of [`Self::clear_server_diagnostics`] calls; absent
989    /// means epoch zero.
990    clear_epochs: HashMap<ServerId, ClearEpoch>,
991    /// Number of registered diagnostics-route servers currently sharing the
992    /// `MAX_DIAGNOSTIC_ENTRIES` budget, explicitly configured via
993    /// [`NotificationCache::set_diagnostics_route_count`].
994    ///
995    /// `None` until that setter is called -- `per_server_budget` then falls
996    /// back to the number of servers whose `order` entry is
997    /// non-empty (i.e. currently holds at least one entry) rather than
998    /// treating an unset count as `1`, which used to hand a single early
999    /// publisher the entire budget with no fair-share partitioning at all
1000    /// (#283). The explicit setter remains the preferred path when the
1001    /// caller knows it up front: it pre-accounts for servers that are
1002    /// registered but have not published anything yet, avoiding a window
1003    /// where an early publisher is temporarily over-allocated before a
1004    /// slower server's first write grows `order`.
1005    diagnostics_route_count: Option<usize>,
1006
1007    /// Count of entries in `entries` whose diagnostics list is currently
1008    /// empty (`[]`), i.e. an LSP server reporting a previously-tracked file
1009    /// as now clean. A plain counter, not a duplicated key set, so `0` is an
1010    /// `O(1)` signal that lets `entry_to_evict` skip its empty-entry search
1011    /// entirely in the common steady state of a codebase full of real
1012    /// diagnostics (#284) -- which entry is empty is still answered by
1013    /// looking the key up in `entries` itself (see the private
1014    /// `is_empty_entry`), not by mirroring membership here. Maintained only
1015    /// by `insert_entry`/`take_entry`.
1016    empty_diagnostics_count: usize,
1017    /// Empty entries removed within `EVICTION_REPLAY_WINDOW`. Only empty
1018    /// entries are recorded: a clear lost to eviction is what a listen
1019    /// re-attaching after its lease gap must still replay, while replaying a
1020    /// removed non-empty entry would make clients re-read "not published" and
1021    /// drop valid errors, so a non-empty entry evicted by capacity is never
1022    /// replayed. Written only by `evict_entry`, never by publishers.
1023    recent_evictions: EvictionRecord,
1024    /// Files whose last entry capacity eviction removed; see
1025    /// [`Self::availability`]. Unlike `recent_evictions` it is written by
1026    /// capacity evictions only, never by a server clear.
1027    eviction_marks: EvictionMarks,
1028    /// Tracker whose synced versions tell whether a push supersedes a pulled
1029    /// slot; see [`Self::attach_documents`].
1030    documents: Option<Arc<DocumentTracker>>,
1031    /// Recent log entries (FIFO queue with max size).
1032    logs: VecDeque<LogEntry>,
1033    /// Recent server messages (FIFO queue with max size).
1034    messages: VecDeque<ServerMessage>,
1035    /// Server ids whose `textDocument/publishDiagnostics` push notifications
1036    /// are known to be dark: `Translator::respawn_if_dead` replaced a crashed
1037    /// process for this id, and the replacement's notification receiver is
1038    /// drained and discarded rather than wired into a running
1039    /// `diagnostics_pump` (#249's documented trade-off -- the pump's
1040    /// remaining dependencies live in `serve_with`'s scope, not
1041    /// `Translator`'s). Once marked, an id is never unmarked here: only a
1042    /// full mcpls process restart actually restores push diagnostics for
1043    /// that server, so clearing this on a later respawn attempt would
1044    /// misreport the cache as fresh again.
1045    push_degraded: HashSet<ServerId>,
1046    /// Workspace-indexing readiness per server, driven by recognized
1047    /// out-of-band signals (rust-analyzer's `experimental/serverStatus`, and
1048    /// a generic `$/progress` `begin`/`end` sequence) rather than the
1049    /// `initialize`/`initialized` handshake. See
1050    /// `Self::observe_indexing_signal` and [`Self::observe_progress`].
1051    indexing: IndexingTracker,
1052}
1053
1054impl Default for NotificationCache {
1055    fn default() -> Self {
1056        Self::new()
1057    }
1058}
1059
1060impl NotificationCache {
1061    /// Create a new notification cache.
1062    #[must_use]
1063    pub fn new() -> Self {
1064        Self {
1065            entries: HashMap::with_capacity(32),
1066            order: HashMap::new(),
1067            files: HashMap::new(),
1068            next_diagnostic_seq: 0,
1069            next_pull_ticket: 0,
1070            pull_sources: HashMap::new(),
1071            clear_epochs: HashMap::new(),
1072            diagnostics_route_count: None,
1073            empty_diagnostics_count: 0,
1074            recent_evictions: EvictionRecord::default(),
1075            eviction_marks: EvictionMarks::default(),
1076            documents: None,
1077            logs: VecDeque::with_capacity(MAX_LOG_ENTRIES),
1078            messages: VecDeque::with_capacity(MAX_SERVER_MESSAGES),
1079            push_degraded: HashSet::new(),
1080            indexing: IndexingTracker::new(),
1081        }
1082    }
1083
1084    /// Configure how many diagnostics-route servers share the global
1085    /// `MAX_DIAGNOSTIC_ENTRIES` budget.
1086    ///
1087    /// Each server's fair share becomes `MAX_DIAGNOSTIC_ENTRIES / count`
1088    /// (minimum 1). This does not cap any server's entries by itself -- the
1089    /// aggregate cache is only ever trimmed once it reaches
1090    /// `MAX_DIAGNOSTIC_ENTRIES` total -- it only decides, at that point,
1091    /// which server's oldest entry is the one that gets evicted. Call once
1092    /// after server registration completes and before diagnostics start
1093    /// flowing, to pre-account for servers that are registered but have not
1094    /// published anything yet. If never called, `per_server_budget` derives
1095    /// the count from the number of servers currently holding at least one
1096    /// entry instead (#283) -- a consumer that forgets to call this still
1097    /// gets fair-share partitioning once more than one server has written an
1098    /// entry, rather than silently handing the whole budget to a single
1099    /// early publisher.
1100    pub(crate) fn set_diagnostics_route_count(&mut self, count: usize) {
1101        self.diagnostics_route_count = Some(count.max(1));
1102    }
1103
1104    #[cfg(test)]
1105    pub(crate) const fn configured_route_count(&self) -> Option<usize> {
1106        self.diagnostics_route_count
1107    }
1108
1109    /// Current per-server fair share of `MAX_DIAGNOSTIC_ENTRIES`, divided
1110    /// evenly across the configured server count and floored at 1 so a
1111    /// large server count can never reduce a server's share to zero.
1112    ///
1113    /// Uses the explicit count from [`Self::set_diagnostics_route_count`]
1114    /// when set; otherwise falls back to the number of servers whose
1115    /// `order` entry is non-empty (#283) -- matching
1116    /// `server_to_evict_from`'s own `!order.is_empty()` filter, so a server
1117    /// that has been fully evicted or reassigned away from (an empty but
1118    /// still-present order map) is not double-counted in the denominator.
1119    /// Both are floored at 1 so a fresh cache with no entries and no
1120    /// explicit count yet still yields a usable budget instead of dividing
1121    /// by zero.
1122    ///
1123    /// This is a tie-breaker for eviction, not a hard per-server cap: a
1124    /// server may hold more than its fair share of entries at any time, as
1125    /// long as the aggregate across all servers stays within
1126    /// `MAX_DIAGNOSTIC_ENTRIES` (#276).
1127    fn per_server_budget(&self) -> usize {
1128        let count = self
1129            .diagnostics_route_count
1130            .unwrap_or_else(|| {
1131                self.order
1132                    .values()
1133                    .filter(|order| !order.is_empty())
1134                    .count()
1135            })
1136            .max(1);
1137        MAX_DIAGNOSTIC_ENTRIES
1138            .checked_div(count)
1139            .unwrap_or(1)
1140            .max(1)
1141    }
1142
1143    /// Picks which server's oldest entry to evict once the aggregate cache
1144    /// is full: whichever registered server holds the most entries, if that
1145    /// exceeds its fair share ([`Self::per_server_budget`]) -- so a noisy
1146    /// server can only ever evict its own entries, never a quiet server's
1147    /// that is still within its share (#266). If every server (including
1148    /// `writer`) is within its share, falls back to `writer`'s own oldest
1149    /// entry, since it is the one currently growing. Falls back further, to
1150    /// whichever server holds the most entries regardless of share, only in
1151    /// the edge case where `writer` has no entries of its own yet (its very
1152    /// first write) while the aggregate is already full purely from other
1153    /// servers each individually within their share -- otherwise there
1154    /// would be nothing to evict from and the aggregate cap could be
1155    /// exceeded despite every server behaving fairly.
1156    ///
1157    /// Ties in entry count are broken by `ServerId`, not left to
1158    /// `HashMap`'s iteration order: `Iterator::max_by_key` returns the
1159    /// *last* equally-maximal element it sees, and a `HashMap`'s iteration
1160    /// order is randomized per process, so an `order.len()`-only key would
1161    /// make the eviction target for a genuine tie vary from run to run.
1162    /// Every candidate here is a distinct `order` key, so pairing
1163    /// the count with `id.as_str()` makes the sort key unique per server --
1164    /// no two entries can ever tie on the full key, which eliminates the
1165    /// non-determinism outright rather than just picking a fixed side of it.
1166    fn server_to_evict_from(&self, writer: &ServerId) -> Option<ServerId> {
1167        let largest = self
1168            .order
1169            .iter()
1170            .filter(|(_, order)| !order.is_empty())
1171            .max_by_key(|(id, order)| (order.len(), id.as_str()));
1172
1173        let budget = self.per_server_budget();
1174        if let Some((id, order)) = largest
1175            && order.len() > budget
1176        {
1177            return Some(id.clone());
1178        }
1179
1180        if self
1181            .order
1182            .get(writer)
1183            .is_some_and(|order| !order.is_empty())
1184        {
1185            return Some(writer.clone());
1186        }
1187
1188        largest.map(|(id, _)| id.clone())
1189    }
1190
1191    /// Whether the cached entry for `key` currently has an empty (`[]`)
1192    /// diagnostics list, i.e. an LSP server reporting a previously-tracked
1193    /// file as now clean. Derived directly from `entries` rather than
1194    /// from a separately maintained key set, so there is nothing else to
1195    /// keep in sync (#284).
1196    fn is_empty_entry(&self, key: &SlotKey) -> bool {
1197        self.entries
1198            .get(key)
1199            .is_some_and(|entry| entry.diagnostics.is_empty())
1200    }
1201
1202    /// Whether `key` is a slot of the file `protect` names.
1203    fn is_protected(&self, key: &SlotKey, protect: Protect<'_>) -> bool {
1204        match protect {
1205            Protect::Nothing => false,
1206            Protect::File(file) => self
1207                .entries
1208                .get(key)
1209                .is_some_and(|entry| entry.file(key) == file),
1210        }
1211    }
1212
1213    /// Oldest unprotected entry in `server`'s own order map whose diagnostics
1214    /// list is empty, if it has one.
1215    fn oldest_empty_entry_in(
1216        &self,
1217        server: &ServerId,
1218        protect: Protect<'_>,
1219    ) -> Option<(u64, SlotKey)> {
1220        let order = self.order.get(server)?;
1221        order
1222            .iter()
1223            .find(|(_, key)| self.is_empty_entry(key) && !self.is_protected(key, protect))
1224            .map(|(&seq, key)| (seq, key.clone()))
1225    }
1226
1227    /// Oldest unprotected entry in `server`'s own order map.
1228    fn oldest_entry_in(&self, server: &ServerId, protect: Protect<'_>) -> Option<(u64, SlotKey)> {
1229        self.order
1230            .get(server)?
1231            .iter()
1232            .find(|(_, key)| !self.is_protected(key, protect))
1233            .map(|(&seq, key)| (seq, key.clone()))
1234    }
1235
1236    /// Which single entry to remove next when the aggregate cache is full
1237    /// and a genuinely new URI needs room, returned as `(owner, seq, key)`
1238    /// so the caller can remove it from every index it appears in.
1239    ///
1240    /// [`Self::server_to_evict_from`] decides which server is fairness's
1241    /// primary target; this picks *which of that server's entries* to
1242    /// actually remove, preferring an empty (`[]`) one over its
1243    /// strictly-oldest entry wherever one can be found without disturbing a
1244    /// server that is within its own fair share (#284):
1245    ///
1246    /// 1. If the chosen victim itself holds an empty entry, evict its oldest
1247    ///    one -- a `[]` publish carries no diagnostic content to lose, so
1248    ///    this lets an older, still-meaningful entry from the same server
1249    ///    survive in its place.
1250    /// 2. Otherwise, if some *other* server that also exceeds
1251    ///    [`Self::per_server_budget`] holds an empty entry, evict that one
1252    ///    instead of destroying the chosen victim's real diagnostics (S1):
1253    ///    fairness only protects a server that is within its share, so an
1254    ///    over-share server's own clean entry is fair game regardless of
1255    ///    which over-share server `server_to_evict_from` happened to name.
1256    ///    Ties use the same `(count, id)` key as `server_to_evict_from`, for
1257    ///    the same determinism reason.
1258    /// 3. Otherwise -- no empty entry exists anywhere over-budget -- falls
1259    ///    back to the chosen victim's strictly-oldest entry, exactly as
1260    ///    before #284.
1261    ///
1262    /// Step 1/2's search is skipped entirely when `empty_diagnostics_count`
1263    /// is `0`, so the common steady state (a codebase full of real
1264    /// diagnostics, no clean-file churn) pays no extra cost over a plain
1265    /// oldest-first lookup (#284).
1266    ///
1267    /// Entries of the file `protect` names are never picked: a pull write
1268    /// must not evict the same file's pushed slot it is merged with. When the
1269    /// chosen server has nothing else, the largest other server's oldest
1270    /// unprotected entry is taken instead.
1271    fn entry_to_evict(
1272        &self,
1273        writer: &ServerId,
1274        protect: Protect<'_>,
1275    ) -> Option<(ServerId, u64, SlotKey)> {
1276        let evict_from = self.server_to_evict_from(writer)?;
1277
1278        if self.empty_diagnostics_count > 0 {
1279            if let Some((seq, key)) = self.oldest_empty_entry_in(&evict_from, protect) {
1280                return Some((evict_from, seq, key));
1281            }
1282
1283            let budget = self.per_server_budget();
1284            let cross_server_pick = self
1285                .order
1286                .iter()
1287                .filter(|(id, order)| order.len() > budget && *id != &evict_from)
1288                .filter_map(|(id, order)| {
1289                    self.oldest_empty_entry_in(id, protect)
1290                        .map(|(seq, key)| (id, order.len(), seq, key))
1291                })
1292                .max_by_key(|(id, len, ..)| (*len, id.as_str()));
1293
1294            if let Some((id, _, seq, key)) = cross_server_pick {
1295                return Some((id.clone(), seq, key));
1296            }
1297        }
1298
1299        if let Some((seq, key)) = self.oldest_entry_in(&evict_from, protect) {
1300            return Some((evict_from, seq, key));
1301        }
1302        self.order
1303            .iter()
1304            .filter(|(id, _)| *id != &evict_from)
1305            .filter_map(|(id, order)| {
1306                self.oldest_entry_in(id, protect)
1307                    .map(|(seq, key)| (id, order.len(), seq, key))
1308            })
1309            .max_by_key(|(id, len, ..)| (*len, id.as_str()))
1310            .map(|(id, _, seq, key)| (id.clone(), seq, key))
1311    }
1312
1313    /// Store diagnostics for a document published by `server_id`, indexed
1314    /// under its canonical file so every spelling of one file is read back as
1315    /// the union of what each published.
1316    ///
1317    /// Each diagnostic's `message` is truncated to `MAX_ENTRY_TEXT_BYTES`,
1318    /// and the whole list is bounded to `MAX_DIAGNOSTICS_ENTRY_BYTES`
1319    /// serialized bytes, before storing (#311). When that bound requires
1320    /// dropping diagnostics, the *survivors* come back sorted by severity
1321    /// ([`ReportedSeverity`] order: `ERROR` first, a diagnostic without a
1322    /// severity counting as information), not in the original
1323    /// publish/file-position order -- see [`Self::diagnostics`].
1324    ///
1325    /// If diagnostics already exist for the URI, they are replaced and the
1326    /// entry is repositioned to the back of its owner's eviction order, so
1327    /// a URI republished on every edit is tracked as most-recently-written
1328    /// and evicted last, not first -- and, since it is not a new distinct
1329    /// URI, never triggers eviction on its own.
1330    ///
1331    /// Eviction is work-conserving (#276): storing diagnostics for a
1332    /// genuinely new URI only evicts an existing entry once the *aggregate*
1333    /// across every server reaches `MAX_DIAGNOSTIC_ENTRIES`, and then only
1334    /// the least-recently-written entry of whichever server most exceeds its
1335    /// fair share, or -- per the fallbacks documented on
1336    /// `server_to_evict_from` -- the writer's own oldest entry when no
1337    /// server exceeds its share. A quieter, non-writer server that is within
1338    /// its fair share is never touched, outside the narrow edge case also
1339    /// documented there. This lets a single active server use the full
1340    /// aggregate budget while other registered servers are idle, instead of
1341    /// being capped at a static equal split regardless of how much of it
1342    /// they actually use. Which exact entry is removed is further refined by
1343    /// emptiness -- see the private `entry_to_evict` (#284).
1344    ///
1345    /// An empty publish replaces only its own source's entry. At most
1346    /// `MAX_SOURCES_PER_FILE` spellings are kept per file: a further alias is
1347    /// dropped before anything is stored, but the canonical spelling is always
1348    /// admitted, evicting the oldest alias, so a server fanning one file out
1349    /// cannot suppress the canonical publishes.
1350    ///
1351    /// The files whose entries were evicted to make room are returned so the
1352    /// caller can tell their subscribers (#649).
1353    pub(crate) fn write_published_diagnostics(
1354        &mut self,
1355        server_id: &ServerId,
1356        published: &PublishedDiagnosticsUri,
1357        version: Option<DocumentVersion>,
1358        diagnostics: BoundedDiagnostics,
1359    ) -> PushWrite {
1360        let source_key = SlotKey::pushed(DiagnosticsKey::of(published.source()));
1361        let canonical_key = DiagnosticsKey::of(published.canonical());
1362
1363        let already_indexed = self
1364            .entries
1365            .get(&source_key)
1366            .is_some_and(|entry| entry.file(&source_key) == &canonical_key);
1367        let at_capacity = self.pushed_slot_count(&canonical_key) >= MAX_SOURCES_PER_FILE;
1368        if !already_indexed && at_capacity {
1369            let alias_to_evict = published
1370                .is_canonical()
1371                .then(|| self.oldest_alias_source(&canonical_key))
1372                .flatten();
1373            let Some(oldest_alias) = alias_to_evict else {
1374                debug!(
1375                    "dropping diagnostics for {}: {MAX_SOURCES_PER_FILE} published URIs already cached for {}",
1376                    published.source().as_ref(),
1377                    published.canonical().as_ref()
1378                );
1379                return PushWrite::Dropped {
1380                    evicted: Vec::new(),
1381                };
1382            };
1383            self.evict_entry(&oldest_alias);
1384        }
1385
1386        let diagnostics = diagnostics.into_vec();
1387
1388        self.drop_superseded_pull(&canonical_key, published.canonical(), version);
1389
1390        // A replacement leaves its previous owner's order map (the owner may
1391        // differ when the diagnostics route changed, e.g. on respawn) and
1392        // never needs room; only a genuinely new URI can trigger eviction.
1393        drop(self.take_entry(&source_key));
1394        let evicted = match self.make_room(server_id, Protect::Nothing) {
1395            Ok(evicted) => evicted,
1396            Err(NoRoom { evicted }) => {
1397                debug!(
1398                    "dropping diagnostics for {}: no entry could be evicted to make room",
1399                    published.source().as_ref()
1400                );
1401                return PushWrite::Dropped { evicted };
1402            }
1403        };
1404
1405        let seq = self.next_seq();
1406        let opening = self.document_opening(published.canonical());
1407        let spelling = if published.is_canonical() {
1408            Spelling::Canonical
1409        } else {
1410            Spelling::Alias(canonical_key)
1411        };
1412        self.insert_entry(
1413            source_key,
1414            CachedEntry {
1415                uri: published.source().clone(),
1416                diagnostics,
1417                owner: server_id.clone(),
1418                seq,
1419                origin: Origin::Pushed {
1420                    spelling,
1421                    version,
1422                    opening,
1423                    covered: Covered::default(),
1424                },
1425            },
1426        );
1427        PushWrite::Stored { evicted }
1428    }
1429
1430    /// [`Self::write_published_diagnostics`] for tests that do not look at
1431    /// what the write evicted.
1432    #[cfg(test)]
1433    pub(crate) fn store_published_diagnostics(
1434        &mut self,
1435        server_id: &ServerId,
1436        published: &PublishedDiagnosticsUri,
1437        version: Option<i32>,
1438        diagnostics: Vec<LspDiagnostic>,
1439    ) {
1440        drop(self.write_published_diagnostics(
1441            server_id,
1442            published,
1443            version.map(DocumentVersion::new),
1444            BoundedDiagnostics::new(published.source(), diagnostics),
1445        ));
1446    }
1447
1448    const fn next_seq(&mut self) -> u64 {
1449        let seq = self.next_diagnostic_seq;
1450        self.next_diagnostic_seq = self.next_diagnostic_seq.saturating_add(1);
1451        seq
1452    }
1453
1454    /// Number of pushed slots (canonical and aliases) indexed under `file`.
1455    fn pushed_slot_count(&self, file: &DiagnosticsKey) -> usize {
1456        self.files.get(file).map_or(0, |slots| {
1457            slots
1458                .iter()
1459                .filter(|slot| slot.provenance == Provenance::Pushed)
1460                .count()
1461        })
1462    }
1463
1464    /// Removes `file`'s pulled slot when a push shows that its content is
1465    /// older than the file, by [`pull_is_superseded`]'s rule.
1466    ///
1467    /// The document's synced version is read from the attached tracker for
1468    /// the server that owns the pulled slot; a push that carries a document
1469    /// version newer than the pulled one supersedes it without the tracker.
1470    fn drop_superseded_pull(
1471        &mut self,
1472        file: &DiagnosticsKey,
1473        canonical: &Uri,
1474        pushed_version: Option<DocumentVersion>,
1475    ) {
1476        let slot = SlotKey::pulled(file.clone());
1477        let Some(entry) = self.entries.get(&slot) else {
1478            return;
1479        };
1480        let Origin::Pulled {
1481            opening: pulled_opening,
1482            version: pulled,
1483            ..
1484        } = entry.origin
1485        else {
1486            return;
1487        };
1488        let owner = entry.owner.clone();
1489        let sync = self.document_sync(&owner, canonical);
1490        let reopened =
1491            self.documents.is_some() && pulled_opening != self.document_opening(canonical);
1492        if reopened || pull_is_superseded(pulled, pushed_version, sync) {
1493            debug!(
1494                "dropping the pulled diagnostics of {}: superseded by a push \
1495                 (pulled {pulled:?}, pushed {pushed_version:?}, {sync:?})",
1496                canonical.as_ref()
1497            );
1498            drop(self.take_entry(&slot));
1499        }
1500    }
1501
1502    /// Attaches the document tracker whose synced versions decide whether a
1503    /// push supersedes a pulled slot. Without one, only a push carrying a
1504    /// newer document version does.
1505    pub(crate) fn attach_documents(&mut self, documents: Arc<DocumentTracker>) {
1506        self.documents = Some(documents);
1507    }
1508
1509    /// What the attached tracker says about the document `canonical` names,
1510    /// for `owner`.
1511    fn document_sync(&self, owner: &ServerId, canonical: &Uri) -> DocumentSync {
1512        let Some(documents) = &self.documents else {
1513            return DocumentSync::Unattached;
1514        };
1515        uri_to_path(canonical)
1516            .and_then(|path| documents.synced_version(&path, owner))
1517            .map_or(DocumentSync::NotOpen, DocumentSync::Synced)
1518    }
1519
1520    /// Which opening of the document `canonical` names the attached tracker
1521    /// holds, `None` when none is attached or it is not open.
1522    fn document_opening(&self, canonical: &Uri) -> Option<Opening> {
1523        let path = uri_to_path(canonical)?;
1524        self.documents.as_ref()?.opening(&path)
1525    }
1526
1527    /// Issues the claim a pull of `server`'s diagnostics for a document at
1528    /// `version` needs to redeem in [`Self::store_pulled_diagnostics`].
1529    ///
1530    /// Taken before the request is sent: the ticket orders concurrent pulls
1531    /// of one file and the epoch exposes a clear that happened meanwhile.
1532    pub(crate) fn begin_pull(&mut self, server: &ServerId, version: DocumentVersion) -> PullStamp {
1533        let ticket = PullTicket(self.next_pull_ticket);
1534        self.next_pull_ticket = self.next_pull_ticket.saturating_add(1);
1535        PullStamp {
1536            ticket,
1537            epoch: self.clear_epochs.get(server).copied().unwrap_or_default(),
1538            version,
1539        }
1540    }
1541
1542    /// Stores a `textDocument/diagnostic` report as `file`'s pulled slot,
1543    /// next to whatever the server pushed for it.
1544    ///
1545    /// The report is discarded unless `stamp`'s ticket is newer than the one
1546    /// stored, the server's diagnostics were not cleared since the stamp was
1547    /// issued, and `check` says the document still has the stamped version.
1548    /// Capacity evictions leave the written file's own slots alone and are
1549    /// returned so the caller can tell their subscribers.
1550    ///
1551    /// A slot that was evicted between two pulls no longer carries a ticket,
1552    /// so an older pull can then be stored; the next pull replaces it.
1553    #[cfg(test)]
1554    pub(crate) fn store_pulled_diagnostics(
1555        &mut self,
1556        server_id: &ServerId,
1557        file: &Uri,
1558        stamp: PullStamp,
1559        check: VersionCheck,
1560        items: BoundedDiagnostics,
1561    ) -> PullWrite {
1562        self.store_pulled_diagnostics_with(
1563            server_id,
1564            file,
1565            stamp,
1566            check,
1567            items,
1568            SnapshotBefore::Take,
1569        )
1570    }
1571
1572    /// As [`Self::store_pulled_diagnostics`], taking the "before" snapshot of
1573    /// a replaced slot only when `before` says so: cloning a file's entries
1574    /// is wasted when no subscriber will compare views.
1575    pub(crate) fn store_pulled_diagnostics_with(
1576        &mut self,
1577        server_id: &ServerId,
1578        file: &Uri,
1579        stamp: PullStamp,
1580        check: VersionCheck,
1581        items: BoundedDiagnostics,
1582        before: SnapshotBefore,
1583    ) -> PullWrite {
1584        let discard = |reason, items| PullWrite::Discarded {
1585            reason,
1586            evicted: Vec::new(),
1587            items,
1588        };
1589        if stamp.epoch
1590            != self
1591                .clear_epochs
1592                .get(server_id)
1593                .copied()
1594                .unwrap_or_default()
1595        {
1596            return discard(Discard::ServerCleared, items);
1597        }
1598        if check == VersionCheck::Moved {
1599            return discard(Discard::VersionMoved, items);
1600        }
1601        let file_key = DiagnosticsKey::of(file);
1602        let slot_key = SlotKey::pulled(file_key.clone());
1603        if self.entries.get(&slot_key).is_some_and(|stored| {
1604            matches!(stored.origin, Origin::Pulled { ticket, .. } if ticket >= stamp.ticket)
1605        }) {
1606            return discard(Discard::OlderTicket, items);
1607        }
1608
1609        let opening = self.document_opening(file);
1610        let same_state = self.entries.get(&slot_key).is_some_and(|entry| {
1611            entry.diagnostics == items.0
1612                && matches!(
1613                    entry.origin,
1614                    Origin::Pulled { opening: stored, version, .. }
1615                        if stored == opening && version == stamp.version
1616                )
1617        });
1618        let gained = self.coverage_gained(&slot_key);
1619        let slot = if same_state && gained.is_empty() {
1620            SlotChange::Identical
1621        } else {
1622            SlotChange::Replaced {
1623                before: (before == SnapshotBefore::Take).then(|| self.diagnostic_sources(file)),
1624            }
1625        };
1626
1627        drop(self.take_entry_with_coverage(&slot_key, gained));
1628        let evicted = match self.make_room(server_id, Protect::File(&file_key)) {
1629            Ok(evicted) => evicted,
1630            Err(NoRoom { evicted }) => {
1631                return PullWrite::Discarded {
1632                    reason: Discard::NoRoom,
1633                    evicted,
1634                    items,
1635                };
1636            }
1637        };
1638
1639        if !items.0.is_empty() {
1640            self.learn_pull_sources(server_id, &items.0);
1641        }
1642        let seq = self.next_seq();
1643        self.insert_entry(
1644            slot_key,
1645            CachedEntry {
1646                uri: file.clone(),
1647                diagnostics: items.0,
1648                owner: server_id.clone(),
1649                seq,
1650                origin: Origin::Pulled {
1651                    ticket: stamp.ticket,
1652                    opening,
1653                    version: stamp.version,
1654                },
1655            },
1656        );
1657        PullWrite::Stored { slot, evicted }
1658    }
1659
1660    /// Stores `diagnostics` as `file`'s pulled slot at document version 1, as
1661    /// an answered, current pull would.
1662    #[cfg(test)]
1663    pub(crate) fn store_pulled_for_test(
1664        &mut self,
1665        server_id: &ServerId,
1666        file: &Uri,
1667        diagnostics: Vec<LspDiagnostic>,
1668    ) {
1669        let stamp = self.begin_pull(server_id, DocumentVersion::FIRST);
1670        drop(self.store_pulled_diagnostics(
1671            server_id,
1672            file,
1673            stamp,
1674            VersionCheck::Current,
1675            BoundedDiagnostics::new(file, diagnostics),
1676        ));
1677    }
1678
1679    /// Stores `uri`'s diagnostics as the canonical spelling of its own file.
1680    #[cfg(test)]
1681    pub(crate) fn store_diagnostics(
1682        &mut self,
1683        server_id: &ServerId,
1684        uri: &Uri,
1685        version: Option<i32>,
1686        diagnostics: Vec<LspDiagnostic>,
1687    ) {
1688        let published = PublishedDiagnosticsUri::for_test(uri.clone(), uri.clone());
1689        self.store_published_diagnostics(server_id, &published, version, diagnostics);
1690    }
1691
1692    /// Adds `entry` under `key`, which must not be cached yet, to every
1693    /// index. Together with [`Self::take_entry`] the only code that touches
1694    /// `order`, `files` and `empty_diagnostics_count`.
1695    fn insert_entry(&mut self, key: SlotKey, entry: CachedEntry) {
1696        debug_assert!(!self.entries.contains_key(&key));
1697        debug_assert_eq!(key.provenance, entry.origin.provenance());
1698        self.eviction_marks.forget_file(entry.file(&key));
1699        self.order
1700            .entry(entry.owner.clone())
1701            .or_default()
1702            .insert(entry.seq, key.clone());
1703        self.files
1704            .entry(entry.file(&key).clone())
1705            .or_default()
1706            .insert(key.clone());
1707        if entry.diagnostics.is_empty() {
1708            self.empty_diagnostics_count = self.empty_diagnostics_count.saturating_add(1);
1709        }
1710        self.entries.insert(key, entry);
1711    }
1712
1713    /// Removes the entry cached under `key` from every index and returns it.
1714    ///
1715    /// The one place a pulled slot leaves the cache (replacement, a superseding
1716    /// push, eviction, a server clear): before it goes, the pushed entries it
1717    /// answered for are marked as covered, so no exit path can lose the
1718    /// evidence that a later read needs to drop what the pull already fixed.
1719    fn take_entry(&mut self, key: &SlotKey) -> Option<CachedEntry> {
1720        let gained = if key.provenance == Provenance::Pulled {
1721            self.coverage_gained(key)
1722        } else {
1723            Vec::new()
1724        };
1725        self.take_entry_with_coverage(key, gained)
1726    }
1727
1728    /// [`Self::take_entry`] with the coverage `key`'s slot gains already
1729    /// computed, for a caller that needed it before removing the entry.
1730    fn take_entry_with_coverage(
1731        &mut self,
1732        key: &SlotKey,
1733        gained: Vec<(SlotKey, BTreeSet<usize>)>,
1734    ) -> Option<CachedEntry> {
1735        for (pushed, gained) in gained {
1736            if let Some(CachedEntry {
1737                origin: Origin::Pushed { covered, .. },
1738                ..
1739            }) = self.entries.get_mut(&pushed)
1740            {
1741                covered.0.extend(gained);
1742            }
1743        }
1744        let entry = self.entries.remove(key)?;
1745        if let Some(order) = self.order.get_mut(&entry.owner) {
1746            order.remove(&entry.seq);
1747        }
1748        let file = entry.file(key);
1749        if let Some(members) = self.files.get_mut(file) {
1750            members.remove(key);
1751            if members.is_empty() {
1752                self.files.remove(file);
1753            }
1754        }
1755        if entry.diagnostics.is_empty() {
1756            self.empty_diagnostics_count = self.empty_diagnostics_count.saturating_sub(1);
1757        }
1758        Some(entry)
1759    }
1760
1761    /// What the pulled slot `slot` would add to the coverage of the pushed
1762    /// entries of its file: for each same-server pushed entry at the slot's
1763    /// document version (and opening), the indices of items that are the same
1764    /// problem as one the pull reported.
1765    fn coverage_gained(&self, slot: &SlotKey) -> Vec<(SlotKey, BTreeSet<usize>)> {
1766        let Some(pulled) = self.entries.get(slot) else {
1767            return Vec::new();
1768        };
1769        let Origin::Pulled {
1770            opening, version, ..
1771        } = &pulled.origin
1772        else {
1773            return Vec::new();
1774        };
1775        let Some(members) = self.files.get(&slot.uri) else {
1776            return Vec::new();
1777        };
1778        let index = PulledIndex::new(&pulled.diagnostics);
1779        members
1780            .iter()
1781            .filter(|key| key.provenance == Provenance::Pushed)
1782            .filter_map(|key| {
1783                let entry = self.entries.get(key)?;
1784                let Origin::Pushed {
1785                    opening: pushed_opening,
1786                    covered,
1787                    ..
1788                } = &entry.origin
1789                else {
1790                    return None;
1791                };
1792                let same_state = entry.owner == pulled.owner
1793                    && entry.origin.version() == Some(*version)
1794                    && pushed_opening.is_none_or(|pushed| Some(pushed) == *opening);
1795                if !same_state {
1796                    return None;
1797                }
1798                let gained: BTreeSet<usize> = entry
1799                    .diagnostics
1800                    .iter()
1801                    .enumerate()
1802                    .filter(|(position, diagnostic)| {
1803                        !covered.0.contains(position) && index.contains_same_problem(diagnostic)
1804                    })
1805                    .map(|(position, _)| position)
1806                    .collect();
1807                (!gained.is_empty()).then(|| (key.clone(), gained))
1808            })
1809            .collect()
1810    }
1811
1812    /// Remembers the `source` values of a non-empty full pull report of
1813    /// `owner`, up to `MAX_LEARNED_PULL_SOURCES`.
1814    fn learn_pull_sources(&mut self, owner: &ServerId, items: &[LspDiagnostic]) {
1815        let learned = self.pull_sources.entry(owner.clone()).or_default();
1816        for source in items.iter().filter_map(DiagnosticSource::of) {
1817            if learned.len() >= MAX_LEARNED_PULL_SOURCES {
1818                break;
1819            }
1820            if !learned.contains(&source) {
1821                learned.push(source);
1822            }
1823        }
1824    }
1825
1826    /// Whether the pushed `entry` of the file `key` (read as `requested`) is
1827    /// older than the state the tracker last synced for its server, and which
1828    /// of its items the server's pull at that state does not repeat.
1829    ///
1830    /// An item is covered when a pull of the entry's own state reported the
1831    /// same problem (recorded when that pull's slot left the cache), or when
1832    /// its `source` is one the server's pull reports carry -- which is how
1833    /// a push-only flycheck item (`rustc`, `clippy`) stays visible: no pull
1834    /// ever reports that source. Nothing is excluded without a pulled slot of
1835    /// the same server at the synced state, and an entry of another opening
1836    /// is never covered by a learned source.
1837    fn push_freshness(
1838        &self,
1839        key: &DiagnosticsKey,
1840        requested: &Uri,
1841        entry: &CachedEntry,
1842    ) -> Freshness {
1843        let Origin::Pushed {
1844            opening, covered, ..
1845        } = &entry.origin
1846        else {
1847            return Freshness::Current;
1848        };
1849        let Some(version) = entry.origin.version() else {
1850            return Freshness::Current;
1851        };
1852        let DocumentSync::Synced(synced) = self.document_sync(&entry.owner, requested) else {
1853            return Freshness::Current;
1854        };
1855        let current_opening = self.document_opening(requested);
1856        let same_opening = opening.is_none_or(|opening| Some(opening) == current_opening);
1857        if same_opening && version == synced {
1858            return Freshness::Current;
1859        }
1860        let answered_at_synced_state =
1861            self.entries
1862                .get(&SlotKey::pulled(key.clone()))
1863                .is_some_and(|pulled| {
1864                    pulled.owner == entry.owner
1865                        && matches!(
1866                            pulled.origin,
1867                            Origin::Pulled { opening: pulled_opening, version: pulled_version, .. }
1868                                if pulled_version == synced && pulled_opening == current_opening
1869                        )
1870                });
1871        if !answered_at_synced_state {
1872            return Freshness::Current;
1873        }
1874        let mut covered = covered.clone();
1875        if same_opening && let Some(learned) = self.pull_sources.get(&entry.owner) {
1876            covered.0.extend(
1877                entry
1878                    .diagnostics
1879                    .iter()
1880                    .enumerate()
1881                    .filter(|(_, diagnostic)| {
1882                        DiagnosticSource::of(diagnostic)
1883                            .is_some_and(|source| learned.contains(&source))
1884                    })
1885                    .map(|(position, _)| position),
1886            );
1887        }
1888        Freshness::Superseded { covered }
1889    }
1890
1891    /// Evicts entries until one more fits under `MAX_DIAGNOSTIC_ENTRIES`, on
1892    /// behalf of a write by `writer`, and returns the files whose slots were
1893    /// evicted.
1894    ///
1895    /// Ends `writer`'s eviction overflow first, since an accepted write does;
1896    /// the overflow is restored when no room can be made, so a refused write
1897    /// leaves it as it was. Slots of the file `protect` names are never evicted.
1898    fn make_room(
1899        &mut self,
1900        writer: &ServerId,
1901        protect: Protect<'_>,
1902    ) -> Result<Vec<DiagnosticsKey>, NoRoom> {
1903        let was_overflowed = self.eviction_marks.clear_overflow(writer);
1904        let mut evicted = Vec::new();
1905        while self.entries.len() >= MAX_DIAGNOSTIC_ENTRIES {
1906            let Some((_, _, victim)) = self.entry_to_evict(writer, protect) else {
1907                if was_overflowed {
1908                    self.eviction_marks.restore_overflow(writer);
1909                }
1910                return Err(NoRoom { evicted });
1911            };
1912            evicted.extend(self.evict_for_capacity(&victim));
1913        }
1914        Ok(evicted)
1915    }
1916
1917    /// Removes the entry under `key` for good (eviction, alias replacement,
1918    /// server clear) and remembers an empty one in the replay ring, so a
1919    /// clear lost in a listen's lease gap is still replayed.
1920    fn evict_entry(&mut self, key: &SlotKey) {
1921        let Some(entry) = self.take_entry(key) else {
1922            return;
1923        };
1924        if entry.diagnostics.is_empty() {
1925            self.record_empty_eviction(entry.file(key).clone(), std::time::Instant::now());
1926        }
1927    }
1928
1929    /// Evicts the entry under `key` to make room and returns its file, after
1930    /// remembering what was lost for [`Self::availability`].
1931    ///
1932    /// A clean entry is remembered only when it was the file's last one: while
1933    /// other slots of the file remain, they answer reads.
1934    fn evict_for_capacity(&mut self, key: &SlotKey) -> Option<DiagnosticsKey> {
1935        let entry = self.entries.get(key)?;
1936        let file = entry.file(key).clone();
1937        let owner = entry.owner.clone();
1938        let content = if entry.diagnostics.is_empty() {
1939            EvictedContent::Clean
1940        } else {
1941            EvictedContent::Lost
1942        };
1943        self.evict_entry(key);
1944        if content == EvictedContent::Lost || !self.files.contains_key(&file) {
1945            self.eviction_marks.record(file.clone(), owner, content);
1946        }
1947        Some(file)
1948    }
1949
1950    fn record_empty_eviction(&mut self, file: DiagnosticsKey, now: std::time::Instant) {
1951        self.recent_evictions.record(file, now);
1952    }
1953
1954    /// Whether `uri` had an empty entry removed within the replay window and
1955    /// has not been cached again since.
1956    fn was_recently_evicted(&self, uri: &Uri, now: std::time::Instant) -> bool {
1957        let key = DiagnosticsKey::of(uri);
1958        !self.has_diagnostics(uri) && self.recent_evictions.contains(&key, now)
1959    }
1960
1961    /// Whether a `subscriptions/listen` attaching now must be told about
1962    /// `uri`: it has cached diagnostics, or a clear for it was removed
1963    /// recently enough that a client re-attaching after a lease gap may have
1964    /// missed it.
1965    pub(crate) fn is_listen_replayable(&self, uri: &Uri) -> bool {
1966        self.has_diagnostics(uri) || self.was_recently_evicted(uri, std::time::Instant::now())
1967    }
1968
1969    /// Panics unless `order`, `files` and `empty_diagnostics_count` describe
1970    /// exactly the cached entries.
1971    #[cfg(test)]
1972    fn assert_consistent(&self) {
1973        let mut ordered = 0;
1974        for (server, order) in &self.order {
1975            for (seq, key) in order {
1976                let entry = self
1977                    .entries
1978                    .get(key)
1979                    .unwrap_or_else(|| panic!("ordered key has no entry"));
1980                assert_eq!((&entry.owner, entry.seq), (server, *seq));
1981                ordered += 1;
1982            }
1983        }
1984        assert_eq!(ordered, self.entries.len(), "order and entries diverge");
1985
1986        let mut filed = 0;
1987        for (file, members) in &self.files {
1988            assert!(!members.is_empty(), "empty file set kept");
1989            for key in members {
1990                let entry = self
1991                    .entries
1992                    .get(key)
1993                    .unwrap_or_else(|| panic!("filed key has no entry"));
1994                assert_eq!(entry.file(key), file);
1995                filed += 1;
1996            }
1997        }
1998        assert_eq!(filed, self.entries.len(), "files and entries diverge");
1999
2000        let empty = self
2001            .entries
2002            .values()
2003            .filter(|entry| entry.diagnostics.is_empty())
2004            .count();
2005        assert_eq!(empty, self.empty_diagnostics_count);
2006
2007        let marks = &self.eviction_marks;
2008        assert_eq!(marks.by_file.len(), marks.order.len(), "marks diverge");
2009        for (seq, file) in &marks.order {
2010            assert_eq!(marks.by_file.get(file).map(|mark| mark.seq), Some(*seq));
2011        }
2012    }
2013
2014    /// The least recently written non-canonical source indexed under
2015    /// `canonical_key`.
2016    fn oldest_alias_source(&self, canonical_key: &DiagnosticsKey) -> Option<SlotKey> {
2017        self.files
2018            .get(canonical_key)?
2019            .iter()
2020            .filter_map(|source| {
2021                let entry = self.entries.get(source)?;
2022                matches!(
2023                    entry.origin,
2024                    Origin::Pushed {
2025                        spelling: Spelling::Alias(_),
2026                        ..
2027                    }
2028                )
2029                .then_some((entry.seq, source))
2030            })
2031            .min_by_key(|(seq, _)| *seq)
2032            .map(|(_, source)| source.clone())
2033    }
2034
2035    /// Keys of every entry cached for the file `key` names: its indexed
2036    /// slots plus a pushed entry stored directly under `key` itself.
2037    fn source_keys(&self, key: &DiagnosticsKey) -> BTreeSet<SlotKey> {
2038        let mut keys: BTreeSet<SlotKey> =
2039            self.files.get(key).into_iter().flatten().cloned().collect();
2040        let direct = SlotKey::pushed(key.clone());
2041        if self.entries.contains_key(&direct) {
2042            keys.insert(direct);
2043        }
2044        keys
2045    }
2046
2047    /// Store a log entry.
2048    ///
2049    /// Maintains a maximum of `MAX_LOG_ENTRIES` entries, removing oldest when full.
2050    /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
2051    pub(crate) fn store_log(&mut self, level: LogLevel, message: String) {
2052        let entry = LogEntry {
2053            level,
2054            message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
2055            timestamp: Utc::now(),
2056        };
2057
2058        if self.logs.len() >= MAX_LOG_ENTRIES {
2059            self.logs.pop_front();
2060        }
2061        self.logs.push_back(entry);
2062    }
2063
2064    /// Store a server message.
2065    ///
2066    /// Maintains a maximum of `MAX_SERVER_MESSAGES` entries, removing oldest when full.
2067    /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
2068    pub(crate) fn store_message(&mut self, message_type: MessageType, message: String) {
2069        let msg = ServerMessage {
2070            message_type,
2071            message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
2072            timestamp: Utc::now(),
2073        };
2074
2075        if self.messages.len() >= MAX_SERVER_MESSAGES {
2076            self.messages.pop_front();
2077        }
2078        self.messages.push_back(msg);
2079    }
2080
2081    /// Record a workspace-readiness signal from an unrecognized/custom LSP
2082    /// notification (`LspNotification::Other`), updating `server_id`'s
2083    /// tracked [`IndexingState`] if the notification is one this cache
2084    /// understands.
2085    ///
2086    /// Currently recognizes rust-analyzer's `experimental/serverStatus`
2087    /// notification: a `quiescent` boolean of `false` marks the
2088    /// server [`IndexingState::Loading`], `true` marks it
2089    /// [`IndexingState::Ready`]. Any other method, or a `serverStatus`
2090    /// payload missing/malformed the field, leaves the current state
2091    /// untouched rather than erroring -- an unrecognized signal is
2092    /// equivalent to no signal.
2093    ///
2094    /// Once a server reaches [`IndexingState::Ready`] it never regresses on
2095    /// its own: this only tracks the *initial* workspace load, not later
2096    /// re-indexing triggered by large-scale file changes. See
2097    /// [`Self::reset_indexing_state`] for the one case that does move a
2098    /// server back out of `Ready`/`Loading`.
2099    pub(crate) fn observe_indexing_signal(
2100        &mut self,
2101        server_id: &ServerId,
2102        method: &str,
2103        params: Option<&serde_json::Value>,
2104    ) {
2105        self.indexing
2106            .observe_server_status(server_id, method, params);
2107    }
2108
2109    /// Record a `$/progress` notification toward `server_id`'s tracked
2110    /// [`IndexingState`] -- the generic LSP counterpart to
2111    /// `Self::observe_indexing_signal`'s rust-analyzer-specific
2112    /// `experimental/serverStatus`. See
2113    /// [`crate::bridge::indexing::IndexingTracker::observe_progress`] for
2114    /// the full begin/end/settle/latch transition rules.
2115    pub(crate) fn observe_progress(
2116        &mut self,
2117        server_id: &ServerId,
2118        params: &lsp_types::ProgressParams,
2119    ) {
2120        self.indexing.observe_progress(server_id, params);
2121    }
2122
2123    /// Configure `server_id`'s [`IndexingPolicy`] -- see
2124    /// [`crate::bridge::indexing::IndexingTracker::set_policy`].
2125    pub(crate) fn set_indexing_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
2126        self.indexing.set_policy(server_id, policy);
2127    }
2128
2129    /// Current tracked workspace-indexing readiness for `server_id`.
2130    ///
2131    /// Returns [`IndexingState::Unknown`] for a server no readiness signal
2132    /// has ever been observed for -- see `Self::observe_indexing_signal`.
2133    ///
2134    /// A `Loading` entry older than `INDEXING_STALENESS_BOUND` is read
2135    /// back as `Unknown` rather than `Loading`: this is the self-heal for a
2136    /// `quiescent: true` notification dropped by a full channel, or a
2137    /// server that stalled mid-index, applied at *read* time based on the
2138    /// signal's own age so it can never be triggered by (and can never
2139    /// affect) any individual caller's own wait -- see
2140    /// `Translator::wait_for_indexing_ready`.
2141    #[must_use]
2142    pub fn indexing_state(&self, server_id: &ServerId) -> IndexingState {
2143        self.indexing.state(server_id)
2144    }
2145
2146    /// Reset `server_id`'s tracked [`IndexingState`] as `reset` says;
2147    /// [`IndexingReset::Forget`] reverts it to [`IndexingState::Unknown`],
2148    /// [`IndexingReset::AwaitReplacement`] to [`IndexingState::Loading`] until
2149    /// the replacement's first signal when the server has reported one before.
2150    ///
2151    /// `Translator::respawn_if_dead` calls this after replacing a crashed
2152    /// server's process, since the new process starts indexing from
2153    /// scratch and has sent no signal of its own yet -- a stale
2154    /// `Ready`/`Loading` carried over from the crashed connection must not
2155    /// leak into requests routed to its replacement. This is the only
2156    /// production caller: a timed-out [`Self::indexing_state`] read does
2157    /// *not* call this, so one caller's wait can never affect another's.
2158    pub(crate) fn reset_indexing_state(&mut self, server_id: &ServerId, reset: IndexingReset) {
2159        self.indexing.reset(server_id, reset);
2160    }
2161
2162    /// Get diagnostics for a document URI.
2163    ///
2164    /// If the stored list was ever truncated by `store_published_diagnostics`'s
2165    /// `MAX_DIAGNOSTICS_ENTRY_BYTES` cap (#311), the diagnostics here are in
2166    /// severity order (`ERROR` first), not the original publish/file-position
2167    /// order -- callers that assume file-position order should not rely on
2168    /// it after a cap-triggered truncation.
2169    ///
2170    /// Looks up the pushed entry stored under exactly `uri`. A file reachable
2171    /// through symlinks may have its diagnostics spread over several
2172    /// published URIs, and a pull may have stored its own list next to them;
2173    /// use [`Self::diagnostic_sources`] to read the union.
2174    #[inline]
2175    #[must_use]
2176    pub fn diagnostics(&self, uri: &Uri) -> Option<DiagnosticInfo> {
2177        self.entries
2178            .get(&SlotKey::pushed(DiagnosticsKey::of(uri)))
2179            .map(CachedEntry::info)
2180    }
2181
2182    /// Snapshot of every entry cached for the file `uri` names, to be
2183    /// [merged](DiagnosticSources::merge) after the cache lock is released.
2184    ///
2185    /// Clones the entries (at most 8 pushed and 1 pulled, each bounded to
2186    /// 1 MiB) so the merge can run without the lock.
2187    ///
2188    /// # Examples
2189    ///
2190    /// ```
2191    /// use lsp_types::Uri;
2192    /// use mcpls_core::bridge::NotificationCache;
2193    ///
2194    /// let cache = NotificationCache::new();
2195    /// let uri = Uri::from("file:///workspace/main.rs".to_owned());
2196    /// let snapshot = cache.diagnostic_sources(&uri);
2197    /// // Merge once the cache lock has been released.
2198    /// assert!(snapshot.merge().is_none());
2199    /// ```
2200    #[must_use]
2201    pub fn diagnostic_sources(&self, uri: &Uri) -> DiagnosticSources {
2202        let key = DiagnosticsKey::of(uri);
2203        let entries = self
2204            .source_keys(&key)
2205            .into_iter()
2206            .filter_map(|source| {
2207                let entry = self.entries.get(&source)?;
2208                Some(SourceEntry {
2209                    info: entry.info(),
2210                    origin: SourceOrigin::from(&entry.origin),
2211                    freshness: self.push_freshness(&key, uri, entry),
2212                })
2213            })
2214            .collect();
2215        DiagnosticSources {
2216            requested: uri.clone(),
2217            entries,
2218        }
2219    }
2220
2221    /// Whether any diagnostics entry (possibly empty) is cached for the file
2222    /// `uri` names, under any of its published URIs.
2223    ///
2224    /// # Examples
2225    ///
2226    /// ```
2227    /// use lsp_types::Uri;
2228    /// use mcpls_core::bridge::NotificationCache;
2229    ///
2230    /// let cache = NotificationCache::new();
2231    /// let uri = Uri::from("file:///workspace/main.rs".to_owned());
2232    /// assert!(!cache.has_diagnostics(&uri));
2233    /// ```
2234    #[must_use]
2235    pub fn has_diagnostics(&self, uri: &Uri) -> bool {
2236        let key = DiagnosticsKey::of(uri);
2237        self.entries.contains_key(&SlotKey::pushed(key.clone())) || self.files.contains_key(&key)
2238    }
2239
2240    /// Whether the cache has an answer for the file `uri` names, read for the
2241    /// diagnostics server `route`.
2242    ///
2243    /// An entry, or the memory that capacity eviction removed only clean
2244    /// entries of the file, is [`DiagnosticsAvailability::Published`]. Lost
2245    /// diagnostics, or no trace of the file although eviction forgot some of
2246    /// `route`'s files, is [`DiagnosticsAvailability::Evicted`]. Anything else
2247    /// is [`DiagnosticsAvailability::Pending`]: nothing was published since
2248    /// the server started.
2249    ///
2250    /// # Examples
2251    ///
2252    /// ```
2253    /// use lsp_types::Uri;
2254    /// use mcpls_core::bridge::{DiagnosticsAvailability, NotificationCache};
2255    ///
2256    /// let cache = NotificationCache::new();
2257    /// let uri = Uri::from("file:///workspace/main.rs".to_owned());
2258    /// assert_eq!(cache.availability(&uri, None), DiagnosticsAvailability::Pending);
2259    /// ```
2260    #[must_use]
2261    pub fn availability(&self, uri: &Uri, route: Option<&ServerId>) -> DiagnosticsAvailability {
2262        if self.has_diagnostics(uri) {
2263            return DiagnosticsAvailability::Published;
2264        }
2265        match self.eviction_marks.content(&DiagnosticsKey::of(uri)) {
2266            Some(EvictedContent::Clean) => DiagnosticsAvailability::Published,
2267            Some(EvictedContent::Lost) => DiagnosticsAvailability::Evicted,
2268            None if route.is_some_and(|server| self.eviction_marks.has_overflowed(server)) => {
2269                DiagnosticsAvailability::Evicted
2270            }
2271            None => DiagnosticsAvailability::Pending,
2272        }
2273    }
2274
2275    /// Server that published the currently cached diagnostics for `uri`, if
2276    /// any. Used to look up that server's negotiated position encoding for a
2277    /// cache-only read that has no live LSP round trip of its own to resolve
2278    /// one from.
2279    #[inline]
2280    #[must_use]
2281    pub fn diagnostics_owner(&self, uri: &Uri) -> Option<&ServerId> {
2282        let key = DiagnosticsKey::of(uri);
2283        self.entries
2284            .get(&SlotKey::pushed(key.clone()))
2285            .or_else(|| {
2286                let slots = self.files.get(&key)?;
2287                slots
2288                    .iter()
2289                    .filter(|slot| slot.provenance == Provenance::Pushed)
2290                    .chain(slots.iter())
2291                    .find_map(|source| self.entries.get(source))
2292            })
2293            .map(|entry| &entry.owner)
2294    }
2295
2296    /// All stored log entries.
2297    #[inline]
2298    #[must_use]
2299    pub const fn logs(&self) -> &VecDeque<LogEntry> {
2300        &self.logs
2301    }
2302
2303    /// All stored server messages.
2304    #[inline]
2305    #[must_use]
2306    pub const fn messages(&self) -> &VecDeque<ServerMessage> {
2307        &self.messages
2308    }
2309
2310    /// Clear all diagnostics owned by a single server.
2311    ///
2312    /// Used when a server crashes and respawns: its own stale entries must
2313    /// be invalidated without disturbing any other server's cache entries
2314    /// (#266). Returns the keys of the cleared files so a caller can tell
2315    /// subscribers which resources changed (see [`diagnostics_cache_key`]).
2316    ///
2317    /// Also invalidates every pull of this server issued before the call, so
2318    /// a report answered by the replaced process is not stored afterwards.
2319    pub(crate) fn clear_server_diagnostics(&mut self, server_id: &ServerId) -> Vec<DiagnosticsKey> {
2320        let epoch = self.clear_epochs.entry(server_id.clone()).or_default();
2321        epoch.0 = epoch.0.saturating_add(1);
2322        self.eviction_marks.forget_server(server_id);
2323        self.pull_sources.remove(server_id);
2324        let Some(order) = self.order.remove(server_id) else {
2325            return Vec::new();
2326        };
2327        let mut cleared = Vec::with_capacity(order.len());
2328        for key in order.into_values() {
2329            if let Some(entry) = self.entries.get(&key) {
2330                cleared.push(entry.file(&key).clone());
2331            }
2332            self.evict_entry(&key);
2333        }
2334        cleared.sort_unstable();
2335        cleared.dedup();
2336        cleared
2337    }
2338
2339    /// Records that publishes of `owner` for `files` never reached the cache
2340    /// (the delivery mailbox could not hold them), so those files read as lost
2341    /// diagnostics instead of `pending`, until the owner's next publish for
2342    /// them. The owner's older entries of such a file are removed: a newer
2343    /// publish exists that the cache cannot show, and older content must not
2344    /// pass for it.
2345    ///
2346    /// Returns the files whose view changed, for the caller to announce.
2347    pub(crate) fn record_lost_files(
2348        &mut self,
2349        owner: &ServerId,
2350        files: &[DiagnosticsKey],
2351    ) -> Vec<DiagnosticsKey> {
2352        for file in files {
2353            let stale: Vec<SlotKey> = self
2354                .files
2355                .get(file)
2356                .into_iter()
2357                .flatten()
2358                .filter(|key| {
2359                    key.provenance == Provenance::Pushed
2360                        && self
2361                            .entries
2362                            .get(key)
2363                            .is_some_and(|entry| entry.owner == *owner)
2364                })
2365                .cloned()
2366                .collect();
2367            for key in stale {
2368                self.evict_entry(&key);
2369            }
2370            if !self.has_diagnostics_for(file) {
2371                self.eviction_marks
2372                    .record(file.clone(), owner.clone(), EvictedContent::Lost);
2373            }
2374        }
2375        files.to_vec()
2376    }
2377
2378    /// Records that `owner` lost more publishes than the mailbox could name,
2379    /// as capacity eviction does.
2380    pub(crate) fn record_unnamed_loss(&mut self, owner: &ServerId) {
2381        self.eviction_marks.restore_overflow(owner);
2382    }
2383
2384    /// Whether any entry is cached for the file `key` names.
2385    fn has_diagnostics_for(&self, key: &DiagnosticsKey) -> bool {
2386        self.files.contains_key(key) || self.entries.contains_key(&SlotKey::pushed(key.clone()))
2387    }
2388
2389    /// Marks `server_id`'s push-based diagnostics as no longer live -- see
2390    /// the `push_degraded` field doc for why this is permanent for the life
2391    /// of the cache.
2392    pub(crate) fn mark_push_degraded(&mut self, server_id: &ServerId) {
2393        self.push_degraded.insert(server_id.clone());
2394    }
2395
2396    /// Marks `server_id`'s push-based diagnostics as live again, after its
2397    /// notification pump was re-wired by a manual restart.
2398    pub(crate) fn clear_push_degraded(&mut self, server_id: &ServerId) {
2399        self.push_degraded.remove(server_id);
2400    }
2401
2402    /// Whether `server_id`'s push-based diagnostics are known to be
2403    /// degraded (see [`Self::mark_push_degraded`]) -- callers such as
2404    /// `get_cached_diagnostics` and `read_resource` use this to flag a
2405    /// cache-only result as potentially incomplete rather than presenting it as
2406    /// current.
2407    #[inline]
2408    #[must_use]
2409    pub(crate) fn is_push_degraded(&self, server_id: &ServerId) -> bool {
2410        self.push_degraded.contains(server_id)
2411    }
2412
2413    /// Get the number of documents with stored diagnostics.
2414    #[inline]
2415    #[must_use]
2416    #[cfg(test)]
2417    pub(crate) fn diagnostics_count(&self) -> usize {
2418        self.entries.len()
2419    }
2420
2421    /// Get the number of stored log entries.
2422    #[inline]
2423    #[must_use]
2424    #[cfg(test)]
2425    pub(crate) fn logs_count(&self) -> usize {
2426        self.logs.len()
2427    }
2428
2429    /// Get the number of stored server messages.
2430    #[inline]
2431    #[must_use]
2432    #[cfg(test)]
2433    pub(crate) fn messages_count(&self) -> usize {
2434        self.messages.len()
2435    }
2436}
2437
2438/// Applies one lifecycle-lane notification to `cache`.
2439///
2440/// Only `$/progress` and the generic `Other` variant (which carries e.g.
2441/// rust-analyzer's `experimental/serverStatus`) ever travel on that lane,
2442/// since the notification lane handles diagnostics/log/showMessage instead
2443/// (see `LspClient::message_loop_inner`'s routing).
2444///
2445/// Shared by `diagnostics_pump`'s lifecycle-lane arm (wiring a freshly
2446/// spawned server's own pump) and `Translator::respawn_if_dead`'s
2447/// lifecycle-lane forwarding task (wiring a respawned server's replacement
2448/// process), so the two can't silently drift apart on which notification
2449/// variants feed which `NotificationCache` method.
2450///
2451/// Public because [`LspServer::take_lifecycle_rx`](crate::lsp::LspServer::take_lifecycle_rx)
2452/// is: whoever drains that receiver needs this as the sink that feeds the
2453/// indexing state.
2454///
2455/// # Examples
2456///
2457/// ```
2458/// use mcpls_core::bridge::{IndexingState, NotificationCache, apply_lifecycle_notification};
2459/// use mcpls_core::config::ServerId;
2460/// use mcpls_core::lsp::LspNotification;
2461///
2462/// let mut cache = NotificationCache::new();
2463/// let server_id = ServerId::from_static("rust");
2464/// apply_lifecycle_notification(
2465///     &mut cache,
2466///     &server_id,
2467///     LspNotification::Other {
2468///         method: "experimental/serverStatus".into(),
2469///         params: Some(serde_json::json!({"quiescent": true})),
2470///     },
2471/// );
2472/// assert_eq!(cache.indexing_state(&server_id), IndexingState::Ready);
2473/// ```
2474pub fn apply_lifecycle_notification(
2475    cache: &mut NotificationCache,
2476    server_id: &ServerId,
2477    notif: crate::lsp::LspNotification,
2478) {
2479    match notif {
2480        crate::lsp::LspNotification::Progress(params) => {
2481            cache.observe_progress(server_id, &params);
2482        }
2483        crate::lsp::LspNotification::Other { method, params } => {
2484            cache.observe_indexing_signal(server_id, &method, params.as_ref());
2485        }
2486        crate::lsp::LspNotification::PublishDiagnostics(_)
2487        | crate::lsp::LspNotification::LogMessage(_)
2488        | crate::lsp::LspNotification::ShowMessage(_) => {}
2489    }
2490}
2491
2492/// Handles one lifecycle-lane notification of `server_id`: warns when a pinned
2493/// `tsserver` was ignored, then applies it to `cache` when there is one.
2494///
2495/// Every consumer of the lifecycle lane goes through here, so no consumer can
2496/// skip a step (#658).
2497pub async fn on_lifecycle(
2498    cache: Option<&tokio::sync::Mutex<NotificationCache>>,
2499    server_id: &ServerId,
2500    pinned_tsserver: Option<&std::path::Path>,
2501    notif: crate::lsp::LspNotification,
2502) {
2503    crate::lsp::tsserver_pin::warn_if_pin_ignored(pinned_tsserver, &notif, server_id);
2504    if let Some(cache) = cache {
2505        apply_lifecycle_notification(&mut *cache.lock().await, server_id, notif);
2506    }
2507}
2508
2509#[cfg(test)]
2510mod pull_tests;
2511
2512#[cfg(test)]
2513mod tests {
2514    use lsp_types::{Position, Range};
2515
2516    use super::bounds::*;
2517    use super::*;
2518    use crate::bridge::indexing::IndexingReset;
2519    use crate::test_lsp::CapturedLogs;
2520
2521    /// Every test in this module that doesn't exercise multi-server
2522    /// fairness routes through one implicit server, so `set_diagnostics_route_count`
2523    /// is left at its default of `1` (full `MAX_DIAGNOSTIC_ENTRIES` budget).
2524    fn test_server() -> ServerId {
2525        ServerId::from_static("test-server")
2526    }
2527
2528    #[test]
2529    fn test_notification_cache_new() {
2530        let cache = NotificationCache::new();
2531        assert_eq!(cache.diagnostics_count(), 0);
2532        assert_eq!(cache.logs_count(), 0);
2533        assert_eq!(cache.messages_count(), 0);
2534    }
2535
2536    #[test]
2537    fn test_store_and_diagnostics() {
2538        let mut cache = NotificationCache::new();
2539        let uri: Uri = Uri::from("file:///test.rs");
2540
2541        let diagnostic = LspDiagnostic {
2542            range: Range {
2543                start: Position {
2544                    line: 0,
2545                    character: 0,
2546                },
2547                end: Position {
2548                    line: 0,
2549                    character: 5,
2550                },
2551            },
2552            severity: Some(lsp_types::DiagnosticSeverity::Error),
2553            message: "test error".to_string().into(),
2554            code: None,
2555            source: None,
2556            code_description: None,
2557            related_information: None,
2558            tags: None,
2559            data: None,
2560        };
2561
2562        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2563
2564        let stored = cache.diagnostics(&uri).unwrap();
2565        assert_eq!(stored.uri, uri);
2566        assert_eq!(stored.version.map(DocumentVersion::get), Some(1));
2567        assert_eq!(stored.diagnostics.len(), 1);
2568        assert_eq!(
2569            stored.diagnostics[0].message,
2570            lsp_types::Message::String("test error".to_string())
2571        );
2572    }
2573
2574    /// #311: a single diagnostic's `message` must be bounded independently
2575    /// of `MAX_DIAGNOSTIC_ENTRIES`, which only caps the number of entries.
2576    #[test]
2577    fn test_store_diagnostics_truncates_oversized_message() {
2578        let mut cache = NotificationCache::new();
2579        let uri: Uri = Uri::from("file:///test.rs");
2580        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
2581
2582        let diagnostic = LspDiagnostic {
2583            range: Range {
2584                start: Position {
2585                    line: 0,
2586                    character: 0,
2587                },
2588                end: Position {
2589                    line: 0,
2590                    character: 5,
2591                },
2592            },
2593            severity: Some(lsp_types::DiagnosticSeverity::Error),
2594            message: oversized.clone().into(),
2595            code: None,
2596            source: None,
2597            code_description: None,
2598            related_information: None,
2599            tags: None,
2600            data: None,
2601        };
2602
2603        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2604
2605        let stored = cache.diagnostics(&uri).unwrap();
2606        let stored = message_as_str(&stored.diagnostics[0].message);
2607        assert!(stored.len() < oversized.len());
2608        assert!(stored.ends_with("... (truncated)"));
2609    }
2610
2611    /// Minimal diagnostic with an arbitrary `message`, for tests that only
2612    /// care about size/count bounds rather than range/severity details.
2613    fn minimal_diagnostic(message: String) -> LspDiagnostic {
2614        LspDiagnostic {
2615            range: Range {
2616                start: Position {
2617                    line: 0,
2618                    character: 0,
2619                },
2620                end: Position {
2621                    line: 0,
2622                    character: 5,
2623                },
2624            },
2625            severity: Some(lsp_types::DiagnosticSeverity::Error),
2626            message: message.into(),
2627            code: None,
2628            source: None,
2629            code_description: None,
2630            related_information: None,
2631            tags: None,
2632            data: None,
2633        }
2634    }
2635
2636    /// #693: one severity order serves the report and the size cap, and a
2637    /// diagnostic without a severity counts as information in both.
2638    #[test]
2639    fn test_reported_severity_orders_most_severe_first_and_defaults_to_information() {
2640        let mut severityless = minimal_diagnostic("none".to_string());
2641        severityless.severity = None;
2642        let mut hint = minimal_diagnostic("hint".to_string());
2643        hint.severity = Some(lsp_types::DiagnosticSeverity::Hint);
2644
2645        assert_eq!(
2646            ReportedSeverity::of(&severityless),
2647            ReportedSeverity::Information
2648        );
2649        assert!(ReportedSeverity::of(&severityless) < ReportedSeverity::of(&hint));
2650        assert!(ReportedSeverity::Error < ReportedSeverity::Warning);
2651        assert!(ReportedSeverity::Warning < ReportedSeverity::Information);
2652    }
2653
2654    /// #311 C1: `MAX_ENTRY_TEXT_BYTES` alone bounds one `message` field, not
2655    /// the whole entry -- many diagnostics, each individually small, must
2656    /// still be capped in aggregate.
2657    #[test]
2658    fn test_store_diagnostics_caps_aggregate_size_for_many_small_diagnostics() {
2659        let mut cache = NotificationCache::new();
2660        let uri: Uri = Uri::from("file:///test.rs");
2661
2662        // Each diagnostic is far under MAX_ENTRY_TEXT_BYTES individually,
2663        // but 5000 of them comfortably exceeds MAX_DIAGNOSTICS_ENTRY_BYTES
2664        // in aggregate.
2665        let diagnostics: Vec<LspDiagnostic> = (0..5000)
2666            .map(|i| {
2667                minimal_diagnostic(format!(
2668                    "diagnostic number {i}, padded: {}",
2669                    "x".repeat(200)
2670                ))
2671            })
2672            .collect();
2673        let original_count = diagnostics.len();
2674
2675        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
2676
2677        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2678        assert!(
2679            stored.len() < original_count,
2680            "aggregate cap must trim the list, kept {} of {original_count}",
2681            stored.len()
2682        );
2683        assert!(!stored.is_empty(), "must keep at least one diagnostic");
2684        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2685        assert!(
2686            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2687            "stored entry must fit the aggregate cap, got {serialized_len} bytes"
2688        );
2689    }
2690
2691    /// #311 S6: a naive flat halve would keep only the first N/2
2692    /// diagnostics even when far more than that would actually fit --
2693    /// truncation must find the largest prefix that fits instead.
2694    #[test]
2695    fn test_store_diagnostics_truncation_keeps_largest_fitting_prefix() {
2696        let mut cache = NotificationCache::new();
2697        let uri: Uri = Uri::from("file:///test.rs");
2698
2699        // Each diagnostic serializes to roughly 300 bytes; ~3800 of them
2700        // fit under the 1 MiB cap, well over half of the 5000 published --
2701        // a flat halve would incorrectly stop at 2500.
2702        let diagnostics: Vec<LspDiagnostic> = (0..5000)
2703            .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
2704            .collect();
2705
2706        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
2707
2708        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2709        assert!(
2710            stored.len() > 2600,
2711            "largest-fitting-prefix search must keep far more than half, kept {}",
2712            stored.len()
2713        );
2714        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2715        assert!(serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES);
2716        // The search must find the *largest* fitting prefix, not just *a*
2717        // fitting one: one more diagnostic than what was kept must no
2718        // longer fit (otherwise it should have been kept too).
2719        let mut with_one_more = stored.clone();
2720        with_one_more.push(minimal_diagnostic(format!(
2721            "diagnostic overflow: {}",
2722            "x".repeat(250)
2723        )));
2724        assert!(
2725            serde_json::to_vec(&with_one_more).unwrap().len() > MAX_DIAGNOSTICS_ENTRY_BYTES,
2726            "kept count must be the largest that fits, not merely a fitting count"
2727        );
2728    }
2729
2730    /// #311 S6: truncation must prefer keeping higher-severity diagnostics,
2731    /// not just whichever the server happened to publish first -- a late
2732    /// `ERROR` must survive over leading `HINT`-level noise.
2733    #[test]
2734    fn test_store_diagnostics_truncation_prefers_higher_severity() {
2735        let mut cache = NotificationCache::new();
2736        let uri: Uri = Uri::from("file:///test.rs");
2737
2738        let mut diagnostics: Vec<LspDiagnostic> = (0..5000)
2739            .map(|i| {
2740                let mut d = minimal_diagnostic(format!("hint {i}: {}", "x".repeat(200)));
2741                d.severity = Some(lsp_types::DiagnosticSeverity::Hint);
2742                d
2743            })
2744            .collect();
2745        let mut trailing_error = minimal_diagnostic("the one real error".to_string());
2746        trailing_error.severity = Some(lsp_types::DiagnosticSeverity::Error);
2747        diagnostics.push(trailing_error);
2748
2749        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
2750
2751        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2752        assert!(
2753            stored
2754                .iter()
2755                .any(|d| message_as_str(&d.message) == "the one real error"),
2756            "the trailing ERROR diagnostic must survive truncation over leading HINT noise"
2757        );
2758    }
2759
2760    /// #311 S7 / M7: truncating the diagnostics list must not be silent --
2761    /// a caller with no visibility into this cache would otherwise have no
2762    /// way to know a `get_cached_diagnostics` result is incomplete.
2763    #[test]
2764    fn test_store_diagnostics_warns_when_truncating_list() {
2765        use tracing_subscriber::layer::SubscriberExt as _;
2766
2767        let mut cache = NotificationCache::new();
2768        let uri: Uri = Uri::from("file:///test.rs");
2769        let diagnostics: Vec<LspDiagnostic> = (0..5000)
2770            .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
2771            .collect();
2772
2773        let captured = CapturedLogs::default();
2774        let subscriber = tracing_subscriber::registry().with(captured.clone());
2775        let guard = tracing::subscriber::set_default(subscriber);
2776        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
2777        drop(guard);
2778
2779        let messages = captured.messages();
2780        assert!(
2781            messages
2782                .iter()
2783                .any(|m| m.contains("highest-severity") && m.contains("file:///test.rs")),
2784            "expected a truncation warning naming the URI, got: {messages:?}"
2785        );
2786    }
2787
2788    /// #311 S7: dropping a diagnostic's `data` breaks the LSP contract that
2789    /// it round-trips to a later `textDocument/codeAction` request -- this
2790    /// must be logged, not silent.
2791    #[test]
2792    fn test_store_diagnostics_warns_when_dropping_data_blob() {
2793        use tracing_subscriber::layer::SubscriberExt as _;
2794
2795        let mut cache = NotificationCache::new();
2796        let uri: Uri = Uri::from("file:///test.rs");
2797        let mut diagnostic = minimal_diagnostic("small message".to_string());
2798        diagnostic.data = Some(serde_json::json!({
2799            "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
2800        }));
2801
2802        let captured = CapturedLogs::default();
2803        let subscriber = tracing_subscriber::registry().with(captured.clone());
2804        let guard = tracing::subscriber::set_default(subscriber);
2805        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2806        drop(guard);
2807
2808        let messages = captured.messages();
2809        assert!(
2810            messages.iter().any(|m| m.contains("code-action")),
2811            "expected a warning noting the code-action quick-fix impact, got: {messages:?}"
2812        );
2813    }
2814
2815    /// #311 C1: a single diagnostic dominated by an oversized `data` blob
2816    /// must be capped even though `message` alone is small -- the aggregate
2817    /// list-halving path can't shrink a one-element list, so the opaque
2818    /// fields on that single diagnostic must be dropped instead.
2819    #[test]
2820    fn test_store_diagnostics_drops_oversized_data_blob_on_single_diagnostic() {
2821        let mut cache = NotificationCache::new();
2822        let uri: Uri = Uri::from("file:///test.rs");
2823
2824        let mut diagnostic = minimal_diagnostic("small message".to_string());
2825        diagnostic.data = Some(serde_json::json!({
2826            "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
2827        }));
2828
2829        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2830
2831        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2832        assert_eq!(stored.len(), 1);
2833        assert_eq!(
2834            stored[0].message,
2835            lsp_types::Message::String("small message".to_string())
2836        );
2837        assert!(
2838            stored[0].data.is_none(),
2839            "oversized data blob must be dropped"
2840        );
2841        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2842        assert!(
2843            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2844            "stored entry must fit the aggregate cap after dropping data, got {serialized_len} bytes"
2845        );
2846    }
2847
2848    /// #311 C1 follow-up: an oversized `source` (not `data`) on a single
2849    /// diagnostic must also be brought back under the cap -- the
2850    /// opaque-field-drop mitigation alone does not touch `source`, which is
2851    /// a plain string and must be truncated instead.
2852    #[test]
2853    fn test_store_diagnostics_truncates_oversized_source_on_single_diagnostic() {
2854        let mut cache = NotificationCache::new();
2855        let uri: Uri = Uri::from("file:///test.rs");
2856
2857        let mut diagnostic = minimal_diagnostic("small message".to_string());
2858        diagnostic.source = Some("x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000));
2859
2860        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2861
2862        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2863        assert_eq!(stored.len(), 1);
2864        assert_eq!(
2865            stored[0].message,
2866            lsp_types::Message::String("small message".to_string())
2867        );
2868        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2869        assert!(
2870            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2871            "stored entry must fit the aggregate cap after truncating source, got {serialized_len} bytes"
2872        );
2873    }
2874
2875    /// #311 C1 follow-up: `cap_diagnostics_entry_size`'s postcondition --
2876    /// the result always fits `MAX_DIAGNOSTICS_ENTRY_BYTES` -- must hold
2877    /// even when every uncapped field is maxed out simultaneously, not just
2878    /// one at a time. This is the terminal-enforcement guarantee itself,
2879    /// exercised end to end through `store_diagnostics` rather than by
2880    /// calling the private function directly.
2881    #[test]
2882    fn test_store_diagnostics_caps_single_diagnostic_with_every_field_maxed_out() {
2883        let mut cache = NotificationCache::new();
2884        let uri: Uri = Uri::from("file:///test.rs");
2885
2886        // Each field individually exceeds MAX_ENTRY_TEXT_BYTES (so
2887        // source/code truncation is exercised) and the combination exceeds
2888        // MAX_DIAGNOSTICS_ENTRY_BYTES, without needing to allocate multiple
2889        // megabytes per field just to prove the same point.
2890        let mut diagnostic = minimal_diagnostic("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
2891        diagnostic.source = Some("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
2892        diagnostic.code = Some(lsp_types::Code::String(
2893            "x".repeat(MAX_ENTRY_TEXT_BYTES + 1000),
2894        ));
2895        diagnostic.data = Some(serde_json::json!({ "blob": "x".repeat(MAX_ENTRY_TEXT_BYTES) }));
2896        diagnostic.tags = Some(vec![lsp_types::DiagnosticTag::Unnecessary; 50]);
2897        diagnostic.related_information = Some(vec![
2898            lsp_types::DiagnosticRelatedInformation {
2899                location: lsp_types::Location {
2900                    uri: uri.clone(),
2901                    range: Range::default(),
2902                },
2903                message: "x".repeat(1000),
2904            };
2905            5
2906        ]);
2907
2908        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2909
2910        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2911        assert_eq!(stored.len(), 1);
2912        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2913        assert!(
2914            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2915            "postcondition must hold even with every field maxed out, got {serialized_len} bytes"
2916        );
2917    }
2918
2919    /// #311 C1 follow-up: exercises `cap_diagnostics_entry_size`'s terminal
2920    /// fallback directly. `message` is the one field the field-specific
2921    /// mitigations never touch (they only cover
2922    /// `source`/`code`/`data`/`code_description`/`related_information`/
2923    /// `tags`), so an oversized, *untruncated* message -- as it would be if
2924    /// this private function were ever called without `store_diagnostics`'s
2925    /// own prior message truncation -- must still be brought under budget
2926    /// by the terminal step, not left to slip through.
2927    #[test]
2928    fn test_cap_diagnostics_entry_size_terminal_fallback_bounds_untruncated_message() {
2929        let uri: Uri = Uri::from("file:///test.rs");
2930        let mut diagnostics = vec![minimal_diagnostic(
2931            "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
2932        )];
2933
2934        cap_diagnostics_entry_size(&uri, &mut diagnostics);
2935
2936        assert_eq!(diagnostics.len(), 1);
2937        assert!(
2938            message_as_str(&diagnostics[0].message).len()
2939                <= DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES + 20,
2940            "terminal fallback must truncate the message itself, got {} bytes",
2941            message_as_str(&diagnostics[0].message).len()
2942        );
2943        let serialized_len = serde_json::to_vec(&diagnostics).unwrap().len();
2944        assert!(
2945            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2946            "postcondition must hold via the terminal fallback, got {serialized_len} bytes"
2947        );
2948    }
2949
2950    /// #311 S5: when no diagnostic carries `data`/`code_description`/
2951    /// `related_information`/`tags` and the cheap size estimate is already
2952    /// under budget, nothing should be modified -- the fast path must not
2953    /// alter content it didn't need to touch.
2954    #[test]
2955    fn test_store_diagnostics_cheap_path_leaves_small_diagnostics_untouched() {
2956        let mut cache = NotificationCache::new();
2957        let uri: Uri = Uri::from("file:///test.rs");
2958
2959        let mut diagnostic = minimal_diagnostic("a small, ordinary diagnostic message".to_string());
2960        diagnostic.source = Some("rustc".to_string());
2961
2962        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
2963
2964        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2965        assert_eq!(stored.len(), 1);
2966        assert_eq!(
2967            stored[0].message,
2968            lsp_types::Message::String("a small, ordinary diagnostic message".to_string())
2969        );
2970        assert_eq!(stored[0].source.as_deref(), Some("rustc"));
2971    }
2972
2973    /// #311 S5 follow-up: the critic's exact counterexample. A NUL-heavy
2974    /// message's *raw* byte length looks small enough for the cheap
2975    /// estimate to skip the real check, but its *serialized* (JSON-escaped)
2976    /// size is up to `JSON_ESCAPE_WORST_CASE_FACTOR`x larger -- each NUL
2977    /// byte costs 6 bytes as `\u0000` once JSON-encoded. Three diagnostics
2978    /// at exactly `MAX_ENTRY_TEXT_BYTES` of NULs each previously passed the
2979    /// old raw-length estimate (787,200 bytes, under the 1 MiB cap) while
2980    /// actually serializing to roughly 4.5 MiB -- letting an entry ~4.5x
2981    /// over budget skip `fits`/truncation/terminal-fallback entirely.
2982    #[test]
2983    fn test_store_diagnostics_cheap_path_escape_safe_for_control_character_heavy_message() {
2984        let mut cache = NotificationCache::new();
2985        let uri: Uri = Uri::from("file:///test.rs");
2986
2987        let nul_heavy_message = "\0".repeat(MAX_ENTRY_TEXT_BYTES);
2988        let diagnostics: Vec<LspDiagnostic> = (0..3)
2989            .map(|_| minimal_diagnostic(nul_heavy_message.clone()))
2990            .collect();
2991
2992        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
2993
2994        let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
2995        let serialized_len = serde_json::to_vec(stored).unwrap().len();
2996        assert!(
2997            serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
2998            "escape-heavy content must not let the cheap-estimate fast path skip the real cap, \
2999             got {serialized_len} bytes"
3000        );
3001    }
3002
3003    #[test]
3004    fn test_store_diagnostics_replaces_existing() {
3005        let mut cache = NotificationCache::new();
3006        let uri: Uri = Uri::from("file:///test.rs");
3007
3008        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3009        assert_eq!(cache.diagnostics_count(), 1);
3010
3011        cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
3012        assert_eq!(cache.diagnostics_count(), 1);
3013
3014        let stored = cache.diagnostics(&uri).unwrap();
3015        assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
3016    }
3017
3018    #[test]
3019    fn test_store_and_get_logs() {
3020        let mut cache = NotificationCache::new();
3021
3022        cache.store_log(LogLevel::Error, "error message".to_string());
3023        cache.store_log(LogLevel::Info, "info message".to_string());
3024
3025        let logs = cache.logs();
3026        assert_eq!(logs.len(), 2);
3027        assert_eq!(logs[0].level, LogLevel::Error);
3028        assert_eq!(logs[0].message, "error message");
3029        assert_eq!(logs[1].level, LogLevel::Info);
3030        assert_eq!(logs[1].message, "info message");
3031    }
3032
3033    #[test]
3034    fn test_logs_max_capacity() {
3035        let mut cache = NotificationCache::new();
3036
3037        // Add more than MAX_LOG_ENTRIES
3038        for i in 0..MAX_LOG_ENTRIES + 10 {
3039            cache.store_log(LogLevel::Info, format!("message {i}"));
3040        }
3041
3042        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
3043
3044        // Oldest entries should be removed (FIFO)
3045        let logs = cache.logs();
3046        assert_eq!(logs.front().unwrap().message, "message 10");
3047        assert_eq!(
3048            logs.back().unwrap().message,
3049            format!("message {}", MAX_LOG_ENTRIES + 9)
3050        );
3051    }
3052
3053    /// #311: `MAX_LOG_ENTRIES` bounds the number of log entries, but not the
3054    /// size of any one entry -- an oversized message must be truncated
3055    /// rather than stored verbatim.
3056    #[test]
3057    fn test_store_log_truncates_oversized_message() {
3058        let mut cache = NotificationCache::new();
3059        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
3060
3061        cache.store_log(LogLevel::Info, oversized.clone());
3062
3063        let stored = &cache.logs()[0].message;
3064        assert!(stored.len() < oversized.len());
3065        assert!(stored.ends_with("... (truncated)"));
3066    }
3067
3068    #[test]
3069    fn test_store_log_does_not_truncate_message_at_or_below_limit() {
3070        let mut cache = NotificationCache::new();
3071        let message = "a".repeat(MAX_ENTRY_TEXT_BYTES);
3072
3073        cache.store_log(LogLevel::Info, message.clone());
3074
3075        assert_eq!(cache.logs()[0].message, message);
3076    }
3077
3078    #[test]
3079    fn test_store_and_get_messages() {
3080        let mut cache = NotificationCache::new();
3081
3082        cache.store_message(MessageType::Error, "error msg".to_string());
3083        cache.store_message(MessageType::Warning, "warning msg".to_string());
3084
3085        let messages = cache.messages();
3086        assert_eq!(messages.len(), 2);
3087        assert_eq!(messages[0].message_type, MessageType::Error);
3088        assert_eq!(messages[0].message, "error msg");
3089        assert_eq!(messages[1].message_type, MessageType::Warning);
3090        assert_eq!(messages[1].message, "warning msg");
3091    }
3092
3093    #[test]
3094    fn test_messages_max_capacity() {
3095        let mut cache = NotificationCache::new();
3096
3097        // Add more than MAX_SERVER_MESSAGES
3098        for i in 0..MAX_SERVER_MESSAGES + 10 {
3099            cache.store_message(MessageType::Info, format!("message {i}"));
3100        }
3101
3102        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
3103
3104        // Oldest entries should be removed (FIFO)
3105        let messages = cache.messages();
3106        assert_eq!(messages.front().unwrap().message, "message 10");
3107        assert_eq!(
3108            messages.back().unwrap().message,
3109            format!("message {}", MAX_SERVER_MESSAGES + 9)
3110        );
3111    }
3112
3113    /// #311: same per-entry byte cap as `store_log`, applied to server messages.
3114    #[test]
3115    fn test_store_message_truncates_oversized_message() {
3116        let mut cache = NotificationCache::new();
3117        let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
3118
3119        cache.store_message(MessageType::Info, oversized.clone());
3120
3121        let stored = &cache.messages()[0].message;
3122        assert!(stored.len() < oversized.len());
3123        assert!(stored.ends_with("... (truncated)"));
3124    }
3125
3126    #[test]
3127    fn test_log_levels() {
3128        let mut cache = NotificationCache::new();
3129
3130        cache.store_log(LogLevel::Error, "error".to_string());
3131        cache.store_log(LogLevel::Warning, "warning".to_string());
3132        cache.store_log(LogLevel::Info, "info".to_string());
3133        cache.store_log(LogLevel::Debug, "debug".to_string());
3134
3135        let logs = cache.logs();
3136        assert_eq!(logs[0].level, LogLevel::Error);
3137        assert_eq!(logs[1].level, LogLevel::Warning);
3138        assert_eq!(logs[2].level, LogLevel::Info);
3139        assert_eq!(logs[3].level, LogLevel::Debug);
3140    }
3141
3142    #[test]
3143    fn test_message_types() {
3144        let mut cache = NotificationCache::new();
3145
3146        cache.store_message(MessageType::Error, "error".to_string());
3147        cache.store_message(MessageType::Warning, "warning".to_string());
3148        cache.store_message(MessageType::Info, "info".to_string());
3149        cache.store_message(MessageType::Log, "log".to_string());
3150
3151        let messages = cache.messages();
3152        assert_eq!(messages[0].message_type, MessageType::Error);
3153        assert_eq!(messages[1].message_type, MessageType::Warning);
3154        assert_eq!(messages[2].message_type, MessageType::Info);
3155        assert_eq!(messages[3].message_type, MessageType::Log);
3156    }
3157
3158    #[test]
3159    fn test_timestamp_ordering() {
3160        let mut cache = NotificationCache::new();
3161
3162        cache.store_log(LogLevel::Info, "first".to_string());
3163        std::thread::sleep(std::time::Duration::from_millis(10));
3164        cache.store_log(LogLevel::Info, "second".to_string());
3165
3166        let logs = cache.logs();
3167        assert!(logs[0].timestamp < logs[1].timestamp);
3168    }
3169
3170    #[test]
3171    fn test_store_diagnostics_empty_list() {
3172        let mut cache = NotificationCache::new();
3173        let uri: Uri = Uri::from("file:///test.rs");
3174
3175        let diagnostic = LspDiagnostic {
3176            range: Range {
3177                start: Position {
3178                    line: 0,
3179                    character: 0,
3180                },
3181                end: Position {
3182                    line: 0,
3183                    character: 5,
3184                },
3185            },
3186            severity: Some(lsp_types::DiagnosticSeverity::Error),
3187            message: "test error".to_string().into(),
3188            code: None,
3189            source: None,
3190            code_description: None,
3191            related_information: None,
3192            tags: None,
3193            data: None,
3194        };
3195
3196        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
3197        assert_eq!(cache.diagnostics(&uri).unwrap().diagnostics.len(), 1);
3198
3199        cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
3200        let stored = cache.diagnostics(&uri).unwrap();
3201        assert_eq!(stored.diagnostics.len(), 0);
3202        assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
3203    }
3204
3205    #[test]
3206    fn test_store_many_diagnostics_single_file() {
3207        let mut cache = NotificationCache::new();
3208        let uri: Uri = Uri::from("file:///test.rs");
3209
3210        let diagnostics: Vec<LspDiagnostic> = (0..100)
3211            .map(|i| LspDiagnostic {
3212                range: Range {
3213                    start: Position {
3214                        line: i,
3215                        character: 0,
3216                    },
3217                    end: Position {
3218                        line: i,
3219                        character: 10,
3220                    },
3221                },
3222                message: format!("Error {i}").into(),
3223                severity: Some(lsp_types::DiagnosticSeverity::Error),
3224                code: None,
3225                source: None,
3226                code_description: None,
3227                related_information: None,
3228                tags: None,
3229                data: None,
3230            })
3231            .collect();
3232
3233        cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
3234
3235        let stored = cache.diagnostics(&uri).unwrap();
3236        assert_eq!(stored.diagnostics.len(), 100);
3237    }
3238
3239    #[test]
3240    fn test_logs_exact_capacity_boundary() {
3241        let mut cache = NotificationCache::new();
3242
3243        for i in 0..MAX_LOG_ENTRIES {
3244            cache.store_log(LogLevel::Info, format!("message {i}"));
3245        }
3246        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
3247
3248        cache.store_log(LogLevel::Info, "overflow".to_string());
3249        assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
3250        assert_eq!(cache.logs().front().unwrap().message, "message 1");
3251    }
3252
3253    #[test]
3254    fn test_messages_exact_capacity_boundary() {
3255        let mut cache = NotificationCache::new();
3256
3257        for i in 0..MAX_SERVER_MESSAGES {
3258            cache.store_message(MessageType::Info, format!("message {i}"));
3259        }
3260        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
3261
3262        cache.store_message(MessageType::Info, "overflow".to_string());
3263        assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
3264        assert_eq!(cache.messages().front().unwrap().message, "message 1");
3265    }
3266
3267    #[test]
3268    fn test_diagnostics_max_capacity() {
3269        let mut cache = NotificationCache::new();
3270
3271        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 10 {
3272            let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
3273            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3274        }
3275
3276        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3277
3278        // Oldest entries should be evicted (FIFO).
3279        let evicted: Uri = Uri::from("file:///test0.rs");
3280        assert!(cache.diagnostics(&evicted).is_none());
3281        let newest: Uri = Uri::from(format!("file:///test{}.rs", MAX_DIAGNOSTIC_ENTRIES + 9));
3282        assert!(cache.diagnostics(&newest).is_some());
3283    }
3284
3285    #[test]
3286    fn test_diagnostics_replacing_existing_uri_does_not_trigger_eviction() {
3287        let mut cache = NotificationCache::new();
3288        let uri: Uri = Uri::from("file:///stable.rs");
3289
3290        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
3291            cache.store_diagnostics(
3292                &test_server(),
3293                &uri,
3294                Some(i32::try_from(i).unwrap()),
3295                vec![],
3296            );
3297        }
3298        assert_eq!(cache.diagnostics_count(), 1);
3299        assert!(cache.diagnostics(&uri).is_some());
3300    }
3301
3302    #[test]
3303    fn test_diagnostics_republish_refreshes_eviction_order() {
3304        // #234 S2 / #266 S3 regression: an actively-edited file, republished
3305        // on every keystroke, must not be evicted ahead of a file that was
3306        // merely opened once and never touched again.
3307        let mut cache = NotificationCache::new();
3308        let actively_edited: Uri = Uri::from("file:///keep.rs");
3309        cache.store_diagnostics(&test_server(), &actively_edited, Some(1), vec![]);
3310
3311        // Fill the rest of the cache with untouched entries.
3312        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
3313            let uri: Uri = Uri::from(format!("file:///untouched{i}.rs"));
3314            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3315        }
3316        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3317
3318        // Republish the actively-edited file -- this must move it to the
3319        // back of the eviction order, not leave it at its original (oldest)
3320        // position.
3321        cache.store_diagnostics(&test_server(), &actively_edited, Some(2), vec![]);
3322
3323        // One more new URI arrives, exceeding the cap by one: the oldest
3324        // *untouched* entry must be evicted, not the republished one.
3325        let overflow: Uri = Uri::from("file:///overflow.rs");
3326        cache.store_diagnostics(&test_server(), &overflow, Some(1), vec![]);
3327
3328        assert!(
3329            cache.diagnostics(&actively_edited).is_some(),
3330            "republished entry must survive eviction after being refreshed"
3331        );
3332        let oldest_untouched: Uri = Uri::from("file:///untouched0.rs");
3333        assert!(
3334            cache.diagnostics(&oldest_untouched).is_none(),
3335            "the oldest never-republished entry must be evicted instead"
3336        );
3337        assert!(cache.diagnostics(&overflow).is_some());
3338    }
3339
3340    #[test]
3341    fn test_store_diagnostics_no_version() {
3342        let mut cache = NotificationCache::new();
3343        let uri: Uri = Uri::from("file:///test.rs");
3344
3345        cache.store_diagnostics(&test_server(), &uri, None, vec![]);
3346        let stored = cache.diagnostics(&uri).unwrap();
3347        assert_eq!(stored.version, None);
3348    }
3349
3350    /// #266/#276: once the *aggregate* cache is full, a noisy server that has
3351    /// grown far past its fair share must have its own oldest entries
3352    /// evicted, never a quiet server's, even though both share one
3353    /// `NotificationCache` and the noisy server was allowed to keep growing
3354    /// past its static equal share while the aggregate still had room.
3355    #[test]
3356    fn test_noisy_server_does_not_evict_quiet_server_entries() {
3357        let mut cache = NotificationCache::new();
3358        cache.set_diagnostics_route_count(2);
3359        let noisy = ServerId::from_static("noisy");
3360        let quiet = ServerId::from_static("quiet");
3361
3362        let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
3363        cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
3364
3365        // Drive the noisy server well past the aggregate cap -- it must be
3366        // allowed to consume nearly all of it since the quiet server leaves
3367        // the rest unused (#276), and once the aggregate is full it must
3368        // only evict its own oldest entries.
3369        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
3370            let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
3371            cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
3372        }
3373
3374        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3375        assert!(
3376            cache.diagnostics(&quiet_uri).is_some(),
3377            "quiet server's only entry must survive the noisy server's overflow"
3378        );
3379
3380        let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
3381        assert!(
3382            cache.diagnostics(&noisy_first).is_none(),
3383            "noisy server's own oldest entries must be evicted once the aggregate cache is full"
3384        );
3385    }
3386
3387    /// #276: a dominant server must be able to exceed its static equal share
3388    /// of the budget while other registered diagnostics-route servers are
3389    /// idle -- eviction is work-conserving and only triggers once the
3390    /// *aggregate* cache reaches `MAX_DIAGNOSTIC_ENTRIES`, not once a single
3391    /// server passes `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count`.
3392    #[test]
3393    fn test_dominant_server_exceeds_equal_share_while_others_idle() {
3394        let mut cache = NotificationCache::new();
3395        cache.set_diagnostics_route_count(4);
3396        let dominant = ServerId::from_static("dominant");
3397
3398        let equal_share = MAX_DIAGNOSTIC_ENTRIES / 4;
3399        let more_than_share = equal_share + 100;
3400        for i in 0..more_than_share {
3401            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
3402            cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
3403        }
3404        assert_eq!(
3405            cache.diagnostics_count(),
3406            more_than_share,
3407            "a dominant server must be able to exceed its static equal share while the aggregate has room"
3408        );
3409
3410        // The other three registered servers never write anything, so the
3411        // dominant server can keep growing all the way to the full budget.
3412        for i in more_than_share..MAX_DIAGNOSTIC_ENTRIES {
3413            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
3414            cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
3415        }
3416        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3417    }
3418
3419    /// M1: eviction-target ties (multiple servers holding the same entry
3420    /// count) must resolve deterministically, not depend on `HashMap`'s
3421    /// per-process randomized iteration order. This pins the exact winner
3422    /// rather than only checking repeat-call stability -- stability across
3423    /// calls would hold trivially even without the fix, since a single
3424    /// `HashMap` instance's iteration order does not change between calls
3425    /// within one process; the real risk is a *different* winner on a
3426    /// *different* process run, which this test can't observe directly, but
3427    /// the pinned assertion below only passes because the tie-break key
3428    /// (`(order.len(), id.as_str())`) is unique per server -- no two
3429    /// distinct `ServerId`s can ever share it, so `max_by_key` never
3430    /// actually has a tie left to resolve by iteration order.
3431    #[test]
3432    fn test_eviction_target_tie_break_is_deterministic() {
3433        let mut cache = NotificationCache::new();
3434        cache.set_diagnostics_route_count(1000); // fair share floors at 1
3435
3436        let a = ServerId::from_static("a");
3437        let b = ServerId::from_static("b");
3438        for i in 0..2 {
3439            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
3440            cache.store_diagnostics(&a, &uri, Some(1), vec![]);
3441        }
3442        for i in 0..2 {
3443            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
3444            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
3445        }
3446
3447        // `a` and `b` are tied at 2 entries each, both over the floor-1
3448        // share -- `"b"` sorts after `"a"` lexicographically, so it is the
3449        // one always picked.
3450        let writer = ServerId::from_static("writer");
3451        assert_eq!(cache.server_to_evict_from(&writer), Some(b));
3452    }
3453
3454    /// M2: `server_to_evict_from`'s "largest in-share server" fallback is
3455    /// reachable and correct through the public `store_diagnostics` API,
3456    /// not just in isolation -- a brand-new server's first write must still
3457    /// evict something when the aggregate cache is already full purely from
3458    /// other servers that are each individually within their fair share.
3459    /// Without this fallback there would be nothing to evict from (the
3460    /// writer has no entries yet, and no one else exceeds their share) and
3461    /// the aggregate could grow past `MAX_DIAGNOSTIC_ENTRIES`.
3462    #[test]
3463    fn test_new_writer_still_evicts_when_every_existing_server_is_in_share() {
3464        let mut cache = NotificationCache::new();
3465        cache.set_diagnostics_route_count(2); // fair share = 500 each
3466
3467        let a = ServerId::from_static("a");
3468        let b = ServerId::from_static("b");
3469        for i in 0..500 {
3470            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
3471            cache.store_diagnostics(&a, &uri, Some(1), vec![]);
3472        }
3473        for i in 0..500 {
3474            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
3475            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
3476        }
3477        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3478
3479        // `c` has never written before -- its very first write hits a full,
3480        // entirely-in-share aggregate.
3481        let c = ServerId::from_static("c");
3482        let new_uri: Uri = Uri::from("file:///c/first.rs");
3483        cache.store_diagnostics(&c, &new_uri, Some(1), vec![]);
3484
3485        assert_eq!(
3486            cache.diagnostics_count(),
3487            MAX_DIAGNOSTIC_ENTRIES,
3488            "the aggregate cap must still be enforced even when every existing server is within share"
3489        );
3490        assert!(cache.diagnostics(&new_uri).is_some());
3491
3492        // `a` and `b` are tied at 500 entries each; the deterministic
3493        // tie-break in `server_to_evict_from` picks `b`, so `b`'s oldest
3494        // entry is the one evicted, not `a`'s.
3495        let b_oldest: Uri = Uri::from("file:///b/file0.rs");
3496        assert!(
3497            cache.diagnostics(&b_oldest).is_none(),
3498            "the largest in-share server (tie-broken to b) must lose its oldest entry"
3499        );
3500        assert!(
3501            cache
3502                .diagnostics(&Uri::from("file:///a/file0.rs".to_owned()))
3503                .is_some(),
3504            "the other in-share server must be untouched"
3505        );
3506    }
3507
3508    /// Re-publishing diagnostics for a URI under its existing owner must not
3509    /// count as a new entry against that server's budget.
3510    #[test]
3511    fn test_repeated_writes_same_owner_do_not_grow_order_map() {
3512        let mut cache = NotificationCache::new();
3513        let server = ServerId::from_static("server");
3514        let uri: Uri = Uri::from("file:///test.rs");
3515
3516        let max_version = i32::try_from(MAX_DIAGNOSTIC_ENTRIES).unwrap() + 10;
3517        for version in 0..max_version {
3518            cache.store_diagnostics(&server, &uri, Some(version), vec![]);
3519        }
3520
3521        assert_eq!(cache.diagnostics_count(), 1);
3522        let stored = cache.diagnostics(&uri).unwrap();
3523        assert_eq!(
3524            stored.version.map(DocumentVersion::get),
3525            Some(max_version - 1)
3526        );
3527    }
3528
3529    /// If a URI's diagnostics route changes to a different server (e.g.
3530    /// after a respawn rebind), the entry must move to the new owner's
3531    /// order map rather than staying attributed to the old one.
3532    #[test]
3533    fn test_store_diagnostics_reassigns_ownership() {
3534        let mut cache = NotificationCache::new();
3535        let old_owner = ServerId::from_static("old");
3536        let new_owner = ServerId::from_static("new");
3537        let uri: Uri = Uri::from("file:///test.rs");
3538
3539        cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
3540        cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
3541
3542        assert_eq!(cache.diagnostics_count(), 1);
3543        let stored = cache.diagnostics(&uri).unwrap();
3544        assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
3545
3546        // The old owner's order map must no longer reference this URI:
3547        // filling the old owner's budget with fresh entries must not evict
3548        // this URI a second time (it's not there to evict) nor corrupt state.
3549        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
3550            let other: Uri = Uri::from(format!("file:///old/file{i}.rs"));
3551            cache.store_diagnostics(&old_owner, &other, Some(1), vec![]);
3552        }
3553        assert!(cache.diagnostics(&uri).is_some());
3554    }
3555
3556    /// #290: `diagnostics_owner` is what a cache-only read (e.g.
3557    /// `get_cached_diagnostics`) uses to resolve the publishing server's
3558    /// negotiated position encoding, so both branches -- an owner on record
3559    /// and none -- must behave correctly.
3560    #[test]
3561    fn test_diagnostics_owner_returns_publisher_after_store() {
3562        let mut cache = NotificationCache::new();
3563        let server = ServerId::from_static("rust");
3564        let uri: Uri = Uri::from("file:///main.rs");
3565
3566        cache.store_diagnostics(&server, &uri, Some(1), vec![]);
3567
3568        assert_eq!(cache.diagnostics_owner(&uri), Some(&server));
3569    }
3570
3571    #[test]
3572    fn test_diagnostics_owner_none_for_untracked_uri() {
3573        let cache = NotificationCache::new();
3574        let uri: Uri = Uri::from("file:///never-seen.rs");
3575
3576        assert_eq!(cache.diagnostics_owner(&uri), None);
3577    }
3578
3579    /// Reassigning ownership (see `test_store_diagnostics_reassigns_ownership`
3580    /// above) must also update `diagnostics_owner`, not just the cached
3581    /// content -- otherwise a stale owner's encoding would be used to
3582    /// convert a different server's diagnostics.
3583    #[test]
3584    fn test_diagnostics_owner_reflects_reassigned_ownership() {
3585        let mut cache = NotificationCache::new();
3586        let old_owner = ServerId::from_static("old");
3587        let new_owner = ServerId::from_static("new");
3588        let uri: Uri = Uri::from("file:///test.rs");
3589
3590        cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
3591        assert_eq!(cache.diagnostics_owner(&uri), Some(&old_owner));
3592
3593        cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
3594        assert_eq!(cache.diagnostics_owner(&uri), Some(&new_owner));
3595    }
3596
3597    /// #266 S2: clearing one server's diagnostics must not disturb another
3598    /// server's cached entries.
3599    #[test]
3600    fn test_clear_server_diagnostics_scopes_to_one_server() {
3601        let mut cache = NotificationCache::new();
3602        let crashed = ServerId::from_static("crashed");
3603        let healthy = ServerId::from_static("healthy");
3604
3605        let crashed_uri: Uri = Uri::from("file:///crashed/main.py");
3606        let healthy_uri: Uri = Uri::from("file:///healthy/main.rs");
3607        cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
3608        cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
3609
3610        cache.clear_server_diagnostics(&crashed);
3611
3612        assert!(cache.diagnostics(&crashed_uri).is_none());
3613        assert!(cache.diagnostics(&healthy_uri).is_some());
3614        assert_eq!(cache.diagnostics_count(), 1);
3615
3616        // Idempotent / no-op for a server with no (or no longer any) entries.
3617        cache.clear_server_diagnostics(&crashed);
3618        assert_eq!(cache.diagnostics_count(), 1);
3619    }
3620
3621    /// #359: `mark_push_degraded` must be scoped per server and, once set,
3622    /// stay set -- there is no "unmark" operation, since only a full mcpls
3623    /// process restart actually restores push diagnostics for a respawned
3624    /// server.
3625    #[test]
3626    fn test_push_degraded_is_scoped_per_server_and_permanent() {
3627        let mut cache = NotificationCache::new();
3628        let degraded = ServerId::from_static("degraded");
3629        let healthy = ServerId::from_static("healthy");
3630
3631        assert!(!cache.is_push_degraded(&degraded));
3632        assert!(!cache.is_push_degraded(&healthy));
3633
3634        cache.mark_push_degraded(&degraded);
3635
3636        assert!(cache.is_push_degraded(&degraded));
3637        assert!(!cache.is_push_degraded(&healthy));
3638
3639        // Marking again is idempotent.
3640        cache.mark_push_degraded(&degraded);
3641        assert!(cache.is_push_degraded(&degraded));
3642    }
3643
3644    /// #276: `set_diagnostics_route_count` shrinking a server's fair share
3645    /// must not retroactively evict any of its already-cached entries --
3646    /// eviction is work-conserving and only fires once the *aggregate* cache
3647    /// is full. Once full, though, the shrunk share is what makes that
3648    /// server the eviction target for a *different* server's write, rather
3649    /// than the write that actually needed room being rejected or evicting
3650    /// its own (nonexistent) entries.
3651    #[test]
3652    fn test_shrinking_budget_affects_eviction_target_not_existing_entries() {
3653        let mut cache = NotificationCache::new();
3654        let server = ServerId::from_static("server");
3655
3656        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
3657            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
3658            cache.store_diagnostics(&server, &uri, Some(1), vec![]);
3659        }
3660        assert_eq!(
3661            cache.diagnostics_count(),
3662            MAX_DIAGNOSTIC_ENTRIES,
3663            "filling to the aggregate cap must not evict anything early"
3664        );
3665
3666        // A drastic shrink relative to the entries `server` already holds --
3667        // must not evict anything by itself.
3668        cache.set_diagnostics_route_count(4);
3669        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3670
3671        // A different server's first write, once the aggregate is full,
3672        // evicts from `server` (now far over its shrunk share) instead.
3673        let other = ServerId::from_static("other");
3674        let new_uri: Uri = Uri::from("file:///other/new.rs");
3675        cache.store_diagnostics(&other, &new_uri, Some(1), vec![]);
3676
3677        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3678        assert!(cache.diagnostics(&new_uri).is_some());
3679        let server_oldest: Uri = Uri::from("file:///file0.rs");
3680        assert!(
3681            cache.diagnostics(&server_oldest).is_none(),
3682            "the pre-existing server's oldest entry, now far over its shrunk share, must be evicted"
3683        );
3684    }
3685
3686    /// #283: an external `NotificationCache` consumer that never calls
3687    /// `set_diagnostics_route_count` must still get fair-share partitioning
3688    /// once more than one server has written an entry -- the pre-#266
3689    /// regression this guards against is an unset count silently giving one
3690    /// server the entire aggregate budget, letting it starve a quiet server.
3691    #[test]
3692    fn test_fair_share_applies_by_default_without_explicit_route_count() {
3693        let mut cache = NotificationCache::new();
3694        let noisy = ServerId::from_static("noisy");
3695        let quiet = ServerId::from_static("quiet");
3696
3697        let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
3698        cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
3699
3700        // `set_diagnostics_route_count` is deliberately never called here.
3701        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
3702            let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
3703            cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
3704        }
3705
3706        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3707        assert!(
3708            cache.diagnostics(&quiet_uri).is_some(),
3709            "quiet server's only entry must survive even without ever calling \
3710             set_diagnostics_route_count"
3711        );
3712        let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
3713        assert!(
3714            cache.diagnostics(&noisy_first).is_none(),
3715            "the noisy server, now auto-derived as one of two servers sharing the budget, \
3716             must still lose its own oldest entries once over its fair share"
3717        );
3718    }
3719
3720    /// #283: with only one server ever writing, the auto-derived fair-share
3721    /// count (from `order.len()`) must stay `1`, letting that
3722    /// server use the whole aggregate budget -- the same as the old default
3723    /// of `1` when the setter went uncalled, not a regression for the
3724    /// common single-server case.
3725    #[test]
3726    fn test_single_server_gets_full_budget_without_explicit_route_count() {
3727        let mut cache = NotificationCache::new();
3728
3729        for i in 0..MAX_DIAGNOSTIC_ENTRIES {
3730            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
3731            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3732        }
3733
3734        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3735    }
3736
3737    /// #284: when the cache is full and a server's own entry must be
3738    /// evicted, an entry with an empty (`[]`) diagnostics list -- a file
3739    /// reported as now clean -- must be evicted ahead of an older entry that
3740    /// still carries real diagnostics, even though the empty entry is not
3741    /// that server's strictly-oldest entry.
3742    #[test]
3743    fn test_empty_diagnostics_entries_evicted_before_non_empty_ones() {
3744        let mut cache = NotificationCache::new();
3745        let server = test_server();
3746
3747        let important: Uri = Uri::from("file:///important.rs");
3748        cache.store_diagnostics(
3749            &server,
3750            &important,
3751            Some(1),
3752            vec![minimal_diagnostic("real error".to_string())],
3753        );
3754
3755        // Fill the rest of the budget with empty ("file is clean") entries,
3756        // all published after `important` and so all newer in eviction
3757        // order.
3758        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
3759            let uri: Uri = Uri::from(format!("file:///clean{i}.rs"));
3760            cache.store_diagnostics(&server, &uri, Some(1), vec![]);
3761        }
3762        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3763
3764        // One more new URI exceeds the cap: `important` is the strictly
3765        // oldest entry, but it must survive in favor of the oldest *empty*
3766        // entry instead.
3767        let overflow: Uri = Uri::from("file:///overflow.rs");
3768        cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
3769
3770        assert!(
3771            cache.diagnostics(&important).is_some(),
3772            "a non-empty entry must survive eviction over empty entries, even though it is older"
3773        );
3774        let oldest_clean: Uri = Uri::from("file:///clean0.rs");
3775        assert!(
3776            cache.diagnostics(&oldest_clean).is_none(),
3777            "the oldest empty entry must be evicted instead of the older non-empty one"
3778        );
3779        assert!(cache.diagnostics(&overflow).is_some());
3780    }
3781
3782    /// #284: storing an empty diagnostics list must still create a fully
3783    /// tracked, cacheable entry -- eviction priority changes which entry is
3784    /// removed once the cache is full, but does not change what gets stored
3785    /// in the first place. See the `tracked` field semantics in
3786    /// `mcp::server::ResourceDiagnosticsResponse` for why callers rely on
3787    /// this: a `[]` publish for a previously-tracked URI must read back as
3788    /// "tracked, zero diagnostics", not as untracked.
3789    #[test]
3790    fn test_empty_diagnostics_entry_is_still_tracked_until_evicted() {
3791        let mut cache = NotificationCache::new();
3792        let uri: Uri = Uri::from("file:///clean.rs");
3793
3794        cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3795
3796        let stored = cache.diagnostics(&uri);
3797        assert!(
3798            stored.is_some(),
3799            "an empty-diagnostics entry must still be tracked"
3800        );
3801        assert_eq!(stored.unwrap().diagnostics.len(), 0);
3802    }
3803
3804    /// #284 S1: an over-share server's own empty ("clean") entry must be
3805    /// evicted before a *different* over-share server's real diagnostics are
3806    /// destroyed, even when the fairness-selected victim (the largest
3807    /// over-share server) itself holds no empty entry of its own.
3808    #[test]
3809    fn test_over_share_servers_empty_entry_evicted_before_a_different_servers_real_diagnostic() {
3810        let mut cache = NotificationCache::new();
3811        cache.set_diagnostics_route_count(3); // fair share = 333
3812
3813        let a = ServerId::from_static("a"); // over share, all real diagnostics
3814        let b = ServerId::from_static("b"); // over share, all empty/clean
3815        let c = ServerId::from_static("c"); // within share
3816
3817        for i in 0..500 {
3818            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
3819            cache.store_diagnostics(
3820                &a,
3821                &uri,
3822                Some(1),
3823                vec![minimal_diagnostic(format!("error {i}"))],
3824            );
3825        }
3826        for i in 0..400 {
3827            let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
3828            cache.store_diagnostics(&b, &uri, Some(1), vec![]);
3829        }
3830        for i in 0..100 {
3831            let uri: Uri = Uri::from(format!("file:///c/file{i}.rs"));
3832            cache.store_diagnostics(&c, &uri, Some(1), vec![]);
3833        }
3834        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3835
3836        // One more write from `a` (the fairness-selected victim, being the
3837        // largest over-share server) must not destroy any of `a`'s real
3838        // diagnostics: `b`, also over its share, has an empty entry to give
3839        // up instead.
3840        let overflow: Uri = Uri::from("file:///a/overflow.rs");
3841        cache.store_diagnostics(
3842            &a,
3843            &overflow,
3844            Some(1),
3845            vec![minimal_diagnostic("overflow error".to_string())],
3846        );
3847
3848        for i in 0..500 {
3849            let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
3850            assert!(
3851                cache.diagnostics(&uri).is_some(),
3852                "server a's real diagnostics must all survive; b has an empty entry to lose \
3853                 instead"
3854            );
3855        }
3856        let b_oldest: Uri = Uri::from("file:///b/file0.rs");
3857        assert!(
3858            cache.diagnostics(&b_oldest).is_none(),
3859            "b's oldest empty entry must be evicted instead of a's real diagnostics"
3860        );
3861        assert!(cache.diagnostics(&overflow).is_some());
3862    }
3863
3864    /// #284: a URI that transitions non-empty -> empty -> non-empty must not
3865    /// be treated as still-empty for eviction priority after the second
3866    /// transition -- emptiness tracking must reflect the *current* state,
3867    /// not the URI's history.
3868    #[test]
3869    fn test_dirty_then_clean_then_dirty_again_updates_emptiness_tracking() {
3870        let mut cache = NotificationCache::new();
3871        let server = test_server();
3872        let uri: Uri = Uri::from("file:///flapping.rs");
3873
3874        cache.store_diagnostics(
3875            &server,
3876            &uri,
3877            Some(1),
3878            vec![minimal_diagnostic("first error".to_string())],
3879        );
3880        cache.store_diagnostics(&server, &uri, Some(2), vec![]); // now clean
3881        cache.store_diagnostics(
3882            &server,
3883            &uri,
3884            Some(3),
3885            vec![minimal_diagnostic("second error".to_string())],
3886        ); // dirty again
3887
3888        // Fill the rest of the budget with genuinely empty entries -- if
3889        // `uri` were still (wrongly) tracked as empty, one of these would be
3890        // evicted in its place instead of `uri` being left alone.
3891        for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
3892            let other: Uri = Uri::from(format!("file:///clean{i}.rs"));
3893            cache.store_diagnostics(&server, &other, Some(1), vec![]);
3894        }
3895        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3896
3897        let overflow: Uri = Uri::from("file:///overflow.rs");
3898        cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
3899
3900        let stored = cache.diagnostics(&uri);
3901        assert!(
3902            stored.is_some_and(|info| info.diagnostics.len() == 1),
3903            "the re-dirtied entry must survive and keep its real diagnostic, not be mistaken \
3904             for an empty entry"
3905        );
3906    }
3907
3908    /// `set_diagnostics_route_count(0)` must clamp to `1`, not panic via
3909    /// division by zero in `per_server_budget`.
3910    #[test]
3911    fn test_set_diagnostics_route_count_zero_clamps_to_one() {
3912        let mut cache = NotificationCache::new();
3913        cache.set_diagnostics_route_count(0);
3914
3915        for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
3916            let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
3917            cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
3918        }
3919
3920        assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
3921    }
3922
3923    // Transition logic is unit-tested in `bridge::indexing`; these tests only cover delegation wiring.
3924
3925    #[test]
3926    fn test_indexing_state_defaults_unknown() {
3927        let cache = NotificationCache::new();
3928        assert_eq!(cache.indexing_state(&test_server()), IndexingState::Unknown);
3929    }
3930
3931    #[test]
3932    fn test_observe_indexing_signal_delegates_to_tracker() {
3933        let mut cache = NotificationCache::new();
3934        let server = test_server();
3935        cache.observe_indexing_signal(
3936            &server,
3937            "experimental/serverStatus",
3938            Some(&serde_json::json!({"quiescent": false})),
3939        );
3940        assert_eq!(cache.indexing_state(&server), IndexingState::Loading);
3941    }
3942
3943    #[test]
3944    fn test_reset_indexing_state_delegates_to_tracker() {
3945        let mut cache = NotificationCache::new();
3946        let server = test_server();
3947        cache.observe_indexing_signal(
3948            &server,
3949            "experimental/serverStatus",
3950            Some(&serde_json::json!({"quiescent": false})),
3951        );
3952        cache.reset_indexing_state(&server, IndexingReset::Forget);
3953        assert_eq!(cache.indexing_state(&server), IndexingState::Unknown);
3954    }
3955
3956    fn file_uri(path: &str) -> Uri {
3957        Uri::from(format!("file:///ws/{path}"))
3958    }
3959
3960    fn published(source: &str) -> PublishedDiagnosticsUri {
3961        PublishedDiagnosticsUri::for_test(file_uri(source), file_uri("main.rs"))
3962    }
3963
3964    fn diagnostic_at(line: u32, message: &str) -> LspDiagnostic {
3965        let mut diagnostic = minimal_diagnostic(message.to_owned());
3966        diagnostic.range.start.line = line;
3967        diagnostic.range.end.line = line;
3968        diagnostic
3969    }
3970
3971    fn merged(cache: &NotificationCache) -> Option<DiagnosticInfo> {
3972        cache.diagnostic_sources(&file_uri("main.rs")).merge()
3973    }
3974
3975    fn messages(info: &DiagnosticInfo) -> Vec<String> {
3976        info.diagnostics
3977            .iter()
3978            .map(|d| message_as_str(&d.message).to_owned())
3979            .collect()
3980    }
3981
3982    /// The rust-analyzer probe sequence: errors arrive on the alias path and
3983    /// an empty list on the canonical one.
3984    #[test]
3985    fn test_published_alias_errors_survive_empty_canonical_publish() {
3986        let mut cache = NotificationCache::new();
3987        let server = test_server();
3988        let errors = vec![diagnostic_at(1, "e1"), diagnostic_at(2, "e2")];
3989
3990        cache.store_published_diagnostics(&server, &published("alias.rs"), None, errors);
3991        cache.store_published_diagnostics(&server, &published("main.rs"), Some(3), vec![]);
3992        assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
3993
3994        cache.store_published_diagnostics(&server, &published("main.rs"), Some(4), vec![]);
3995        assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
3996
3997        cache.store_published_diagnostics(&server, &published("alias.rs"), None, vec![]);
3998        assert!(merged(&cache).unwrap().diagnostics.is_empty());
3999    }
4000
4001    #[test]
4002    fn test_published_empty_publish_clears_only_its_own_source() {
4003        let mut cache = NotificationCache::new();
4004        let server = test_server();
4005        cache.store_published_diagnostics(
4006            &server,
4007            &published("a1.rs"),
4008            None,
4009            vec![diagnostic_at(1, "e1")],
4010        );
4011        cache.store_published_diagnostics(
4012            &server,
4013            &published("a2.rs"),
4014            None,
4015            vec![diagnostic_at(2, "e2")],
4016        );
4017        assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
4018
4019        cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![]);
4020
4021        assert_eq!(messages(&merged(&cache).unwrap()), ["e2"]);
4022    }
4023
4024    /// Dedup must not depend on duplicates being adjacent after the sort.
4025    #[test]
4026    fn test_merge_removes_duplicates_sharing_a_start_with_another_diagnostic() {
4027        let mut cache = NotificationCache::new();
4028        let server = test_server();
4029        let x = diagnostic_at(1, "x");
4030        let y = diagnostic_at(1, "y");
4031        cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![x.clone(), y]);
4032        cache.store_published_diagnostics(&server, &published("a2.rs"), None, vec![x]);
4033
4034        assert_eq!(messages(&merged(&cache).unwrap()), ["x", "y"]);
4035    }
4036
4037    #[test]
4038    fn test_merge_orders_by_range_start() {
4039        let mut cache = NotificationCache::new();
4040        let server = test_server();
4041        cache.store_published_diagnostics(
4042            &server,
4043            &published("a1.rs"),
4044            None,
4045            vec![diagnostic_at(5, "late")],
4046        );
4047        cache.store_published_diagnostics(
4048            &server,
4049            &published("a2.rs"),
4050            None,
4051            vec![diagnostic_at(1, "early")],
4052        );
4053
4054        assert_eq!(messages(&merged(&cache).unwrap()), ["early", "late"]);
4055    }
4056
4057    #[test]
4058    fn test_merged_version_is_the_canonical_sources() {
4059        let mut cache = NotificationCache::new();
4060        let server = test_server();
4061        cache.store_published_diagnostics(
4062            &server,
4063            &published("main.rs"),
4064            Some(7),
4065            vec![diagnostic_at(1, "a")],
4066        );
4067        cache.store_published_diagnostics(
4068            &server,
4069            &published("alias.rs"),
4070            None,
4071            vec![diagnostic_at(2, "b")],
4072        );
4073        assert_eq!(
4074            merged(&cache).unwrap().version.map(DocumentVersion::get),
4075            Some(7)
4076        );
4077
4078        cache.store_published_diagnostics(
4079            &server,
4080            &published("main.rs"),
4081            Some(8),
4082            vec![diagnostic_at(1, "a")],
4083        );
4084        assert_eq!(
4085            merged(&cache).unwrap().version.map(DocumentVersion::get),
4086            Some(8)
4087        );
4088    }
4089
4090    #[test]
4091    fn test_merge_is_bounded_by_the_entry_size_cap() {
4092        let mut cache = NotificationCache::new();
4093        let server = test_server();
4094        let big = "x".repeat(200 * 1024);
4095        for source in [
4096            "a1.rs", "a2.rs", "a3.rs", "a4.rs", "a5.rs", "a6.rs", "a7.rs", "a8.rs",
4097        ] {
4098            let diagnostics = (0..4)
4099                .map(|n| diagnostic_at(n, &format!("{source}-{n}-{big}")))
4100                .collect();
4101            cache.store_published_diagnostics(&server, &published(source), None, diagnostics);
4102        }
4103
4104        let info = merged(&cache).unwrap();
4105
4106        let bytes = serde_json::to_vec(&info.diagnostics).unwrap().len();
4107        assert!(bytes <= MAX_DIAGNOSTICS_ENTRY_BYTES, "{bytes} bytes");
4108    }
4109
4110    #[test]
4111    fn test_single_source_is_returned_unchanged() {
4112        let mut cache = NotificationCache::new();
4113        cache.store_published_diagnostics(
4114            &test_server(),
4115            &published("alias.rs"),
4116            Some(2),
4117            vec![diagnostic_at(1, "e")],
4118        );
4119
4120        let info = merged(&cache).unwrap();
4121
4122        assert_eq!(info.uri, file_uri("alias.rs"));
4123        assert_eq!(info.version.map(DocumentVersion::get), Some(2));
4124    }
4125
4126    #[test]
4127    fn test_ninth_source_is_dropped_without_an_orphan_entry() {
4128        let mut cache = NotificationCache::new();
4129        let server = test_server();
4130        for n in 0..MAX_SOURCES_PER_FILE {
4131            cache.store_published_diagnostics(
4132                &server,
4133                &published(&format!("a{n}.rs")),
4134                None,
4135                vec![diagnostic_at(1, "e")],
4136            );
4137        }
4138
4139        cache.store_published_diagnostics(
4140            &server,
4141            &published("extra.rs"),
4142            None,
4143            vec![diagnostic_at(9, "extra")],
4144        );
4145
4146        assert_eq!(cache.diagnostics_count(), MAX_SOURCES_PER_FILE);
4147        assert!(cache.diagnostics(&file_uri("extra.rs")).is_none());
4148        assert!(!messages(&merged(&cache).unwrap()).contains(&"extra".to_owned()));
4149    }
4150
4151    /// #704: a file whose newer publish was lost reads as lost, not as the
4152    /// older content or as pending, until the owner publishes it again.
4153    #[test]
4154    fn test_lost_publish_replaces_older_entry_with_a_lost_mark() {
4155        let mut cache = NotificationCache::new();
4156        let server = test_server();
4157        let uri = file_uri("a.rs");
4158        cache.store_diagnostics(&server, &uri, None, vec![diagnostic_at(1, "old")]);
4159
4160        let changed = cache.record_lost_files(&server, &[DiagnosticsKey::of(&uri)]);
4161
4162        assert_eq!(changed, [DiagnosticsKey::of(&uri)]);
4163        assert!(!cache.has_diagnostics(&uri));
4164        assert_eq!(
4165            cache.availability(&uri, Some(&server)),
4166            DiagnosticsAvailability::Evicted
4167        );
4168        cache.store_diagnostics(&server, &uri, None, vec![diagnostic_at(2, "new")]);
4169        assert_eq!(
4170            cache.availability(&uri, Some(&server)),
4171            DiagnosticsAvailability::Published
4172        );
4173        cache.assert_consistent();
4174    }
4175
4176    /// An unnamed loss marks the owner so an unknown file reads as possibly evicted.
4177    #[test]
4178    fn test_unnamed_lost_publishes_make_unknown_files_read_evicted() {
4179        let mut cache = NotificationCache::new();
4180        let server = test_server();
4181
4182        cache.record_unnamed_loss(&server);
4183
4184        assert_eq!(
4185            cache.availability(&file_uri("unknown.rs"), Some(&server)),
4186            DiagnosticsAvailability::Evicted
4187        );
4188    }
4189
4190    /// #692: a refused publish is reported as dropped, so nothing is announced for it.
4191    #[test]
4192    fn test_a_publish_over_the_source_cap_is_reported_as_dropped() {
4193        let mut cache = NotificationCache::new();
4194        let server = test_server();
4195        for n in 0..MAX_SOURCES_PER_FILE {
4196            let write = cache.write_published_diagnostics(
4197                &server,
4198                &published(&format!("a{n}.rs")),
4199                None,
4200                BoundedDiagnostics::new(&file_uri("a.rs"), vec![diagnostic_at(1, "e")]),
4201            );
4202            std::assert_matches!(write, PushWrite::Stored { .. });
4203        }
4204
4205        let write = cache.write_published_diagnostics(
4206            &server,
4207            &published("extra.rs"),
4208            None,
4209            BoundedDiagnostics::new(&file_uri("a.rs"), vec![diagnostic_at(9, "extra")]),
4210        );
4211
4212        std::assert_matches!(write, PushWrite::Dropped { .. });
4213    }
4214
4215    #[test]
4216    fn test_republishing_an_indexed_source_at_the_cap_is_accepted() {
4217        let mut cache = NotificationCache::new();
4218        let server = test_server();
4219        for n in 0..MAX_SOURCES_PER_FILE {
4220            cache.store_published_diagnostics(
4221                &server,
4222                &published(&format!("a{n}.rs")),
4223                None,
4224                vec![],
4225            );
4226        }
4227
4228        cache.store_published_diagnostics(
4229            &server,
4230            &published("a0.rs"),
4231            None,
4232            vec![diagnostic_at(1, "again")],
4233        );
4234
4235        assert_eq!(messages(&merged(&cache).unwrap()), ["again"]);
4236    }
4237
4238    #[test]
4239    fn test_clear_server_diagnostics_prunes_the_source_index() {
4240        let mut cache = NotificationCache::new();
4241        let server = test_server();
4242        cache.store_published_diagnostics(
4243            &server,
4244            &published("a1.rs"),
4245            None,
4246            vec![diagnostic_at(1, "e")],
4247        );
4248
4249        cache.clear_server_diagnostics(&server);
4250
4251        cache.assert_consistent();
4252        assert!(cache.files.is_empty());
4253        assert!(!cache.has_diagnostics(&file_uri("main.rs")));
4254        assert!(merged(&cache).is_none());
4255    }
4256
4257    #[test]
4258    fn test_eviction_prunes_the_source_index() {
4259        let mut cache = NotificationCache::new();
4260        let server = test_server();
4261        cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![]);
4262        for n in 0..MAX_DIAGNOSTIC_ENTRIES {
4263            let uri = Uri::from(format!("file:///other/{n}.rs"));
4264            cache.store_diagnostics(&server, &uri, None, vec![]);
4265        }
4266
4267        assert!(cache.diagnostics(&file_uri("a1.rs")).is_none());
4268        cache.assert_consistent();
4269        assert!(
4270            !cache
4271                .files
4272                .contains_key(&DiagnosticsKey::of(&file_uri("main.rs")))
4273        );
4274    }
4275
4276    #[test]
4277    fn test_diagnostics_owner_resolves_through_the_source_index() {
4278        let mut cache = NotificationCache::new();
4279        let server = test_server();
4280        cache.store_published_diagnostics(&server, &published("alias.rs"), None, vec![]);
4281
4282        assert_eq!(cache.diagnostics_owner(&file_uri("main.rs")), Some(&server));
4283    }
4284
4285    /// S5: a server fanning one file out over many spellings must not be able
4286    /// to suppress the canonical publishes.
4287    #[test]
4288    fn test_canonical_source_is_admitted_when_aliases_fill_the_cap() {
4289        let mut cache = NotificationCache::new();
4290        let server = test_server();
4291        for n in 0..MAX_SOURCES_PER_FILE {
4292            cache.store_published_diagnostics(
4293                &server,
4294                &published(&format!("a{n}.rs")),
4295                None,
4296                vec![diagnostic_at(
4297                    u32::try_from(n).unwrap(),
4298                    &format!("alias{n}"),
4299                )],
4300            );
4301        }
4302
4303        cache.store_published_diagnostics(
4304            &server,
4305            &published("main.rs"),
4306            Some(5),
4307            vec![diagnostic_at(99, "canonical")],
4308        );
4309
4310        let info = merged(&cache).unwrap();
4311        let shown = messages(&info);
4312        assert!(shown.contains(&"canonical".to_owned()), "{shown:?}");
4313        assert!(
4314            !shown.contains(&"alias0".to_owned()),
4315            "oldest alias is evicted"
4316        );
4317        assert_eq!(shown.len(), MAX_SOURCES_PER_FILE);
4318        assert_eq!(info.version.map(DocumentVersion::get), Some(5));
4319        assert_eq!(cache.diagnostics_count(), MAX_SOURCES_PER_FILE);
4320    }
4321
4322    #[test]
4323    fn test_alias_beyond_the_cap_is_still_dropped_when_canonical_is_present() {
4324        let mut cache = NotificationCache::new();
4325        let server = test_server();
4326        cache.store_published_diagnostics(&server, &published("main.rs"), None, vec![]);
4327        for n in 0..MAX_SOURCES_PER_FILE - 1 {
4328            cache.store_published_diagnostics(
4329                &server,
4330                &published(&format!("a{n}.rs")),
4331                None,
4332                vec![],
4333            );
4334        }
4335
4336        cache.store_published_diagnostics(
4337            &server,
4338            &published("extra.rs"),
4339            None,
4340            vec![diagnostic_at(1, "x")],
4341        );
4342
4343        assert!(cache.diagnostics(&file_uri("extra.rs")).is_none());
4344        assert!(cache.diagnostics(&file_uri("main.rs")).is_some());
4345    }
4346
4347    /// The merge must not compare every diagnostic against every other one
4348    /// sharing its start: 8 sources of 3000 same-start diagnostics each.
4349    #[test]
4350    fn test_merge_handles_many_same_start_diagnostics_across_sources() {
4351        const PER_SOURCE: u32 = 3000;
4352        let mut cache = NotificationCache::new();
4353        let server = test_server();
4354        for source in 0..MAX_SOURCES_PER_FILE {
4355            let diagnostics = (0..PER_SOURCE)
4356                .map(|n| diagnostic_at(0, &format!("diagnostic-{n}")))
4357                .collect();
4358            cache.store_published_diagnostics(
4359                &server,
4360                &published(&format!("a{source}.rs")),
4361                None,
4362                diagnostics,
4363            );
4364        }
4365
4366        let info = merged(&cache).unwrap();
4367
4368        assert_eq!(info.diagnostics.len(), PER_SOURCE as usize);
4369    }
4370
4371    /// Diagnostics equal in range and message but differing elsewhere are
4372    /// distinct and both survive the merge.
4373    #[test]
4374    fn test_merge_keeps_diagnostics_that_differ_outside_range_and_message() {
4375        let mut cache = NotificationCache::new();
4376        let server = test_server();
4377        let plain = diagnostic_at(1, "same");
4378        let mut coded = diagnostic_at(1, "same");
4379        coded.code = Some(lsp_types::Code::String("E1".to_owned()));
4380        cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![plain]);
4381        cache.store_published_diagnostics(&server, &published("a2.rs"), None, vec![coded]);
4382
4383        assert_eq!(merged(&cache).unwrap().diagnostics.len(), 2);
4384    }
4385
4386    #[test]
4387    fn test_clear_server_diagnostics_leaves_no_dangling_file_index() {
4388        let mut cache = NotificationCache::new();
4389        let (a, b) = (ServerId::from_static("a"), ServerId::from_static("b"));
4390        cache.store_published_diagnostics(&a, &published("a1.rs"), None, vec![]);
4391        cache.store_published_diagnostics(
4392            &b,
4393            &published("main.rs"),
4394            None,
4395            vec![diagnostic_at(1, "e")],
4396        );
4397
4398        cache.clear_server_diagnostics(&a);
4399
4400        cache.assert_consistent();
4401        assert_eq!(cache.files.len(), 1);
4402        assert!(cache.diagnostics(&file_uri("main.rs")).is_some());
4403        assert!(cache.diagnostics(&file_uri("a1.rs")).is_none());
4404    }
4405
4406    #[derive(Debug, Clone)]
4407    enum CacheOp {
4408        Publish {
4409            server: u8,
4410            file: u8,
4411            alias: Option<u8>,
4412            empty: bool,
4413        },
4414        Clear {
4415            server: u8,
4416        },
4417    }
4418
4419    fn cache_op() -> impl proptest::strategy::Strategy<Value = CacheOp> {
4420        use proptest::prelude::*;
4421
4422        prop_oneof![
4423            4 => (0..3u8, 0..3u8, proptest::option::of(0..3u8), any::<bool>()).prop_map(
4424                |(server, file, alias, empty)| CacheOp::Publish { server, file, alias, empty }
4425            ),
4426            1 => (0..3u8).prop_map(|server| CacheOp::Clear { server }),
4427        ]
4428    }
4429
4430    proptest::proptest! {
4431        #[test]
4432        fn cache_indices_stay_consistent_under_random_operations(
4433            ops in proptest::collection::vec(cache_op(), 0..60)
4434        ) {
4435            let mut cache = NotificationCache::new();
4436            for op in ops {
4437                match op {
4438                    CacheOp::Publish { server, file, alias, empty } => {
4439                        let canonical = file_uri(&format!("f{file}.rs"));
4440                        let source = alias.map_or_else(
4441                            || canonical.clone(),
4442                            |n| file_uri(&format!("alias{n}.rs")),
4443                        );
4444                        let diagnostics = if empty { vec![] } else { vec![diagnostic_at(1, "e")] };
4445                        cache.store_published_diagnostics(
4446                            &ServerId::new(format!("s{server}")).unwrap(),
4447                            &PublishedDiagnosticsUri::for_test(source, canonical),
4448                            None,
4449                            diagnostics,
4450                        );
4451                    }
4452                    CacheOp::Clear { server } => {
4453                        cache.clear_server_diagnostics(&ServerId::new(format!("s{server}")).unwrap());
4454                    }
4455                }
4456                cache.assert_consistent();
4457            }
4458        }
4459    }
4460
4461    fn first_uri() -> Uri {
4462        Uri::from("file:///first.rs")
4463    }
4464
4465    fn error_diagnostic() -> LspDiagnostic {
4466        LspDiagnostic {
4467            severity: Some(lsp_types::DiagnosticSeverity::Error),
4468            message: "broken".to_owned().into(),
4469            ..LspDiagnostic::default()
4470        }
4471    }
4472
4473    /// Fills the cache with `MAX_DIAGNOSTIC_ENTRIES` entries: `file:///first.rs`
4474    /// (empty or not, per `first_empty`) then non-empty fillers.
4475    fn full_cache(first_empty: bool) -> NotificationCache {
4476        let mut cache = NotificationCache::new();
4477        let first: Uri = Uri::from("file:///first.rs");
4478        let diagnostics = if first_empty {
4479            vec![]
4480        } else {
4481            vec![error_diagnostic()]
4482        };
4483        cache.store_diagnostics(&test_server(), &first, None, diagnostics);
4484        for i in 1..MAX_DIAGNOSTIC_ENTRIES {
4485            let uri: Uri = Uri::from(format!("file:///filler{i}.rs"));
4486            cache.store_diagnostics(&test_server(), &uri, None, vec![error_diagnostic()]);
4487        }
4488        cache
4489    }
4490
4491    fn evict_one(cache: &mut NotificationCache) {
4492        let overflow: Uri = Uri::from("file:///overflow.rs");
4493        cache.store_diagnostics(&test_server(), &overflow, None, vec![error_diagnostic()]);
4494    }
4495
4496    #[test]
4497    fn test_evicted_empty_entry_is_listen_replayable() {
4498        let mut cache = full_cache(true);
4499        assert!(cache.is_listen_replayable(&first_uri()));
4500        evict_one(&mut cache);
4501        assert!(!cache.has_diagnostics(&first_uri()));
4502        assert!(cache.is_listen_replayable(&first_uri()));
4503        assert!(!cache.is_listen_replayable(&Uri::from("file:///unrelated.rs")));
4504    }
4505
4506    fn write_error(cache: &mut NotificationCache, file: &str) -> PushWrite {
4507        let uri = Uri::from(file);
4508        cache.write_published_diagnostics(
4509            &test_server(),
4510            &PublishedDiagnosticsUri::for_test(uri.clone(), uri),
4511            None,
4512            BoundedDiagnostics::new(&Uri::from(file), vec![error_diagnostic()]),
4513        )
4514    }
4515
4516    /// #649: a push that needs room names the file whose entry it evicted.
4517    #[test]
4518    fn test_a_push_reports_the_file_it_evicts() {
4519        let mut cache = full_cache(false);
4520        let write = write_error(&mut cache, "file:///overflow.rs");
4521        assert_eq!(write.evicted(), [DiagnosticsKey::of(&first_uri())]);
4522        assert!(!cache.has_diagnostics(&first_uri()));
4523    }
4524
4525    /// #649: replacing a cached entry, or writing below capacity, evicts nothing.
4526    #[test]
4527    fn test_a_push_that_needs_no_room_reports_no_eviction() {
4528        let mut cache = full_cache(false);
4529        assert!(
4530            write_error(&mut cache, "file:///first.rs")
4531                .evicted()
4532                .is_empty()
4533        );
4534        let mut roomy = NotificationCache::new();
4535        assert!(write_error(&mut roomy, "file:///a.rs").evicted().is_empty());
4536    }
4537
4538    fn route() -> ServerId {
4539        test_server()
4540    }
4541
4542    /// #666: a clean file whose entry was evicted still reads as published
4543    /// and clean, not as pending.
4544    #[test]
4545    fn test_evicted_clean_entry_reads_as_published() {
4546        let mut cache = full_cache(true);
4547        assert!(cache.has_diagnostics(&first_uri()));
4548        evict_one(&mut cache);
4549        assert!(!cache.has_diagnostics(&first_uri()));
4550        assert_eq!(
4551            cache.availability(&first_uri(), Some(&route())),
4552            DiagnosticsAvailability::Published
4553        );
4554    }
4555
4556    /// #666: a file whose diagnostics were evicted reads as evicted: what the
4557    /// server said is unknown, which is not the same as nothing published.
4558    #[test]
4559    fn test_evicted_non_empty_entry_reads_as_evicted() {
4560        let mut cache = full_cache(false);
4561        evict_one(&mut cache);
4562        assert_eq!(
4563            cache.availability(&first_uri(), Some(&route())),
4564            DiagnosticsAvailability::Evicted
4565        );
4566    }
4567
4568    #[test]
4569    fn test_unpublished_file_reads_as_pending_even_next_to_evictions() {
4570        let mut cache = full_cache(false);
4571        evict_one(&mut cache);
4572        let unseen = Uri::from("file:///never-published.rs");
4573        assert_eq!(
4574            cache.availability(&unseen, Some(&route())),
4575            DiagnosticsAvailability::Pending
4576        );
4577        assert_eq!(
4578            cache.availability(&unseen, None),
4579            DiagnosticsAvailability::Pending
4580        );
4581    }
4582
4583    /// #666: a respawn forgets what was evicted along with the diagnostics, so
4584    /// the file reads as pending again until the replacement publishes.
4585    #[test]
4586    fn test_clearing_a_server_forgets_its_eviction_marks() {
4587        let mut cache = full_cache(false);
4588        evict_one(&mut cache);
4589        drop(cache.clear_server_diagnostics(&route()));
4590        assert_eq!(
4591            cache.availability(&first_uri(), Some(&route())),
4592            DiagnosticsAvailability::Pending
4593        );
4594    }
4595
4596    #[test]
4597    fn test_a_new_publish_replaces_the_mark_of_its_file() {
4598        let mut cache = full_cache(true);
4599        evict_one(&mut cache);
4600        let first: Uri = Uri::from("file:///first.rs");
4601        cache.store_diagnostics(&test_server(), &first, None, vec![error_diagnostic()]);
4602        assert_eq!(
4603            cache.availability(&first_uri(), None),
4604            DiagnosticsAvailability::Published
4605        );
4606        assert!(
4607            cache
4608                .eviction_marks
4609                .content(&DiagnosticsKey::of(&first))
4610                .is_none()
4611        );
4612    }
4613
4614    /// Lost content is never downgraded to clean by a later clean eviction of
4615    /// the same file.
4616    #[test]
4617    fn test_lost_content_survives_a_later_clean_eviction_of_the_file() {
4618        let mut marks = EvictionMarks::default();
4619        let file = DiagnosticsKey::of(&first_uri());
4620        marks.record(file.clone(), route(), EvictedContent::Lost);
4621        marks.record(file.clone(), route(), EvictedContent::Clean);
4622        assert_eq!(marks.content(&file), Some(EvictedContent::Lost));
4623    }
4624
4625    fn overflowed_after_churn() -> NotificationCache {
4626        let mut cache = full_cache(false);
4627        for i in 0..=MAX_EVICTION_MARKS {
4628            drop(write_error(&mut cache, &format!("file:///churn{i}.rs")));
4629        }
4630        assert!(cache.eviction_marks.has_overflowed(&route()));
4631        cache
4632    }
4633
4634    /// #680: the overflow ends with the owner's next accepted push, so a file
4635    /// forgotten earlier reads as pending again.
4636    #[test]
4637    fn test_overflow_ends_with_the_owners_next_accepted_push() {
4638        let mut cache = NotificationCache::new();
4639        cache.eviction_marks.overflowed.insert(route());
4640        let unseen = Uri::from("file:///never-published.rs");
4641        assert_eq!(
4642            cache.availability(&unseen, Some(&route())),
4643            DiagnosticsAvailability::Evicted
4644        );
4645
4646        drop(write_error(&mut cache, "file:///fresh.rs"));
4647
4648        assert!(!cache.eviction_marks.has_overflowed(&route()));
4649        assert_eq!(
4650            cache.availability(&unseen, Some(&route())),
4651            DiagnosticsAvailability::Pending
4652        );
4653    }
4654
4655    /// #680: a pull is a write too, and another server's write leaves the
4656    /// overflow alone.
4657    #[test]
4658    fn test_overflow_ends_with_the_owners_next_accepted_pull_only() {
4659        let mut cache = NotificationCache::new();
4660        cache.eviction_marks.overflowed.insert(route());
4661        let other = ServerId::from_static("other-server");
4662        cache.store_pulled_for_test(&other, &Uri::from("file:///other.rs"), Vec::new());
4663        assert!(cache.eviction_marks.has_overflowed(&route()));
4664
4665        cache.store_pulled_for_test(&route(), &Uri::from("file:///pulled.rs"), Vec::new());
4666        assert!(!cache.eviction_marks.has_overflowed(&route()));
4667    }
4668
4669    /// #680: the clear runs before the write's own evictions, so a write that
4670    /// evicts into full marks leaves the server overflowed, and one that needs
4671    /// no room ends it.
4672    #[test]
4673    fn test_overflow_clear_precedes_the_evictions_of_the_same_write() {
4674        let mut cache = overflowed_after_churn();
4675        drop(write_error(&mut cache, "file:///another-new.rs"));
4676        assert!(cache.eviction_marks.has_overflowed(&route()));
4677
4678        drop(write_error(&mut cache, "file:///another-new.rs"));
4679        assert!(!cache.eviction_marks.has_overflowed(&route()));
4680        cache.assert_consistent();
4681    }
4682
4683    /// #666 eviction pressure: once more files were evicted than the marks
4684    /// remember, a file without a mark is reported as possibly evicted for the
4685    /// server that lost marks, and still as pending for any other route.
4686    #[test]
4687    fn test_overflowing_the_marks_makes_unmarked_files_read_as_evicted_for_that_server() {
4688        let mut cache = full_cache(false);
4689        for i in 0..=MAX_EVICTION_MARKS {
4690            drop(write_error(&mut cache, &format!("file:///churn{i}.rs")));
4691        }
4692        assert!(cache.eviction_marks.has_overflowed(&route()));
4693        assert!(cache.eviction_marks.by_file.len() <= MAX_EVICTION_MARKS);
4694        assert!(cache.eviction_marks.order.len() <= MAX_EVICTION_MARKS);
4695        cache.assert_consistent();
4696
4697        assert_eq!(
4698            cache.availability(&first_uri(), Some(&route())),
4699            DiagnosticsAvailability::Evicted,
4700            "its mark was the oldest, forgotten"
4701        );
4702        let unseen = Uri::from("file:///never-published.rs");
4703        assert_eq!(
4704            cache.availability(&unseen, Some(&route())),
4705            DiagnosticsAvailability::Evicted
4706        );
4707        assert_eq!(
4708            cache.availability(&unseen, Some(&ServerId::from_static("other"))),
4709            DiagnosticsAvailability::Pending
4710        );
4711
4712        drop(cache.clear_server_diagnostics(&route()));
4713        assert_eq!(
4714            cache.availability(&unseen, Some(&route())),
4715            DiagnosticsAvailability::Pending
4716        );
4717    }
4718
4719    #[test]
4720    fn test_evicted_non_empty_entry_is_not_replayable() {
4721        let mut cache = full_cache(false);
4722        evict_one(&mut cache);
4723        assert!(!cache.has_diagnostics(&first_uri()));
4724        assert!(!cache.is_listen_replayable(&first_uri()));
4725    }
4726
4727    #[test]
4728    fn test_recached_entry_leaves_the_replay_ring_view() {
4729        let mut cache = full_cache(true);
4730        evict_one(&mut cache);
4731        let first: Uri = Uri::from("file:///first.rs");
4732        cache.store_diagnostics(&test_server(), &first, None, vec![error_diagnostic()]);
4733        assert!(cache.has_diagnostics(&first_uri()));
4734        assert!(!cache.was_recently_evicted(&first_uri(), std::time::Instant::now()));
4735    }
4736
4737    #[test]
4738    fn test_eviction_replay_expires_after_the_window() {
4739        let mut cache = NotificationCache::new();
4740        let now = std::time::Instant::now();
4741        let gone = Uri::from("file:///gone.rs");
4742        cache.record_empty_eviction(DiagnosticsKey::of(&gone), now);
4743        assert!(cache.was_recently_evicted(&gone, now + EVICTION_REPLAY_WINDOW));
4744        assert!(!cache.was_recently_evicted(
4745            &gone,
4746            now + EVICTION_REPLAY_WINDOW + std::time::Duration::from_secs(1)
4747        ));
4748    }
4749
4750    #[test]
4751    fn test_eviction_record_is_bounded_and_keeps_the_newest() {
4752        let mut cache = NotificationCache::new();
4753        let now = std::time::Instant::now();
4754        for i in 0..MAX_RECENT_EVICTIONS + 44 {
4755            let gone = Uri::from(format!("file:///gone{i}.rs"));
4756            cache.record_empty_eviction(
4757                DiagnosticsKey::of(&gone),
4758                now + std::time::Duration::from_millis(u64::try_from(i).unwrap()),
4759            );
4760        }
4761        assert_eq!(cache.recent_evictions.len(), MAX_RECENT_EVICTIONS);
4762        let at = now + std::time::Duration::from_secs(1);
4763        assert!(!cache.was_recently_evicted(&Uri::from("file:///gone0.rs"), at));
4764        assert!(!cache.was_recently_evicted(&Uri::from("file:///gone43.rs"), at));
4765        assert!(cache.was_recently_evicted(&Uri::from("file:///gone44.rs"), at));
4766        let newest = MAX_RECENT_EVICTIONS + 43;
4767        assert!(cache.was_recently_evicted(&Uri::from(format!("file:///gone{newest}.rs")), at));
4768    }
4769
4770    #[test]
4771    fn test_clearing_a_full_cache_keeps_every_empty_clear_replayable() {
4772        let mut cache = NotificationCache::new();
4773        let server = test_server();
4774        let uris: Vec<Uri> = (0..MAX_DIAGNOSTIC_ENTRIES)
4775            .map(|i| Uri::from(format!("file:///clean{i}.rs")))
4776            .collect();
4777        for uri in &uris {
4778            cache.store_diagnostics(&server, uri, None, vec![]);
4779        }
4780
4781        cache.clear_server_diagnostics(&server);
4782
4783        assert!(uris.iter().all(|uri| cache.is_listen_replayable(uri)));
4784    }
4785
4786    #[test]
4787    fn test_eviction_order_stays_within_twice_the_record_under_churn() {
4788        let mut cache = NotificationCache::new();
4789        let now = std::time::Instant::now();
4790        let keys: Vec<DiagnosticsKey> = (0..3)
4791            .map(|i| DiagnosticsKey::of(&Uri::from(format!("file:///hot{i}.rs"))))
4792            .collect();
4793        for tick in 0..10_000u64 {
4794            let key = keys[usize::try_from(tick % 3).unwrap()].clone();
4795            cache.record_empty_eviction(key, now + std::time::Duration::from_micros(tick));
4796            let record = &cache.recent_evictions;
4797            assert!(record.at.len() <= MAX_RECENT_EVICTIONS);
4798            assert!(record.order.len() <= 2 * record.at.len());
4799        }
4800        assert_eq!(cache.recent_evictions.len(), 3);
4801    }
4802
4803    #[test]
4804    fn test_eviction_record_stays_bounded_under_churn_past_the_cap() {
4805        const KEYS: usize = MAX_RECENT_EVICTIONS + 300;
4806        let mut cache = NotificationCache::new();
4807        let now = std::time::Instant::now();
4808        let keys: Vec<DiagnosticsKey> = (0..KEYS)
4809            .map(|i| DiagnosticsKey::of(&Uri::from(format!("file:///churn{i}.rs"))))
4810            .collect();
4811        for tick in 0..KEYS * 8 {
4812            let key = keys[tick % KEYS].clone();
4813            let at = now + std::time::Duration::from_micros(u64::try_from(tick).unwrap());
4814            cache.record_empty_eviction(key, at);
4815            let record = &cache.recent_evictions;
4816            assert!(record.at.len() <= MAX_RECENT_EVICTIONS);
4817            assert!(record.order.len() <= 2 * record.at.len() + 1);
4818        }
4819        assert_eq!(cache.recent_evictions.len(), MAX_RECENT_EVICTIONS);
4820    }
4821
4822    #[test]
4823    fn test_compaction_keeps_the_oldest_first_expiry_order() {
4824        let mut record = EvictionRecord::default();
4825        let now = std::time::Instant::now();
4826        let a = DiagnosticsKey::of(&Uri::from("file:///a.rs"));
4827        let b = DiagnosticsKey::of(&Uri::from("file:///b.rs"));
4828        record.record(a.clone(), now);
4829        record.record(b.clone(), now + std::time::Duration::from_secs(1));
4830        record.record(a.clone(), now + std::time::Duration::from_secs(2));
4831        record.record(b.clone(), now + std::time::Duration::from_secs(3));
4832        record.compact();
4833        let order: Vec<_> = record.order.iter().map(|(key, _)| key.clone()).collect();
4834        assert_eq!(order, vec![a, b]);
4835    }
4836
4837    #[test]
4838    fn test_eviction_record_overflow_warns_once_per_window() {
4839        use tracing_subscriber::layer::SubscriberExt as _;
4840
4841        let mut record = EvictionRecord::default();
4842        let now = std::time::Instant::now();
4843        let captured = CapturedLogs::default();
4844        let subscriber = tracing_subscriber::registry().with(captured.clone());
4845        let guard = tracing::subscriber::set_default(subscriber);
4846        for i in 0..MAX_RECENT_EVICTIONS + 10 {
4847            let key = DiagnosticsKey::of(&Uri::from(format!("file:///gone{i}.rs")));
4848            record.record(
4849                key,
4850                now + std::time::Duration::from_millis(u64::try_from(i).unwrap()),
4851            );
4852        }
4853        drop(guard);
4854
4855        let warnings = captured
4856            .messages()
4857            .iter()
4858            .filter(|m| m.contains("diagnostics clears evicted"))
4859            .count();
4860        assert_eq!(warnings, 1);
4861    }
4862
4863    #[test]
4864    fn test_eviction_record_deduplicates_and_prunes_expired_entries() {
4865        let mut cache = NotificationCache::new();
4866        let now = std::time::Instant::now();
4867        let a = DiagnosticsKey::of(&Uri::from("file:///a.rs"));
4868        cache.record_empty_eviction(a.clone(), now);
4869        cache.record_empty_eviction(a, now);
4870        assert_eq!(cache.recent_evictions.len(), 1);
4871        let later = now + EVICTION_REPLAY_WINDOW + std::time::Duration::from_secs(1);
4872        cache.record_empty_eviction(DiagnosticsKey::of(&Uri::from("file:///b.rs")), later);
4873        assert_eq!(cache.recent_evictions.len(), 1);
4874    }
4875
4876    #[test]
4877    fn test_alias_eviction_of_an_empty_entry_is_recorded() {
4878        let mut cache = NotificationCache::new();
4879        let server = test_server();
4880        for n in 0..MAX_SOURCES_PER_FILE {
4881            cache.store_published_diagnostics(
4882                &server,
4883                &published(&format!("a{n}.rs")),
4884                None,
4885                vec![],
4886            );
4887        }
4888        assert!(cache.recent_evictions.is_empty());
4889
4890        cache.store_published_diagnostics(
4891            &server,
4892            &PublishedDiagnosticsUri::for_test(file_uri("main.rs"), file_uri("main.rs")),
4893            None,
4894            vec![diagnostic_at(1, "e")],
4895        );
4896
4897        assert!(cache.diagnostics(&file_uri("a0.rs")).is_none());
4898        assert_eq!(cache.recent_evictions.len(), 1);
4899        assert!(cache.is_listen_replayable(&file_uri("main.rs")));
4900    }
4901
4902    #[test]
4903    fn test_clearing_a_server_records_its_empty_entries() {
4904        let mut cache = NotificationCache::new();
4905        let empty = file_uri("empty.rs");
4906        let broken = file_uri("broken.rs");
4907        cache.store_diagnostics(&test_server(), &empty, None, vec![]);
4908        cache.store_diagnostics(&test_server(), &broken, None, vec![error_diagnostic()]);
4909
4910        let cleared = cache.clear_server_diagnostics(&test_server());
4911
4912        assert_eq!(cleared.len(), 2);
4913        assert!(cache.is_listen_replayable(&empty));
4914        assert!(!cache.is_listen_replayable(&broken));
4915    }
4916}