Skip to main content

miette/handlers/
narratable.rs

1use std::fmt;
2
3use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
4
5use crate::{
6    LabeledSpan, MietteError, MietteSpanContents, ReportHandler, SourceCode, SourceSpan,
7    SpanContents,
8    diagnostic_chain::DiagnosticChain,
9    protocol::{Diagnostic, Severity},
10};
11
12/**
13[`ReportHandler`] that renders plain text and avoids extraneous graphics.
14It's optimized for screen readers and braille users, but is also used in any
15non-graphical environments, such as non-TTY output.
16*/
17#[derive(Debug, Clone)]
18pub struct NarratableReportHandler {
19    context_lines: usize,
20    with_cause_chain: bool,
21    footer: Option<String>,
22}
23
24impl NarratableReportHandler {
25    /// Create a new [`NarratableReportHandler`]. There are no customization
26    /// options.
27    #[must_use]
28    pub const fn new() -> Self {
29        Self { footer: None, context_lines: 1, with_cause_chain: true }
30    }
31
32    /// Include the cause chain of the top-level error in the report, if
33    /// available.
34    #[must_use]
35    pub const fn with_cause_chain(mut self) -> Self {
36        self.with_cause_chain = true;
37        self
38    }
39
40    /// Do not include the cause chain of the top-level error in the report.
41    #[must_use]
42    pub const fn without_cause_chain(mut self) -> Self {
43        self.with_cause_chain = false;
44        self
45    }
46
47    /// Set the footer to be displayed at the end of the report.
48    #[must_use]
49    pub fn with_footer(mut self, footer: String) -> Self {
50        self.footer = Some(footer);
51        self
52    }
53
54    /// Sets the number of lines of context to show around each error.
55    #[must_use]
56    pub const fn with_context_lines(mut self, lines: usize) -> Self {
57        self.context_lines = lines;
58        self
59    }
60}
61
62impl Default for NarratableReportHandler {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl NarratableReportHandler {
69    /// Render a [`Diagnostic`]. This function is mostly internal and meant to
70    /// be called by the toplevel [`ReportHandler`] handler, but is
71    /// made public to make it easier (possible) to test in isolation from
72    /// global state.
73    pub fn render_report(
74        &self,
75        f: &mut impl fmt::Write,
76        diagnostic: &dyn Diagnostic,
77    ) -> fmt::Result {
78        self.render_header(f, diagnostic)?;
79        if self.with_cause_chain {
80            self.render_causes(f, diagnostic)?;
81        }
82        let src = diagnostic.source_code();
83        self.render_snippets(f, diagnostic, src)?;
84        self.render_footer(f, diagnostic)?;
85        self.render_related(f, diagnostic, src)?;
86        if let Some(footer) = &self.footer {
87            writeln!(f, "{footer}")?;
88        }
89        Ok(())
90    }
91
92    fn render_header(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
93        writeln!(f, "{diagnostic}")?;
94        let severity = match diagnostic.severity() {
95            Some(Severity::Error) | None => "error",
96            Some(Severity::Warning) => "warning",
97            Some(Severity::Advice) => "advice",
98        };
99        writeln!(f, "    Diagnostic severity: {severity}")?;
100        Ok(())
101    }
102
103    fn render_causes(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
104        if let Some(cause_iter) = diagnostic
105            .diagnostic_source()
106            .map(DiagnosticChain::from_diagnostic)
107            .or_else(|| diagnostic.source().map(DiagnosticChain::from_stderror))
108        {
109            for error in cause_iter {
110                writeln!(f, "    Caused by: {error}")?;
111            }
112        }
113
114        Ok(())
115    }
116
117    fn render_footer(&self, f: &mut impl fmt::Write, diagnostic: &dyn Diagnostic) -> fmt::Result {
118        if let Some(help) = diagnostic.help() {
119            writeln!(f, "diagnostic help: {help}")?;
120        }
121        if let Some(code) = diagnostic.code() {
122            writeln!(f, "diagnostic code: {code}")?;
123        }
124        if let Some(url) = diagnostic.url() {
125            writeln!(f, "For more details, see:\n{url}")?;
126        }
127        Ok(())
128    }
129
130    fn render_related(
131        &self,
132        f: &mut impl fmt::Write,
133        diagnostic: &dyn Diagnostic,
134        parent_src: Option<&dyn SourceCode>,
135    ) -> fmt::Result {
136        let related = diagnostic.related();
137        if !related.is_empty() {
138            writeln!(f)?;
139            for rel in related.iter().copied() {
140                match rel.severity() {
141                    Some(Severity::Error) | None => write!(f, "Error: ")?,
142                    Some(Severity::Warning) => write!(f, "Warning: ")?,
143                    Some(Severity::Advice) => write!(f, "Advice: ")?,
144                };
145                self.render_header(f, rel)?;
146                writeln!(f)?;
147                self.render_causes(f, rel)?;
148                let src = rel.source_code().or(parent_src);
149                self.render_snippets(f, rel, src)?;
150                self.render_footer(f, rel)?;
151                self.render_related(f, rel, src)?;
152            }
153        }
154        Ok(())
155    }
156
157    fn render_snippets(
158        &self,
159        f: &mut impl fmt::Write,
160        diagnostic: &dyn Diagnostic,
161        source_code: Option<&dyn SourceCode>,
162    ) -> fmt::Result {
163        if let Some(source) = source_code {
164            {
165                let mut labels = diagnostic.labels();
166                labels.sort_unstable_by_key(|l| l.inner().offset());
167                if !labels.is_empty() {
168                    let contents = labels
169                        .iter()
170                        .map(|label| {
171                            source.read_span(label.inner(), self.context_lines, self.context_lines)
172                        })
173                        .collect::<Result<Vec<MietteSpanContents<'_>>, MietteError>>()
174                        .map_err(|_| fmt::Error)?;
175                    let mut contexts = Vec::new();
176                    for (right, right_conts) in labels.iter().cloned().zip(contents.iter()) {
177                        if contexts.is_empty() {
178                            contexts.push((right, right_conts));
179                        } else {
180                            let (left, left_conts) = contexts.last().unwrap().clone();
181                            let left_end = left.offset() + left.len();
182                            let right_end = right.offset() + right.len();
183                            if left_conts.line() + left_conts.line_count() >= right_conts.line() {
184                                // The snippets will overlap, so we create one Big Chunky Boi
185                                let new_span = LabeledSpan::new(
186                                    left.label().map(String::from),
187                                    left.offset(),
188                                    if right_end >= left_end {
189                                        // Right end goes past left end
190                                        right_end - left.offset()
191                                    } else {
192                                        // right is contained inside left
193                                        left.len()
194                                    },
195                                );
196                                if source
197                                    .read_span(
198                                        new_span.inner(),
199                                        self.context_lines,
200                                        self.context_lines,
201                                    )
202                                    .is_ok()
203                                {
204                                    contexts.pop();
205                                    contexts.push((
206                                        new_span, // We'll throw this away later
207                                        left_conts,
208                                    ));
209                                } else {
210                                    contexts.push((right, right_conts));
211                                }
212                            } else {
213                                contexts.push((right, right_conts));
214                            }
215                        }
216                    }
217                    for (ctx, _) in contexts {
218                        self.render_context(f, source, &ctx, &labels[..])?;
219                    }
220                }
221            }
222        }
223        Ok(())
224    }
225
226    fn render_context(
227        &self,
228        f: &mut impl fmt::Write,
229        source: &dyn SourceCode,
230        context: &LabeledSpan,
231        labels: &[LabeledSpan],
232    ) -> fmt::Result {
233        let (contents, lines) = self.get_lines(source, context.inner())?;
234        write!(f, "Begin snippet")?;
235        if let Some(filename) = contents.name() {
236            write!(f, " for {filename}")?;
237        }
238        writeln!(f, " starting at line {}, column {}", contents.line() + 1, contents.column() + 1)?;
239        writeln!(f)?;
240        for line in &lines {
241            writeln!(f, "snippet line {}: {}", line.line_number, line.text)?;
242            let relevant =
243                labels.iter().filter_map(|l| line.span_attach(l.inner()).map(|a| (a, l)));
244            for (attach, label) in relevant {
245                match attach {
246                    SpanAttach::Contained { col_start, col_end } if col_start == col_end => {
247                        write!(f, "    label at line {}, column {}", line.line_number, col_start,)?;
248                    }
249                    SpanAttach::Contained { col_start, col_end } => {
250                        write!(
251                            f,
252                            "    label at line {}, columns {} to {}",
253                            line.line_number, col_start, col_end,
254                        )?;
255                    }
256                    SpanAttach::Starts { col_start } => {
257                        write!(
258                            f,
259                            "    label starting at line {}, column {}",
260                            line.line_number, col_start,
261                        )?;
262                    }
263                    SpanAttach::Ends { col_end } => {
264                        write!(
265                            f,
266                            "    label ending at line {}, column {}",
267                            line.line_number, col_end,
268                        )?;
269                    }
270                }
271                if let Some(label) = label.label() {
272                    write!(f, ": {label}")?;
273                }
274                writeln!(f)?;
275            }
276        }
277        Ok(())
278    }
279
280    fn get_lines<'a>(
281        &'a self,
282        source: &'a dyn SourceCode,
283        context_span: &'a SourceSpan,
284    ) -> Result<(MietteSpanContents<'a>, Vec<Line<'a>>), fmt::Error> {
285        let context_data = source
286            .read_span(context_span, self.context_lines, self.context_lines)
287            .map_err(|_| fmt::Error)?;
288        let context = std::str::from_utf8(context_data.data()).expect("Bad utf8 detected");
289        let mut line = context_data.line();
290        let mut column = context_data.column();
291        let mut offset = context_data.span().offset() as usize;
292        // Byte offset of `context[0]` into the original source.
293        let base = offset;
294        let mut line_offset = offset;
295        let mut iter = context.chars().peekable();
296        // Bytes of visible text accumulated for the current line (no terminator).
297        let mut line_len = 0usize;
298        let mut lines = Vec::new();
299        while let Some(char) = iter.next() {
300            offset += char.len_utf8();
301            let mut at_end_of_file = false;
302            match char {
303                '\r' => {
304                    if iter.next_if_eq(&'\n').is_some() {
305                        offset += 1;
306                        line += 1;
307                        column = 0;
308                    } else {
309                        line_len += char.len_utf8();
310                        column += 1;
311                    }
312                    at_end_of_file = iter.peek().is_none();
313                }
314                '\n' => {
315                    at_end_of_file = iter.peek().is_none();
316                    line += 1;
317                    column = 0;
318                }
319                _ => {
320                    line_len += char.len_utf8();
321                    column += 1;
322                }
323            }
324
325            if iter.peek().is_none() && !at_end_of_file {
326                line += 1;
327            }
328
329            if column == 0 || iter.peek().is_none() {
330                let text_start = line_offset - base;
331                lines.push(Line {
332                    line_number: line,
333                    offset: line_offset,
334                    text: &context[text_start..text_start + line_len],
335                    at_end_of_file,
336                });
337                line_len = 0;
338                line_offset = offset;
339            }
340        }
341        Ok((context_data, lines))
342    }
343}
344
345impl ReportHandler for NarratableReportHandler {
346    fn debug(&self, diagnostic: &dyn Diagnostic, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347        if f.alternate() {
348            return fmt::Debug::fmt(diagnostic, f);
349        }
350
351        self.render_report(f, diagnostic)
352    }
353}
354
355/*
356Support types
357*/
358
359struct Line<'a> {
360    line_number: usize,
361    offset: usize,
362    text: &'a str,
363    at_end_of_file: bool,
364}
365
366enum SpanAttach {
367    Contained { col_start: usize, col_end: usize },
368    Starts { col_start: usize },
369    Ends { col_end: usize },
370}
371
372/// Returns column at offset, and nearest boundary if offset is in the middle of
373/// the character
374fn safe_get_column(text: &str, offset: usize, start: bool) -> usize {
375    let mut column = text.get(0..offset).map(UnicodeWidthStr::width).unwrap_or_else(|| {
376        let mut column = 0;
377        for (idx, c) in text.char_indices() {
378            if offset <= idx {
379                break;
380            }
381            column += c.width().unwrap_or(0);
382        }
383        column
384    });
385    if start {
386        // Offset are zero-based, so plus one
387        column += 1;
388    } // On the other hand for end span, offset refers for the next column
389    // So we should do -1. column+1-1 == column
390    column
391}
392
393impl Line<'_> {
394    fn span_attach(&self, span: &SourceSpan) -> Option<SpanAttach> {
395        let span_offset = span.offset() as usize;
396        let span_end = span_offset + span.len() as usize;
397        let line_end = self.offset + self.text.len();
398
399        let start_after = span_offset >= self.offset;
400        let end_before = self.at_end_of_file || span_end <= line_end;
401
402        if start_after && end_before {
403            let col_start = safe_get_column(self.text, span_offset - self.offset, true);
404            let col_end = if span.is_empty() {
405                col_start
406            } else {
407                // span_end refers to the next character after token
408                // while col_end refers to the exact character, so -1
409                safe_get_column(self.text, span_end - self.offset, false)
410            };
411            return Some(SpanAttach::Contained { col_start, col_end });
412        }
413        if start_after && span_offset <= line_end {
414            let col_start = safe_get_column(self.text, span_offset - self.offset, true);
415            return Some(SpanAttach::Starts { col_start });
416        }
417        if end_before && span_end >= self.offset {
418            let col_end = safe_get_column(self.text, span_end - self.offset, false);
419            return Some(SpanAttach::Ends { col_end });
420        }
421        None
422    }
423}