Skip to main content

typst_kit/
diagnostics.rs

1//! Diagnostic pretty-printing.
2
3#![cfg(feature = "emit-diagnostics")]
4
5use std::collections::HashMap;
6use std::io;
7use std::ops::Range;
8
9use codespan_reporting::diagnostic::{Diagnostic, Label};
10use codespan_reporting::files::Files;
11use codespan_reporting::term;
12use termcolor::{Color, ColorSpec, WriteColor};
13use typst_library::World;
14use typst_library::diag::{FileError, Severity, SourceDiagnostic, Tracepoint};
15use typst_syntax::{DiagSpan, DiagSpanKind, FileId, Lines, Source, Spanned};
16
17type CodespanResult<T> = Result<T, CodespanError>;
18type CodespanError = codespan_reporting::files::Error;
19
20pub use term::termcolor;
21
22/// Extends the [`World`] for diagnostic printing.
23pub trait DiagnosticWorld: World {
24    /// Formats a file ID for user-facing display.
25    ///
26    /// In the CLI, this formats as a path relative to the working directory.
27    fn name(&self, id: FileId) -> String;
28}
29
30/// Which format to use for diagnostics.
31#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
32pub enum DiagnosticFormat {
33    /// Displays a richly formatted message showing the source code and context.
34    #[default]
35    Human,
36    /// Displays a short single-line diagnostic.
37    Short,
38}
39
40/// Emits diagnostic messages to a writable, colorized output.
41pub fn emit<'a>(
42    dest: &mut dyn WriteColor,
43    world: &dyn DiagnosticWorld,
44    diagnostics: impl IntoIterator<Item = &'a SourceDiagnostic>,
45    format: DiagnosticFormat,
46) -> Result<(), codespan_reporting::files::Error> {
47    let mut files = WorldFiles { world, sources: HashMap::new() };
48
49    let mut config = term::Config { tab_width: 2, ..Default::default() };
50    if format == DiagnosticFormat::Short {
51        config.display_style = term::DisplayStyle::Short;
52    }
53
54    for diagnostic in diagnostics {
55        let diag = match diagnostic.severity {
56            Severity::Error => Diagnostic::error(),
57            Severity::Warning => Diagnostic::warning(),
58        }
59        .with_message(diagnostic.message.clone())
60        .with_notes(
61            diagnostic
62                .hints
63                .iter()
64                .filter(|s| s.span.is_detached())
65                .map(|s| format!("hint: {}", s.v))
66                .collect(),
67        )
68        .with_labels(
69            diagnostic
70                .span
71                .id()
72                .and_then(|id| {
73                    let range = files.range(diagnostic.span)?;
74                    Some(Label::primary(id, range))
75                })
76                .into_iter()
77                .chain(diagnostic.hints.iter().filter_map(|hint| {
78                    let id = hint.span.id()?;
79                    let range = files.range(hint.span)?;
80                    Some(Label::secondary(id, range).with_message(&hint.v))
81                }))
82                .collect(),
83        );
84
85        term::emit(dest, &config, &files, &diag)?;
86
87        // Stacktrace-like helper diagnostics.
88        if format == DiagnosticFormat::Human {
89            let mut traced = false;
90            for point in &diagnostic.trace {
91                emit_trace(dest, &mut files, point)?;
92                traced = true;
93            }
94
95            if traced {
96                writeln!(dest)?;
97            }
98        }
99    }
100
101    Ok(())
102}
103
104/// Emits a tracepoint.
105fn emit_trace(
106    dest: &mut dyn WriteColor,
107    files: &mut WorldFiles,
108    point: &Spanned<Tracepoint>,
109) -> Result<(), codespan_reporting::files::Error> {
110    let Some(id) = point.span.id() else { return Ok(()) };
111    let Some(range) = files.range(point.span) else { return Ok(()) };
112    let lines = files.lines(id)?;
113
114    let name = files.name(id)?;
115    let line_index = files.line_index(id, range.start)?;
116    let line = files.line_number(id, line_index)?;
117    let column = files.column_number(id, line_index, range.start)?;
118    let text = &lines.text()[range];
119
120    // Displays what kind of tracepoint we have and where.
121    write!(dest, "  {} at ", point.v)?;
122    dest.set_color(ColorSpec::new().set_underline(true))?;
123    write!(dest, "{name}:{line}:{column}")?;
124    dest.reset()?;
125    writeln!(dest)?;
126
127    // Displays the context in the source in a single line.
128    let mut lines = text.lines();
129    write!(dest, "    ")?;
130    dest.set_color(ColorSpec::new().set_fg(Some(Color::Ansi256(248))))?;
131    if let Some(first) = lines.next() {
132        write!(dest, "{first}")?;
133    }
134    if let Some(last) = lines.next_back()
135        && let Some(last_char) = last.chars().next_back()
136        && !last_char.is_whitespace()
137    {
138        // If the traced source text is multi-line, try to display it
139        // with inner ellipses followed by the last character.
140        write!(dest, "…{last_char}")?;
141    }
142    dest.reset()?;
143    writeln!(dest)?;
144
145    Ok(())
146}
147
148/// Provides file contents and metadata to `codespan-reporting`.
149struct WorldFiles<'a> {
150    world: &'a dyn DiagnosticWorld,
151    sources: HashMap<FileId, Source>,
152}
153
154impl WorldFiles<'_> {
155    /// Determine the byte range of a span, also remembering the source file
156    /// for future line / column lookups.
157    fn range(&mut self, span: impl Into<DiagSpan>) -> Option<Range<usize>> {
158        match span.into().get() {
159            DiagSpanKind::Detached => None,
160            DiagSpanKind::Number { id, num, sub_range } => {
161                let source = self.world.source(id).ok()?;
162                let range = source.range(num, sub_range);
163                self.sources.entry(id).or_insert(source);
164                range
165            }
166            DiagSpanKind::Range { id: _, range } => Some(range),
167        }
168    }
169
170    /// Lookup line metadata for a file by id. If a source file was remembered,
171    /// it will be used. Otherwise, we load as a file as compute line metadata.
172    fn lines(&self, id: FileId) -> CodespanResult<Lines<String>> {
173        match self.sources.get(&id) {
174            Some(source) => Ok(source.lines().clone()),
175            None => self
176                .world
177                .file(id)
178                .and_then(|file| file.lines().map_err(Into::into))
179                .map_err(|err| match err {
180                    FileError::NotFound(_) => CodespanError::FileMissing,
181                    other => CodespanError::Io(io::Error::other(other)),
182                }),
183        }
184    }
185}
186
187impl<'a> Files<'a> for WorldFiles<'_> {
188    type FileId = FileId;
189    type Name = String;
190    type Source = Lines<String>;
191
192    fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> {
193        Ok(self.world.name(id))
194    }
195
196    fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> {
197        self.lines(id)
198    }
199
200    fn line_index(&'a self, id: FileId, given: usize) -> CodespanResult<usize> {
201        let lines = self.lines(id)?;
202        lines
203            .byte_to_line(given)
204            .ok_or_else(|| CodespanError::IndexTooLarge { given, max: lines.len_bytes() })
205    }
206
207    fn line_range(
208        &'a self,
209        id: FileId,
210        given: usize,
211    ) -> CodespanResult<std::ops::Range<usize>> {
212        let lines = self.lines(id)?;
213        lines
214            .line_to_range(given)
215            .ok_or_else(|| CodespanError::LineTooLarge { given, max: lines.len_lines() })
216    }
217
218    fn column_number(
219        &'a self,
220        id: FileId,
221        _: usize,
222        given: usize,
223    ) -> CodespanResult<usize> {
224        let lines = self.lines(id)?;
225        lines.byte_to_column(given).ok_or_else(|| {
226            let max = lines.len_bytes();
227            if given <= max {
228                CodespanError::InvalidCharBoundary { given }
229            } else {
230                CodespanError::IndexTooLarge { given, max }
231            }
232        })
233    }
234}