Skip to main content

mdbookkit/
diagnostics.rs

1//! Error reporting for preprocessors.
2
3use std::{
4    borrow::Borrow,
5    collections::BTreeMap,
6    fmt::{self, Write as _},
7    io::Write as _,
8};
9
10use miette::{
11    Diagnostic, GraphicalReportHandler, GraphicalTheme, LabeledSpan, MietteError,
12    MietteSpanContents, Severity, SourceCode, SourceSpan, SpanContents,
13};
14use owo_colors::Style;
15use tap::{Pipe, Tap, TapFallible};
16use tracing::{Level, debug, error, info, level_filters::LevelFilter, trace, warn};
17
18use crate::{
19    emit_debug,
20    env::{is_colored, is_logging},
21    error::{ExpectFmt, put_severity},
22    logging::stderr,
23};
24
25/// Trait for Markdown diagnostics. This will eventually be printed to stderr.
26///
27/// Each [`IssueItem`] represents a specific message, such as a warning, associated with
28/// an [`Issue`] (the type and severity of the issue) and a location in the Markdown
29/// source, represented by [`LabeledSpan`].
30pub trait IssueItem: Send + Sync {
31    type Kind: Issue;
32    fn issue(&self) -> Self::Kind;
33    fn label(&self) -> LabeledSpan;
34}
35
36/// Trait for diagnostics classes, like an error code.
37pub trait Issue: fmt::Debug + Default + Clone + Send + Sync {
38    fn title(&self) -> impl fmt::Display;
39    fn level(&self) -> Level;
40}
41
42/// A collection of [`IssueItem`]s associated with a Markdown file.
43pub struct Diagnostics<'a, K, P> {
44    text: &'a str,
45    name: K,
46    issues: Vec<P>,
47}
48
49impl<K, P> Diagnostics<'_, K, P>
50where
51    K: Title,
52    P: IssueItem,
53{
54    /// Render a report of the diagnostics using [miette]'s graphical reporting
55    pub fn to_report(&self) -> String {
56        let handler = if is_colored() {
57            GraphicalTheme::unicode()
58        } else {
59            GraphicalTheme::unicode_nocolor()
60        }
61        .tap_mut(|t| t.characters.error = "error:".into())
62        .tap_mut(|t| t.characters.warning = "warning:".into())
63        .tap_mut(|t| t.characters.advice = "info:".into())
64        .tap_mut(|t| t.styles.advice = Style::new().green().stderr())
65        .tap_mut(|t| t.styles.warning = Style::new().yellow().stderr())
66        .tap_mut(|t| t.styles.error = Style::new().red().stderr())
67        .tap_mut(|t| {
68            // pre-emptively specify colors for all diagnostics, just for this collection
69            // doing this because miette doesn't support associating colors with labels yet
70            t.styles.highlights = if is_colored() {
71                self.issues
72                    .iter()
73                    .map(|item| level_style(item.issue().level()))
74                    .collect()
75            } else {
76                vec![Style::new()]
77            }
78        })
79        .pipe(GraphicalReportHandler::new_themed);
80
81        let mut output = String::new();
82        handler.render_report(&mut output, self).expect_fmt();
83        output
84    }
85
86    pub fn to_traces(&self) {
87        for item in self.issues.iter() {
88            let issue = item.issue();
89            let label = item.label();
90            let source = self
91                .read_span(label.inner(), 0, 0)
92                .expect("self.read_span infallible");
93            let path = source.name().unwrap_or("<anonymous>");
94            let line = source.line() + 1;
95            let column = source.column() + 1;
96            let title = issue.title();
97            let level = issue.level();
98            let label = label.label().unwrap_or_default();
99            let message = format_args!("{path}:{line}:{column}: {title}");
100            let message = if label.is_empty() {
101                message
102            } else {
103                format_args!("{message}: {label}")
104            };
105            if level >= Level::TRACE {
106                trace!("{message}")
107            } else if level >= Level::DEBUG {
108                debug!("{message}")
109            } else if level >= Level::INFO {
110                info!("{message}")
111            } else if level >= Level::WARN {
112                warn!("{message}")
113            } else {
114                error!("{message}")
115            }
116        }
117    }
118}
119
120impl<'a, K, P> Diagnostics<'a, K, P>
121where
122    P: IssueItem,
123{
124    pub fn new(text: &'a str, name: K, issues: Vec<P>) -> Self {
125        Self { text, name, issues }
126    }
127
128    pub fn name(&self) -> &K {
129        &self.name
130    }
131
132    fn status(&self) -> P::Kind {
133        self.issues
134            .iter()
135            .map(|p| p.issue())
136            .min_by_key(|s| s.level())
137            .unwrap_or_default()
138    }
139}
140
141impl<K, P> Diagnostic for Diagnostics<'_, K, P>
142where
143    K: Title,
144    P: IssueItem,
145{
146    fn severity(&self) -> Option<Severity> {
147        match self.status().level() {
148            Level::ERROR => Some(Severity::Error),
149            Level::WARN => Some(Severity::Warning),
150            _ => Some(Severity::Advice),
151        }
152    }
153
154    fn source_code(&self) -> Option<&dyn SourceCode> {
155        Some(self)
156    }
157
158    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
159        Some(Box::new(self.issues.iter().map(|p| p.label())))
160    }
161
162    fn help(&self) -> Option<Box<dyn fmt::Display + '_>> {
163        // miette doesn't print the file name if there are no labels to report
164        // so we print it here
165        if self.issues.is_empty() {
166            Some(Box::new(format!("in {}", self.name)))
167        } else {
168            None
169        }
170    }
171}
172
173impl<K, P> SourceCode for Diagnostics<'_, K, P>
174where
175    K: Title,
176    P: Send + Sync,
177{
178    fn read_span<'a>(
179        &'a self,
180        span: &SourceSpan,
181        context_lines_before: usize,
182        context_lines_after: usize,
183    ) -> Result<Box<dyn SpanContents<'a> + 'a>, MietteError> {
184        let inner = self
185            .text
186            .read_span(span, context_lines_before, context_lines_after)?;
187        let contents = MietteSpanContents::new_named(
188            self.name.to_string(),
189            inner.data(),
190            *inner.span(),
191            inner.line(),
192            inner.column(),
193            inner.line_count(),
194        )
195        .with_language("markdown");
196        Ok(Box::new(contents))
197    }
198}
199
200impl<K, P: IssueItem> fmt::Debug for Diagnostics<'_, K, P> {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        fmt::Debug::fmt(&self.status(), f)
203    }
204}
205
206impl<K, P: IssueItem> fmt::Display for Diagnostics<'_, K, P> {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        fmt::Display::fmt(&self.status().title(), f)
209    }
210}
211
212impl<K, P: IssueItem> std::error::Error for Diagnostics<'_, K, P> {}
213
214/// Builder for printing diagnostics over multiple files.
215pub struct ReportBuilder<'a, K, P, F> {
216    items: Vec<Diagnostics<'a, K, P>>,
217    name_display: F,
218    level_filter: LevelFilter,
219}
220
221impl<'a, K, P, F> ReportBuilder<'a, K, P, F> {
222    pub fn new(items: Vec<Diagnostics<'a, K, P>>, name_display: F) -> Self {
223        Self {
224            items,
225            name_display,
226            level_filter: max_level(),
227        }
228    }
229
230    /// Specify how file names should be printed.
231    pub fn name_display<G>(self, name_display: G) -> ReportBuilder<'a, K, P, G>
232    where
233        G: for<'b> Fn(&'b K) -> String,
234    {
235        let Self {
236            items,
237            level_filter,
238            ..
239        } = self;
240        ReportBuilder {
241            items,
242            name_display,
243            level_filter,
244        }
245    }
246
247    pub fn level_filter(mut self, level: LevelFilter) -> Self {
248        self.level_filter = level;
249        self
250    }
251
252    pub fn filtered<Q>(mut self, mut f: impl FnMut(&K) -> bool) -> Self
253    where
254        K: Borrow<Q>,
255        Q: Eq + ?Sized,
256    {
257        self.items.retain(|d| f(&d.name));
258        self
259    }
260}
261
262impl<'a, K, P, F> ReportBuilder<'a, K, P, F>
263where
264    P: IssueItem,
265{
266    pub fn build(self) -> Reporter<'a, P>
267    where
268        F: for<'b> Fn(&'b K) -> String,
269    {
270        let Self {
271            items,
272            name_display,
273            level_filter,
274        } = self;
275
276        let items = items
277            .into_iter()
278            .flat_map(|Diagnostics { text, name, issues }| {
279                Self::grouped(level_filter, issues)
280                    .into_iter()
281                    .map(|(level, issues)| {
282                        let name = name_display(&name);
283                        (level, Diagnostics { text, name, issues })
284                    })
285                    .collect::<Vec<_>>()
286            })
287            .collect::<Vec<_>>()
288            .tap_mut(|items| {
289                items.sort_by(|(l1, d1), (l2, d2)| (l2, &d1.name).cmp(&(l1, &d2.name)))
290            })
291            .into_iter()
292            .map(|(_, d)| d)
293            .collect();
294
295        Reporter { items }
296    }
297
298    fn grouped(max: LevelFilter, issues: Vec<P>) -> BTreeMap<Level, Vec<P>> {
299        let mut groups = BTreeMap::<_, Vec<_>>::new();
300        for item in issues {
301            let level = item.issue().level();
302            if level > max {
303                continue;
304            }
305            groups.entry(level).or_default().push(item);
306        }
307        groups
308    }
309}
310
311pub struct Reporter<'a, P> {
312    items: Vec<Diagnostics<'a, String, P>>,
313}
314
315impl<P> Reporter<'_, P>
316where
317    P: IssueItem,
318{
319    pub fn to_level(&self) -> Option<Level> {
320        self.items.iter().map(|p| p.status().level()).min()
321    }
322
323    pub fn to_stderr(&self) -> &Self {
324        if self.items.is_empty() {
325            return self;
326        }
327
328        if is_logging() {
329            self.to_traces();
330        } else {
331            write!(stderr(), "\n{}", self.to_report())
332                .tap_err(emit_debug!())
333                .ok();
334            if let Some(level) = self.to_level() {
335                // explicitly set severity because graphical reports
336                // do not go through tracing
337                put_severity(level);
338            }
339        };
340
341        self
342    }
343
344    pub fn to_report(&self) -> String {
345        self.items.iter().fold(String::new(), |mut out, diag| {
346            writeln!(out, "{}", diag.to_report()).expect_fmt();
347            out
348        })
349    }
350
351    pub fn to_traces(&self) {
352        for item in self.items.iter() {
353            item.to_traces();
354        }
355    }
356}
357
358const fn level_style(level: Level) -> Style {
359    match level {
360        Level::TRACE => Style::new().dimmed(),
361        Level::DEBUG => Style::new().blue(),
362        Level::INFO => Style::new().green(),
363        Level::WARN => Style::new().yellow(),
364        Level::ERROR => Style::new().red(),
365    }
366}
367
368/// [LevelFilter::current] always returns `TRACE` for some reason
369fn max_level() -> LevelFilter {
370    if tracing::enabled!(Level::TRACE) {
371        LevelFilter::TRACE
372    } else if tracing::enabled!(Level::DEBUG) {
373        LevelFilter::DEBUG
374    } else if tracing::enabled!(Level::INFO) {
375        LevelFilter::INFO
376    } else if tracing::enabled!(Level::WARN) {
377        LevelFilter::WARN
378    } else {
379        LevelFilter::ERROR
380    }
381}
382
383trait StyleCompat {
384    fn stderr(self) -> Self;
385}
386
387impl StyleCompat for Style {
388    fn stderr(self) -> Self {
389        if is_colored() { self } else { Style::new() }
390    }
391}
392
393pub trait Title: fmt::Display + Send + Sync {}
394
395impl<K: fmt::Display + Send + Sync> Title for K {}
396
397#[macro_export]
398macro_rules! plural {
399    ( $num:expr, $singular:expr ) => {
400        $crate::plural!($num, $singular, concat!($singular, "s"))
401    };
402    ( $num:expr, $singular:expr, $plural:expr ) => {{
403        let num = $num;
404        match num {
405            1 => format!("{num} {}", $singular),
406            _ => format!("{num} {}", $plural),
407        }
408    }};
409}