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