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 latest_for(&self, uri: &DocumentUri) -> Option<&DiagnosticSnapshot> {
self.by_uri.get(uri)
}
pub(crate) fn summary(&self, limit: usize) -> DiagnosticsSummary {
let total_files = self.by_uri.len();
let files = self
.by_uri
.iter()
.take(limit)
.map(|(uri, snapshot)| FileDiagnosticsSummary {
uri: uri.clone(),
error_count: snapshot
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == Some(DiagnosticSeverity::Error))
.count(),
warning_count: snapshot
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == Some(DiagnosticSeverity::Warning))
.count(),
total_count: snapshot.diagnostics.len(),
})
.collect();
DiagnosticsSummary { files, total_files }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DiagnosticsSummary {
pub(crate) files: Vec<FileDiagnosticsSummary>,
pub(crate) total_files: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FileDiagnosticsSummary {
pub(crate) uri: DocumentUri,
pub(crate) error_count: usize,
pub(crate) warning_count: usize,
pub(crate) total_count: usize,
}
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)
}
pub(crate) fn format_diagnostics_tool(path: Option<&Path>, summary: DiagnosticsSummary) -> String {
if summary.files.is_empty() {
return redact_sensitive_text(&match path {
Some(path) => format!(
"no cached diagnostics for {}; run after edit or use project checks",
path.display()
),
None => "no cached LSP diagnostics; run after edit or use project checks".to_string(),
});
}
let mut lines = Vec::new();
for file in &summary.files {
let path = file
.uri
.to_file_path()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| file.uri.as_str().to_string());
lines.push(format!(
"{}: {} diagnostics ({} errors, {} warnings)",
path, file.total_count, file.error_count, file.warning_count
));
}
if summary.total_files > summary.files.len() {
lines.push(format!(
"... truncated {} files",
summary.total_files - summary.files.len()
));
}
redact_sensitive_text(&lines.join("\n"))
}
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()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp::LspPosition;
use serde_json::json;
use std::time::Duration;
fn diagnostic(message: &str) -> LspDiagnostic {
LspDiagnostic {
range: LspRange {
start: LspPosition {
line: 0,
character: 0,
},
end: LspPosition {
line: 0,
character: 1,
},
},
severity: Some(DiagnosticSeverity::Error),
code: Some("E1".to_string()),
source: Some("fake".to_string()),
message: message.to_string(),
}
}
#[test]
fn publish_diagnostics_fixture_parses() {
let parsed: LspDiagnostic = serde_json::from_value(json!({
"range": {"start": {"line": 1, "character": 2}, "end": {"line": 3, "character": 4}},
"severity": 1,
"code": 123,
"source": "rust-analyzer",
"message": "expected type"
}))
.unwrap();
assert_eq!(parsed.severity, Some(DiagnosticSeverity::Error));
assert_eq!(parsed.code.as_deref(), Some("123"));
assert_eq!(parsed.range.start.line, 1);
}
#[test]
fn diagnostics_store_tracks_version_freshness() {
let mut store = DiagnosticsStore::default();
let uri = DocumentUri("file:///tmp/a.rs".to_string());
let sync_started = Instant::now();
store.update(
uri.clone(),
Some(DocumentVersion(1)),
vec![diagnostic("old")],
sync_started + Duration::from_millis(1),
);
assert!(
store
.fresh_for(&uri, DocumentVersion(2), sync_started)
.is_none()
);
assert!(
store
.fresh_for(&uri, DocumentVersion(1), sync_started)
.is_some()
);
}
#[test]
fn diagnostics_store_accepts_unversioned_after_sync() {
let mut store = DiagnosticsStore::default();
let uri = DocumentUri("file:///tmp/a.rs".to_string());
let sync_started = Instant::now();
store.update(
uri.clone(),
None,
vec![diagnostic("fresh")],
sync_started + Duration::from_millis(1),
);
assert!(
store
.fresh_for(&uri, DocumentVersion(9), sync_started)
.is_some()
);
}
#[test]
fn diagnostics_store_rejects_unversioned_before_sync() {
let mut store = DiagnosticsStore::default();
let uri = DocumentUri("file:///tmp/a.rs".to_string());
let sync_started = Instant::now();
store.update(
uri.clone(),
None,
vec![diagnostic("stale")],
sync_started - Duration::from_millis(1),
);
assert!(
store
.fresh_for(&uri, DocumentVersion(9), sync_started)
.is_none()
);
}
#[test]
fn injected_diagnostics_orders_errors_warnings_caps_and_redacts() {
let mut warning = diagnostic("warning token sk-testsecret000000000000000000000000");
warning.severity = Some(DiagnosticSeverity::Warning);
let error = diagnostic("error before warning");
let output = format_injected_diagnostics(
"rust-analyzer",
Path::new("src/lib.rs"),
&[warning, error],
);
assert!(output.starts_with("DIAGNOSTICS (rust-analyzer, src/lib.rs):"));
assert!(
output.find("error before warning") < output.find("warning token").or(Some(usize::MAX))
);
assert!(output.contains("<redacted>"));
assert!(!output.contains("sk-testsecret"));
}
#[test]
fn injected_diagnostics_caps_at_utf8_boundary() {
let long = diagnostic(&"é".repeat(3000));
let output = format_injected_diagnostics("server", Path::new("a.rs"), &[long]);
assert!(output.len() <= crate::lsp::MAX_INJECTED_DIAGNOSTICS_BYTES);
assert!(output.is_char_boundary(output.len()));
}
}