1use colored::Colorize;
2use rustc_hash::FxHashMap;
3use std::path::Path;
4
5use crate::locale;
6use tsz::checker::diagnostics::{Diagnostic, DiagnosticCategory, DiagnosticRelatedInformation};
7use tsz::lsp::position::LineMap;
8
9pub struct Reporter {
10 pretty: bool,
11 color: bool,
12 cwd: Option<String>,
13 sources: FxHashMap<String, String>,
14 line_maps: FxHashMap<String, LineMap>,
15}
16
17impl Reporter {
18 pub fn new(color: bool) -> Self {
19 Self {
20 pretty: color,
21 color,
22 cwd: std::env::current_dir()
23 .ok()
24 .map(|p| p.to_string_lossy().into_owned()),
25 sources: FxHashMap::default(),
26 line_maps: FxHashMap::default(),
27 }
28 }
29
30 pub const fn set_pretty(&mut self, pretty: bool) {
33 self.pretty = pretty;
34 }
35
36 pub fn render(&mut self, diagnostics: &[Diagnostic]) -> String {
38 let mut out = String::new();
39
40 if self.pretty {
41 self.render_pretty(&mut out, diagnostics);
42 } else {
43 self.render_plain(&mut out, diagnostics);
44 }
45
46 out
47 }
48
49 fn render_plain(&mut self, out: &mut String, diagnostics: &[Diagnostic]) {
53 for (index, diagnostic) in diagnostics.iter().enumerate() {
54 if index > 0 {
55 out.push('\n');
56 }
57 self.format_diagnostic_plain(out, diagnostic);
58 }
59 if !diagnostics.is_empty() {
61 out.push('\n');
62 }
63 }
64
65 fn render_pretty(&mut self, out: &mut String, diagnostics: &[Diagnostic]) {
68 for diagnostic in diagnostics {
69 self.format_diagnostic_pretty(out, diagnostic);
70 out.push('\n');
72 out.push('\n');
73 }
74
75 if !diagnostics.is_empty() {
77 out.push('\n');
78 self.format_summary(out, diagnostics);
79 }
80 }
81
82 fn format_diagnostic_plain(&mut self, out: &mut String, diagnostic: &Diagnostic) {
85 let file_display = self.relative_path(&diagnostic.file);
86
87 if let Some((line, col)) = self.position_for(&diagnostic.file, diagnostic.start) {
88 out.push_str(&format!("{file_display}({line},{col})"));
89 } else if !diagnostic.file.is_empty() {
90 out.push_str(&file_display);
91 }
92
93 out.push_str(": ");
94 out.push_str(&self.format_category_label(diagnostic.category));
95 if diagnostic.code != 0 {
96 out.push(' ');
97 out.push_str(&self.format_code_label(diagnostic.code));
98 }
99 out.push_str(": ");
100 let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
102 out.push_str(&message);
103
104 for related in &diagnostic.related_information {
106 out.push('\n');
107 self.format_related_plain(out, related);
108 }
109 }
110
111 fn format_diagnostic_pretty(&mut self, out: &mut String, diagnostic: &Diagnostic) {
119 let file_display = self.relative_path(&diagnostic.file);
120
121 if let Some((line, col)) = self.position_for(&diagnostic.file, diagnostic.start) {
123 if self.color {
124 out.push_str(&file_display.cyan().to_string());
125 out.push(':');
126 out.push_str(&line.to_string().yellow().to_string());
127 out.push(':');
128 out.push_str(&col.to_string().yellow().to_string());
129 } else {
130 out.push_str(&format!("{file_display}:{line}:{col}"));
131 }
132 } else if !diagnostic.file.is_empty() {
133 if self.color {
134 out.push_str(&file_display.cyan().to_string());
135 } else {
136 out.push_str(&file_display);
137 }
138 }
139
140 out.push_str(" - ");
141 out.push_str(&self.format_category_label(diagnostic.category));
142
143 if diagnostic.code != 0 {
144 if self.color {
145 out.push_str(&format!(" TS{}: ", diagnostic.code).dimmed().to_string());
146 } else {
147 out.push_str(&format!(" TS{}: ", diagnostic.code));
148 }
149 } else {
150 out.push_str(": ");
151 }
152 let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
154 out.push_str(&message);
155
156 if let Some(snippet) =
158 self.format_snippet_pretty(&diagnostic.file, diagnostic.start, diagnostic.length, 0)
159 {
160 out.push('\n');
161 out.push_str(&snippet);
162 }
163
164 for related in &diagnostic.related_information {
166 out.push('\n');
167 self.format_related_pretty(out, related);
168 }
169 }
170
171 fn format_snippet_pretty(
178 &mut self,
179 file: &str,
180 start: u32,
181 length: u32,
182 indent: usize,
183 ) -> Option<String> {
184 if file.is_empty() || length == 0 {
185 return None;
186 }
187
188 let (line_num, column) = self.position_for(file, start)?;
189 let source = self.sources.get(file)?;
190
191 let lines: Vec<&str> = source.lines().collect();
193 let line_idx = (line_num - 1) as usize;
194 if line_idx >= lines.len() {
195 return None;
196 }
197
198 let line_text = lines[line_idx];
199 let indent_str = " ".repeat(indent);
200 let line_num_str = line_num.to_string();
201 let line_num_width = line_num_str.len();
202
203 let mut snippet = String::new();
205 snippet.push('\n');
207
208 if self.color {
209 snippet.push_str(&indent_str);
210 snippet.push_str(&line_num_str.reversed().to_string());
212 snippet.push(' ');
213 snippet.push_str(line_text);
214 snippet.push('\n');
215
216 snippet.push_str(&indent_str);
218 snippet.push_str(&" ".repeat(line_num_width).reversed().to_string());
219 snippet.push(' ');
220
221 let underline = self.build_underline(line_text, column, length);
222 snippet.push_str(&underline.red().to_string());
223 } else {
224 snippet.push_str(&indent_str);
225 snippet.push_str(&line_num_str);
226 snippet.push(' ');
227 snippet.push_str(line_text);
228 snippet.push('\n');
229
230 snippet.push_str(&indent_str);
232 snippet.push_str(&" ".repeat(line_num_width));
233 snippet.push(' ');
234
235 let underline = self.build_underline(line_text, column, length);
236 snippet.push_str(&underline);
237 }
238
239 Some(snippet)
240 }
241
242 fn build_underline(&self, line_text: &str, column: u32, length: u32) -> String {
245 let mut underline = String::new();
246 let col_0 = (column - 1) as usize;
247
248 for (i, ch) in line_text.chars().enumerate() {
249 if i < col_0 {
250 if ch == '\t' {
252 underline.push_str(" ");
253 } else {
254 underline.push(' ');
255 }
256 } else if i < col_0 + length as usize {
257 if ch == '\t' {
259 underline.push_str("~~~~");
260 } else {
261 underline.push('~');
262 }
263 } else {
264 break;
265 }
266 }
267
268 if underline.trim().is_empty() && length > 0 {
270 underline = " ".repeat(col_0) + "~";
271 }
272
273 underline
274 }
275
276 fn format_related_plain(&mut self, out: &mut String, related: &DiagnosticRelatedInformation) {
278 let file_display = self.relative_path(&related.file);
279 if let Some((line, col)) = self.position_for(&related.file, related.start) {
280 out.push_str(&format!(" {file_display}({line},{col})"));
281 } else if !related.file.is_empty() {
282 out.push_str(&format!(" {file_display}"));
283 }
284 out.push_str(": ");
285 let message = self.translate_message(related.code, &related.message_text);
286 out.push_str(&message);
287 }
288
289 fn format_related_pretty(&mut self, out: &mut String, related: &DiagnosticRelatedInformation) {
297 let file_display = self.relative_path(&related.file);
298
299 out.push_str(" ");
301 if let Some((line, col)) = self.position_for(&related.file, related.start) {
302 if self.color {
303 out.push_str(&file_display.cyan().to_string());
304 out.push(':');
305 out.push_str(&line.to_string().yellow().to_string());
306 out.push(':');
307 out.push_str(&col.to_string().yellow().to_string());
308 } else {
309 out.push_str(&format!("{file_display}:{line}:{col}"));
310 }
311 } else if !related.file.is_empty() {
312 if self.color {
313 out.push_str(&file_display.cyan().to_string());
314 } else {
315 out.push_str(&file_display);
316 }
317 }
318
319 if let Some(snippet) =
321 self.format_snippet_pretty_related(&related.file, related.start, related.length)
322 {
323 out.push_str(&snippet);
324 }
325
326 out.push('\n');
328 out.push_str(" ");
329 let message = self.translate_message(related.code, &related.message_text);
330 out.push_str(&message);
331 }
332
333 fn format_snippet_pretty_related(
335 &mut self,
336 file: &str,
337 start: u32,
338 length: u32,
339 ) -> Option<String> {
340 if file.is_empty() || length == 0 {
341 return None;
342 }
343
344 let (line_num, column) = self.position_for(file, start)?;
345 let source = self.sources.get(file)?;
346
347 let lines: Vec<&str> = source.lines().collect();
348 let line_idx = (line_num - 1) as usize;
349 if line_idx >= lines.len() {
350 return None;
351 }
352
353 let line_text = lines[line_idx];
354 let line_num_str = line_num.to_string();
355 let line_num_width = line_num_str.len();
356
357 let mut snippet = String::new();
358 snippet.push('\n');
359
360 if self.color {
361 snippet.push_str(" ");
362 snippet.push_str(&line_num_str.reversed().to_string());
363 snippet.push(' ');
364 snippet.push_str(line_text);
365 snippet.push('\n');
366
367 snippet.push_str(" ");
368 snippet.push_str(&" ".repeat(line_num_width).reversed().to_string());
369 snippet.push(' ');
370
371 let underline = self.build_underline(line_text, column, length);
373 snippet.push_str(&underline.cyan().to_string());
374 } else {
375 snippet.push_str(" ");
376 snippet.push_str(&line_num_str);
377 snippet.push(' ');
378 snippet.push_str(line_text);
379 snippet.push('\n');
380
381 snippet.push_str(" ");
382 snippet.push_str(&" ".repeat(line_num_width));
383 snippet.push(' ');
384
385 let underline = self.build_underline(line_text, column, length);
386 snippet.push_str(&underline);
387 }
388
389 Some(snippet)
390 }
391
392 fn format_summary(&self, out: &mut String, diagnostics: &[Diagnostic]) {
394 let error_count = diagnostics
395 .iter()
396 .filter(|d| d.category == DiagnosticCategory::Error)
397 .count();
398
399 if error_count == 0 {
400 return;
401 }
402
403 let mut file_errors: Vec<(String, u32)> = Vec::new();
405 let mut seen_files: FxHashMap<String, usize> = FxHashMap::default();
406
407 for diag in diagnostics {
408 if diag.category != DiagnosticCategory::Error {
409 continue;
410 }
411 let file_display = self.relative_path(&diag.file);
412 if let Some(&idx) = seen_files.get(&file_display) {
413 file_errors[idx].1 += 1;
415 } else {
416 seen_files.insert(file_display.clone(), file_errors.len());
417 file_errors.push((file_display, 1));
418 }
419 }
420
421 let mut first_error_lines: FxHashMap<String, u32> = FxHashMap::default();
423 for diag in diagnostics {
424 if diag.category != DiagnosticCategory::Error {
425 continue;
426 }
427 let file_display = self.relative_path(&diag.file);
428 if let std::collections::hash_map::Entry::Vacant(entry) =
429 first_error_lines.entry(file_display.clone())
430 && let Some((line, _)) = self.line_maps.get(&diag.file).and_then(|lm| {
431 let source = self.sources.get(&diag.file)?;
432 let pos = lm.offset_to_position(diag.start, source);
433 Some((pos.line + 1, pos.character + 1))
434 })
435 {
436 entry.insert(line);
437 }
438 }
439
440 let error_word = if error_count == 1 { "error" } else { "errors" };
441 let unique_file_count = file_errors.len();
442
443 if unique_file_count == 1 {
444 let (ref file, _count) = file_errors[0];
445 let first_line = first_error_lines.get(file).copied().unwrap_or(1);
446
447 if error_count == 1 {
448 if self.color {
450 out.push_str(&format!(
451 "Found 1 error in {}{}\n",
452 file,
453 format!(":{first_line}").dimmed()
454 ));
455 } else {
456 out.push_str(&format!("Found 1 error in {file}:{first_line}\n"));
457 }
458 } else {
459 if self.color {
461 out.push_str(&format!(
462 "Found {} errors in the same file, starting at: {}{}\n",
463 error_count,
464 file,
465 format!(":{first_line}").dimmed()
466 ));
467 } else {
468 out.push_str(&format!(
469 "Found {error_count} errors in the same file, starting at: {file}:{first_line}\n"
470 ));
471 }
472 }
473 out.push('\n');
475 } else {
476 out.push_str(&format!(
478 "Found {error_count} {error_word} in {unique_file_count} files."
479 ));
480 out.push('\n');
481 out.push('\n');
482
483 out.push_str("Errors Files");
485
486 for (file, count) in &file_errors {
487 let first_line = first_error_lines.get(file).copied().unwrap_or(1);
488 out.push('\n');
489 out.push_str(&format!("{count:>6} {file}:{first_line}"));
490 }
491 out.push('\n');
492 }
493 }
494
495 fn relative_path(&self, file: &str) -> String {
497 if file.is_empty() {
498 return file.to_string();
499 }
500
501 if let Some(ref cwd) = self.cwd {
502 let file_path = Path::new(file);
503 let cwd_path = Path::new(cwd);
504 if let Ok(relative) = file_path.strip_prefix(cwd_path) {
505 return relative.to_string_lossy().into_owned();
506 }
507 }
508
509 file.to_string()
510 }
511
512 fn position_for(&mut self, file: &str, offset: u32) -> Option<(u32, u32)> {
513 self.ensure_source(file)?;
514 if !self.line_maps.contains_key(file) {
515 let source = self.sources.get(file)?;
516 let map = LineMap::build(source);
517 self.line_maps.insert(file.to_string(), map);
518 }
519
520 let source = self.sources.get(file)?;
521 let line_map = self.line_maps.get(file)?;
522 let position = line_map.offset_to_position(offset, source);
523 Some((position.line + 1, position.character + 1))
524 }
525
526 fn ensure_source(&mut self, file: &str) -> Option<()> {
527 if !self.sources.contains_key(file) {
528 let path = Path::new(file);
529 let contents = std::fs::read_to_string(path).ok()?;
530 self.sources.insert(file.to_string(), contents);
531 }
532 Some(())
533 }
534
535 fn format_category_label(&self, category: DiagnosticCategory) -> String {
536 let label = match category {
537 DiagnosticCategory::Error => "error",
538 DiagnosticCategory::Warning => "warning",
539 DiagnosticCategory::Suggestion => "suggestion",
540 DiagnosticCategory::Message => "message",
541 };
542
543 if !self.color {
544 return label.to_string();
545 }
546
547 match category {
548 DiagnosticCategory::Error => label.red().bold().to_string(),
549 DiagnosticCategory::Warning => label.yellow().bold().to_string(),
550 DiagnosticCategory::Suggestion => label.blue().bold().to_string(),
551 DiagnosticCategory::Message => label.cyan().bold().to_string(),
552 }
553 }
554
555 fn format_code_label(&self, code: u32) -> String {
556 if code == 0 {
557 return String::new();
558 }
559
560 let label = format!("TS{code}");
561 if self.color {
562 label.bright_blue().to_string()
563 } else {
564 label
565 }
566 }
567
568 fn translate_message(&self, code: u32, message: &str) -> String {
573 locale::translate(code, message)
574 }
575}