use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
use crate::linter::diagnostic::{Diagnostic, Severity};
use crate::text::LineIndex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Pretty,
Concise,
Json,
}
pub fn render_findings(
diagnostics: &[Diagnostic],
mode: OutputMode,
use_color: bool,
source_for: &dyn Fn(Option<&Path>) -> Option<String>,
) -> String {
match mode {
OutputMode::Json => {
serde_json::to_string_pretty(diagnostics).unwrap_or_else(|_| "[]".to_string())
}
OutputMode::Concise => render_concise(diagnostics, source_for),
OutputMode::Pretty => render_pretty(diagnostics, use_color, source_for),
}
}
fn render_concise(
diagnostics: &[Diagnostic],
source_for: &dyn Fn(Option<&Path>) -> Option<String>,
) -> String {
let mut out = String::new();
for diag in diagnostics {
let path = diag.path.as_deref();
let (line, column) = match source_for(path) {
Some(text) => {
let lc = LineIndex::new(&text).byte_to_lc(diag.start);
(lc.line, lc.column)
}
None => (0, 0),
};
let location = path
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<stdin>".to_string());
let _ = writeln!(
out,
"{location}:{line}:{column}: {}[{}] {}",
diag.severity.label(),
diag.rule,
diag.message
);
}
out
}
fn render_pretty(
diagnostics: &[Diagnostic],
use_color: bool,
source_for: &dyn Fn(Option<&Path>) -> Option<String>,
) -> String {
let renderer = if use_color {
Renderer::styled()
} else {
Renderer::plain()
};
let mut by_path: BTreeMap<Option<&PathBuf>, Vec<&Diagnostic>> = BTreeMap::new();
for d in diagnostics {
by_path.entry(d.path.as_ref()).or_default().push(d);
}
let mut out = String::new();
for (path, mut diags) in by_path {
diags.sort_by_key(|d| (d.start, d.end));
let origin = path
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<stdin>".to_string());
let Some(source) = source_for(path.map(PathBuf::as_path)) else {
for d in &diags {
let _ = writeln!(
out,
"{origin}: {}[{}] {}",
d.severity.label(),
d.rule,
d.message
);
}
continue;
};
for d in &diags {
let snippet = Snippet::source(&source).path(&origin).annotation(
AnnotationKind::Primary
.span(d.start..d.end)
.label(&d.message),
);
let group = severity_level(d.severity)
.primary_title(d.rule.as_str())
.element(snippet);
let rendered = renderer.render(&[group]);
let _ = writeln!(out, "{rendered}");
for fix in &d.fixes {
let _ = writeln!(out, " = help: {}", fix.description);
}
}
}
out
}
fn severity_level(s: Severity) -> Level<'static> {
match s {
Severity::Error => Level::ERROR,
Severity::Warning => Level::WARNING,
Severity::Info => Level::INFO,
Severity::Hint => Level::HELP,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::diagnostic::{Applicability, Fix};
fn warning(start: usize, end: usize, rule: &str, message: &str) -> Diagnostic {
Diagnostic {
path: None,
start,
end,
rule: rule.to_string(),
severity: Severity::Warning,
message: message.to_string(),
fixes: Vec::new(),
suppressed: false,
}
}
#[test]
fn pretty_draws_snippet_with_rule_and_source() {
let src = "x = 1\ny = 2\n";
let diag = warning(0, 1, "unused-binding", "`x` is never used");
let out = render_findings(&[diag], OutputMode::Pretty, false, &|_| {
Some(src.to_string())
});
assert!(out.contains("unused-binding"), "missing rule title:\n{out}");
assert!(out.contains("x = 1"), "missing source line:\n{out}");
assert!(out.contains('^'), "missing caret underline:\n{out}");
assert!(out.contains("<stdin>"), "missing origin:\n{out}");
}
#[test]
fn pretty_sorts_by_offset() {
let src = "a\nbb\nccc\n";
let later = warning(5, 8, "later", "later finding");
let earlier = warning(0, 1, "earlier", "earlier finding");
let out = render_findings(&[later, earlier], OutputMode::Pretty, false, &|_| {
Some(src.to_string())
});
let e = out.find("earlier").expect("earlier rendered");
let l = out.find("later").expect("later rendered");
assert!(e < l, "diagnostics not sorted by offset:\n{out}");
}
#[test]
fn pretty_color_flag_toggles_ansi() {
let src = "x = 1\n";
let diag = warning(0, 1, "rule", "msg");
let styled = render_findings(
std::slice::from_ref(&diag),
OutputMode::Pretty,
true,
&|_| Some(src.to_string()),
);
let plain = render_findings(
std::slice::from_ref(&diag),
OutputMode::Pretty,
false,
&|_| Some(src.to_string()),
);
assert!(
styled.contains('\u{1b}'),
"styled output lacks ANSI:\n{styled}"
);
assert!(!plain.contains('\u{1b}'), "plain output has ANSI:\n{plain}");
}
#[test]
fn pretty_shows_fix_as_help_note() {
let src = "x = 1\n";
let mut diag = warning(2, 3, "assign-in-cond", "use `==`");
diag.fixes.push(Fix {
description: "Change `=` to `==`".to_string(),
content: "==".to_string(),
start: 2,
end: 3,
applicability: Applicability::Safe,
});
let out = render_findings(&[diag], OutputMode::Pretty, false, &|_| {
Some(src.to_string())
});
assert!(
out.contains("= help: Change `=` to `==`"),
"missing fix help note:\n{out}"
);
}
#[test]
fn pretty_falls_back_when_source_missing() {
let diag = warning(0, 1, "some-rule", "some message");
let out = render_findings(&[diag], OutputMode::Pretty, false, &|_| None);
assert_eq!(out, "<stdin>: warning[some-rule] some message\n");
}
#[test]
fn concise_format_is_stable() {
let src = "x = 1\n";
let diag = warning(0, 1, "unused-binding", "`x` is never used");
let out = render_findings(&[diag], OutputMode::Concise, false, &|_| {
Some(src.to_string())
});
assert_eq!(
out,
"<stdin>:1:1: warning[unused-binding] `x` is never used\n"
);
}
}