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::{Applicability, Diagnostic, Severity};
use crate::linter::docs::rule_doc_url;
use crate::linter::rules::is_shipped_rule;
use crate::text::LineIndex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Pretty,
Concise,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
pub mode: OutputMode,
pub use_color: bool,
pub rule_links: bool,
}
impl RenderOptions {
pub fn new(mode: OutputMode, use_color: bool) -> Self {
Self {
mode,
use_color,
rule_links: true,
}
}
pub fn without_rule_links(mut self) -> Self {
self.rule_links = false;
self
}
}
pub fn render_findings(
diagnostics: &[Diagnostic],
options: RenderOptions,
source_for: &dyn Fn(Option<&Path>) -> Option<String>,
) -> String {
match options.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, options, 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.range.start().into());
(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.body
);
}
out
}
fn render_pretty(
diagnostics: &[Diagnostic],
options: RenderOptions,
source_for: &dyn Fn(Option<&Path>) -> Option<String>,
) -> String {
let renderer = if options.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.range.start(), d.range.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.body
);
}
continue;
};
for d in &diags {
let snippet = Snippet::source(&source).path(&origin).annotation(
AnnotationKind::Primary
.span(usize::from(d.range.start())..usize::from(d.range.end()))
.label(&d.message.body),
);
let group = severity_level(d.severity)
.primary_title(d.rule)
.element(snippet);
let rendered = renderer.render(&[group]);
let _ = writeln!(out, "{rendered}");
if let Some(suggestion) = &d.message.suggestion {
let _ = writeln!(out, " = help: {suggestion}");
}
for fix in &d.fixes {
let _ = writeln!(
out,
" = help: {} ({})",
fix.description,
fix_note(fix.applicability)
);
}
if options.rule_links && is_shipped_rule(d.rule) {
let _ = writeln!(
out,
" = help: for further information visit {}",
rule_doc_url(d.rule)
);
}
}
}
out
}
fn fix_note(applicability: Applicability) -> &'static str {
match applicability {
Applicability::Safe => "safe fix",
Applicability::Unsafe => "unsafe fix, requires `--unsafe-fixes`",
}
}
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::Fix;
use rowan::TextRange;
fn pretty() -> RenderOptions {
RenderOptions::new(OutputMode::Pretty, false)
}
fn warning(start: u32, end: u32, rule: &'static str, message: &str) -> Diagnostic {
Diagnostic::new(rule, TextRange::new(start.into(), end.into()), message)
}
#[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], pretty(), &|_| 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], pretty(), &|_| 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),
RenderOptions::new(OutputMode::Pretty, true),
&|_| Some(src.to_string()),
);
let plain = render_findings(std::slice::from_ref(&diag), pretty(), &|_| {
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}");
}
fn diag_with_fix(applicability: Applicability) -> Diagnostic {
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,
});
diag
}
#[test]
fn pretty_shows_fix_as_help_note() {
let src = "x = 1\n";
let out = render_findings(&[diag_with_fix(Applicability::Safe)], pretty(), &|_| {
Some(src.to_string())
});
assert!(
out.contains("= help: Change `=` to `==` (safe fix)"),
"missing fix help note:\n{out}"
);
}
#[test]
fn pretty_marks_unsafe_fix_in_help_note() {
let src = "x = 1\n";
let out = render_findings(&[diag_with_fix(Applicability::Unsafe)], pretty(), &|_| {
Some(src.to_string())
});
assert!(
out.contains("= help: Change `=` to `==` (unsafe fix, requires `--unsafe-fixes`)"),
"unsafe fix not marked in help note:\n{out}"
);
}
#[test]
fn pretty_links_a_shipped_rule_to_its_reference_section() {
let src = "x = 1\n";
let diag = warning(0, 1, "unused-binding", "`x` is never used");
let out = render_findings(&[diag], pretty(), &|_| Some(src.to_string()));
assert!(
out.contains(
"= help: for further information visit \
https://fatou.dev/reference/rules.html#unused-binding"
),
"missing rule reference link:\n{out}"
);
}
#[test]
fn pretty_does_not_link_a_pseudo_rule() {
let src = "x = 1\n";
let diag = warning(0, 1, crate::linter::check::PARSE_ERROR_RULE, "boom");
let out = render_findings(&[diag], pretty(), &|_| Some(src.to_string()));
assert!(
!out.contains("for further information"),
"`parse-error` has no reference section to link:\n{out}"
);
}
#[test]
fn without_rule_links_drops_the_reference_link() {
let src = "x = 1\n";
let diag = warning(0, 1, "unused-binding", "`x` is never used");
let out = render_findings(&[diag], pretty().without_rule_links(), &|_| {
Some(src.to_string())
});
assert!(
out.contains("unused-binding"),
"finding not rendered:\n{out}"
);
assert!(
!out.contains("for further information"),
"rule link not suppressed:\n{out}"
);
}
#[test]
fn pretty_falls_back_when_source_missing() {
let diag = warning(0, 1, "some-rule", "some message");
let out = render_findings(&[diag], pretty(), &|_| 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],
RenderOptions::new(OutputMode::Concise, false),
&|_| Some(src.to_string()),
);
assert_eq!(
out,
"<stdin>:1:1: warning[unused-binding] `x` is never used\n"
);
}
}