mago_reporting/formatter/
rich.rs1use 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
36pub(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 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 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}