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    // Apply filters
62    let issues = apply_filters(issues, config);
63
64    // Determine if we should use colors
65    let use_colors = config.color_choice.should_use_colors(std::io::stdout().is_terminal());
66
67    // Create a buffer for codespan (it requires WriteColor)
68    let mut buffer = if use_colors { Buffer::ansi() } else { Buffer::no_color() };
69
70    let editor_url = if use_colors { config.editor_url.as_deref() } else { None };
71    let files = DatabaseFiles { database, editor_url, line_hint: Cell::new(None), column_hint: Cell::new(None) };
72
73    let highest_level = issues.get_highest_level();
74    let mut errors = 0;
75    let mut warnings = 0;
76    let mut notes = 0;
77    let mut help = 0;
78    let mut suggestions = 0;
79
80    for issue in issues {
81        match &issue.level {
82            Level::Note => {
83                notes += 1;
84            }
85            Level::Help => {
86                help += 1;
87            }
88            Level::Warning => {
89                warnings += 1;
90            }
91            Level::Error => {
92                errors += 1;
93            }
94        }
95
96        if !issue.edits.is_empty() {
97            suggestions += 1;
98        }
99
100        // Set line/column hints from the primary annotation for OSC 8 hyperlinks
101        if editor_url.is_some() {
102            if let Some(annotation) = issue.annotations.iter().find(|a| a.is_primary()) {
103                if let Ok(file) = database.get_ref(&annotation.span.file_id) {
104                    let line = file.line_number(annotation.span.start.offset) + 1;
105                    let column = file.column_number(annotation.span.start.offset) + 1;
106                    files.line_hint.set(Some(line));
107                    files.column_hint.set(Some(column));
108                }
109            } else {
110                files.line_hint.set(None);
111                files.column_hint.set(None);
112            }
113        }
114
115        let diagnostic: Diagnostic<FileId> = issue.into();
116
117        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
118    }
119
120    if let Some(highest_level) = highest_level {
121        let total_issues = errors + warnings + notes + help;
122        let mut message_notes = vec![];
123        if errors > 0 {
124            message_notes.push(format!("{errors} error(s)"));
125        }
126
127        if warnings > 0 {
128            message_notes.push(format!("{warnings} warning(s)"));
129        }
130
131        if notes > 0 {
132            message_notes.push(format!("{notes} note(s)"));
133        }
134
135        if help > 0 {
136            message_notes.push(format!("{help} help message(s)"));
137        }
138
139        let mut diagnostic: Diagnostic<FileId> = Diagnostic::new(highest_level.into()).with_message(format!(
140            "found {} issues: {}",
141            total_issues,
142            message_notes.join(", ")
143        ));
144
145        if suggestions > 0 {
146            diagnostic = diagnostic.with_notes(vec![format!("{} issues contain auto-fix suggestions", suggestions)]);
147        }
148
149        term::emit_to_write_style(&mut buffer, codespan_config, &files, &diagnostic)?;
150    }
151
152    // Write buffer to writer
153    writer.write_all(buffer.as_slice())?;
154
155    Ok(())
156}
157
158fn apply_filters(issues: &IssueCollection, config: &FormatterConfig) -> IssueCollection {
159    let mut filtered = issues.clone();
160
161    if let Some(min_level) = config.minimum_level {
162        filtered = filtered.with_minimum_level(min_level);
163    }
164
165    if config.filter_fixable {
166        filtered = filtered.with_edits();
167    }
168
169    if config.sort {
170        filtered = filtered.sorted();
171    }
172
173    filtered
174}
175
176struct DatabaseFiles<'a> {
177    database: &'a ReadDatabase,
178    editor_url: Option<&'a str>,
179    line_hint: Cell<Option<u32>>,
180    column_hint: Cell<Option<u32>>,
181}
182
183impl<'a> Files<'a> for DatabaseFiles<'_> {
184    type FileId = FileId;
185    type Name = Cow<'a, str>;
186    type Source = &'a str;
187
188    fn name(&'a self, file_id: FileId) -> Result<Cow<'a, str>, Error> {
189        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;
190        let name = file.name.as_ref();
191
192        if let (Some(template), Some(path)) = (self.editor_url, file.path.as_ref()) {
193            let abs_path = path.display().to_string();
194            let line = self.line_hint.get().unwrap_or(1);
195            let column = self.column_hint.get().unwrap_or(1);
196
197            Ok(Cow::Owned(osc8_hyperlink(template, &abs_path, line, column, name)))
198        } else {
199            Ok(Cow::Borrowed(name))
200        }
201    }
202
203    fn source(&'a self, file_id: FileId) -> Result<&'a str, Error> {
204        self.database.get_ref(&file_id).map(|source| source.contents.as_ref()).map_err(|_| Error::FileMissing)
205    }
206
207    fn line_index(&self, file_id: FileId, byte_index: usize) -> Result<usize, Error> {
208        let file = self.database.get_ref(&file_id).map_err(|_| Error::FileMissing)?;
209
210        Ok(file.line_number(
211            byte_index.try_into().map_err(|_| Error::IndexTooLarge { given: byte_index, max: u32::MAX as usize })?,
212        ) as usize)
213    }
214
215    fn line_range(&self, file_id: FileId, line_index: usize) -> Result<Range<usize>, Error> {
216        let file = self.database.get(&file_id).map_err(|_| Error::FileMissing)?;
217
218        codespan_line_range(&file.lines, file.size, line_index)
219    }
220}
221
222fn codespan_line_start(lines: &[u32], size: u32, line_index: usize) -> Result<usize, Error> {
223    match line_index.cmp(&lines.len()) {
224        Ordering::Less => Ok(lines.get(line_index).copied().expect("failed despite previous check") as usize),
225        Ordering::Equal => Ok(size as usize),
226        Ordering::Greater => Err(Error::LineTooLarge { given: line_index, max: lines.len() - 1 }),
227    }
228}
229
230fn codespan_line_range(lines: &[u32], size: u32, line_index: usize) -> Result<Range<usize>, Error> {
231    let line_start = codespan_line_start(lines, size, line_index)?;
232    let next_line_start = codespan_line_start(lines, size, line_index + 1)?;
233
234    Ok(line_start..next_line_start)
235}
236
237impl From<AnnotationKind> for LabelStyle {
238    fn from(kind: AnnotationKind) -> LabelStyle {
239        match kind {
240            AnnotationKind::Primary => LabelStyle::Primary,
241            AnnotationKind::Secondary => LabelStyle::Secondary,
242        }
243    }
244}
245
246impl From<Annotation> for Label<FileId> {
247    fn from(annotation: Annotation) -> Label<FileId> {
248        let mut label = Label::new(annotation.kind.into(), annotation.span.file_id, annotation.span);
249
250        if let Some(message) = annotation.message {
251            label.message = message;
252        }
253
254        label
255    }
256}
257
258impl From<Level> for Severity {
259    fn from(level: Level) -> Severity {
260        match level {
261            Level::Note => Severity::Note,
262            Level::Help => Severity::Help,
263            Level::Warning => Severity::Warning,
264            Level::Error => Severity::Error,
265        }
266    }
267}
268
269impl From<Issue> for Diagnostic<FileId> {
270    fn from(issue: Issue) -> Diagnostic<FileId> {
271        let mut diagnostic = Diagnostic::new(issue.level.into()).with_message(issue.message);
272
273        if let Some(code) = issue.code {
274            diagnostic.code = Some(code);
275        }
276
277        for annotation in issue.annotations {
278            diagnostic.labels.push(annotation.into());
279        }
280
281        for note in issue.notes {
282            diagnostic.notes.push(note);
283        }
284
285        if let Some(help) = issue.help {
286            diagnostic.notes.push(format!("Help: {help}"));
287        }
288
289        if let Some(link) = issue.link {
290            diagnostic.notes.push(format!("See: {link}"));
291        }
292
293        diagnostic
294    }
295}