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