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