Skip to main content

mago_reporting/formatter/
rich.rs

1use std::borrow::Cow;
2use std::cell::Cell;
3use std::cmp::Ordering;
4use std::io::IsTerminal;
5use std::io::Write;
6use std::ops::Range;
7
8use codespan_reporting::diagnostic::Diagnostic;
9use codespan_reporting::diagnostic::Label;
10use codespan_reporting::diagnostic::LabelStyle;
11use codespan_reporting::diagnostic::Severity;
12use codespan_reporting::files::Error;
13use codespan_reporting::files::Files;
14use codespan_reporting::term;
15use codespan_reporting::term::Config;
16use codespan_reporting::term::DisplayStyle;
17use mago_database::file::FileId;
18use termcolor::Buffer;
19
20use mago_database::DatabaseReader;
21use mago_database::ReadDatabase;
22
23use crate::Annotation;
24use crate::AnnotationKind;
25use crate::Issue;
26use crate::IssueCollection;
27use crate::Level;
28use crate::error::ReportingError;
29use crate::formatter::Formatter;
30use crate::formatter::FormatterConfig;
31use crate::formatter::utils::osc8_hyperlink;
32
33/// Formatter that outputs issues in rich diagnostic format with full context.
34pub(crate) struct RichFormatter;
35
36impl Formatter for RichFormatter {
37    fn format(
38        &self,
39        writer: &mut dyn Write,
40        issues: &IssueCollection,
41        database: &ReadDatabase,
42        config: &FormatterConfig,
43    ) -> Result<(), ReportingError> {
44        codespan_format_with_config(
45            writer,
46            issues,
47            database,
48            config,
49            &Config { display_style: DisplayStyle::Rich, ..Default::default() },
50        )
51    }
52}
53
54pub(super) fn codespan_format_with_config(
55    writer: &mut dyn Write,
56    issues: &IssueCollection,
57    database: &ReadDatabase,
58    config: &FormatterConfig,
59    codespan_config: &Config,
60) -> Result<(), ReportingError> {
61    let use_colors = config.color_choice.should_use_colors(std::io::stdout().is_terminal());
62    let mut buffer = if use_colors { Buffer::ansi() } else { Buffer::no_color() };
63
64    let editor_url = if use_colors { config.editor_url.as_deref() } else { None };
65    let files = DatabaseFiles { database, editor_url, line_hint: Cell::new(None), column_hint: Cell::new(None) };
66
67    let mut highest_level: Option<Level> = None;
68    let mut errors = 0;
69    let mut warnings = 0;
70    let mut notes = 0;
71    let mut help = 0;
72    let mut suggestions = 0;
73
74    for issue in crate::formatter::utils::filter_issues(issues, config, true) {
75        match issue.level {
76            Level::Note => notes += 1,
77            Level::Help => help += 1,
78            Level::Warning => warnings += 1,
79            Level::Error => errors += 1,
80        }
81
82        highest_level = Some(highest_level.map_or(issue.level, |cur| cur.max(issue.level)));
83
84        if !issue.edits.is_empty() {
85            suggestions += 1;
86        }
87
88        if editor_url.is_some() {
89            if let Some(annotation) = issue.annotations.iter().find(|a| a.is_primary()) {
90                if let Ok(file) = database.get_ref(&annotation.span.file_id) {
91                    let line = file.line_number(annotation.span.start.offset) + 1;
92                    let column = file.column_number(annotation.span.start.offset) + 1;
93                    files.line_hint.set(Some(line));
94                    files.column_hint.set(Some(column));
95                }
96            } else {
97                files.line_hint.set(None);
98                files.column_hint.set(None);
99            }
100        }
101
102        let diagnostic: Diagnostic<FileId> = issue.into();
103
104        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
105    }
106
107    if let Some(highest_level) = highest_level {
108        let total_issues = errors + warnings + notes + help;
109        let mut message_notes = vec![];
110        if errors > 0 {
111            message_notes.push(format!("{errors} error(s)"));
112        }
113
114        if warnings > 0 {
115            message_notes.push(format!("{warnings} warning(s)"));
116        }
117
118        if notes > 0 {
119            message_notes.push(format!("{notes} note(s)"));
120        }
121
122        if help > 0 {
123            message_notes.push(format!("{help} help message(s)"));
124        }
125
126        let mut diagnostic: Diagnostic<FileId> = Diagnostic::new(highest_level.into()).with_message(format!(
127            "found {} issues: {}",
128            total_issues,
129            message_notes.join(", ")
130        ));
131
132        if suggestions > 0 {
133            diagnostic = diagnostic.with_notes(vec![format!("{} issues contain auto-fix suggestions", suggestions)]);
134        }
135
136        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
137    }
138
139    // Write buffer to writer
140    writer.write_all(buffer.as_slice())?;
141
142    Ok(())
143}
144
145struct DatabaseFiles<'a> {
146    database: &'a ReadDatabase,
147    editor_url: Option<&'a str>,
148    line_hint: Cell<Option<u32>>,
149    column_hint: Cell<Option<u32>>,
150}
151
152impl<'a> Files<'a> for DatabaseFiles<'_> {
153    type FileId = FileId;
154    type Name = Cow<'a, str>;
155    type Source = &'a str;
156
157    fn name(&'a self, file_id: FileId) -> Result<Cow<'a, str>, Error> {
158        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;
159        let name = file.name.as_ref();
160
161        if let (Some(template), Some(path)) = (self.editor_url, file.path.as_ref()) {
162            let abs_path = path.display().to_string();
163            let line = self.line_hint.get().unwrap_or(1);
164            let column = self.column_hint.get().unwrap_or(1);
165
166            Ok(Cow::Owned(osc8_hyperlink(template, &abs_path, line, column, name)))
167        } else {
168            Ok(Cow::Borrowed(name))
169        }
170    }
171
172    fn source(&'a self, file_id: FileId) -> Result<&'a str, Error> {
173        self.database.get_ref(&file_id).map(|source| source.contents.as_ref()).map_err(|_| Error::FileMissing)
174    }
175
176    fn line_index(&self, file_id: FileId, byte_index: usize) -> Result<usize, Error> {
177        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;
178
179        Ok(file.line_number(
180            byte_index.try_into().map_err(|_| Error::IndexTooLarge { given: byte_index, max: u32::MAX as usize })?,
181        ) as usize)
182    }
183
184    fn line_range(&self, file_id: FileId, line_index: usize) -> Result<Range<usize>, Error> {
185        let file = self.database.get(&file_id).map_err(|_| Error::FileMissing)?;
186
187        codespan_line_range(&file.lines, file.size, line_index)
188    }
189}
190
191fn codespan_line_start(lines: &[u32], size: u32, line_index: usize) -> Result<usize, Error> {
192    match line_index.cmp(&lines.len()) {
193        Ordering::Less => Ok(lines.get(line_index).copied().expect("failed despite previous check") as usize),
194        Ordering::Equal => Ok(size as usize),
195        Ordering::Greater => Err(Error::LineTooLarge { given: line_index, max: lines.len() - 1 }),
196    }
197}
198
199fn codespan_line_range(lines: &[u32], size: u32, line_index: usize) -> Result<Range<usize>, Error> {
200    let line_start = codespan_line_start(lines, size, line_index)?;
201    let next_line_start = codespan_line_start(lines, size, line_index + 1)?;
202
203    Ok(line_start..next_line_start)
204}
205
206impl From<AnnotationKind> for LabelStyle {
207    fn from(kind: AnnotationKind) -> LabelStyle {
208        match kind {
209            AnnotationKind::Primary => LabelStyle::Primary,
210            AnnotationKind::Secondary => LabelStyle::Secondary,
211        }
212    }
213}
214
215impl From<Annotation> for Label<FileId> {
216    fn from(annotation: Annotation) -> Label<FileId> {
217        let mut label = Label::new(annotation.kind.into(), annotation.span.file_id, annotation.span);
218
219        if let Some(message) = annotation.message {
220            label.message = message;
221        }
222
223        label
224    }
225}
226
227impl From<&Annotation> for Label<FileId> {
228    fn from(annotation: &Annotation) -> Label<FileId> {
229        let mut label = Label::new(annotation.kind.into(), annotation.span.file_id, annotation.span);
230
231        if let Some(message) = &annotation.message {
232            label.message = message.clone();
233        }
234
235        label
236    }
237}
238
239impl From<Level> for Severity {
240    fn from(level: Level) -> Severity {
241        match level {
242            Level::Note => Severity::Note,
243            Level::Help => Severity::Help,
244            Level::Warning => Severity::Warning,
245            Level::Error => Severity::Error,
246        }
247    }
248}
249
250impl From<Issue> for Diagnostic<FileId> {
251    fn from(issue: Issue) -> Diagnostic<FileId> {
252        let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message);
253
254        if let Some(code) = issue.code {
255            diagnostic.code = Some(code);
256        }
257
258        for annotation in issue.annotations {
259            diagnostic.labels.push(annotation.into());
260        }
261
262        for note in issue.notes {
263            diagnostic.notes.push(note);
264        }
265
266        if let Some(help) = issue.help {
267            diagnostic.notes.push(format!("Help: {help}"));
268        }
269
270        if let Some(link) = issue.link {
271            diagnostic.notes.push(format!("See: {link}"));
272        }
273
274        diagnostic
275    }
276}
277
278impl From<&Issue> for Diagnostic<FileId> {
279    fn from(issue: &Issue) -> Diagnostic<FileId> {
280        let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message.clone());
281
282        if let Some(code) = &issue.code {
283            diagnostic.code = Some(code.clone());
284        }
285
286        for annotation in &issue.annotations {
287            diagnostic.labels.push(annotation.into());
288        }
289
290        for note in &issue.notes {
291            diagnostic.notes.push(note.clone());
292        }
293
294        if let Some(help) = &issue.help {
295            diagnostic.notes.push(format!("Help: {help}"));
296        }
297
298        if let Some(link) = &issue.link {
299            diagnostic.notes.push(format!("See: {link}"));
300        }
301
302        diagnostic
303    }
304}