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;
34
35pub(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 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 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}