Skip to main content

tsz_cli/
reporter.rs

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    /// Set whether pretty mode is enabled (source snippets, colon-separated locations).
31    /// By default, pretty mode matches the color setting.
32    pub const fn set_pretty(&mut self, pretty: bool) {
33        self.pretty = pretty;
34    }
35
36    /// Render all diagnostics, matching tsc output format exactly.
37    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    /// Render diagnostics in non-pretty mode (--pretty false).
50    /// Format: `file(line,col): error TScode: message`
51    /// No source snippets, no summary line.
52    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        // tsc always ends non-pretty output with a newline
60        if !diagnostics.is_empty() {
61            out.push('\n');
62        }
63    }
64
65    /// Render diagnostics in pretty mode (--pretty true / default with terminal).
66    /// Format: `file:line:col - error TScode: message` + source snippet + summary.
67    fn render_pretty(&mut self, out: &mut String, diagnostics: &[Diagnostic]) {
68        for diagnostic in diagnostics {
69            self.format_diagnostic_pretty(out, diagnostic);
70            // Each diagnostic ends with a trailing blank line (tsc format)
71            out.push('\n');
72            out.push('\n');
73        }
74
75        // Summary line (preceded by extra blank line = two blank lines after last diagnostic)
76        if !diagnostics.is_empty() {
77            out.push('\n');
78            self.format_summary(out, diagnostics);
79        }
80    }
81
82    /// Format a single diagnostic in non-pretty mode.
83    /// `file(line,col): error TScode: message`
84    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        // Translate message using current locale if available
101        let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
102        out.push_str(&message);
103
104        // Non-pretty: related info is shown inline
105        for related in &diagnostic.related_information {
106            out.push('\n');
107            self.format_related_plain(out, related);
108        }
109    }
110
111    /// Format a single diagnostic in pretty mode.
112    /// ```text
113    /// file:line:col - error TScode: message
114    ///
115    /// {line_num} {source_line}
116    /// {spaces}{tildes}
117    /// ```
118    fn format_diagnostic_pretty(&mut self, out: &mut String, diagnostic: &Diagnostic) {
119        let file_display = self.relative_path(&diagnostic.file);
120
121        // Header line: file:line:col - error TScode: message
122        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        // Translate message using current locale if available
153        let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
154        out.push_str(&message);
155
156        // Source snippet
157        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        // Related information
165        for related in &diagnostic.related_information {
166            out.push('\n');
167            self.format_related_pretty(out, related);
168        }
169    }
170
171    /// Format a source code snippet in pretty mode, matching tsc's format exactly.
172    /// ```text
173    /// {line_num} {source_line}
174    /// {spaces}{tildes}
175    /// ```
176    /// The `indent` parameter adds leading spaces (used for related info: 4 spaces).
177    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        // Get the line containing the error
192        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        // Source line: {indent}{line_num} {source_line}
204        let mut snippet = String::new();
205        // Empty line before source (tsc always has a blank line between header and source)
206        snippet.push('\n');
207
208        if self.color {
209            snippet.push_str(&indent_str);
210            // tsc uses reverse video for line numbers
211            snippet.push_str(&line_num_str.reversed().to_string());
212            snippet.push(' ');
213            snippet.push_str(line_text);
214            snippet.push('\n');
215
216            // Underline line
217            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            // Underline line: spaces matching line_num width + space + column offset + tildes
231            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    /// Build the underline string (spaces + tildes) for a given column and length.
243    /// Column is 1-indexed. The underline aligns within the source line.
244    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                // Before the error span - pad with spaces (tabs expand to spaces)
251                if ch == '\t' {
252                    underline.push_str("    ");
253                } else {
254                    underline.push(' ');
255                }
256            } else if i < col_0 + length as usize {
257                // Within the error span
258                if ch == '\t' {
259                    underline.push_str("~~~~");
260                } else {
261                    underline.push('~');
262                }
263            } else {
264                break;
265            }
266        }
267
268        // If underline is empty but we have a length, show at least one ~
269        if underline.trim().is_empty() && length > 0 {
270            underline = " ".repeat(col_0) + "~";
271        }
272
273        underline
274    }
275
276    /// Format related information in non-pretty mode.
277    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    /// Format related information in pretty mode.
290    /// ```text
291    ///   file:line:col
292    ///     {line_num} {source_line}
293    ///     {spaces}{tildes}
294    ///     message
295    /// ```
296    fn format_related_pretty(&mut self, out: &mut String, related: &DiagnosticRelatedInformation) {
297        let file_display = self.relative_path(&related.file);
298
299        // Location line (2-space indent)
300        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        // Source snippet (4-space indent)
320        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        // Message (4-space indent)
327        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    /// Format snippet for related info with 4-space indent and cyan underline.
334    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            // Related info uses cyan underline (not red)
372            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    /// Format the error summary line at the end of pretty output, matching tsc exactly.
393    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        // Collect unique files that have errors and find first error line per file
404        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                // Update count for existing file entry
414                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        // Find the first error line per file for the summary
422        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                // "Found 1 error in file:line\n\n" (tsc adds trailing blank line)
449                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                // "Found N errors in the same file, starting at: file:line\n\n"
460                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            // tsc adds a trailing blank line after single-file summaries
474            out.push('\n');
475        } else {
476            // "Found N errors in M files." + file table (no trailing blank line)
477            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            // "Errors  Files" table
484            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    /// Get a file path relative to cwd (matching tsc behavior).
496    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    /// Translate a diagnostic message using the current locale.
569    ///
570    /// If a locale is set and has a translation for the given code, returns
571    /// the translated message. Otherwise returns the original message.
572    fn translate_message(&self, code: u32, message: &str) -> String {
573        locale::translate(code, message)
574    }
575}