use std::collections::HashMap;
use std::path::{Path, PathBuf};
use lsp_types::{Diagnostic, DiagnosticSeverity};
const MAX_PER_FILE: usize = 8;
const FLOOD_THRESHOLD: usize = 40;
const MAX_PROJECT_DIAGNOSTICS_FILES: usize = 5;
pub fn pretty(d: &Diagnostic) -> String {
let severity = match d.severity {
Some(DiagnosticSeverity::ERROR) => "ERROR",
Some(DiagnosticSeverity::WARNING) => "WARN",
Some(DiagnosticSeverity::INFORMATION) => "INFO",
Some(DiagnosticSeverity::HINT) => "HINT",
_ => "ERROR",
};
let line = d.range.start.line.saturating_add(1);
let col = d.range.start.character.saturating_add(1);
format!("{severity} [{line}:{col}] {}", d.message)
}
pub fn report(file: &str, issues: &[Diagnostic]) -> Option<String> {
let errors: Vec<&Diagnostic> = issues
.iter()
.filter(|d| d.severity == Some(DiagnosticSeverity::ERROR))
.collect();
if errors.is_empty() {
return None;
}
let total = errors.len();
if total > FLOOD_THRESHOLD {
let preview: Vec<String> = errors.iter().take(3).map(|d| pretty(d)).collect();
return Some(format!(
"<diagnostics file=\"{file}\">\n\
{total} errors reported — too many to be from a single edit. The language server may \
not fully support this file, or it has broad pre-existing issues; not enumerating. \
Fix the root cause rather than chasing each line. First few:\n{}\n</diagnostics>",
preview.join("\n")
));
}
let limited = errors.iter().take(MAX_PER_FILE);
let mut body: Vec<String> = limited.map(|d| pretty(d)).collect();
if total > MAX_PER_FILE {
body.push(format!("... and {} more", total - MAX_PER_FILE));
}
Some(format!(
"<diagnostics file=\"{file}\">\n{}\n</diagnostics>",
body.join("\n")
))
}
pub fn build_report_block(
current_file: &Path,
all_diagnostics: &HashMap<PathBuf, Vec<Diagnostic>>,
) -> String {
let current_canonical = current_file
.canonicalize()
.unwrap_or_else(|_| current_file.to_path_buf());
let mut out = String::new();
if let Some(issues) = lookup_diagnostics(¤t_canonical, all_diagnostics)
&& let Some(block) = report(¤t_file.display().to_string(), issues)
{
out.push_str("\n\nLSP errors detected in this file, please fix:\n");
out.push_str(&block);
}
let mut other_paths: Vec<&PathBuf> = all_diagnostics
.keys()
.filter(|p| {
p.canonicalize()
.map(|c| c != current_canonical)
.unwrap_or(p.as_path() != current_canonical)
})
.collect();
other_paths.sort();
let mut other_count = 0;
for path in other_paths {
if other_count >= MAX_PROJECT_DIAGNOSTICS_FILES {
break;
}
let Some(issues) = all_diagnostics.get(path) else {
continue;
};
let Some(block) = report(&path.display().to_string(), issues) else {
continue;
};
if other_count == 0 {
out.push_str("\n\nLSP errors detected in other files:\n");
} else {
out.push('\n');
}
out.push_str(&block);
other_count += 1;
}
out
}
fn lookup_diagnostics<'a>(
target: &Path,
map: &'a HashMap<PathBuf, Vec<Diagnostic>>,
) -> Option<&'a Vec<Diagnostic>> {
if let Some(v) = map.get(target) {
return Some(v);
}
for (k, v) in map.iter() {
if k.canonicalize().map(|c| c == target).unwrap_or(k == target) {
return Some(v);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{NumberOrString, Position, Range};
fn diag(severity: DiagnosticSeverity, line: u32, col: u32, msg: &str) -> Diagnostic {
Diagnostic {
range: Range {
start: Position {
line,
character: col,
},
end: Position {
line,
character: col,
},
},
severity: Some(severity),
code: Some(NumberOrString::String("E0001".to_string())),
code_description: None,
source: Some("rustc".to_string()),
message: msg.to_string(),
related_information: None,
tags: None,
data: None,
}
}
#[test]
fn pretty_uses_severity_label_and_one_based_coordinates() {
let d = diag(DiagnosticSeverity::ERROR, 4, 2, "type mismatch");
assert_eq!(pretty(&d), "ERROR [5:3] type mismatch");
}
#[test]
fn pretty_handles_all_severities() {
for (sev, label) in [
(DiagnosticSeverity::ERROR, "ERROR"),
(DiagnosticSeverity::WARNING, "WARN"),
(DiagnosticSeverity::INFORMATION, "INFO"),
(DiagnosticSeverity::HINT, "HINT"),
] {
assert!(pretty(&diag(sev, 0, 0, "x")).starts_with(label));
}
}
#[test]
fn regression_missing_severity_defaults_to_error() {
let mut d = diag(DiagnosticSeverity::ERROR, 0, 0, "x");
d.severity = None;
assert!(pretty(&d).starts_with("ERROR"));
}
#[test]
fn report_returns_none_for_no_errors() {
assert!(report("x.rs", &[]).is_none());
let warnings = vec![diag(DiagnosticSeverity::WARNING, 0, 0, "unused")];
assert!(report("x.rs", &warnings).is_none());
}
#[test]
fn regression_report_filters_to_errors_only() {
let issues = vec![
diag(DiagnosticSeverity::ERROR, 0, 0, "real error"),
diag(DiagnosticSeverity::WARNING, 1, 0, "unused"),
diag(DiagnosticSeverity::INFORMATION, 2, 0, "fyi"),
diag(DiagnosticSeverity::HINT, 3, 0, "consider"),
];
let block = report("x.rs", &issues).unwrap();
assert!(block.contains("real error"));
assert!(!block.contains("unused"));
assert!(!block.contains("fyi"));
assert!(!block.contains("consider"));
}
#[test]
fn report_wraps_in_diagnostics_tags() {
let block = report("/tmp/x.rs", &[diag(DiagnosticSeverity::ERROR, 0, 0, "msg")]).unwrap();
assert!(block.starts_with("<diagnostics file=\"/tmp/x.rs\">\n"));
assert!(block.ends_with("</diagnostics>"));
}
#[test]
fn regression_report_caps_at_max_per_file_with_overflow_footer() {
let issues: Vec<Diagnostic> = (0..MAX_PER_FILE + 7)
.map(|i| diag(DiagnosticSeverity::ERROR, i as u32, 0, &format!("err {i}")))
.collect();
let block = report("x.rs", &issues).unwrap();
let line_count = block.lines().count();
assert_eq!(line_count, MAX_PER_FILE + 3);
assert!(block.contains("... and 7 more"));
assert!(block.contains("err 0"));
assert!(block.contains(&format!("err {}", MAX_PER_FILE - 1)));
assert!(!block.contains("err 25"));
}
#[test]
fn report_floods_collapse_to_summary() {
let issues: Vec<Diagnostic> = (0..FLOOD_THRESHOLD + 200)
.map(|i| {
diag(
DiagnosticSeverity::ERROR,
i as u32,
0,
"Unresolved symbol: X",
)
})
.collect();
let block = report("core.janet", &issues).unwrap();
let total = FLOOD_THRESHOLD + 200;
assert!(block.contains(&format!("{total} errors reported")));
assert!(block.contains("not enumerating"));
assert!(block.contains("root cause"));
assert!(
block.lines().count() < 8,
"flood must stay compact: {block}"
);
assert!(!block.contains("... and"));
}
#[test]
fn report_below_cap_has_no_overflow_footer() {
let issues = vec![diag(DiagnosticSeverity::ERROR, 0, 0, "one"); 3];
let block = report("x.rs", &issues).unwrap();
assert!(!block.contains("and") && !block.contains("more"));
}
#[test]
fn build_report_block_returns_empty_when_no_diagnostics() {
let block = build_report_block(Path::new("/tmp/x.rs"), &HashMap::new());
assert_eq!(block, "");
}
#[test]
fn build_report_block_emits_current_file_section() {
let path = PathBuf::from("/tmp/edited.rs");
let mut map = HashMap::new();
map.insert(
path.clone(),
vec![diag(DiagnosticSeverity::ERROR, 0, 0, "bad type")],
);
let block = build_report_block(&path, &map);
assert!(block.contains("errors detected in this file"));
assert!(block.contains("bad type"));
assert!(!block.contains("errors detected in other files"));
}
#[test]
fn build_report_block_emits_other_files_section_when_relevant() {
let current = PathBuf::from("/tmp/a.rs");
let other = PathBuf::from("/tmp/b.rs");
let mut map = HashMap::new();
map.insert(
other.clone(),
vec![diag(DiagnosticSeverity::ERROR, 0, 0, "downstream break")],
);
let block = build_report_block(¤t, &map);
assert!(!block.contains("errors detected in this file"));
assert!(block.contains("errors detected in other files"));
assert!(block.contains("downstream break"));
}
#[test]
fn regression_build_report_block_caps_other_files() {
let current = PathBuf::from("/tmp/current.rs");
let mut map = HashMap::new();
for i in 0..MAX_PROJECT_DIAGNOSTICS_FILES + 5 {
let p = PathBuf::from(format!("/tmp/other{i:02}.rs"));
map.insert(
p,
vec![diag(
DiagnosticSeverity::ERROR,
0,
0,
&format!("err in {i}"),
)],
);
}
let block = build_report_block(¤t, &map);
let other_blocks = block.matches("<diagnostics file=").count();
assert_eq!(
other_blocks, MAX_PROJECT_DIAGNOSTICS_FILES,
"must cap at MAX_PROJECT_DIAGNOSTICS_FILES, got {other_blocks}"
);
}
#[test]
fn regression_warning_only_files_do_not_appear() {
let current = PathBuf::from("/tmp/main.rs");
let warn_only = PathBuf::from("/tmp/warn.rs");
let bad = PathBuf::from("/tmp/bad.rs");
let mut map = HashMap::new();
map.insert(
warn_only.clone(),
vec![diag(DiagnosticSeverity::WARNING, 0, 0, "unused")],
);
map.insert(
bad.clone(),
vec![diag(DiagnosticSeverity::ERROR, 0, 0, "real")],
);
let block = build_report_block(¤t, &map);
assert!(!block.contains("unused"));
assert!(block.contains("real"));
}
#[test]
fn regression_current_file_section_preserves_caller_path() {
let tmp = std::env::temp_dir().join(format!(
"dirge-diag-path-test-{}-{}.rs",
std::process::id(),
crate::time_util::now_unix_nanos()
));
std::fs::write(&tmp, "// test\n").unwrap();
let canonical = tmp.canonicalize().unwrap();
let mut map = HashMap::new();
map.insert(canonical, vec![diag(DiagnosticSeverity::ERROR, 0, 0, "x")]);
let block = build_report_block(&tmp, &map);
assert!(
block.contains(&tmp.display().to_string()),
"expected caller path {} in: {block}",
tmp.display()
);
std::fs::remove_file(&tmp).ok();
}
#[test]
fn current_file_matches_through_canonicalize() {
let tmp = std::env::temp_dir().join(format!(
"dirge-diagnostic-test-{}-{}.rs",
std::process::id(),
crate::time_util::now_unix_nanos()
));
std::fs::write(&tmp, "// test\n").unwrap();
let canonical = tmp.canonicalize().unwrap();
let mut map = HashMap::new();
map.insert(
canonical.clone(),
vec![diag(DiagnosticSeverity::ERROR, 0, 0, "x")],
);
let block = build_report_block(&tmp, &map);
assert!(
block.contains("errors detected in this file"),
"expected current-file section; got: {block}"
);
std::fs::remove_file(&tmp).ok();
}
}