use crate::{
lsp::{
DiagnosticSeverity, DocumentUri, DocumentVersion, LspRange, MAX_INJECTED_DIAGNOSTICS,
MAX_INJECTED_DIAGNOSTICS_BYTES,
},
output::redact_sensitive_text,
};
use serde::Deserialize;
use std::{collections::BTreeMap, path::Path, time::Instant};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LspDiagnostic {
pub(crate) range: LspRange,
pub(crate) severity: Option<DiagnosticSeverity>,
#[serde(default, deserialize_with = "deserialize_diagnostic_code")]
pub(crate) code: Option<String>,
pub(crate) source: Option<String>,
pub(crate) message: String,
}
#[derive(Debug, Clone)]
pub(crate) struct DiagnosticSnapshot {
pub(crate) version: Option<DocumentVersion>,
pub(crate) received_at: Instant,
pub(crate) diagnostics: Vec<LspDiagnostic>,
}
#[derive(Debug, Default, Clone)]
pub(crate) struct DiagnosticsStore {
by_uri: BTreeMap<DocumentUri, DiagnosticSnapshot>,
}
impl DiagnosticsStore {
pub(crate) fn update(
&mut self,
uri: DocumentUri,
version: Option<DocumentVersion>,
diagnostics: Vec<LspDiagnostic>,
received_at: Instant,
) {
self.by_uri.insert(
uri,
DiagnosticSnapshot {
version,
received_at,
diagnostics,
},
);
}
pub(crate) fn fresh_for(
&self,
uri: &DocumentUri,
min_version: DocumentVersion,
sync_started_at: Instant,
) -> Option<&DiagnosticSnapshot> {
let snapshot = self.by_uri.get(uri)?;
match snapshot.version {
Some(version) if version >= min_version => Some(snapshot),
Some(_) => None,
None if snapshot.received_at >= sync_started_at => Some(snapshot),
None => None,
}
}
}
pub(crate) fn format_injected_diagnostics(
server: &str,
path: &Path,
diagnostics: &[LspDiagnostic],
) -> String {
let mut selected = diagnostics
.iter()
.filter(|diagnostic| {
matches!(
diagnostic.severity,
Some(DiagnosticSeverity::Error | DiagnosticSeverity::Warning)
)
})
.collect::<Vec<_>>();
if selected.is_empty() {
return String::new();
}
selected.sort_by_key(|diagnostic| diagnostic.severity.unwrap_or(DiagnosticSeverity::Hint));
let shown = selected.len().min(MAX_INJECTED_DIAGNOSTICS);
let mut output = format!("DIAGNOSTICS ({}, {}):", server, path.display());
let mut omitted = selected.len() - shown;
for diagnostic in selected.iter().take(shown) {
let severity = match diagnostic.severity {
Some(DiagnosticSeverity::Error) => "error",
Some(DiagnosticSeverity::Warning) => "warning",
_ => continue,
};
let source = diagnostic
.source
.as_deref()
.map(|source| format!(" [{source}]"))
.unwrap_or_default();
let code = diagnostic
.code
.as_deref()
.map(|code| format!(" {code}"))
.unwrap_or_default();
let line = format!(
"\n- {severity}{}{} at {}:{}: {}",
source,
code,
diagnostic.range.start.line + 1,
diagnostic.range.start.character + 1,
diagnostic.message.replace(['\r', '\n'], " ")
);
if output.len() + line.len() > MAX_INJECTED_DIAGNOSTICS_BYTES {
omitted += 1;
break;
}
output.push_str(&line);
}
if omitted > 0 {
let line = format!("\n... truncated {omitted} diagnostics");
push_utf8_capped(&mut output, &line, MAX_INJECTED_DIAGNOSTICS_BYTES);
}
redact_sensitive_text(&output)
}
fn push_utf8_capped(output: &mut String, line: &str, max_bytes: usize) {
let remaining = max_bytes.saturating_sub(output.len());
if remaining == 0 {
return;
}
if line.len() <= remaining {
output.push_str(line);
return;
}
let end = line
.char_indices()
.map(|(index, _)| index)
.take_while(|index| *index <= remaining)
.last()
.unwrap_or(0);
output.push_str(&line[..end]);
}
fn deserialize_diagnostic_code<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
return Ok(None);
};
Ok(match value {
serde_json::Value::String(value) => Some(value),
serde_json::Value::Number(value) => Some(value.to_string()),
other => Some(other.to_string()),
})
}