use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use lsp_types::{Diagnostic as LspDiagnostic, Uri};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::bridge::indexing::{IndexingPolicy, IndexingReset, IndexingState, IndexingTracker};
use crate::bridge::resources::{DiagnosticsResourceUri, PublishedDiagnosticsUri};
use crate::bridge::{
DiagnosticsAvailability, DocumentTracker, DocumentVersion, Opening, uri_to_path,
};
use crate::config::{BoundedText, ServerId};
use crate::util::{WarnLimiter, truncate_string};
mod bounds;
mod pulled_index;
pub use bounds::BoundedDiagnostics;
use bounds::{MAX_ENTRY_TEXT_BYTES, cap_diagnostics_entry_size};
use pulled_index::{PulledIndex, point};
pub use pulled_index::{ReportedSeverity, reported_code};
const MAX_LOG_ENTRIES: usize = 100;
pub const MAX_DIAGNOSTIC_ENTRIES: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DiagnosticsKey(String);
impl DiagnosticsKey {
pub(crate) fn of(uri: &Uri) -> Self {
let text: &str = uri.as_ref();
if cfg!(windows) {
Self(text.to_ascii_lowercase())
} else {
Self(text.to_owned())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Spelling {
Canonical,
Alias(DiagnosticsKey),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum Provenance {
Pushed,
Pulled,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct SlotKey {
uri: DiagnosticsKey,
provenance: Provenance,
}
impl SlotKey {
const fn pushed(uri: DiagnosticsKey) -> Self {
Self {
uri,
provenance: Provenance::Pushed,
}
}
const fn pulled(uri: DiagnosticsKey) -> Self {
Self {
uri,
provenance: Provenance::Pulled,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct PullTicket(u64);
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ClearEpoch(u64);
#[derive(Debug, Clone, Copy)]
pub struct PullStamp {
ticket: PullTicket,
epoch: ClearEpoch,
version: DocumentVersion,
}
impl PullStamp {
pub(crate) const fn version(self) -> DocumentVersion {
self.version
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionCheck {
Current,
Moved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Discard {
OlderTicket,
ServerCleared,
VersionMoved,
NoRoom,
}
#[derive(Debug)]
pub enum SlotChange {
Identical,
Replaced { before: Option<DiagnosticSources> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapshotBefore {
Take,
Skip,
}
#[derive(Debug)]
struct NoRoom {
evicted: Vec<DiagnosticsKey>,
}
#[derive(Debug)]
#[must_use]
pub enum PullWrite {
Stored {
slot: SlotChange,
evicted: Vec<DiagnosticsKey>,
},
Discarded {
reason: Discard,
evicted: Vec<DiagnosticsKey>,
items: BoundedDiagnostics,
},
}
#[derive(Debug)]
#[must_use]
pub enum PushWrite {
Stored {
evicted: Vec<DiagnosticsKey>,
},
Dropped {
evicted: Vec<DiagnosticsKey>,
},
}
impl PushWrite {
#[must_use]
pub fn evicted(&self) -> &[DiagnosticsKey] {
match self {
Self::Stored { evicted } | Self::Dropped { evicted } => evicted,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeOutcome {
Changed,
Unchanged,
}
impl ChangeOutcome {
pub(crate) fn of(before: Option<&DiagnosticInfo>, after: Option<&DiagnosticInfo>) -> Self {
Self::compare(before, after, |d| serde_json::to_vec(d).ok())
}
fn compare(
before: Option<&DiagnosticInfo>,
after: Option<&DiagnosticInfo>,
serialize: impl Fn(&LspDiagnostic) -> Option<Vec<u8>>,
) -> Self {
let fingerprint = |info: &DiagnosticInfo| -> Option<Vec<Vec<u8>>> {
let mut items = info
.diagnostics
.iter()
.map(&serialize)
.collect::<Option<Vec<_>>>()?;
items.sort_unstable();
Some(items)
};
match (before, after) {
(None, None) => Self::Unchanged,
(Some(before), Some(after)) => match (fingerprint(before), fingerprint(after)) {
(Some(before), Some(after)) if before == after => Self::Unchanged,
_ => Self::Changed,
},
_ => Self::Changed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentSync {
Synced(DocumentVersion),
NotOpen,
Unattached,
}
fn pull_is_superseded(
pulled: DocumentVersion,
pushed: Option<DocumentVersion>,
sync: DocumentSync,
) -> bool {
match (pushed, sync) {
(Some(pushed), _) if pushed > pulled => true,
(_, DocumentSync::Unattached) => false,
(_, DocumentSync::NotOpen) => true,
(_, DocumentSync::Synced(current)) => current != pulled,
}
}
#[derive(Debug, Clone, Copy)]
enum Protect<'a> {
Nothing,
File(&'a DiagnosticsKey),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Covered(BTreeSet<usize>);
const MAX_LEARNED_PULL_SOURCES: usize = 32;
const MAX_DIAGNOSTIC_SOURCE_BYTES: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
struct DiagnosticSource(BoundedText<MAX_DIAGNOSTIC_SOURCE_BYTES>);
impl DiagnosticSource {
fn of(diagnostic: &LspDiagnostic) -> Option<Self> {
let text = diagnostic.source.as_deref()?;
BoundedText::new(text).ok().map(Self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Freshness {
Current,
Superseded { covered: Covered },
}
impl Freshness {
fn excludes(&self, index: usize) -> bool {
match self {
Self::Current => false,
Self::Superseded { covered } => covered.0.contains(&index),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Origin {
Pushed {
spelling: Spelling,
version: Option<DocumentVersion>,
opening: Option<Opening>,
covered: Covered,
},
Pulled {
ticket: PullTicket,
opening: Option<Opening>,
version: DocumentVersion,
},
}
impl Origin {
const fn version(&self) -> Option<DocumentVersion> {
match self {
Self::Pushed { version, .. } => *version,
Self::Pulled { version, .. } => Some(*version),
}
}
const fn provenance(&self) -> Provenance {
match self {
Self::Pushed { .. } => Provenance::Pushed,
Self::Pulled { .. } => Provenance::Pulled,
}
}
}
#[derive(Debug)]
struct CachedEntry {
uri: Uri,
diagnostics: Vec<LspDiagnostic>,
owner: ServerId,
seq: u64,
origin: Origin,
}
impl CachedEntry {
fn info(&self) -> DiagnosticInfo {
DiagnosticInfo {
uri: self.uri.clone(),
version: self.origin.version(),
diagnostics: self.diagnostics.clone(),
}
}
const fn file<'a>(&'a self, key: &'a SlotKey) -> &'a DiagnosticsKey {
match &self.origin {
Origin::Pushed {
spelling: Spelling::Alias(canonical),
..
} => canonical,
Origin::Pushed {
spelling: Spelling::Canonical,
..
}
| Origin::Pulled { .. } => &key.uri,
}
}
}
pub fn diagnostics_cache_key(uri: &DiagnosticsResourceUri) -> Option<DiagnosticsKey> {
let path = crate::bridge::resources::parse_uri(uri.as_str()).ok()?;
let lsp_uri = crate::bridge::try_path_to_uri(path.as_path())?;
Some(DiagnosticsKey::of(&lsp_uri))
}
const MAX_SOURCES_PER_FILE: usize = 8;
const MAX_SERVER_MESSAGES: usize = 50;
const MAX_RECENT_EVICTIONS: usize = MAX_DIAGNOSTIC_ENTRIES;
const MAX_EVICTION_MARKS: usize = MAX_DIAGNOSTIC_ENTRIES;
const EVICTION_REPLAY_WINDOW: std::time::Duration = std::time::Duration::from_mins(2);
#[derive(Debug, Default)]
struct EvictionRecord {
at: HashMap<DiagnosticsKey, std::time::Instant>,
order: VecDeque<(DiagnosticsKey, std::time::Instant)>,
overflow_warn: WarnLimiter,
}
impl EvictionRecord {
fn record(&mut self, file: DiagnosticsKey, now: std::time::Instant) {
self.prune_expired(now);
self.at.insert(file.clone(), now);
self.order.push_back((file, now));
while self.at.len() > MAX_RECENT_EVICTIONS {
self.forget_oldest(now);
}
if self.order.len() > self.at.len().saturating_mul(2) {
self.compact();
}
}
fn contains(&self, key: &DiagnosticsKey, now: std::time::Instant) -> bool {
self.at
.get(key)
.is_some_and(|at| now.saturating_duration_since(*at) <= EVICTION_REPLAY_WINDOW)
}
fn is_live(&self, key: &DiagnosticsKey, at: std::time::Instant) -> bool {
self.at.get(key) == Some(&at)
}
fn prune_expired(&mut self, now: std::time::Instant) {
while let Some((key, at)) = self
.order
.pop_front_if(|(_, at)| now.saturating_duration_since(*at) > EVICTION_REPLAY_WINDOW)
{
if self.is_live(&key, at) {
self.at.remove(&key);
}
}
}
fn forget_oldest(&mut self, now: std::time::Instant) {
while let Some((key, at)) = self.order.pop_front() {
if self.is_live(&key, at) {
self.at.remove(&key);
self.warn_overflow(now);
return;
}
}
}
fn warn_overflow(&mut self, now: std::time::Instant) {
if self.overflow_warn.due(now, EVICTION_REPLAY_WINDOW) {
warn!(
"more than {MAX_RECENT_EVICTIONS} diagnostics clears evicted within {}s, \
forgetting the oldest; a re-attaching listen may miss them",
EVICTION_REPLAY_WINDOW.as_secs()
);
}
}
fn compact(&mut self) {
let mut live: Vec<_> = self.at.iter().map(|(k, at)| (k.clone(), *at)).collect();
live.sort_by_key(|(_, at)| *at);
self.order = live.into();
}
#[cfg(test)]
fn len(&self) -> usize {
self.at.len()
}
#[cfg(test)]
fn is_empty(&self) -> bool {
self.at.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EvictedContent {
Clean,
Lost,
}
#[derive(Debug)]
struct EvictionMark {
owner: ServerId,
content: EvictedContent,
seq: u64,
}
#[derive(Debug, Default)]
struct EvictionMarks {
by_file: HashMap<DiagnosticsKey, EvictionMark>,
order: BTreeMap<u64, DiagnosticsKey>,
next_seq: u64,
overflowed: HashSet<ServerId>,
}
impl EvictionMarks {
fn record(&mut self, file: DiagnosticsKey, owner: ServerId, content: EvictedContent) {
let content = match self.by_file.get(&file) {
Some(previous) if previous.content == EvictedContent::Lost => EvictedContent::Lost,
_ => content,
};
self.forget_file(&file);
let seq = self.next_seq;
self.next_seq = self.next_seq.saturating_add(1);
self.order.insert(seq, file.clone());
self.by_file.insert(
file,
EvictionMark {
owner,
content,
seq,
},
);
while self.by_file.len() > MAX_EVICTION_MARKS {
let Some((_, oldest)) = self.order.pop_first() else {
break;
};
if let Some(mark) = self.by_file.remove(&oldest) {
self.overflowed.insert(mark.owner);
}
}
}
fn forget_file(&mut self, file: &DiagnosticsKey) {
if let Some(mark) = self.by_file.remove(file) {
self.order.remove(&mark.seq);
}
}
fn forget_server(&mut self, server: &ServerId) {
self.by_file.retain(|_, mark| &mark.owner != server);
self.order.retain(|_, file| self.by_file.contains_key(file));
self.overflowed.remove(server);
}
fn content(&self, file: &DiagnosticsKey) -> Option<EvictedContent> {
self.by_file.get(file).map(|mark| mark.content)
}
fn has_overflowed(&self, server: &ServerId) -> bool {
self.overflowed.contains(server)
}
fn clear_overflow(&mut self, server: &ServerId) -> bool {
self.overflowed.remove(server)
}
fn restore_overflow(&mut self, server: &ServerId) {
self.overflowed.insert(server.clone());
}
}
pub fn message_as_str(message: &lsp_types::Message) -> &str {
match message {
lsp_types::Message::String(s) => s,
lsp_types::Message::MarkupContent(m) => &m.value,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticInfo {
pub uri: Uri,
pub version: Option<DocumentVersion>,
pub diagnostics: Vec<LspDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SourceOrigin {
Pushed(Spelling),
Pulled,
}
impl SourceOrigin {
const fn is_pulled(&self) -> bool {
matches!(self, Self::Pulled)
}
}
impl From<&Origin> for SourceOrigin {
fn from(origin: &Origin) -> Self {
match origin {
Origin::Pushed { spelling, .. } => Self::Pushed(spelling.clone()),
Origin::Pulled { .. } => Self::Pulled,
}
}
}
#[derive(Debug, Clone)]
struct SourceEntry {
info: DiagnosticInfo,
origin: SourceOrigin,
freshness: Freshness,
}
#[derive(Debug, Clone)]
pub struct DiagnosticSources {
requested: Uri,
entries: Vec<SourceEntry>,
}
impl DiagnosticSources {
pub(crate) fn with_pulled(
mut self,
file: &Uri,
version: Option<DocumentVersion>,
diagnostics: BoundedDiagnostics,
) -> Self {
self.entries.retain(|entry| !entry.origin.is_pulled());
self.entries.push(SourceEntry {
info: DiagnosticInfo {
uri: file.clone(),
version,
diagnostics: diagnostics.0,
},
origin: SourceOrigin::Pulled,
freshness: Freshness::Current,
});
self
}
#[must_use]
pub fn merge(self) -> Option<DiagnosticInfo> {
let Self { requested, entries } = self;
let has_pulled = entries.iter().any(|entry| entry.origin.is_pulled());
if entries.len() <= 1 && !has_pulled {
return entries.into_iter().next().map(|entry| entry.info);
}
let version = entries
.iter()
.find(|entry| {
entry.origin == SourceOrigin::Pushed(Spelling::Canonical)
&& entry.freshness == Freshness::Current
})
.and_then(|entry| entry.info.version)
.or_else(|| {
entries
.iter()
.find(|entry| entry.origin.is_pulled())
.and_then(|entry| entry.info.version)
});
let (pulled, pushed): (Vec<SourceEntry>, Vec<SourceEntry>) = entries
.into_iter()
.partition(|entry| entry.origin.is_pulled());
let pulled: Vec<LspDiagnostic> = pulled
.into_iter()
.flat_map(|entry| entry.info.diagnostics)
.collect();
let index = PulledIndex::new(&pulled);
let mut seen: HashSet<Vec<u8>> = HashSet::new();
let mut kept_pushed = Vec::new();
let pushed_items = pushed.into_iter().flat_map(|entry| {
let SourceEntry {
info, freshness, ..
} = entry;
info.diagnostics
.into_iter()
.enumerate()
.filter(move |(position, _)| !freshness.excludes(*position))
.map(|(_, diagnostic)| diagnostic)
});
for diagnostic in pushed_items {
if index.contains_same_problem(&diagnostic) {
continue;
}
if serde_json::to_vec(&diagnostic).map_or(true, |bytes| seen.insert(bytes)) {
kept_pushed.push(diagnostic);
}
}
drop(index);
let mut merged = pulled;
merged.extend(kept_pushed);
merged.sort_by_key(|d| (point(d.range.start), point(d.range.end)));
cap_diagnostics_entry_size(&requested, &mut merged);
Some(DiagnosticInfo {
uri: requested,
version,
diagnostics: merged,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LogEntry {
pub level: LogLevel,
pub message: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
#[schemars(inline)]
pub enum LogLevel {
Error,
Warning,
Info,
Debug,
}
impl LogLevel {
const fn rank(self) -> u8 {
match self {
Self::Error => 0,
Self::Warning => 1,
Self::Info => 2,
Self::Debug => 3,
}
}
#[must_use]
pub const fn meets(self, min: Self) -> bool {
self.rank() <= min.rank()
}
}
impl From<lsp_types::MessageType> for LogLevel {
fn from(msg_type: lsp_types::MessageType) -> Self {
match msg_type {
lsp_types::MessageType::Error => Self::Error,
lsp_types::MessageType::Warning => Self::Warning,
lsp_types::MessageType::Info => Self::Info,
_ => Self::Debug,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ServerMessage {
pub message_type: MessageType,
pub message: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MessageType {
Error,
Warning,
Info,
Log,
}
impl From<lsp_types::MessageType> for MessageType {
fn from(msg_type: lsp_types::MessageType) -> Self {
match msg_type {
lsp_types::MessageType::Error => Self::Error,
lsp_types::MessageType::Warning => Self::Warning,
lsp_types::MessageType::Info => Self::Info,
_ => Self::Log,
}
}
}
#[derive(Debug)]
pub struct NotificationCache {
entries: HashMap<SlotKey, CachedEntry>,
order: HashMap<ServerId, BTreeMap<u64, SlotKey>>,
files: HashMap<DiagnosticsKey, BTreeSet<SlotKey>>,
next_diagnostic_seq: u64,
next_pull_ticket: u64,
pull_sources: HashMap<ServerId, Vec<DiagnosticSource>>,
clear_epochs: HashMap<ServerId, ClearEpoch>,
diagnostics_route_count: Option<usize>,
empty_diagnostics_count: usize,
recent_evictions: EvictionRecord,
eviction_marks: EvictionMarks,
documents: Option<Arc<DocumentTracker>>,
logs: VecDeque<LogEntry>,
messages: VecDeque<ServerMessage>,
push_degraded: HashSet<ServerId>,
indexing: IndexingTracker,
}
impl Default for NotificationCache {
fn default() -> Self {
Self::new()
}
}
impl NotificationCache {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::with_capacity(32),
order: HashMap::new(),
files: HashMap::new(),
next_diagnostic_seq: 0,
next_pull_ticket: 0,
pull_sources: HashMap::new(),
clear_epochs: HashMap::new(),
diagnostics_route_count: None,
empty_diagnostics_count: 0,
recent_evictions: EvictionRecord::default(),
eviction_marks: EvictionMarks::default(),
documents: None,
logs: VecDeque::with_capacity(MAX_LOG_ENTRIES),
messages: VecDeque::with_capacity(MAX_SERVER_MESSAGES),
push_degraded: HashSet::new(),
indexing: IndexingTracker::new(),
}
}
pub(crate) fn set_diagnostics_route_count(&mut self, count: usize) {
self.diagnostics_route_count = Some(count.max(1));
}
#[cfg(test)]
pub(crate) const fn configured_route_count(&self) -> Option<usize> {
self.diagnostics_route_count
}
fn per_server_budget(&self) -> usize {
let count = self
.diagnostics_route_count
.unwrap_or_else(|| {
self.order
.values()
.filter(|order| !order.is_empty())
.count()
})
.max(1);
MAX_DIAGNOSTIC_ENTRIES
.checked_div(count)
.unwrap_or(1)
.max(1)
}
fn server_to_evict_from(&self, writer: &ServerId) -> Option<ServerId> {
let largest = self
.order
.iter()
.filter(|(_, order)| !order.is_empty())
.max_by_key(|(id, order)| (order.len(), id.as_str()));
let budget = self.per_server_budget();
if let Some((id, order)) = largest
&& order.len() > budget
{
return Some(id.clone());
}
if self
.order
.get(writer)
.is_some_and(|order| !order.is_empty())
{
return Some(writer.clone());
}
largest.map(|(id, _)| id.clone())
}
fn is_empty_entry(&self, key: &SlotKey) -> bool {
self.entries
.get(key)
.is_some_and(|entry| entry.diagnostics.is_empty())
}
fn is_protected(&self, key: &SlotKey, protect: Protect<'_>) -> bool {
match protect {
Protect::Nothing => false,
Protect::File(file) => self
.entries
.get(key)
.is_some_and(|entry| entry.file(key) == file),
}
}
fn oldest_empty_entry_in(
&self,
server: &ServerId,
protect: Protect<'_>,
) -> Option<(u64, SlotKey)> {
let order = self.order.get(server)?;
order
.iter()
.find(|(_, key)| self.is_empty_entry(key) && !self.is_protected(key, protect))
.map(|(&seq, key)| (seq, key.clone()))
}
fn oldest_entry_in(&self, server: &ServerId, protect: Protect<'_>) -> Option<(u64, SlotKey)> {
self.order
.get(server)?
.iter()
.find(|(_, key)| !self.is_protected(key, protect))
.map(|(&seq, key)| (seq, key.clone()))
}
fn entry_to_evict(
&self,
writer: &ServerId,
protect: Protect<'_>,
) -> Option<(ServerId, u64, SlotKey)> {
let evict_from = self.server_to_evict_from(writer)?;
if self.empty_diagnostics_count > 0 {
if let Some((seq, key)) = self.oldest_empty_entry_in(&evict_from, protect) {
return Some((evict_from, seq, key));
}
let budget = self.per_server_budget();
let cross_server_pick = self
.order
.iter()
.filter(|(id, order)| order.len() > budget && *id != &evict_from)
.filter_map(|(id, order)| {
self.oldest_empty_entry_in(id, protect)
.map(|(seq, key)| (id, order.len(), seq, key))
})
.max_by_key(|(id, len, ..)| (*len, id.as_str()));
if let Some((id, _, seq, key)) = cross_server_pick {
return Some((id.clone(), seq, key));
}
}
if let Some((seq, key)) = self.oldest_entry_in(&evict_from, protect) {
return Some((evict_from, seq, key));
}
self.order
.iter()
.filter(|(id, _)| *id != &evict_from)
.filter_map(|(id, order)| {
self.oldest_entry_in(id, protect)
.map(|(seq, key)| (id, order.len(), seq, key))
})
.max_by_key(|(id, len, ..)| (*len, id.as_str()))
.map(|(id, _, seq, key)| (id.clone(), seq, key))
}
pub(crate) fn write_published_diagnostics(
&mut self,
server_id: &ServerId,
published: &PublishedDiagnosticsUri,
version: Option<DocumentVersion>,
diagnostics: BoundedDiagnostics,
) -> PushWrite {
let source_key = SlotKey::pushed(DiagnosticsKey::of(published.source()));
let canonical_key = DiagnosticsKey::of(published.canonical());
let already_indexed = self
.entries
.get(&source_key)
.is_some_and(|entry| entry.file(&source_key) == &canonical_key);
let at_capacity = self.pushed_slot_count(&canonical_key) >= MAX_SOURCES_PER_FILE;
if !already_indexed && at_capacity {
let alias_to_evict = published
.is_canonical()
.then(|| self.oldest_alias_source(&canonical_key))
.flatten();
let Some(oldest_alias) = alias_to_evict else {
debug!(
"dropping diagnostics for {}: {MAX_SOURCES_PER_FILE} published URIs already cached for {}",
published.source().as_ref(),
published.canonical().as_ref()
);
return PushWrite::Dropped {
evicted: Vec::new(),
};
};
self.evict_entry(&oldest_alias);
}
let diagnostics = diagnostics.into_vec();
self.drop_superseded_pull(&canonical_key, published.canonical(), version);
drop(self.take_entry(&source_key));
let evicted = match self.make_room(server_id, Protect::Nothing) {
Ok(evicted) => evicted,
Err(NoRoom { evicted }) => {
debug!(
"dropping diagnostics for {}: no entry could be evicted to make room",
published.source().as_ref()
);
return PushWrite::Dropped { evicted };
}
};
let seq = self.next_seq();
let opening = self.document_opening(published.canonical());
let spelling = if published.is_canonical() {
Spelling::Canonical
} else {
Spelling::Alias(canonical_key)
};
self.insert_entry(
source_key,
CachedEntry {
uri: published.source().clone(),
diagnostics,
owner: server_id.clone(),
seq,
origin: Origin::Pushed {
spelling,
version,
opening,
covered: Covered::default(),
},
},
);
PushWrite::Stored { evicted }
}
#[cfg(test)]
pub(crate) fn store_published_diagnostics(
&mut self,
server_id: &ServerId,
published: &PublishedDiagnosticsUri,
version: Option<i32>,
diagnostics: Vec<LspDiagnostic>,
) {
drop(self.write_published_diagnostics(
server_id,
published,
version.map(DocumentVersion::new),
BoundedDiagnostics::new(published.source(), diagnostics),
));
}
const fn next_seq(&mut self) -> u64 {
let seq = self.next_diagnostic_seq;
self.next_diagnostic_seq = self.next_diagnostic_seq.saturating_add(1);
seq
}
fn pushed_slot_count(&self, file: &DiagnosticsKey) -> usize {
self.files.get(file).map_or(0, |slots| {
slots
.iter()
.filter(|slot| slot.provenance == Provenance::Pushed)
.count()
})
}
fn drop_superseded_pull(
&mut self,
file: &DiagnosticsKey,
canonical: &Uri,
pushed_version: Option<DocumentVersion>,
) {
let slot = SlotKey::pulled(file.clone());
let Some(entry) = self.entries.get(&slot) else {
return;
};
let Origin::Pulled {
opening: pulled_opening,
version: pulled,
..
} = entry.origin
else {
return;
};
let owner = entry.owner.clone();
let sync = self.document_sync(&owner, canonical);
let reopened =
self.documents.is_some() && pulled_opening != self.document_opening(canonical);
if reopened || pull_is_superseded(pulled, pushed_version, sync) {
debug!(
"dropping the pulled diagnostics of {}: superseded by a push \
(pulled {pulled:?}, pushed {pushed_version:?}, {sync:?})",
canonical.as_ref()
);
drop(self.take_entry(&slot));
}
}
pub(crate) fn attach_documents(&mut self, documents: Arc<DocumentTracker>) {
self.documents = Some(documents);
}
fn document_sync(&self, owner: &ServerId, canonical: &Uri) -> DocumentSync {
let Some(documents) = &self.documents else {
return DocumentSync::Unattached;
};
uri_to_path(canonical)
.and_then(|path| documents.synced_version(&path, owner))
.map_or(DocumentSync::NotOpen, DocumentSync::Synced)
}
fn document_opening(&self, canonical: &Uri) -> Option<Opening> {
let path = uri_to_path(canonical)?;
self.documents.as_ref()?.opening(&path)
}
pub(crate) fn begin_pull(&mut self, server: &ServerId, version: DocumentVersion) -> PullStamp {
let ticket = PullTicket(self.next_pull_ticket);
self.next_pull_ticket = self.next_pull_ticket.saturating_add(1);
PullStamp {
ticket,
epoch: self.clear_epochs.get(server).copied().unwrap_or_default(),
version,
}
}
#[cfg(test)]
pub(crate) fn store_pulled_diagnostics(
&mut self,
server_id: &ServerId,
file: &Uri,
stamp: PullStamp,
check: VersionCheck,
items: BoundedDiagnostics,
) -> PullWrite {
self.store_pulled_diagnostics_with(
server_id,
file,
stamp,
check,
items,
SnapshotBefore::Take,
)
}
pub(crate) fn store_pulled_diagnostics_with(
&mut self,
server_id: &ServerId,
file: &Uri,
stamp: PullStamp,
check: VersionCheck,
items: BoundedDiagnostics,
before: SnapshotBefore,
) -> PullWrite {
let discard = |reason, items| PullWrite::Discarded {
reason,
evicted: Vec::new(),
items,
};
if stamp.epoch
!= self
.clear_epochs
.get(server_id)
.copied()
.unwrap_or_default()
{
return discard(Discard::ServerCleared, items);
}
if check == VersionCheck::Moved {
return discard(Discard::VersionMoved, items);
}
let file_key = DiagnosticsKey::of(file);
let slot_key = SlotKey::pulled(file_key.clone());
if self.entries.get(&slot_key).is_some_and(|stored| {
matches!(stored.origin, Origin::Pulled { ticket, .. } if ticket >= stamp.ticket)
}) {
return discard(Discard::OlderTicket, items);
}
let opening = self.document_opening(file);
let same_state = self.entries.get(&slot_key).is_some_and(|entry| {
entry.diagnostics == items.0
&& matches!(
entry.origin,
Origin::Pulled { opening: stored, version, .. }
if stored == opening && version == stamp.version
)
});
let gained = self.coverage_gained(&slot_key);
let slot = if same_state && gained.is_empty() {
SlotChange::Identical
} else {
SlotChange::Replaced {
before: (before == SnapshotBefore::Take).then(|| self.diagnostic_sources(file)),
}
};
drop(self.take_entry_with_coverage(&slot_key, gained));
let evicted = match self.make_room(server_id, Protect::File(&file_key)) {
Ok(evicted) => evicted,
Err(NoRoom { evicted }) => {
return PullWrite::Discarded {
reason: Discard::NoRoom,
evicted,
items,
};
}
};
if !items.0.is_empty() {
self.learn_pull_sources(server_id, &items.0);
}
let seq = self.next_seq();
self.insert_entry(
slot_key,
CachedEntry {
uri: file.clone(),
diagnostics: items.0,
owner: server_id.clone(),
seq,
origin: Origin::Pulled {
ticket: stamp.ticket,
opening,
version: stamp.version,
},
},
);
PullWrite::Stored { slot, evicted }
}
#[cfg(test)]
pub(crate) fn store_pulled_for_test(
&mut self,
server_id: &ServerId,
file: &Uri,
diagnostics: Vec<LspDiagnostic>,
) {
let stamp = self.begin_pull(server_id, DocumentVersion::FIRST);
drop(self.store_pulled_diagnostics(
server_id,
file,
stamp,
VersionCheck::Current,
BoundedDiagnostics::new(file, diagnostics),
));
}
#[cfg(test)]
pub(crate) fn store_diagnostics(
&mut self,
server_id: &ServerId,
uri: &Uri,
version: Option<i32>,
diagnostics: Vec<LspDiagnostic>,
) {
let published = PublishedDiagnosticsUri::for_test(uri.clone(), uri.clone());
self.store_published_diagnostics(server_id, &published, version, diagnostics);
}
fn insert_entry(&mut self, key: SlotKey, entry: CachedEntry) {
debug_assert!(!self.entries.contains_key(&key));
debug_assert_eq!(key.provenance, entry.origin.provenance());
self.eviction_marks.forget_file(entry.file(&key));
self.order
.entry(entry.owner.clone())
.or_default()
.insert(entry.seq, key.clone());
self.files
.entry(entry.file(&key).clone())
.or_default()
.insert(key.clone());
if entry.diagnostics.is_empty() {
self.empty_diagnostics_count = self.empty_diagnostics_count.saturating_add(1);
}
self.entries.insert(key, entry);
}
fn take_entry(&mut self, key: &SlotKey) -> Option<CachedEntry> {
let gained = if key.provenance == Provenance::Pulled {
self.coverage_gained(key)
} else {
Vec::new()
};
self.take_entry_with_coverage(key, gained)
}
fn take_entry_with_coverage(
&mut self,
key: &SlotKey,
gained: Vec<(SlotKey, BTreeSet<usize>)>,
) -> Option<CachedEntry> {
for (pushed, gained) in gained {
if let Some(CachedEntry {
origin: Origin::Pushed { covered, .. },
..
}) = self.entries.get_mut(&pushed)
{
covered.0.extend(gained);
}
}
let entry = self.entries.remove(key)?;
if let Some(order) = self.order.get_mut(&entry.owner) {
order.remove(&entry.seq);
}
let file = entry.file(key);
if let Some(members) = self.files.get_mut(file) {
members.remove(key);
if members.is_empty() {
self.files.remove(file);
}
}
if entry.diagnostics.is_empty() {
self.empty_diagnostics_count = self.empty_diagnostics_count.saturating_sub(1);
}
Some(entry)
}
fn coverage_gained(&self, slot: &SlotKey) -> Vec<(SlotKey, BTreeSet<usize>)> {
let Some(pulled) = self.entries.get(slot) else {
return Vec::new();
};
let Origin::Pulled {
opening, version, ..
} = &pulled.origin
else {
return Vec::new();
};
let Some(members) = self.files.get(&slot.uri) else {
return Vec::new();
};
let index = PulledIndex::new(&pulled.diagnostics);
members
.iter()
.filter(|key| key.provenance == Provenance::Pushed)
.filter_map(|key| {
let entry = self.entries.get(key)?;
let Origin::Pushed {
opening: pushed_opening,
covered,
..
} = &entry.origin
else {
return None;
};
let same_state = entry.owner == pulled.owner
&& entry.origin.version() == Some(*version)
&& pushed_opening.is_none_or(|pushed| Some(pushed) == *opening);
if !same_state {
return None;
}
let gained: BTreeSet<usize> = entry
.diagnostics
.iter()
.enumerate()
.filter(|(position, diagnostic)| {
!covered.0.contains(position) && index.contains_same_problem(diagnostic)
})
.map(|(position, _)| position)
.collect();
(!gained.is_empty()).then(|| (key.clone(), gained))
})
.collect()
}
fn learn_pull_sources(&mut self, owner: &ServerId, items: &[LspDiagnostic]) {
let learned = self.pull_sources.entry(owner.clone()).or_default();
for source in items.iter().filter_map(DiagnosticSource::of) {
if learned.len() >= MAX_LEARNED_PULL_SOURCES {
break;
}
if !learned.contains(&source) {
learned.push(source);
}
}
}
fn push_freshness(
&self,
key: &DiagnosticsKey,
requested: &Uri,
entry: &CachedEntry,
) -> Freshness {
let Origin::Pushed {
opening, covered, ..
} = &entry.origin
else {
return Freshness::Current;
};
let Some(version) = entry.origin.version() else {
return Freshness::Current;
};
let DocumentSync::Synced(synced) = self.document_sync(&entry.owner, requested) else {
return Freshness::Current;
};
let current_opening = self.document_opening(requested);
let same_opening = opening.is_none_or(|opening| Some(opening) == current_opening);
if same_opening && version == synced {
return Freshness::Current;
}
let answered_at_synced_state =
self.entries
.get(&SlotKey::pulled(key.clone()))
.is_some_and(|pulled| {
pulled.owner == entry.owner
&& matches!(
pulled.origin,
Origin::Pulled { opening: pulled_opening, version: pulled_version, .. }
if pulled_version == synced && pulled_opening == current_opening
)
});
if !answered_at_synced_state {
return Freshness::Current;
}
let mut covered = covered.clone();
if same_opening && let Some(learned) = self.pull_sources.get(&entry.owner) {
covered.0.extend(
entry
.diagnostics
.iter()
.enumerate()
.filter(|(_, diagnostic)| {
DiagnosticSource::of(diagnostic)
.is_some_and(|source| learned.contains(&source))
})
.map(|(position, _)| position),
);
}
Freshness::Superseded { covered }
}
fn make_room(
&mut self,
writer: &ServerId,
protect: Protect<'_>,
) -> Result<Vec<DiagnosticsKey>, NoRoom> {
let was_overflowed = self.eviction_marks.clear_overflow(writer);
let mut evicted = Vec::new();
while self.entries.len() >= MAX_DIAGNOSTIC_ENTRIES {
let Some((_, _, victim)) = self.entry_to_evict(writer, protect) else {
if was_overflowed {
self.eviction_marks.restore_overflow(writer);
}
return Err(NoRoom { evicted });
};
evicted.extend(self.evict_for_capacity(&victim));
}
Ok(evicted)
}
fn evict_entry(&mut self, key: &SlotKey) {
let Some(entry) = self.take_entry(key) else {
return;
};
if entry.diagnostics.is_empty() {
self.record_empty_eviction(entry.file(key).clone(), std::time::Instant::now());
}
}
fn evict_for_capacity(&mut self, key: &SlotKey) -> Option<DiagnosticsKey> {
let entry = self.entries.get(key)?;
let file = entry.file(key).clone();
let owner = entry.owner.clone();
let content = if entry.diagnostics.is_empty() {
EvictedContent::Clean
} else {
EvictedContent::Lost
};
self.evict_entry(key);
if content == EvictedContent::Lost || !self.files.contains_key(&file) {
self.eviction_marks.record(file.clone(), owner, content);
}
Some(file)
}
fn record_empty_eviction(&mut self, file: DiagnosticsKey, now: std::time::Instant) {
self.recent_evictions.record(file, now);
}
fn was_recently_evicted(&self, uri: &Uri, now: std::time::Instant) -> bool {
let key = DiagnosticsKey::of(uri);
!self.has_diagnostics(uri) && self.recent_evictions.contains(&key, now)
}
pub(crate) fn is_listen_replayable(&self, uri: &Uri) -> bool {
self.has_diagnostics(uri) || self.was_recently_evicted(uri, std::time::Instant::now())
}
#[cfg(test)]
fn assert_consistent(&self) {
let mut ordered = 0;
for (server, order) in &self.order {
for (seq, key) in order {
let entry = self
.entries
.get(key)
.unwrap_or_else(|| panic!("ordered key has no entry"));
assert_eq!((&entry.owner, entry.seq), (server, *seq));
ordered += 1;
}
}
assert_eq!(ordered, self.entries.len(), "order and entries diverge");
let mut filed = 0;
for (file, members) in &self.files {
assert!(!members.is_empty(), "empty file set kept");
for key in members {
let entry = self
.entries
.get(key)
.unwrap_or_else(|| panic!("filed key has no entry"));
assert_eq!(entry.file(key), file);
filed += 1;
}
}
assert_eq!(filed, self.entries.len(), "files and entries diverge");
let empty = self
.entries
.values()
.filter(|entry| entry.diagnostics.is_empty())
.count();
assert_eq!(empty, self.empty_diagnostics_count);
let marks = &self.eviction_marks;
assert_eq!(marks.by_file.len(), marks.order.len(), "marks diverge");
for (seq, file) in &marks.order {
assert_eq!(marks.by_file.get(file).map(|mark| mark.seq), Some(*seq));
}
}
fn oldest_alias_source(&self, canonical_key: &DiagnosticsKey) -> Option<SlotKey> {
self.files
.get(canonical_key)?
.iter()
.filter_map(|source| {
let entry = self.entries.get(source)?;
matches!(
entry.origin,
Origin::Pushed {
spelling: Spelling::Alias(_),
..
}
)
.then_some((entry.seq, source))
})
.min_by_key(|(seq, _)| *seq)
.map(|(_, source)| source.clone())
}
fn source_keys(&self, key: &DiagnosticsKey) -> BTreeSet<SlotKey> {
let mut keys: BTreeSet<SlotKey> =
self.files.get(key).into_iter().flatten().cloned().collect();
let direct = SlotKey::pushed(key.clone());
if self.entries.contains_key(&direct) {
keys.insert(direct);
}
keys
}
pub(crate) fn store_log(&mut self, level: LogLevel, message: String) {
let entry = LogEntry {
level,
message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
timestamp: Utc::now(),
};
if self.logs.len() >= MAX_LOG_ENTRIES {
self.logs.pop_front();
}
self.logs.push_back(entry);
}
pub(crate) fn store_message(&mut self, message_type: MessageType, message: String) {
let msg = ServerMessage {
message_type,
message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
timestamp: Utc::now(),
};
if self.messages.len() >= MAX_SERVER_MESSAGES {
self.messages.pop_front();
}
self.messages.push_back(msg);
}
pub(crate) fn observe_indexing_signal(
&mut self,
server_id: &ServerId,
method: &str,
params: Option<&serde_json::Value>,
) {
self.indexing
.observe_server_status(server_id, method, params);
}
pub(crate) fn observe_progress(
&mut self,
server_id: &ServerId,
params: &lsp_types::ProgressParams,
) {
self.indexing.observe_progress(server_id, params);
}
pub(crate) fn set_indexing_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
self.indexing.set_policy(server_id, policy);
}
#[must_use]
pub fn indexing_state(&self, server_id: &ServerId) -> IndexingState {
self.indexing.state(server_id)
}
pub(crate) fn reset_indexing_state(&mut self, server_id: &ServerId, reset: IndexingReset) {
self.indexing.reset(server_id, reset);
}
#[inline]
#[must_use]
pub fn diagnostics(&self, uri: &Uri) -> Option<DiagnosticInfo> {
self.entries
.get(&SlotKey::pushed(DiagnosticsKey::of(uri)))
.map(CachedEntry::info)
}
#[must_use]
pub fn diagnostic_sources(&self, uri: &Uri) -> DiagnosticSources {
let key = DiagnosticsKey::of(uri);
let entries = self
.source_keys(&key)
.into_iter()
.filter_map(|source| {
let entry = self.entries.get(&source)?;
Some(SourceEntry {
info: entry.info(),
origin: SourceOrigin::from(&entry.origin),
freshness: self.push_freshness(&key, uri, entry),
})
})
.collect();
DiagnosticSources {
requested: uri.clone(),
entries,
}
}
#[must_use]
pub fn has_diagnostics(&self, uri: &Uri) -> bool {
let key = DiagnosticsKey::of(uri);
self.entries.contains_key(&SlotKey::pushed(key.clone())) || self.files.contains_key(&key)
}
#[must_use]
pub fn availability(&self, uri: &Uri, route: Option<&ServerId>) -> DiagnosticsAvailability {
if self.has_diagnostics(uri) {
return DiagnosticsAvailability::Published;
}
match self.eviction_marks.content(&DiagnosticsKey::of(uri)) {
Some(EvictedContent::Clean) => DiagnosticsAvailability::Published,
Some(EvictedContent::Lost) => DiagnosticsAvailability::Evicted,
None if route.is_some_and(|server| self.eviction_marks.has_overflowed(server)) => {
DiagnosticsAvailability::Evicted
}
None => DiagnosticsAvailability::Pending,
}
}
#[inline]
#[must_use]
pub fn diagnostics_owner(&self, uri: &Uri) -> Option<&ServerId> {
let key = DiagnosticsKey::of(uri);
self.entries
.get(&SlotKey::pushed(key.clone()))
.or_else(|| {
let slots = self.files.get(&key)?;
slots
.iter()
.filter(|slot| slot.provenance == Provenance::Pushed)
.chain(slots.iter())
.find_map(|source| self.entries.get(source))
})
.map(|entry| &entry.owner)
}
#[inline]
#[must_use]
pub const fn logs(&self) -> &VecDeque<LogEntry> {
&self.logs
}
#[inline]
#[must_use]
pub const fn messages(&self) -> &VecDeque<ServerMessage> {
&self.messages
}
pub(crate) fn clear_server_diagnostics(&mut self, server_id: &ServerId) -> Vec<DiagnosticsKey> {
let epoch = self.clear_epochs.entry(server_id.clone()).or_default();
epoch.0 = epoch.0.saturating_add(1);
self.eviction_marks.forget_server(server_id);
self.pull_sources.remove(server_id);
let Some(order) = self.order.remove(server_id) else {
return Vec::new();
};
let mut cleared = Vec::with_capacity(order.len());
for key in order.into_values() {
if let Some(entry) = self.entries.get(&key) {
cleared.push(entry.file(&key).clone());
}
self.evict_entry(&key);
}
cleared.sort_unstable();
cleared.dedup();
cleared
}
pub(crate) fn record_lost_files(
&mut self,
owner: &ServerId,
files: &[DiagnosticsKey],
) -> Vec<DiagnosticsKey> {
for file in files {
let stale: Vec<SlotKey> = self
.files
.get(file)
.into_iter()
.flatten()
.filter(|key| {
key.provenance == Provenance::Pushed
&& self
.entries
.get(key)
.is_some_and(|entry| entry.owner == *owner)
})
.cloned()
.collect();
for key in stale {
self.evict_entry(&key);
}
if !self.has_diagnostics_for(file) {
self.eviction_marks
.record(file.clone(), owner.clone(), EvictedContent::Lost);
}
}
files.to_vec()
}
pub(crate) fn record_unnamed_loss(&mut self, owner: &ServerId) {
self.eviction_marks.restore_overflow(owner);
}
fn has_diagnostics_for(&self, key: &DiagnosticsKey) -> bool {
self.files.contains_key(key) || self.entries.contains_key(&SlotKey::pushed(key.clone()))
}
pub(crate) fn mark_push_degraded(&mut self, server_id: &ServerId) {
self.push_degraded.insert(server_id.clone());
}
pub(crate) fn clear_push_degraded(&mut self, server_id: &ServerId) {
self.push_degraded.remove(server_id);
}
#[inline]
#[must_use]
pub(crate) fn is_push_degraded(&self, server_id: &ServerId) -> bool {
self.push_degraded.contains(server_id)
}
#[inline]
#[must_use]
#[cfg(test)]
pub(crate) fn diagnostics_count(&self) -> usize {
self.entries.len()
}
#[inline]
#[must_use]
#[cfg(test)]
pub(crate) fn logs_count(&self) -> usize {
self.logs.len()
}
#[inline]
#[must_use]
#[cfg(test)]
pub(crate) fn messages_count(&self) -> usize {
self.messages.len()
}
}
pub fn apply_lifecycle_notification(
cache: &mut NotificationCache,
server_id: &ServerId,
notif: crate::lsp::LspNotification,
) {
match notif {
crate::lsp::LspNotification::Progress(params) => {
cache.observe_progress(server_id, ¶ms);
}
crate::lsp::LspNotification::Other { method, params } => {
cache.observe_indexing_signal(server_id, &method, params.as_ref());
}
crate::lsp::LspNotification::PublishDiagnostics(_)
| crate::lsp::LspNotification::LogMessage(_)
| crate::lsp::LspNotification::ShowMessage(_) => {}
}
}
pub async fn on_lifecycle(
cache: Option<&tokio::sync::Mutex<NotificationCache>>,
server_id: &ServerId,
pinned_tsserver: Option<&std::path::Path>,
notif: crate::lsp::LspNotification,
) {
crate::lsp::tsserver_pin::warn_if_pin_ignored(pinned_tsserver, ¬if, server_id);
if let Some(cache) = cache {
apply_lifecycle_notification(&mut *cache.lock().await, server_id, notif);
}
}
#[cfg(test)]
mod pull_tests;
#[cfg(test)]
mod tests {
use lsp_types::{Position, Range};
use super::bounds::*;
use super::*;
use crate::bridge::indexing::IndexingReset;
use crate::test_lsp::CapturedLogs;
fn test_server() -> ServerId {
ServerId::from_static("test-server")
}
#[test]
fn test_notification_cache_new() {
let cache = NotificationCache::new();
assert_eq!(cache.diagnostics_count(), 0);
assert_eq!(cache.logs_count(), 0);
assert_eq!(cache.messages_count(), 0);
}
#[test]
fn test_store_and_diagnostics() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostic = LspDiagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::Error),
message: "test error".to_string().into(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
};
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.uri, uri);
assert_eq!(stored.version.map(DocumentVersion::get), Some(1));
assert_eq!(stored.diagnostics.len(), 1);
assert_eq!(
stored.diagnostics[0].message,
lsp_types::Message::String("test error".to_string())
);
}
#[test]
fn test_store_diagnostics_truncates_oversized_message() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
let diagnostic = LspDiagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::Error),
message: oversized.clone().into(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
};
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = cache.diagnostics(&uri).unwrap();
let stored = message_as_str(&stored.diagnostics[0].message);
assert!(stored.len() < oversized.len());
assert!(stored.ends_with("... (truncated)"));
}
fn minimal_diagnostic(message: String) -> LspDiagnostic {
LspDiagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::Error),
message: message.into(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
}
}
#[test]
fn test_reported_severity_orders_most_severe_first_and_defaults_to_information() {
let mut severityless = minimal_diagnostic("none".to_string());
severityless.severity = None;
let mut hint = minimal_diagnostic("hint".to_string());
hint.severity = Some(lsp_types::DiagnosticSeverity::Hint);
assert_eq!(
ReportedSeverity::of(&severityless),
ReportedSeverity::Information
);
assert!(ReportedSeverity::of(&severityless) < ReportedSeverity::of(&hint));
assert!(ReportedSeverity::Error < ReportedSeverity::Warning);
assert!(ReportedSeverity::Warning < ReportedSeverity::Information);
}
#[test]
fn test_store_diagnostics_caps_aggregate_size_for_many_small_diagnostics() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostics: Vec<LspDiagnostic> = (0..5000)
.map(|i| {
minimal_diagnostic(format!(
"diagnostic number {i}, padded: {}",
"x".repeat(200)
))
})
.collect();
let original_count = diagnostics.len();
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert!(
stored.len() < original_count,
"aggregate cap must trim the list, kept {} of {original_count}",
stored.len()
);
assert!(!stored.is_empty(), "must keep at least one diagnostic");
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"stored entry must fit the aggregate cap, got {serialized_len} bytes"
);
}
#[test]
fn test_store_diagnostics_truncation_keeps_largest_fitting_prefix() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostics: Vec<LspDiagnostic> = (0..5000)
.map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
.collect();
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert!(
stored.len() > 2600,
"largest-fitting-prefix search must keep far more than half, kept {}",
stored.len()
);
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES);
let mut with_one_more = stored.clone();
with_one_more.push(minimal_diagnostic(format!(
"diagnostic overflow: {}",
"x".repeat(250)
)));
assert!(
serde_json::to_vec(&with_one_more).unwrap().len() > MAX_DIAGNOSTICS_ENTRY_BYTES,
"kept count must be the largest that fits, not merely a fitting count"
);
}
#[test]
fn test_store_diagnostics_truncation_prefers_higher_severity() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostics: Vec<LspDiagnostic> = (0..5000)
.map(|i| {
let mut d = minimal_diagnostic(format!("hint {i}: {}", "x".repeat(200)));
d.severity = Some(lsp_types::DiagnosticSeverity::Hint);
d
})
.collect();
let mut trailing_error = minimal_diagnostic("the one real error".to_string());
trailing_error.severity = Some(lsp_types::DiagnosticSeverity::Error);
diagnostics.push(trailing_error);
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert!(
stored
.iter()
.any(|d| message_as_str(&d.message) == "the one real error"),
"the trailing ERROR diagnostic must survive truncation over leading HINT noise"
);
}
#[test]
fn test_store_diagnostics_warns_when_truncating_list() {
use tracing_subscriber::layer::SubscriberExt as _;
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostics: Vec<LspDiagnostic> = (0..5000)
.map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
.collect();
let captured = CapturedLogs::default();
let subscriber = tracing_subscriber::registry().with(captured.clone());
let guard = tracing::subscriber::set_default(subscriber);
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
drop(guard);
let messages = captured.messages();
assert!(
messages
.iter()
.any(|m| m.contains("highest-severity") && m.contains("file:///test.rs")),
"expected a truncation warning naming the URI, got: {messages:?}"
);
}
#[test]
fn test_store_diagnostics_warns_when_dropping_data_blob() {
use tracing_subscriber::layer::SubscriberExt as _;
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostic = minimal_diagnostic("small message".to_string());
diagnostic.data = Some(serde_json::json!({
"blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
}));
let captured = CapturedLogs::default();
let subscriber = tracing_subscriber::registry().with(captured.clone());
let guard = tracing::subscriber::set_default(subscriber);
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
drop(guard);
let messages = captured.messages();
assert!(
messages.iter().any(|m| m.contains("code-action")),
"expected a warning noting the code-action quick-fix impact, got: {messages:?}"
);
}
#[test]
fn test_store_diagnostics_drops_oversized_data_blob_on_single_diagnostic() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostic = minimal_diagnostic("small message".to_string());
diagnostic.data = Some(serde_json::json!({
"blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
}));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert_eq!(stored.len(), 1);
assert_eq!(
stored[0].message,
lsp_types::Message::String("small message".to_string())
);
assert!(
stored[0].data.is_none(),
"oversized data blob must be dropped"
);
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"stored entry must fit the aggregate cap after dropping data, got {serialized_len} bytes"
);
}
#[test]
fn test_store_diagnostics_truncates_oversized_source_on_single_diagnostic() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostic = minimal_diagnostic("small message".to_string());
diagnostic.source = Some("x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert_eq!(stored.len(), 1);
assert_eq!(
stored[0].message,
lsp_types::Message::String("small message".to_string())
);
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"stored entry must fit the aggregate cap after truncating source, got {serialized_len} bytes"
);
}
#[test]
fn test_store_diagnostics_caps_single_diagnostic_with_every_field_maxed_out() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostic = minimal_diagnostic("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
diagnostic.source = Some("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
diagnostic.code = Some(lsp_types::Code::String(
"x".repeat(MAX_ENTRY_TEXT_BYTES + 1000),
));
diagnostic.data = Some(serde_json::json!({ "blob": "x".repeat(MAX_ENTRY_TEXT_BYTES) }));
diagnostic.tags = Some(vec![lsp_types::DiagnosticTag::Unnecessary; 50]);
diagnostic.related_information = Some(vec![
lsp_types::DiagnosticRelatedInformation {
location: lsp_types::Location {
uri: uri.clone(),
range: Range::default(),
},
message: "x".repeat(1000),
};
5
]);
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert_eq!(stored.len(), 1);
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"postcondition must hold even with every field maxed out, got {serialized_len} bytes"
);
}
#[test]
fn test_cap_diagnostics_entry_size_terminal_fallback_bounds_untruncated_message() {
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostics = vec![minimal_diagnostic(
"x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
)];
cap_diagnostics_entry_size(&uri, &mut diagnostics);
assert_eq!(diagnostics.len(), 1);
assert!(
message_as_str(&diagnostics[0].message).len()
<= DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES + 20,
"terminal fallback must truncate the message itself, got {} bytes",
message_as_str(&diagnostics[0].message).len()
);
let serialized_len = serde_json::to_vec(&diagnostics).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"postcondition must hold via the terminal fallback, got {serialized_len} bytes"
);
}
#[test]
fn test_store_diagnostics_cheap_path_leaves_small_diagnostics_untouched() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let mut diagnostic = minimal_diagnostic("a small, ordinary diagnostic message".to_string());
diagnostic.source = Some("rustc".to_string());
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
assert_eq!(stored.len(), 1);
assert_eq!(
stored[0].message,
lsp_types::Message::String("a small, ordinary diagnostic message".to_string())
);
assert_eq!(stored[0].source.as_deref(), Some("rustc"));
}
#[test]
fn test_store_diagnostics_cheap_path_escape_safe_for_control_character_heavy_message() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let nul_heavy_message = "\0".repeat(MAX_ENTRY_TEXT_BYTES);
let diagnostics: Vec<LspDiagnostic> = (0..3)
.map(|_| minimal_diagnostic(nul_heavy_message.clone()))
.collect();
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
let stored = &cache.diagnostics(&uri).unwrap().diagnostics;
let serialized_len = serde_json::to_vec(stored).unwrap().len();
assert!(
serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
"escape-heavy content must not let the cheap-estimate fast path skip the real cap, \
got {serialized_len} bytes"
);
}
#[test]
fn test_store_diagnostics_replaces_existing() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_count(), 1);
cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
assert_eq!(cache.diagnostics_count(), 1);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
}
#[test]
fn test_store_and_get_logs() {
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error message".to_string());
cache.store_log(LogLevel::Info, "info message".to_string());
let logs = cache.logs();
assert_eq!(logs.len(), 2);
assert_eq!(logs[0].level, LogLevel::Error);
assert_eq!(logs[0].message, "error message");
assert_eq!(logs[1].level, LogLevel::Info);
assert_eq!(logs[1].message, "info message");
}
#[test]
fn test_logs_max_capacity() {
let mut cache = NotificationCache::new();
for i in 0..MAX_LOG_ENTRIES + 10 {
cache.store_log(LogLevel::Info, format!("message {i}"));
}
assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
let logs = cache.logs();
assert_eq!(logs.front().unwrap().message, "message 10");
assert_eq!(
logs.back().unwrap().message,
format!("message {}", MAX_LOG_ENTRIES + 9)
);
}
#[test]
fn test_store_log_truncates_oversized_message() {
let mut cache = NotificationCache::new();
let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
cache.store_log(LogLevel::Info, oversized.clone());
let stored = &cache.logs()[0].message;
assert!(stored.len() < oversized.len());
assert!(stored.ends_with("... (truncated)"));
}
#[test]
fn test_store_log_does_not_truncate_message_at_or_below_limit() {
let mut cache = NotificationCache::new();
let message = "a".repeat(MAX_ENTRY_TEXT_BYTES);
cache.store_log(LogLevel::Info, message.clone());
assert_eq!(cache.logs()[0].message, message);
}
#[test]
fn test_store_and_get_messages() {
let mut cache = NotificationCache::new();
cache.store_message(MessageType::Error, "error msg".to_string());
cache.store_message(MessageType::Warning, "warning msg".to_string());
let messages = cache.messages();
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].message_type, MessageType::Error);
assert_eq!(messages[0].message, "error msg");
assert_eq!(messages[1].message_type, MessageType::Warning);
assert_eq!(messages[1].message, "warning msg");
}
#[test]
fn test_messages_max_capacity() {
let mut cache = NotificationCache::new();
for i in 0..MAX_SERVER_MESSAGES + 10 {
cache.store_message(MessageType::Info, format!("message {i}"));
}
assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
let messages = cache.messages();
assert_eq!(messages.front().unwrap().message, "message 10");
assert_eq!(
messages.back().unwrap().message,
format!("message {}", MAX_SERVER_MESSAGES + 9)
);
}
#[test]
fn test_store_message_truncates_oversized_message() {
let mut cache = NotificationCache::new();
let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
cache.store_message(MessageType::Info, oversized.clone());
let stored = &cache.messages()[0].message;
assert!(stored.len() < oversized.len());
assert!(stored.ends_with("... (truncated)"));
}
#[test]
fn test_log_levels() {
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Error, "error".to_string());
cache.store_log(LogLevel::Warning, "warning".to_string());
cache.store_log(LogLevel::Info, "info".to_string());
cache.store_log(LogLevel::Debug, "debug".to_string());
let logs = cache.logs();
assert_eq!(logs[0].level, LogLevel::Error);
assert_eq!(logs[1].level, LogLevel::Warning);
assert_eq!(logs[2].level, LogLevel::Info);
assert_eq!(logs[3].level, LogLevel::Debug);
}
#[test]
fn test_message_types() {
let mut cache = NotificationCache::new();
cache.store_message(MessageType::Error, "error".to_string());
cache.store_message(MessageType::Warning, "warning".to_string());
cache.store_message(MessageType::Info, "info".to_string());
cache.store_message(MessageType::Log, "log".to_string());
let messages = cache.messages();
assert_eq!(messages[0].message_type, MessageType::Error);
assert_eq!(messages[1].message_type, MessageType::Warning);
assert_eq!(messages[2].message_type, MessageType::Info);
assert_eq!(messages[3].message_type, MessageType::Log);
}
#[test]
fn test_timestamp_ordering() {
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Info, "first".to_string());
std::thread::sleep(std::time::Duration::from_millis(10));
cache.store_log(LogLevel::Info, "second".to_string());
let logs = cache.logs();
assert!(logs[0].timestamp < logs[1].timestamp);
}
#[test]
fn test_store_diagnostics_empty_list() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostic = LspDiagnostic {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 5,
},
},
severity: Some(lsp_types::DiagnosticSeverity::Error),
message: "test error".to_string().into(),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
};
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
assert_eq!(cache.diagnostics(&uri).unwrap().diagnostics.len(), 1);
cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.diagnostics.len(), 0);
assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
}
#[test]
fn test_store_many_diagnostics_single_file() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
let diagnostics: Vec<LspDiagnostic> = (0..100)
.map(|i| LspDiagnostic {
range: Range {
start: Position {
line: i,
character: 0,
},
end: Position {
line: i,
character: 10,
},
},
message: format!("Error {i}").into(),
severity: Some(lsp_types::DiagnosticSeverity::Error),
code: None,
source: None,
code_description: None,
related_information: None,
tags: None,
data: None,
})
.collect();
cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.diagnostics.len(), 100);
}
#[test]
fn test_logs_exact_capacity_boundary() {
let mut cache = NotificationCache::new();
for i in 0..MAX_LOG_ENTRIES {
cache.store_log(LogLevel::Info, format!("message {i}"));
}
assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
cache.store_log(LogLevel::Info, "overflow".to_string());
assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
assert_eq!(cache.logs().front().unwrap().message, "message 1");
}
#[test]
fn test_messages_exact_capacity_boundary() {
let mut cache = NotificationCache::new();
for i in 0..MAX_SERVER_MESSAGES {
cache.store_message(MessageType::Info, format!("message {i}"));
}
assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
cache.store_message(MessageType::Info, "overflow".to_string());
assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
assert_eq!(cache.messages().front().unwrap().message, "message 1");
}
#[test]
fn test_diagnostics_max_capacity() {
let mut cache = NotificationCache::new();
for i in 0..MAX_DIAGNOSTIC_ENTRIES + 10 {
let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let evicted: Uri = Uri::from("file:///test0.rs");
assert!(cache.diagnostics(&evicted).is_none());
let newest: Uri = Uri::from(format!("file:///test{}.rs", MAX_DIAGNOSTIC_ENTRIES + 9));
assert!(cache.diagnostics(&newest).is_some());
}
#[test]
fn test_diagnostics_replacing_existing_uri_does_not_trigger_eviction() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///stable.rs");
for i in 0..MAX_DIAGNOSTIC_ENTRIES {
cache.store_diagnostics(
&test_server(),
&uri,
Some(i32::try_from(i).unwrap()),
vec![],
);
}
assert_eq!(cache.diagnostics_count(), 1);
assert!(cache.diagnostics(&uri).is_some());
}
#[test]
fn test_diagnostics_republish_refreshes_eviction_order() {
let mut cache = NotificationCache::new();
let actively_edited: Uri = Uri::from("file:///keep.rs");
cache.store_diagnostics(&test_server(), &actively_edited, Some(1), vec![]);
for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
let uri: Uri = Uri::from(format!("file:///untouched{i}.rs"));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
cache.store_diagnostics(&test_server(), &actively_edited, Some(2), vec![]);
let overflow: Uri = Uri::from("file:///overflow.rs");
cache.store_diagnostics(&test_server(), &overflow, Some(1), vec![]);
assert!(
cache.diagnostics(&actively_edited).is_some(),
"republished entry must survive eviction after being refreshed"
);
let oldest_untouched: Uri = Uri::from("file:///untouched0.rs");
assert!(
cache.diagnostics(&oldest_untouched).is_none(),
"the oldest never-republished entry must be evicted instead"
);
assert!(cache.diagnostics(&overflow).is_some());
}
#[test]
fn test_store_diagnostics_no_version() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///test.rs");
cache.store_diagnostics(&test_server(), &uri, None, vec![]);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.version, None);
}
#[test]
fn test_noisy_server_does_not_evict_quiet_server_entries() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(2);
let noisy = ServerId::from_static("noisy");
let quiet = ServerId::from_static("quiet");
let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
assert!(
cache.diagnostics(&quiet_uri).is_some(),
"quiet server's only entry must survive the noisy server's overflow"
);
let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
assert!(
cache.diagnostics(&noisy_first).is_none(),
"noisy server's own oldest entries must be evicted once the aggregate cache is full"
);
}
#[test]
fn test_dominant_server_exceeds_equal_share_while_others_idle() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(4);
let dominant = ServerId::from_static("dominant");
let equal_share = MAX_DIAGNOSTIC_ENTRIES / 4;
let more_than_share = equal_share + 100;
for i in 0..more_than_share {
let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
}
assert_eq!(
cache.diagnostics_count(),
more_than_share,
"a dominant server must be able to exceed its static equal share while the aggregate has room"
);
for i in more_than_share..MAX_DIAGNOSTIC_ENTRIES {
let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
}
#[test]
fn test_eviction_target_tie_break_is_deterministic() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(1000);
let a = ServerId::from_static("a");
let b = ServerId::from_static("b");
for i in 0..2 {
let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
cache.store_diagnostics(&a, &uri, Some(1), vec![]);
}
for i in 0..2 {
let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
cache.store_diagnostics(&b, &uri, Some(1), vec![]);
}
let writer = ServerId::from_static("writer");
assert_eq!(cache.server_to_evict_from(&writer), Some(b));
}
#[test]
fn test_new_writer_still_evicts_when_every_existing_server_is_in_share() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(2);
let a = ServerId::from_static("a");
let b = ServerId::from_static("b");
for i in 0..500 {
let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
cache.store_diagnostics(&a, &uri, Some(1), vec![]);
}
for i in 0..500 {
let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
cache.store_diagnostics(&b, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let c = ServerId::from_static("c");
let new_uri: Uri = Uri::from("file:///c/first.rs");
cache.store_diagnostics(&c, &new_uri, Some(1), vec![]);
assert_eq!(
cache.diagnostics_count(),
MAX_DIAGNOSTIC_ENTRIES,
"the aggregate cap must still be enforced even when every existing server is within share"
);
assert!(cache.diagnostics(&new_uri).is_some());
let b_oldest: Uri = Uri::from("file:///b/file0.rs");
assert!(
cache.diagnostics(&b_oldest).is_none(),
"the largest in-share server (tie-broken to b) must lose its oldest entry"
);
assert!(
cache
.diagnostics(&Uri::from("file:///a/file0.rs".to_owned()))
.is_some(),
"the other in-share server must be untouched"
);
}
#[test]
fn test_repeated_writes_same_owner_do_not_grow_order_map() {
let mut cache = NotificationCache::new();
let server = ServerId::from_static("server");
let uri: Uri = Uri::from("file:///test.rs");
let max_version = i32::try_from(MAX_DIAGNOSTIC_ENTRIES).unwrap() + 10;
for version in 0..max_version {
cache.store_diagnostics(&server, &uri, Some(version), vec![]);
}
assert_eq!(cache.diagnostics_count(), 1);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(
stored.version.map(DocumentVersion::get),
Some(max_version - 1)
);
}
#[test]
fn test_store_diagnostics_reassigns_ownership() {
let mut cache = NotificationCache::new();
let old_owner = ServerId::from_static("old");
let new_owner = ServerId::from_static("new");
let uri: Uri = Uri::from("file:///test.rs");
cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
assert_eq!(cache.diagnostics_count(), 1);
let stored = cache.diagnostics(&uri).unwrap();
assert_eq!(stored.version.map(DocumentVersion::get), Some(2));
for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
let other: Uri = Uri::from(format!("file:///old/file{i}.rs"));
cache.store_diagnostics(&old_owner, &other, Some(1), vec![]);
}
assert!(cache.diagnostics(&uri).is_some());
}
#[test]
fn test_diagnostics_owner_returns_publisher_after_store() {
let mut cache = NotificationCache::new();
let server = ServerId::from_static("rust");
let uri: Uri = Uri::from("file:///main.rs");
cache.store_diagnostics(&server, &uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_owner(&uri), Some(&server));
}
#[test]
fn test_diagnostics_owner_none_for_untracked_uri() {
let cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///never-seen.rs");
assert_eq!(cache.diagnostics_owner(&uri), None);
}
#[test]
fn test_diagnostics_owner_reflects_reassigned_ownership() {
let mut cache = NotificationCache::new();
let old_owner = ServerId::from_static("old");
let new_owner = ServerId::from_static("new");
let uri: Uri = Uri::from("file:///test.rs");
cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_owner(&uri), Some(&old_owner));
cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
assert_eq!(cache.diagnostics_owner(&uri), Some(&new_owner));
}
#[test]
fn test_clear_server_diagnostics_scopes_to_one_server() {
let mut cache = NotificationCache::new();
let crashed = ServerId::from_static("crashed");
let healthy = ServerId::from_static("healthy");
let crashed_uri: Uri = Uri::from("file:///crashed/main.py");
let healthy_uri: Uri = Uri::from("file:///healthy/main.rs");
cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
cache.clear_server_diagnostics(&crashed);
assert!(cache.diagnostics(&crashed_uri).is_none());
assert!(cache.diagnostics(&healthy_uri).is_some());
assert_eq!(cache.diagnostics_count(), 1);
cache.clear_server_diagnostics(&crashed);
assert_eq!(cache.diagnostics_count(), 1);
}
#[test]
fn test_push_degraded_is_scoped_per_server_and_permanent() {
let mut cache = NotificationCache::new();
let degraded = ServerId::from_static("degraded");
let healthy = ServerId::from_static("healthy");
assert!(!cache.is_push_degraded(°raded));
assert!(!cache.is_push_degraded(&healthy));
cache.mark_push_degraded(°raded);
assert!(cache.is_push_degraded(°raded));
assert!(!cache.is_push_degraded(&healthy));
cache.mark_push_degraded(°raded);
assert!(cache.is_push_degraded(°raded));
}
#[test]
fn test_shrinking_budget_affects_eviction_target_not_existing_entries() {
let mut cache = NotificationCache::new();
let server = ServerId::from_static("server");
for i in 0..MAX_DIAGNOSTIC_ENTRIES {
let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
cache.store_diagnostics(&server, &uri, Some(1), vec![]);
}
assert_eq!(
cache.diagnostics_count(),
MAX_DIAGNOSTIC_ENTRIES,
"filling to the aggregate cap must not evict anything early"
);
cache.set_diagnostics_route_count(4);
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let other = ServerId::from_static("other");
let new_uri: Uri = Uri::from("file:///other/new.rs");
cache.store_diagnostics(&other, &new_uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
assert!(cache.diagnostics(&new_uri).is_some());
let server_oldest: Uri = Uri::from("file:///file0.rs");
assert!(
cache.diagnostics(&server_oldest).is_none(),
"the pre-existing server's oldest entry, now far over its shrunk share, must be evicted"
);
}
#[test]
fn test_fair_share_applies_by_default_without_explicit_route_count() {
let mut cache = NotificationCache::new();
let noisy = ServerId::from_static("noisy");
let quiet = ServerId::from_static("quiet");
let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
assert!(
cache.diagnostics(&quiet_uri).is_some(),
"quiet server's only entry must survive even without ever calling \
set_diagnostics_route_count"
);
let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
assert!(
cache.diagnostics(&noisy_first).is_none(),
"the noisy server, now auto-derived as one of two servers sharing the budget, \
must still lose its own oldest entries once over its fair share"
);
}
#[test]
fn test_single_server_gets_full_budget_without_explicit_route_count() {
let mut cache = NotificationCache::new();
for i in 0..MAX_DIAGNOSTIC_ENTRIES {
let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
}
#[test]
fn test_empty_diagnostics_entries_evicted_before_non_empty_ones() {
let mut cache = NotificationCache::new();
let server = test_server();
let important: Uri = Uri::from("file:///important.rs");
cache.store_diagnostics(
&server,
&important,
Some(1),
vec![minimal_diagnostic("real error".to_string())],
);
for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
let uri: Uri = Uri::from(format!("file:///clean{i}.rs"));
cache.store_diagnostics(&server, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let overflow: Uri = Uri::from("file:///overflow.rs");
cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
assert!(
cache.diagnostics(&important).is_some(),
"a non-empty entry must survive eviction over empty entries, even though it is older"
);
let oldest_clean: Uri = Uri::from("file:///clean0.rs");
assert!(
cache.diagnostics(&oldest_clean).is_none(),
"the oldest empty entry must be evicted instead of the older non-empty one"
);
assert!(cache.diagnostics(&overflow).is_some());
}
#[test]
fn test_empty_diagnostics_entry_is_still_tracked_until_evicted() {
let mut cache = NotificationCache::new();
let uri: Uri = Uri::from("file:///clean.rs");
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
let stored = cache.diagnostics(&uri);
assert!(
stored.is_some(),
"an empty-diagnostics entry must still be tracked"
);
assert_eq!(stored.unwrap().diagnostics.len(), 0);
}
#[test]
fn test_over_share_servers_empty_entry_evicted_before_a_different_servers_real_diagnostic() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(3);
let a = ServerId::from_static("a"); let b = ServerId::from_static("b"); let c = ServerId::from_static("c");
for i in 0..500 {
let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
cache.store_diagnostics(
&a,
&uri,
Some(1),
vec![minimal_diagnostic(format!("error {i}"))],
);
}
for i in 0..400 {
let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
cache.store_diagnostics(&b, &uri, Some(1), vec![]);
}
for i in 0..100 {
let uri: Uri = Uri::from(format!("file:///c/file{i}.rs"));
cache.store_diagnostics(&c, &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let overflow: Uri = Uri::from("file:///a/overflow.rs");
cache.store_diagnostics(
&a,
&overflow,
Some(1),
vec![minimal_diagnostic("overflow error".to_string())],
);
for i in 0..500 {
let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
assert!(
cache.diagnostics(&uri).is_some(),
"server a's real diagnostics must all survive; b has an empty entry to lose \
instead"
);
}
let b_oldest: Uri = Uri::from("file:///b/file0.rs");
assert!(
cache.diagnostics(&b_oldest).is_none(),
"b's oldest empty entry must be evicted instead of a's real diagnostics"
);
assert!(cache.diagnostics(&overflow).is_some());
}
#[test]
fn test_dirty_then_clean_then_dirty_again_updates_emptiness_tracking() {
let mut cache = NotificationCache::new();
let server = test_server();
let uri: Uri = Uri::from("file:///flapping.rs");
cache.store_diagnostics(
&server,
&uri,
Some(1),
vec![minimal_diagnostic("first error".to_string())],
);
cache.store_diagnostics(&server, &uri, Some(2), vec![]); cache.store_diagnostics(
&server,
&uri,
Some(3),
vec![minimal_diagnostic("second error".to_string())],
);
for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
let other: Uri = Uri::from(format!("file:///clean{i}.rs"));
cache.store_diagnostics(&server, &other, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
let overflow: Uri = Uri::from("file:///overflow.rs");
cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
let stored = cache.diagnostics(&uri);
assert!(
stored.is_some_and(|info| info.diagnostics.len() == 1),
"the re-dirtied entry must survive and keep its real diagnostic, not be mistaken \
for an empty entry"
);
}
#[test]
fn test_set_diagnostics_route_count_zero_clamps_to_one() {
let mut cache = NotificationCache::new();
cache.set_diagnostics_route_count(0);
for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
}
assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
}
#[test]
fn test_indexing_state_defaults_unknown() {
let cache = NotificationCache::new();
assert_eq!(cache.indexing_state(&test_server()), IndexingState::Unknown);
}
#[test]
fn test_observe_indexing_signal_delegates_to_tracker() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.observe_indexing_signal(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
assert_eq!(cache.indexing_state(&server), IndexingState::Loading);
}
#[test]
fn test_reset_indexing_state_delegates_to_tracker() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.observe_indexing_signal(
&server,
"experimental/serverStatus",
Some(&serde_json::json!({"quiescent": false})),
);
cache.reset_indexing_state(&server, IndexingReset::Forget);
assert_eq!(cache.indexing_state(&server), IndexingState::Unknown);
}
fn file_uri(path: &str) -> Uri {
Uri::from(format!("file:///ws/{path}"))
}
fn published(source: &str) -> PublishedDiagnosticsUri {
PublishedDiagnosticsUri::for_test(file_uri(source), file_uri("main.rs"))
}
fn diagnostic_at(line: u32, message: &str) -> LspDiagnostic {
let mut diagnostic = minimal_diagnostic(message.to_owned());
diagnostic.range.start.line = line;
diagnostic.range.end.line = line;
diagnostic
}
fn merged(cache: &NotificationCache) -> Option<DiagnosticInfo> {
cache.diagnostic_sources(&file_uri("main.rs")).merge()
}
fn messages(info: &DiagnosticInfo) -> Vec<String> {
info.diagnostics
.iter()
.map(|d| message_as_str(&d.message).to_owned())
.collect()
}
#[test]
fn test_published_alias_errors_survive_empty_canonical_publish() {
let mut cache = NotificationCache::new();
let server = test_server();
let errors = vec![diagnostic_at(1, "e1"), diagnostic_at(2, "e2")];
cache.store_published_diagnostics(&server, &published("alias.rs"), None, errors);
cache.store_published_diagnostics(&server, &published("main.rs"), Some(3), vec![]);
assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
cache.store_published_diagnostics(&server, &published("main.rs"), Some(4), vec![]);
assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
cache.store_published_diagnostics(&server, &published("alias.rs"), None, vec![]);
assert!(merged(&cache).unwrap().diagnostics.is_empty());
}
#[test]
fn test_published_empty_publish_clears_only_its_own_source() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(
&server,
&published("a1.rs"),
None,
vec![diagnostic_at(1, "e1")],
);
cache.store_published_diagnostics(
&server,
&published("a2.rs"),
None,
vec![diagnostic_at(2, "e2")],
);
assert_eq!(messages(&merged(&cache).unwrap()), ["e1", "e2"]);
cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![]);
assert_eq!(messages(&merged(&cache).unwrap()), ["e2"]);
}
#[test]
fn test_merge_removes_duplicates_sharing_a_start_with_another_diagnostic() {
let mut cache = NotificationCache::new();
let server = test_server();
let x = diagnostic_at(1, "x");
let y = diagnostic_at(1, "y");
cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![x.clone(), y]);
cache.store_published_diagnostics(&server, &published("a2.rs"), None, vec![x]);
assert_eq!(messages(&merged(&cache).unwrap()), ["x", "y"]);
}
#[test]
fn test_merge_orders_by_range_start() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(
&server,
&published("a1.rs"),
None,
vec![diagnostic_at(5, "late")],
);
cache.store_published_diagnostics(
&server,
&published("a2.rs"),
None,
vec![diagnostic_at(1, "early")],
);
assert_eq!(messages(&merged(&cache).unwrap()), ["early", "late"]);
}
#[test]
fn test_merged_version_is_the_canonical_sources() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(
&server,
&published("main.rs"),
Some(7),
vec![diagnostic_at(1, "a")],
);
cache.store_published_diagnostics(
&server,
&published("alias.rs"),
None,
vec![diagnostic_at(2, "b")],
);
assert_eq!(
merged(&cache).unwrap().version.map(DocumentVersion::get),
Some(7)
);
cache.store_published_diagnostics(
&server,
&published("main.rs"),
Some(8),
vec![diagnostic_at(1, "a")],
);
assert_eq!(
merged(&cache).unwrap().version.map(DocumentVersion::get),
Some(8)
);
}
#[test]
fn test_merge_is_bounded_by_the_entry_size_cap() {
let mut cache = NotificationCache::new();
let server = test_server();
let big = "x".repeat(200 * 1024);
for source in [
"a1.rs", "a2.rs", "a3.rs", "a4.rs", "a5.rs", "a6.rs", "a7.rs", "a8.rs",
] {
let diagnostics = (0..4)
.map(|n| diagnostic_at(n, &format!("{source}-{n}-{big}")))
.collect();
cache.store_published_diagnostics(&server, &published(source), None, diagnostics);
}
let info = merged(&cache).unwrap();
let bytes = serde_json::to_vec(&info.diagnostics).unwrap().len();
assert!(bytes <= MAX_DIAGNOSTICS_ENTRY_BYTES, "{bytes} bytes");
}
#[test]
fn test_single_source_is_returned_unchanged() {
let mut cache = NotificationCache::new();
cache.store_published_diagnostics(
&test_server(),
&published("alias.rs"),
Some(2),
vec![diagnostic_at(1, "e")],
);
let info = merged(&cache).unwrap();
assert_eq!(info.uri, file_uri("alias.rs"));
assert_eq!(info.version.map(DocumentVersion::get), Some(2));
}
#[test]
fn test_ninth_source_is_dropped_without_an_orphan_entry() {
let mut cache = NotificationCache::new();
let server = test_server();
for n in 0..MAX_SOURCES_PER_FILE {
cache.store_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
vec![diagnostic_at(1, "e")],
);
}
cache.store_published_diagnostics(
&server,
&published("extra.rs"),
None,
vec![diagnostic_at(9, "extra")],
);
assert_eq!(cache.diagnostics_count(), MAX_SOURCES_PER_FILE);
assert!(cache.diagnostics(&file_uri("extra.rs")).is_none());
assert!(!messages(&merged(&cache).unwrap()).contains(&"extra".to_owned()));
}
#[test]
fn test_lost_publish_replaces_older_entry_with_a_lost_mark() {
let mut cache = NotificationCache::new();
let server = test_server();
let uri = file_uri("a.rs");
cache.store_diagnostics(&server, &uri, None, vec![diagnostic_at(1, "old")]);
let changed = cache.record_lost_files(&server, &[DiagnosticsKey::of(&uri)]);
assert_eq!(changed, [DiagnosticsKey::of(&uri)]);
assert!(!cache.has_diagnostics(&uri));
assert_eq!(
cache.availability(&uri, Some(&server)),
DiagnosticsAvailability::Evicted
);
cache.store_diagnostics(&server, &uri, None, vec![diagnostic_at(2, "new")]);
assert_eq!(
cache.availability(&uri, Some(&server)),
DiagnosticsAvailability::Published
);
cache.assert_consistent();
}
#[test]
fn test_unnamed_lost_publishes_make_unknown_files_read_evicted() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.record_unnamed_loss(&server);
assert_eq!(
cache.availability(&file_uri("unknown.rs"), Some(&server)),
DiagnosticsAvailability::Evicted
);
}
#[test]
fn test_a_publish_over_the_source_cap_is_reported_as_dropped() {
let mut cache = NotificationCache::new();
let server = test_server();
for n in 0..MAX_SOURCES_PER_FILE {
let write = cache.write_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
BoundedDiagnostics::new(&file_uri("a.rs"), vec![diagnostic_at(1, "e")]),
);
std::assert_matches!(write, PushWrite::Stored { .. });
}
let write = cache.write_published_diagnostics(
&server,
&published("extra.rs"),
None,
BoundedDiagnostics::new(&file_uri("a.rs"), vec![diagnostic_at(9, "extra")]),
);
std::assert_matches!(write, PushWrite::Dropped { .. });
}
#[test]
fn test_republishing_an_indexed_source_at_the_cap_is_accepted() {
let mut cache = NotificationCache::new();
let server = test_server();
for n in 0..MAX_SOURCES_PER_FILE {
cache.store_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
vec![],
);
}
cache.store_published_diagnostics(
&server,
&published("a0.rs"),
None,
vec![diagnostic_at(1, "again")],
);
assert_eq!(messages(&merged(&cache).unwrap()), ["again"]);
}
#[test]
fn test_clear_server_diagnostics_prunes_the_source_index() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(
&server,
&published("a1.rs"),
None,
vec![diagnostic_at(1, "e")],
);
cache.clear_server_diagnostics(&server);
cache.assert_consistent();
assert!(cache.files.is_empty());
assert!(!cache.has_diagnostics(&file_uri("main.rs")));
assert!(merged(&cache).is_none());
}
#[test]
fn test_eviction_prunes_the_source_index() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![]);
for n in 0..MAX_DIAGNOSTIC_ENTRIES {
let uri = Uri::from(format!("file:///other/{n}.rs"));
cache.store_diagnostics(&server, &uri, None, vec![]);
}
assert!(cache.diagnostics(&file_uri("a1.rs")).is_none());
cache.assert_consistent();
assert!(
!cache
.files
.contains_key(&DiagnosticsKey::of(&file_uri("main.rs")))
);
}
#[test]
fn test_diagnostics_owner_resolves_through_the_source_index() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(&server, &published("alias.rs"), None, vec![]);
assert_eq!(cache.diagnostics_owner(&file_uri("main.rs")), Some(&server));
}
#[test]
fn test_canonical_source_is_admitted_when_aliases_fill_the_cap() {
let mut cache = NotificationCache::new();
let server = test_server();
for n in 0..MAX_SOURCES_PER_FILE {
cache.store_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
vec![diagnostic_at(
u32::try_from(n).unwrap(),
&format!("alias{n}"),
)],
);
}
cache.store_published_diagnostics(
&server,
&published("main.rs"),
Some(5),
vec![diagnostic_at(99, "canonical")],
);
let info = merged(&cache).unwrap();
let shown = messages(&info);
assert!(shown.contains(&"canonical".to_owned()), "{shown:?}");
assert!(
!shown.contains(&"alias0".to_owned()),
"oldest alias is evicted"
);
assert_eq!(shown.len(), MAX_SOURCES_PER_FILE);
assert_eq!(info.version.map(DocumentVersion::get), Some(5));
assert_eq!(cache.diagnostics_count(), MAX_SOURCES_PER_FILE);
}
#[test]
fn test_alias_beyond_the_cap_is_still_dropped_when_canonical_is_present() {
let mut cache = NotificationCache::new();
let server = test_server();
cache.store_published_diagnostics(&server, &published("main.rs"), None, vec![]);
for n in 0..MAX_SOURCES_PER_FILE - 1 {
cache.store_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
vec![],
);
}
cache.store_published_diagnostics(
&server,
&published("extra.rs"),
None,
vec![diagnostic_at(1, "x")],
);
assert!(cache.diagnostics(&file_uri("extra.rs")).is_none());
assert!(cache.diagnostics(&file_uri("main.rs")).is_some());
}
#[test]
fn test_merge_handles_many_same_start_diagnostics_across_sources() {
const PER_SOURCE: u32 = 3000;
let mut cache = NotificationCache::new();
let server = test_server();
for source in 0..MAX_SOURCES_PER_FILE {
let diagnostics = (0..PER_SOURCE)
.map(|n| diagnostic_at(0, &format!("diagnostic-{n}")))
.collect();
cache.store_published_diagnostics(
&server,
&published(&format!("a{source}.rs")),
None,
diagnostics,
);
}
let info = merged(&cache).unwrap();
assert_eq!(info.diagnostics.len(), PER_SOURCE as usize);
}
#[test]
fn test_merge_keeps_diagnostics_that_differ_outside_range_and_message() {
let mut cache = NotificationCache::new();
let server = test_server();
let plain = diagnostic_at(1, "same");
let mut coded = diagnostic_at(1, "same");
coded.code = Some(lsp_types::Code::String("E1".to_owned()));
cache.store_published_diagnostics(&server, &published("a1.rs"), None, vec![plain]);
cache.store_published_diagnostics(&server, &published("a2.rs"), None, vec![coded]);
assert_eq!(merged(&cache).unwrap().diagnostics.len(), 2);
}
#[test]
fn test_clear_server_diagnostics_leaves_no_dangling_file_index() {
let mut cache = NotificationCache::new();
let (a, b) = (ServerId::from_static("a"), ServerId::from_static("b"));
cache.store_published_diagnostics(&a, &published("a1.rs"), None, vec![]);
cache.store_published_diagnostics(
&b,
&published("main.rs"),
None,
vec![diagnostic_at(1, "e")],
);
cache.clear_server_diagnostics(&a);
cache.assert_consistent();
assert_eq!(cache.files.len(), 1);
assert!(cache.diagnostics(&file_uri("main.rs")).is_some());
assert!(cache.diagnostics(&file_uri("a1.rs")).is_none());
}
#[derive(Debug, Clone)]
enum CacheOp {
Publish {
server: u8,
file: u8,
alias: Option<u8>,
empty: bool,
},
Clear {
server: u8,
},
}
fn cache_op() -> impl proptest::strategy::Strategy<Value = CacheOp> {
use proptest::prelude::*;
prop_oneof![
4 => (0..3u8, 0..3u8, proptest::option::of(0..3u8), any::<bool>()).prop_map(
|(server, file, alias, empty)| CacheOp::Publish { server, file, alias, empty }
),
1 => (0..3u8).prop_map(|server| CacheOp::Clear { server }),
]
}
proptest::proptest! {
#[test]
fn cache_indices_stay_consistent_under_random_operations(
ops in proptest::collection::vec(cache_op(), 0..60)
) {
let mut cache = NotificationCache::new();
for op in ops {
match op {
CacheOp::Publish { server, file, alias, empty } => {
let canonical = file_uri(&format!("f{file}.rs"));
let source = alias.map_or_else(
|| canonical.clone(),
|n| file_uri(&format!("alias{n}.rs")),
);
let diagnostics = if empty { vec![] } else { vec![diagnostic_at(1, "e")] };
cache.store_published_diagnostics(
&ServerId::new(format!("s{server}")).unwrap(),
&PublishedDiagnosticsUri::for_test(source, canonical),
None,
diagnostics,
);
}
CacheOp::Clear { server } => {
cache.clear_server_diagnostics(&ServerId::new(format!("s{server}")).unwrap());
}
}
cache.assert_consistent();
}
}
}
fn first_uri() -> Uri {
Uri::from("file:///first.rs")
}
fn error_diagnostic() -> LspDiagnostic {
LspDiagnostic {
severity: Some(lsp_types::DiagnosticSeverity::Error),
message: "broken".to_owned().into(),
..LspDiagnostic::default()
}
}
fn full_cache(first_empty: bool) -> NotificationCache {
let mut cache = NotificationCache::new();
let first: Uri = Uri::from("file:///first.rs");
let diagnostics = if first_empty {
vec![]
} else {
vec![error_diagnostic()]
};
cache.store_diagnostics(&test_server(), &first, None, diagnostics);
for i in 1..MAX_DIAGNOSTIC_ENTRIES {
let uri: Uri = Uri::from(format!("file:///filler{i}.rs"));
cache.store_diagnostics(&test_server(), &uri, None, vec![error_diagnostic()]);
}
cache
}
fn evict_one(cache: &mut NotificationCache) {
let overflow: Uri = Uri::from("file:///overflow.rs");
cache.store_diagnostics(&test_server(), &overflow, None, vec![error_diagnostic()]);
}
#[test]
fn test_evicted_empty_entry_is_listen_replayable() {
let mut cache = full_cache(true);
assert!(cache.is_listen_replayable(&first_uri()));
evict_one(&mut cache);
assert!(!cache.has_diagnostics(&first_uri()));
assert!(cache.is_listen_replayable(&first_uri()));
assert!(!cache.is_listen_replayable(&Uri::from("file:///unrelated.rs")));
}
fn write_error(cache: &mut NotificationCache, file: &str) -> PushWrite {
let uri = Uri::from(file);
cache.write_published_diagnostics(
&test_server(),
&PublishedDiagnosticsUri::for_test(uri.clone(), uri),
None,
BoundedDiagnostics::new(&Uri::from(file), vec![error_diagnostic()]),
)
}
#[test]
fn test_a_push_reports_the_file_it_evicts() {
let mut cache = full_cache(false);
let write = write_error(&mut cache, "file:///overflow.rs");
assert_eq!(write.evicted(), [DiagnosticsKey::of(&first_uri())]);
assert!(!cache.has_diagnostics(&first_uri()));
}
#[test]
fn test_a_push_that_needs_no_room_reports_no_eviction() {
let mut cache = full_cache(false);
assert!(
write_error(&mut cache, "file:///first.rs")
.evicted()
.is_empty()
);
let mut roomy = NotificationCache::new();
assert!(write_error(&mut roomy, "file:///a.rs").evicted().is_empty());
}
fn route() -> ServerId {
test_server()
}
#[test]
fn test_evicted_clean_entry_reads_as_published() {
let mut cache = full_cache(true);
assert!(cache.has_diagnostics(&first_uri()));
evict_one(&mut cache);
assert!(!cache.has_diagnostics(&first_uri()));
assert_eq!(
cache.availability(&first_uri(), Some(&route())),
DiagnosticsAvailability::Published
);
}
#[test]
fn test_evicted_non_empty_entry_reads_as_evicted() {
let mut cache = full_cache(false);
evict_one(&mut cache);
assert_eq!(
cache.availability(&first_uri(), Some(&route())),
DiagnosticsAvailability::Evicted
);
}
#[test]
fn test_unpublished_file_reads_as_pending_even_next_to_evictions() {
let mut cache = full_cache(false);
evict_one(&mut cache);
let unseen = Uri::from("file:///never-published.rs");
assert_eq!(
cache.availability(&unseen, Some(&route())),
DiagnosticsAvailability::Pending
);
assert_eq!(
cache.availability(&unseen, None),
DiagnosticsAvailability::Pending
);
}
#[test]
fn test_clearing_a_server_forgets_its_eviction_marks() {
let mut cache = full_cache(false);
evict_one(&mut cache);
drop(cache.clear_server_diagnostics(&route()));
assert_eq!(
cache.availability(&first_uri(), Some(&route())),
DiagnosticsAvailability::Pending
);
}
#[test]
fn test_a_new_publish_replaces_the_mark_of_its_file() {
let mut cache = full_cache(true);
evict_one(&mut cache);
let first: Uri = Uri::from("file:///first.rs");
cache.store_diagnostics(&test_server(), &first, None, vec![error_diagnostic()]);
assert_eq!(
cache.availability(&first_uri(), None),
DiagnosticsAvailability::Published
);
assert!(
cache
.eviction_marks
.content(&DiagnosticsKey::of(&first))
.is_none()
);
}
#[test]
fn test_lost_content_survives_a_later_clean_eviction_of_the_file() {
let mut marks = EvictionMarks::default();
let file = DiagnosticsKey::of(&first_uri());
marks.record(file.clone(), route(), EvictedContent::Lost);
marks.record(file.clone(), route(), EvictedContent::Clean);
assert_eq!(marks.content(&file), Some(EvictedContent::Lost));
}
fn overflowed_after_churn() -> NotificationCache {
let mut cache = full_cache(false);
for i in 0..=MAX_EVICTION_MARKS {
drop(write_error(&mut cache, &format!("file:///churn{i}.rs")));
}
assert!(cache.eviction_marks.has_overflowed(&route()));
cache
}
#[test]
fn test_overflow_ends_with_the_owners_next_accepted_push() {
let mut cache = NotificationCache::new();
cache.eviction_marks.overflowed.insert(route());
let unseen = Uri::from("file:///never-published.rs");
assert_eq!(
cache.availability(&unseen, Some(&route())),
DiagnosticsAvailability::Evicted
);
drop(write_error(&mut cache, "file:///fresh.rs"));
assert!(!cache.eviction_marks.has_overflowed(&route()));
assert_eq!(
cache.availability(&unseen, Some(&route())),
DiagnosticsAvailability::Pending
);
}
#[test]
fn test_overflow_ends_with_the_owners_next_accepted_pull_only() {
let mut cache = NotificationCache::new();
cache.eviction_marks.overflowed.insert(route());
let other = ServerId::from_static("other-server");
cache.store_pulled_for_test(&other, &Uri::from("file:///other.rs"), Vec::new());
assert!(cache.eviction_marks.has_overflowed(&route()));
cache.store_pulled_for_test(&route(), &Uri::from("file:///pulled.rs"), Vec::new());
assert!(!cache.eviction_marks.has_overflowed(&route()));
}
#[test]
fn test_overflow_clear_precedes_the_evictions_of_the_same_write() {
let mut cache = overflowed_after_churn();
drop(write_error(&mut cache, "file:///another-new.rs"));
assert!(cache.eviction_marks.has_overflowed(&route()));
drop(write_error(&mut cache, "file:///another-new.rs"));
assert!(!cache.eviction_marks.has_overflowed(&route()));
cache.assert_consistent();
}
#[test]
fn test_overflowing_the_marks_makes_unmarked_files_read_as_evicted_for_that_server() {
let mut cache = full_cache(false);
for i in 0..=MAX_EVICTION_MARKS {
drop(write_error(&mut cache, &format!("file:///churn{i}.rs")));
}
assert!(cache.eviction_marks.has_overflowed(&route()));
assert!(cache.eviction_marks.by_file.len() <= MAX_EVICTION_MARKS);
assert!(cache.eviction_marks.order.len() <= MAX_EVICTION_MARKS);
cache.assert_consistent();
assert_eq!(
cache.availability(&first_uri(), Some(&route())),
DiagnosticsAvailability::Evicted,
"its mark was the oldest, forgotten"
);
let unseen = Uri::from("file:///never-published.rs");
assert_eq!(
cache.availability(&unseen, Some(&route())),
DiagnosticsAvailability::Evicted
);
assert_eq!(
cache.availability(&unseen, Some(&ServerId::from_static("other"))),
DiagnosticsAvailability::Pending
);
drop(cache.clear_server_diagnostics(&route()));
assert_eq!(
cache.availability(&unseen, Some(&route())),
DiagnosticsAvailability::Pending
);
}
#[test]
fn test_evicted_non_empty_entry_is_not_replayable() {
let mut cache = full_cache(false);
evict_one(&mut cache);
assert!(!cache.has_diagnostics(&first_uri()));
assert!(!cache.is_listen_replayable(&first_uri()));
}
#[test]
fn test_recached_entry_leaves_the_replay_ring_view() {
let mut cache = full_cache(true);
evict_one(&mut cache);
let first: Uri = Uri::from("file:///first.rs");
cache.store_diagnostics(&test_server(), &first, None, vec![error_diagnostic()]);
assert!(cache.has_diagnostics(&first_uri()));
assert!(!cache.was_recently_evicted(&first_uri(), std::time::Instant::now()));
}
#[test]
fn test_eviction_replay_expires_after_the_window() {
let mut cache = NotificationCache::new();
let now = std::time::Instant::now();
let gone = Uri::from("file:///gone.rs");
cache.record_empty_eviction(DiagnosticsKey::of(&gone), now);
assert!(cache.was_recently_evicted(&gone, now + EVICTION_REPLAY_WINDOW));
assert!(!cache.was_recently_evicted(
&gone,
now + EVICTION_REPLAY_WINDOW + std::time::Duration::from_secs(1)
));
}
#[test]
fn test_eviction_record_is_bounded_and_keeps_the_newest() {
let mut cache = NotificationCache::new();
let now = std::time::Instant::now();
for i in 0..MAX_RECENT_EVICTIONS + 44 {
let gone = Uri::from(format!("file:///gone{i}.rs"));
cache.record_empty_eviction(
DiagnosticsKey::of(&gone),
now + std::time::Duration::from_millis(u64::try_from(i).unwrap()),
);
}
assert_eq!(cache.recent_evictions.len(), MAX_RECENT_EVICTIONS);
let at = now + std::time::Duration::from_secs(1);
assert!(!cache.was_recently_evicted(&Uri::from("file:///gone0.rs"), at));
assert!(!cache.was_recently_evicted(&Uri::from("file:///gone43.rs"), at));
assert!(cache.was_recently_evicted(&Uri::from("file:///gone44.rs"), at));
let newest = MAX_RECENT_EVICTIONS + 43;
assert!(cache.was_recently_evicted(&Uri::from(format!("file:///gone{newest}.rs")), at));
}
#[test]
fn test_clearing_a_full_cache_keeps_every_empty_clear_replayable() {
let mut cache = NotificationCache::new();
let server = test_server();
let uris: Vec<Uri> = (0..MAX_DIAGNOSTIC_ENTRIES)
.map(|i| Uri::from(format!("file:///clean{i}.rs")))
.collect();
for uri in &uris {
cache.store_diagnostics(&server, uri, None, vec![]);
}
cache.clear_server_diagnostics(&server);
assert!(uris.iter().all(|uri| cache.is_listen_replayable(uri)));
}
#[test]
fn test_eviction_order_stays_within_twice_the_record_under_churn() {
let mut cache = NotificationCache::new();
let now = std::time::Instant::now();
let keys: Vec<DiagnosticsKey> = (0..3)
.map(|i| DiagnosticsKey::of(&Uri::from(format!("file:///hot{i}.rs"))))
.collect();
for tick in 0..10_000u64 {
let key = keys[usize::try_from(tick % 3).unwrap()].clone();
cache.record_empty_eviction(key, now + std::time::Duration::from_micros(tick));
let record = &cache.recent_evictions;
assert!(record.at.len() <= MAX_RECENT_EVICTIONS);
assert!(record.order.len() <= 2 * record.at.len());
}
assert_eq!(cache.recent_evictions.len(), 3);
}
#[test]
fn test_eviction_record_stays_bounded_under_churn_past_the_cap() {
const KEYS: usize = MAX_RECENT_EVICTIONS + 300;
let mut cache = NotificationCache::new();
let now = std::time::Instant::now();
let keys: Vec<DiagnosticsKey> = (0..KEYS)
.map(|i| DiagnosticsKey::of(&Uri::from(format!("file:///churn{i}.rs"))))
.collect();
for tick in 0..KEYS * 8 {
let key = keys[tick % KEYS].clone();
let at = now + std::time::Duration::from_micros(u64::try_from(tick).unwrap());
cache.record_empty_eviction(key, at);
let record = &cache.recent_evictions;
assert!(record.at.len() <= MAX_RECENT_EVICTIONS);
assert!(record.order.len() <= 2 * record.at.len() + 1);
}
assert_eq!(cache.recent_evictions.len(), MAX_RECENT_EVICTIONS);
}
#[test]
fn test_compaction_keeps_the_oldest_first_expiry_order() {
let mut record = EvictionRecord::default();
let now = std::time::Instant::now();
let a = DiagnosticsKey::of(&Uri::from("file:///a.rs"));
let b = DiagnosticsKey::of(&Uri::from("file:///b.rs"));
record.record(a.clone(), now);
record.record(b.clone(), now + std::time::Duration::from_secs(1));
record.record(a.clone(), now + std::time::Duration::from_secs(2));
record.record(b.clone(), now + std::time::Duration::from_secs(3));
record.compact();
let order: Vec<_> = record.order.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(order, vec![a, b]);
}
#[test]
fn test_eviction_record_overflow_warns_once_per_window() {
use tracing_subscriber::layer::SubscriberExt as _;
let mut record = EvictionRecord::default();
let now = std::time::Instant::now();
let captured = CapturedLogs::default();
let subscriber = tracing_subscriber::registry().with(captured.clone());
let guard = tracing::subscriber::set_default(subscriber);
for i in 0..MAX_RECENT_EVICTIONS + 10 {
let key = DiagnosticsKey::of(&Uri::from(format!("file:///gone{i}.rs")));
record.record(
key,
now + std::time::Duration::from_millis(u64::try_from(i).unwrap()),
);
}
drop(guard);
let warnings = captured
.messages()
.iter()
.filter(|m| m.contains("diagnostics clears evicted"))
.count();
assert_eq!(warnings, 1);
}
#[test]
fn test_eviction_record_deduplicates_and_prunes_expired_entries() {
let mut cache = NotificationCache::new();
let now = std::time::Instant::now();
let a = DiagnosticsKey::of(&Uri::from("file:///a.rs"));
cache.record_empty_eviction(a.clone(), now);
cache.record_empty_eviction(a, now);
assert_eq!(cache.recent_evictions.len(), 1);
let later = now + EVICTION_REPLAY_WINDOW + std::time::Duration::from_secs(1);
cache.record_empty_eviction(DiagnosticsKey::of(&Uri::from("file:///b.rs")), later);
assert_eq!(cache.recent_evictions.len(), 1);
}
#[test]
fn test_alias_eviction_of_an_empty_entry_is_recorded() {
let mut cache = NotificationCache::new();
let server = test_server();
for n in 0..MAX_SOURCES_PER_FILE {
cache.store_published_diagnostics(
&server,
&published(&format!("a{n}.rs")),
None,
vec![],
);
}
assert!(cache.recent_evictions.is_empty());
cache.store_published_diagnostics(
&server,
&PublishedDiagnosticsUri::for_test(file_uri("main.rs"), file_uri("main.rs")),
None,
vec![diagnostic_at(1, "e")],
);
assert!(cache.diagnostics(&file_uri("a0.rs")).is_none());
assert_eq!(cache.recent_evictions.len(), 1);
assert!(cache.is_listen_replayable(&file_uri("main.rs")));
}
#[test]
fn test_clearing_a_server_records_its_empty_entries() {
let mut cache = NotificationCache::new();
let empty = file_uri("empty.rs");
let broken = file_uri("broken.rs");
cache.store_diagnostics(&test_server(), &empty, None, vec![]);
cache.store_diagnostics(&test_server(), &broken, None, vec![error_diagnostic()]);
let cleared = cache.clear_server_diagnostics(&test_server());
assert_eq!(cleared.len(), 2);
assert!(cache.is_listen_replayable(&empty));
assert!(!cache.is_listen_replayable(&broken));
}
}