use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use chrono::{DateTime, Utc};
use lsp_types::{Diagnostic as LspDiagnostic, Uri};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::bridge::indexing::{IndexingPolicy, IndexingState, IndexingTracker};
use crate::config::ServerId;
use crate::util::{truncate_str, truncate_string};
const MAX_LOG_ENTRIES: usize = 100;
const MAX_ENTRY_TEXT_BYTES: usize = 256 * 1024;
const MAX_DIAGNOSTICS_ENTRY_BYTES: usize = 1024 * 1024;
const MAX_DIAGNOSTIC_ENTRIES: usize = 1000;
fn uri_cache_key(uri: &str) -> std::borrow::Cow<'_, str> {
if cfg!(windows) {
std::borrow::Cow::Owned(uri.to_ascii_lowercase())
} else {
std::borrow::Cow::Borrowed(uri)
}
}
const MAX_SERVER_MESSAGES: usize = 50;
const DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES: usize = 256;
const JSON_ESCAPE_WORST_CASE_FACTOR: usize = 6;
const DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES: usize = 1024;
const fn diagnostic_severity_rank(diagnostic: &LspDiagnostic) -> u8 {
match diagnostic.severity {
Some(lsp_types::DiagnosticSeverity::Error) => 0,
Some(lsp_types::DiagnosticSeverity::Warning) => 1,
Some(lsp_types::DiagnosticSeverity::Information) => 2,
Some(_) | None => 3,
}
}
fn largest_fitting_prefix(
diagnostics: &[LspDiagnostic],
fits: impl Fn(&[LspDiagnostic]) -> bool,
) -> usize {
let (mut lo, mut hi) = (0usize, diagnostics.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if fits(&diagnostics[..mid]) {
lo = mid;
} else {
hi = mid - 1;
}
}
lo
}
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,
}
}
fn truncate_message(message: lsp_types::Message, max_bytes: usize) -> lsp_types::Message {
match message {
lsp_types::Message::String(s) => lsp_types::Message::String(truncate_string(s, max_bytes)),
lsp_types::Message::MarkupContent(mut m) => {
m.value = truncate_string(m.value, max_bytes);
lsp_types::Message::MarkupContent(m)
}
}
}
fn cap_diagnostics_entry_size(uri: &Uri, diagnostics: &mut Vec<LspDiagnostic>) {
let fits = |ds: &[LspDiagnostic]| {
serde_json::to_vec(ds).is_ok_and(|bytes| bytes.len() <= MAX_DIAGNOSTICS_ENTRY_BYTES)
};
let cheaply_estimable = diagnostics.iter().all(|d| {
d.data.is_none()
&& d.code_description.is_none()
&& d.related_information.is_none()
&& d.tags.is_none()
});
if cheaply_estimable {
let estimated: usize = diagnostics
.iter()
.map(|d| {
let raw_string_bytes = message_as_str(&d.message).len()
+ d.source.as_deref().map_or(0, str::len)
+ match &d.code {
Some(lsp_types::Code::String(s)) => s.len(),
_ => 0,
};
raw_string_bytes * JSON_ESCAPE_WORST_CASE_FACTOR
+ DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES
})
.sum();
if estimated <= MAX_DIAGNOSTICS_ENTRY_BYTES {
return;
}
}
if fits(diagnostics) {
return;
}
let original_count = diagnostics.len();
diagnostics.sort_by_key(diagnostic_severity_rank);
let keep = largest_fitting_prefix(diagnostics, fits).max(1);
diagnostics.truncate(keep);
if diagnostics.len() < original_count {
warn!(
"diagnostics for {} exceeded the {MAX_DIAGNOSTICS_ENTRY_BYTES}-byte cache cap; kept \
the {} highest-severity of {original_count} diagnostics",
uri.as_ref(),
diagnostics.len(),
);
}
if diagnostics.len() == 1 && !fits(diagnostics) {
let diagnostic = &mut diagnostics[0];
let had_data = diagnostic.data.is_some();
diagnostic.data = None;
diagnostic.code_description = None;
diagnostic.related_information = None;
diagnostic.tags = None;
warn!(
"diagnostic for {} exceeded the cache cap; dropped its data/code_description/\
related_information/tags fields{}",
uri.as_ref(),
if had_data {
" (a later code-action request for this diagnostic may not resolve its quick fix)"
} else {
""
},
);
}
if diagnostics.len() == 1 && !fits(diagnostics) {
let diagnostic = &mut diagnostics[0];
if let Some(source) = &diagnostic.source {
diagnostic.source = Some(truncate_str(source, MAX_ENTRY_TEXT_BYTES));
}
if let Some(lsp_types::Code::String(code)) = &diagnostic.code {
diagnostic.code = Some(lsp_types::Code::String(truncate_str(
code,
MAX_ENTRY_TEXT_BYTES,
)));
}
}
if !fits(diagnostics) {
diagnostics.truncate(1);
if let Some(diagnostic) = diagnostics.first_mut() {
let placeholder = lsp_types::Message::String(String::new());
diagnostic.message = truncate_message(
std::mem::replace(&mut diagnostic.message, placeholder),
DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES,
);
diagnostic.source = None;
diagnostic.code = None;
diagnostic.code_description = None;
diagnostic.related_information = None;
diagnostic.tags = None;
diagnostic.data = None;
}
warn!(
"diagnostic for {} still exceeded the cache cap after every other mitigation; \
truncated its message to {DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES} bytes and \
cleared all other fields",
uri.as_ref(),
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticInfo {
pub uri: Uri,
pub version: Option<i32>,
pub diagnostics: Vec<LspDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub level: LogLevel,
pub message: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Error,
Warning,
Info,
Debug,
}
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)]
pub struct ServerMessage {
pub message_type: MessageType,
pub message: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[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 {
diagnostics: HashMap<String, DiagnosticInfo>,
diagnostics_owners: HashMap<String, ServerId>,
diagnostic_order: HashMap<ServerId, BTreeMap<u64, String>>,
diagnostic_seq: HashMap<String, u64>,
next_diagnostic_seq: u64,
diagnostics_route_count: Option<usize>,
empty_diagnostics_count: usize,
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 {
diagnostics: HashMap::with_capacity(32),
diagnostics_owners: HashMap::with_capacity(32),
diagnostic_order: HashMap::new(),
diagnostic_seq: HashMap::with_capacity(32),
next_diagnostic_seq: 0,
diagnostics_route_count: None,
empty_diagnostics_count: 0,
logs: VecDeque::with_capacity(MAX_LOG_ENTRIES),
messages: VecDeque::with_capacity(MAX_SERVER_MESSAGES),
push_degraded: HashSet::new(),
indexing: IndexingTracker::new(),
}
}
pub fn set_diagnostics_route_count(&mut self, count: usize) {
self.diagnostics_route_count = Some(count.max(1));
}
fn per_server_budget(&self) -> usize {
let count = self
.diagnostics_route_count
.unwrap_or_else(|| {
self.diagnostic_order
.values()
.filter(|order| !order.is_empty())
.count()
})
.max(1);
(MAX_DIAGNOSTIC_ENTRIES / count).max(1)
}
fn server_to_evict_from(&self, writer: &ServerId) -> Option<ServerId> {
let largest = self
.diagnostic_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
.diagnostic_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: &str) -> bool {
self.diagnostics
.get(key)
.is_some_and(|info| info.diagnostics.is_empty())
}
fn oldest_empty_entry_in(&self, server: &ServerId) -> Option<(u64, String)> {
let order = self.diagnostic_order.get(server)?;
order
.iter()
.find(|(_, key)| self.is_empty_entry(key))
.map(|(&seq, key)| (seq, key.clone()))
}
fn entry_to_evict(&self, writer: &ServerId) -> Option<(ServerId, u64, String)> {
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) {
return Some((evict_from, seq, key));
}
let budget = self.per_server_budget();
let cross_server_pick = self
.diagnostic_order
.iter()
.filter(|(id, order)| order.len() > budget && *id != &evict_from)
.filter_map(|(id, order)| {
self.oldest_empty_entry_in(id)
.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));
}
}
let order = self.diagnostic_order.get(&evict_from)?;
let (&seq, key) = order.iter().next()?;
Some((evict_from, seq, key.clone()))
}
pub fn store_diagnostics(
&mut self,
server_id: &ServerId,
uri: &Uri,
version: Option<i32>,
mut diagnostics: Vec<LspDiagnostic>,
) {
for diagnostic in &mut diagnostics {
let placeholder = lsp_types::Message::String(String::new());
diagnostic.message = truncate_message(
std::mem::replace(&mut diagnostic.message, placeholder),
MAX_ENTRY_TEXT_BYTES,
);
}
cap_diagnostics_entry_size(uri, &mut diagnostics);
let key = uri_cache_key(uri.as_ref()).into_owned();
let info = DiagnosticInfo {
uri: uri.clone(),
version,
diagnostics,
};
let mut is_new_entry = true;
if let Some(old_seq) = self.diagnostic_seq.remove(&key) {
is_new_entry = false;
if let Some(previous_owner) = self.diagnostics_owners.get(&key)
&& let Some(order) = self.diagnostic_order.get_mut(previous_owner)
{
order.remove(&old_seq);
}
}
if is_new_entry {
while self.diagnostics.len() >= MAX_DIAGNOSTIC_ENTRIES
&& let Some((owner, seq, evict_key)) = self.entry_to_evict(server_id)
{
if let Some(order) = self.diagnostic_order.get_mut(&owner) {
order.remove(&seq);
}
self.diagnostic_seq.remove(&evict_key);
self.diagnostics_owners.remove(&evict_key);
if let Some(removed) = self.diagnostics.remove(&evict_key)
&& removed.diagnostics.is_empty()
{
self.empty_diagnostics_count -= 1;
}
}
}
self.diagnostics_owners
.insert(key.clone(), server_id.clone());
let seq = self.next_diagnostic_seq;
self.next_diagnostic_seq += 1;
self.diagnostic_order
.entry(server_id.clone())
.or_default()
.insert(seq, key.clone());
self.diagnostic_seq.insert(key.clone(), seq);
let was_empty = self.is_empty_entry(&key);
let is_empty_now = info.diagnostics.is_empty();
match (was_empty, is_empty_now) {
(false, true) => self.empty_diagnostics_count += 1,
(true, false) => self.empty_diagnostics_count -= 1,
_ => {}
}
self.diagnostics.insert(key, info);
}
pub 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 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 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 fn reset_indexing_state(&mut self, server_id: &ServerId) {
self.indexing.reset(server_id);
}
#[inline]
#[must_use]
pub fn diagnostics(&self, uri: &str) -> Option<&DiagnosticInfo> {
self.diagnostics.get(uri_cache_key(uri).as_ref())
}
#[inline]
#[must_use]
pub fn diagnostics_owner(&self, uri: &str) -> Option<&ServerId> {
self.diagnostics_owners.get(uri_cache_key(uri).as_ref())
}
#[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 fn clear_diagnostics(&mut self, uri: &str) -> Option<DiagnosticInfo> {
let key = uri_cache_key(uri).into_owned();
if let Some(owner) = self.diagnostics_owners.remove(&key)
&& let Some(seq) = self.diagnostic_seq.remove(&key)
&& let Some(order) = self.diagnostic_order.get_mut(&owner)
{
order.remove(&seq);
}
let removed = self.diagnostics.remove(&key);
if removed
.as_ref()
.is_some_and(|info| info.diagnostics.is_empty())
{
self.empty_diagnostics_count -= 1;
}
removed
}
pub fn clear_server_diagnostics(&mut self, server_id: &ServerId) {
let Some(order) = self.diagnostic_order.remove(server_id) else {
return;
};
for (_, key) in order {
if self
.diagnostics
.remove(&key)
.is_some_and(|info| info.diagnostics.is_empty())
{
self.empty_diagnostics_count -= 1;
}
self.diagnostics_owners.remove(&key);
self.diagnostic_seq.remove(&key);
}
}
pub fn mark_push_degraded(&mut self, server_id: &ServerId) {
self.push_degraded.insert(server_id.clone());
}
#[inline]
#[must_use]
pub fn is_push_degraded(&self, server_id: &ServerId) -> bool {
self.push_degraded.contains(server_id)
}
pub fn clear_all_diagnostics(&mut self) {
self.diagnostics.clear();
self.diagnostics_owners.clear();
self.diagnostic_order.clear();
self.diagnostic_seq.clear();
self.empty_diagnostics_count = 0;
}
pub fn clear_logs(&mut self) {
self.logs.clear();
}
pub fn clear_messages(&mut self) {
self.messages.clear();
}
#[inline]
#[must_use]
pub fn diagnostics_count(&self) -> usize {
self.diagnostics.len()
}
#[inline]
#[must_use]
pub fn logs_count(&self) -> usize {
self.logs.len()
}
#[inline]
#[must_use]
pub 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(_) => {}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use lsp_types::{Position, Range};
use super::*;
use crate::test_lsp::CapturedLogs;
fn test_server() -> ServerId {
ServerId::from("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.as_ref()).unwrap();
assert_eq!(stored.uri, uri);
assert_eq!(stored.version, 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.as_ref()).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_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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).unwrap();
assert_eq!(stored.version, Some(2));
}
#[test]
fn test_clear_diagnostics() {
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);
let cleared = cache.clear_diagnostics(uri.as_ref());
assert!(cleared.is_some());
assert_eq!(cache.diagnostics_count(), 0);
}
#[test]
fn test_clear_all_diagnostics() {
let mut cache = NotificationCache::new();
let uri1: Uri = Uri::from("file:///test1.rs");
let uri2: Uri = Uri::from("file:///test2.rs");
cache.store_diagnostics(&test_server(), &uri1, Some(1), vec![]);
cache.store_diagnostics(&test_server(), &uri2, Some(1), vec![]);
assert_eq!(cache.diagnostics_count(), 2);
cache.clear_all_diagnostics();
assert_eq!(cache.diagnostics_count(), 0);
}
#[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_clear_logs() {
let mut cache = NotificationCache::new();
cache.store_log(LogLevel::Info, "test".to_string());
assert_eq!(cache.logs_count(), 1);
cache.clear_logs();
assert_eq!(cache.logs_count(), 0);
}
#[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_clear_messages() {
let mut cache = NotificationCache::new();
cache.store_message(MessageType::Info, "test".to_string());
assert_eq!(cache.messages_count(), 1);
cache.clear_messages();
assert_eq!(cache.messages_count(), 0);
}
#[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.as_ref()).unwrap().diagnostics.len(),
1
);
cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
let stored = cache.diagnostics(uri.as_ref()).unwrap();
assert_eq!(stored.diagnostics.len(), 0);
assert_eq!(stored.version, 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.as_ref()).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.as_ref()).is_none());
let newest: Uri = Uri::from(format!("file:///test{}.rs", MAX_DIAGNOSTIC_ENTRIES + 9));
assert!(cache.diagnostics(newest.as_ref()).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.as_ref()).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.as_ref()).is_some(),
"republished entry must survive eviction after being refreshed"
);
let oldest_untouched: Uri = Uri::from("file:///untouched0.rs");
assert!(
cache.diagnostics(oldest_untouched.as_ref()).is_none(),
"the oldest never-republished entry must be evicted instead"
);
assert!(cache.diagnostics(overflow.as_ref()).is_some());
}
#[test]
fn test_clear_diagnostics_then_refill_does_not_evict_early() {
let mut cache = NotificationCache::new();
let first: Uri = Uri::from("file:///first.rs");
cache.store_diagnostics(&test_server(), &first, Some(1), vec![]);
cache.clear_diagnostics(first.as_ref());
assert_eq!(cache.diagnostics_count(), 0);
for i in 0..MAX_DIAGNOSTIC_ENTRIES {
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 first_of_batch: Uri = Uri::from("file:///test0.rs");
assert!(cache.diagnostics(first_of_batch.as_ref()).is_some());
}
#[test]
fn test_clear_diagnostics_nonexistent() {
let mut cache = NotificationCache::new();
let result = cache.clear_diagnostics("file:///nonexistent.rs");
assert!(result.is_none());
}
#[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.as_ref()).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("noisy");
let quiet = ServerId::from("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.as_ref()).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.as_ref()).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("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("a");
let b = ServerId::from("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("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("a");
let b = ServerId::from("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("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.as_ref()).is_some());
let b_oldest: Uri = Uri::from("file:///b/file0.rs");
assert!(
cache.diagnostics(b_oldest.as_ref()).is_none(),
"the largest in-share server (tie-broken to b) must lose its oldest entry"
);
assert!(
cache.diagnostics("file:///a/file0.rs").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("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.as_ref()).unwrap();
assert_eq!(stored.version, Some(max_version - 1));
}
#[test]
fn test_store_diagnostics_reassigns_ownership() {
let mut cache = NotificationCache::new();
let old_owner = ServerId::from("old");
let new_owner = ServerId::from("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.as_ref()).unwrap();
assert_eq!(stored.version, 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.as_ref()).is_some());
}
#[test]
fn test_diagnostics_owner_returns_publisher_after_store() {
let mut cache = NotificationCache::new();
let server = ServerId::from("rust");
let uri: Uri = Uri::from("file:///main.rs");
cache.store_diagnostics(&server, &uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_owner(uri.as_ref()), 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.as_ref()), None);
}
#[test]
fn test_diagnostics_owner_reflects_reassigned_ownership() {
let mut cache = NotificationCache::new();
let old_owner = ServerId::from("old");
let new_owner = ServerId::from("new");
let uri: Uri = Uri::from("file:///test.rs");
cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&old_owner));
cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&new_owner));
}
#[test]
fn test_clear_server_diagnostics_scopes_to_one_server() {
let mut cache = NotificationCache::new();
let crashed = ServerId::from("crashed");
let healthy = ServerId::from("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.as_ref()).is_none());
assert!(cache.diagnostics(healthy_uri.as_ref()).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("degraded");
let healthy = ServerId::from("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("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("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.as_ref()).is_some());
let server_oldest: Uri = Uri::from("file:///file0.rs");
assert!(
cache.diagnostics(server_oldest.as_ref()).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("noisy");
let quiet = ServerId::from("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.as_ref()).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.as_ref()).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.as_ref()).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.as_ref()).is_none(),
"the oldest empty entry must be evicted instead of the older non-empty one"
);
assert!(cache.diagnostics(overflow.as_ref()).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.as_ref());
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("a"); let b = ServerId::from("b"); let c = ServerId::from("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.as_ref()).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.as_ref()).is_none(),
"b's oldest empty entry must be evicted instead of a's real diagnostics"
);
assert!(cache.diagnostics(overflow.as_ref()).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.as_ref());
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);
assert_eq!(cache.indexing_state(&server), IndexingState::Unknown);
}
}