use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
use crate::text::LineIndex;
use super::diagnostic::{Diagnostic, RelatedInfo, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputMode {
#[default]
Pretty,
Concise,
}
pub fn render_findings(
diagnostics: &[Diagnostic],
mode: OutputMode,
source_for: &dyn Fn(&Path) -> Option<String>,
) -> String {
match mode {
OutputMode::Pretty => render_pretty(diagnostics, source_for),
OutputMode::Concise => render_concise(diagnostics, source_for),
}
}
fn group_by_path(diagnostics: &[Diagnostic]) -> BTreeMap<&PathBuf, Vec<&Diagnostic>> {
let mut by_path: BTreeMap<&PathBuf, Vec<&Diagnostic>> = BTreeMap::new();
for d in diagnostics {
by_path.entry(&d.path).or_default().push(d);
}
by_path
}
fn render_pretty(
diagnostics: &[Diagnostic],
source_for: &dyn Fn(&Path) -> Option<String>,
) -> String {
let renderer = Renderer::plain();
let mut out = String::new();
for (path, diags) in group_by_path(diagnostics) {
let Some(source) = source_for(path) else {
for d in &diags {
let _ = writeln!(out, "{}", concise_line(path, None, d));
}
continue;
};
let origin = path.display().to_string();
for d in &diags {
let level = severity_level(d.severity);
let span = clamp_span(&source, d.start, d.end);
let (same_file, cross): (Vec<&RelatedInfo>, Vec<&RelatedInfo>) = d
.related
.iter()
.partition(|ri| ri.path.as_path() == path.as_path());
let extra: Vec<(String, String, &RelatedInfo)> = cross
.iter()
.filter_map(|ri| {
let src = source_for(&ri.path)?;
Some((ri.path.display().to_string(), src, *ri))
})
.collect();
let mut snippet = Snippet::source(&source)
.path(&origin)
.annotation(AnnotationKind::Primary.span(span).label(&d.message));
for ri in &same_file {
let s = clamp_span(&source, ri.start, ri.end);
snippet = snippet.annotation(AnnotationKind::Context.span(s).label(&ri.message));
}
let mut group = level.primary_title(d.rule).element(snippet);
for (origin2, src2, ri) in &extra {
let s = clamp_span(src2, ri.start, ri.end);
let secondary = Snippet::source(src2)
.path(origin2.as_str())
.annotation(AnnotationKind::Context.span(s).label(ri.message.as_str()));
group = group.element(secondary);
}
let _ = writeln!(out, "{}", renderer.render(&[group]));
}
}
out
}
fn render_concise(
diagnostics: &[Diagnostic],
source_for: &dyn Fn(&Path) -> Option<String>,
) -> String {
let mut out = String::new();
for (path, diags) in group_by_path(diagnostics) {
let source = source_for(path);
let index = source.as_deref().map(|s| (s, LineIndex::new(s)));
for d in &diags {
let resolved = index.as_ref().map(|(s, idx)| (*s, idx));
let _ = writeln!(out, "{}", concise_line(path, resolved, d));
}
}
out
}
fn concise_line(path: &Path, source: Option<(&str, &LineIndex)>, d: &Diagnostic) -> String {
let severity = severity_word(d.severity);
match source {
Some((text, index)) => {
let lc = index.line_col(text, d.start);
format!(
"{}:{}:{}: {severity} [{}] {}",
path.display(),
lc.line,
lc.column,
d.rule,
d.message,
)
}
None => format!("{}: {severity} [{}] {}", path.display(), d.rule, d.message),
}
}
fn clamp_span(source: &str, start: usize, end: usize) -> std::ops::Range<usize> {
let len = source.len();
let start = start.min(len);
let end = end.clamp(start, len);
start..end
}
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,
}
}
fn severity_word(s: Severity) -> &'static str {
match s {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Hint => "hint",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn diag(start: usize, end: usize, message: &str) -> Diagnostic {
Diagnostic {
rule: "parse",
severity: Severity::Error,
path: PathBuf::from("x.tex"),
start,
end,
message: message.to_owned(),
fix: None,
related: Vec::new(),
}
}
#[test]
fn concise_resolves_line_and_column() {
let source = "\\foo\n\\bar{".to_owned();
let diags = [diag(9, 10, "expected '}'")];
let rendered = render_findings(&diags, OutputMode::Concise, &|_| Some(source.clone()));
assert_eq!(rendered, "x.tex:2:5: error [parse] expected '}'\n");
}
#[test]
fn concise_without_source_omits_location() {
let diags = [diag(0, 1, "boom")];
let rendered = render_findings(&diags, OutputMode::Concise, &|_| None);
assert_eq!(rendered, "x.tex: error [parse] boom\n");
}
#[test]
fn pretty_includes_message_and_origin() {
let source = "\\foo{bar\n".to_owned();
let diags = [diag(4, 5, "unclosed group")];
let rendered = render_findings(&diags, OutputMode::Pretty, &|_| Some(source.clone()));
assert!(rendered.contains("unclosed group"), "got: {rendered}");
assert!(rendered.contains("x.tex"), "got: {rendered}");
}
#[test]
fn pretty_renders_same_file_related_as_context() {
let source = "\\label{a}\\label{a}\n".to_owned();
let mut d = diag(9, 18, "label `a` is defined more than once");
d.related.push(RelatedInfo {
path: PathBuf::from("x.tex"),
start: 7,
end: 8,
message: "first definition of `a`".to_owned(),
});
let rendered = render_findings(&[d], OutputMode::Pretty, &|_| Some(source.clone()));
assert!(
rendered.contains("defined more than once"),
"got: {rendered}"
);
assert!(
rendered.contains("first definition of `a`"),
"got: {rendered}"
);
}
#[test]
fn pretty_renders_cross_file_related_as_second_snippet() {
let main = "\\label{dup}\\ref{dup}\n".to_owned();
let chap = "\\label{dup}\n".to_owned();
let mut d = diag(0, 11, "label `dup` is also defined in `chap.tex`");
d.path = PathBuf::from("main.tex");
d.related.push(RelatedInfo {
path: PathBuf::from("chap.tex"),
start: 0,
end: 0,
message: "other definition of `dup`".to_owned(),
});
let rendered = render_findings(&[d], OutputMode::Pretty, &|p| match p.to_str() {
Some("main.tex") => Some(main.clone()),
Some("chap.tex") => Some(chap.clone()),
_ => None,
});
assert!(rendered.contains("main.tex"), "got: {rendered}");
assert!(rendered.contains("chap.tex"), "got: {rendered}");
assert!(
rendered.contains("other definition of `dup`"),
"got: {rendered}"
);
}
}