use lsp_types::{Diagnostic as LspDiagnostic, Uri};
use tracing::warn;
use super::{ReportedSeverity, message_as_str};
use crate::util::{truncate_str, truncate_string};
pub(super) const MAX_ENTRY_TEXT_BYTES: usize = 256 * 1024;
pub(super) const MAX_DIAGNOSTICS_ENTRY_BYTES: usize = 1024 * 1024;
#[derive(Debug)]
pub struct BoundedDiagnostics {
list: Vec<LspDiagnostic>,
bytes: usize,
}
impl BoundedDiagnostics {
pub(crate) fn into_vec(self) -> Vec<LspDiagnostic> {
self.list
}
pub(crate) fn as_slice(&self) -> &[LspDiagnostic] {
&self.list
}
pub(crate) const fn buffered_bytes(&self) -> usize {
self.bytes
}
#[cfg(test)]
pub(super) fn unbounded(list: Vec<LspDiagnostic>) -> Self {
let bytes = serialized_len(&list).unwrap_or(MAX_DIAGNOSTICS_ENTRY_BYTES);
Self { list, bytes }
}
pub(crate) fn new(uri: &Uri, mut diagnostics: Vec<LspDiagnostic>) -> Self {
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,
);
}
let bytes = cap_diagnostics_entry_size(uri, &mut diagnostics);
Self {
list: diagnostics,
bytes,
}
}
}
const DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES: usize = 256;
const JSON_ESCAPE_WORST_CASE_FACTOR: usize = 6;
pub(super) const DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES: usize = 1024;
fn largest_fitting_prefix(
diagnostics: &[LspDiagnostic],
fits: impl Fn(&[LspDiagnostic]) -> bool,
) -> usize {
let (mut lo, mut hi) = (0usize, diagnostics.len());
#[allow(
clippy::arithmetic_side_effects,
clippy::indexing_slicing,
reason = "binary search over 0..=len: lo < hi gives mid in 1..=hi"
)]
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if fits(&diagnostics[..mid]) {
lo = mid;
} else {
hi = mid - 1;
}
}
lo
}
pub(super) 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 cheap_size_estimate(diagnostics: &[LspDiagnostic]) -> Option<usize> {
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()
});
cheaply_estimable.then(|| {
diagnostics
.iter()
.map(|d| {
let raw_string_bytes = message_as_str(&d.message)
.len()
.saturating_add(d.source.as_deref().map_or(0, str::len))
.saturating_add(match &d.code {
Some(lsp_types::Code::String(s)) => s.len(),
_ => 0,
});
raw_string_bytes
.saturating_mul(JSON_ESCAPE_WORST_CASE_FACTOR)
.saturating_add(DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES)
})
.sum()
})
}
fn serialized_len(diagnostics: &[LspDiagnostic]) -> Option<usize> {
serde_json::to_vec(diagnostics)
.ok()
.map(|bytes| bytes.len())
}
pub(super) fn cap_diagnostics_entry_size(uri: &Uri, diagnostics: &mut Vec<LspDiagnostic>) -> usize {
let measured_fit = |ds: &[LspDiagnostic]| {
serialized_len(ds).filter(|&len| len <= MAX_DIAGNOSTICS_ENTRY_BYTES)
};
let fits = |ds: &[LspDiagnostic]| measured_fit(ds).is_some();
if let Some(estimated) = cheap_size_estimate(diagnostics)
.filter(|&estimated| estimated <= MAX_DIAGNOSTICS_ENTRY_BYTES)
{
return estimated;
}
if let Some(measured) = measured_fit(diagnostics) {
return measured;
}
let original_count = diagnostics.len();
diagnostics.sort_by_key(ReportedSeverity::of);
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) {
#[allow(clippy::indexing_slicing, reason = "len == 1 checked above")]
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) {
#[allow(clippy::indexing_slicing, reason = "len == 1 checked above")]
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 let Some(measured) = measured_fit(diagnostics) {
return measured;
}
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(),
);
serialized_len(diagnostics).unwrap_or(MAX_DIAGNOSTICS_ENTRY_BYTES)
}