mago_reporting/formatter/
rich.rs

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