use colored::Colorize;
use rustc_hash::FxHashMap;
use std::path::Path;
use crate::locale;
use tsz::checker::diagnostics::{Diagnostic, DiagnosticCategory, DiagnosticRelatedInformation};
use tsz::lsp::position::LineMap;
pub struct Reporter {
pretty: bool,
color: bool,
cwd: Option<String>,
sources: FxHashMap<String, String>,
line_maps: FxHashMap<String, LineMap>,
}
impl Reporter {
pub fn new(color: bool) -> Self {
Self {
pretty: color,
color,
cwd: std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned()),
sources: FxHashMap::default(),
line_maps: FxHashMap::default(),
}
}
pub const fn set_pretty(&mut self, pretty: bool) {
self.pretty = pretty;
}
pub fn render(&mut self, diagnostics: &[Diagnostic]) -> String {
let mut out = String::new();
if self.pretty {
self.render_pretty(&mut out, diagnostics);
} else {
self.render_plain(&mut out, diagnostics);
}
out
}
fn render_plain(&mut self, out: &mut String, diagnostics: &[Diagnostic]) {
for (index, diagnostic) in diagnostics.iter().enumerate() {
if index > 0 {
out.push('\n');
}
self.format_diagnostic_plain(out, diagnostic);
}
if !diagnostics.is_empty() {
out.push('\n');
}
}
fn render_pretty(&mut self, out: &mut String, diagnostics: &[Diagnostic]) {
for diagnostic in diagnostics {
self.format_diagnostic_pretty(out, diagnostic);
out.push('\n');
out.push('\n');
}
if !diagnostics.is_empty() {
out.push('\n');
self.format_summary(out, diagnostics);
}
}
fn format_diagnostic_plain(&mut self, out: &mut String, diagnostic: &Diagnostic) {
let file_display = self.relative_path(&diagnostic.file);
if let Some((line, col)) = self.position_for(&diagnostic.file, diagnostic.start) {
out.push_str(&format!("{file_display}({line},{col})"));
} else if !diagnostic.file.is_empty() {
out.push_str(&file_display);
}
out.push_str(": ");
out.push_str(&self.format_category_label(diagnostic.category));
if diagnostic.code != 0 {
out.push(' ');
out.push_str(&self.format_code_label(diagnostic.code));
}
out.push_str(": ");
let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
out.push_str(&message);
for related in &diagnostic.related_information {
out.push('\n');
self.format_related_plain(out, related);
}
}
fn format_diagnostic_pretty(&mut self, out: &mut String, diagnostic: &Diagnostic) {
let file_display = self.relative_path(&diagnostic.file);
if let Some((line, col)) = self.position_for(&diagnostic.file, diagnostic.start) {
if self.color {
out.push_str(&file_display.cyan().to_string());
out.push(':');
out.push_str(&line.to_string().yellow().to_string());
out.push(':');
out.push_str(&col.to_string().yellow().to_string());
} else {
out.push_str(&format!("{file_display}:{line}:{col}"));
}
} else if !diagnostic.file.is_empty() {
if self.color {
out.push_str(&file_display.cyan().to_string());
} else {
out.push_str(&file_display);
}
}
out.push_str(" - ");
out.push_str(&self.format_category_label(diagnostic.category));
if diagnostic.code != 0 {
if self.color {
out.push_str(&format!(" TS{}: ", diagnostic.code).dimmed().to_string());
} else {
out.push_str(&format!(" TS{}: ", diagnostic.code));
}
} else {
out.push_str(": ");
}
let message = self.translate_message(diagnostic.code, &diagnostic.message_text);
out.push_str(&message);
if let Some(snippet) =
self.format_snippet_pretty(&diagnostic.file, diagnostic.start, diagnostic.length, 0)
{
out.push('\n');
out.push_str(&snippet);
}
for related in &diagnostic.related_information {
out.push('\n');
self.format_related_pretty(out, related);
}
}
fn format_snippet_pretty(
&mut self,
file: &str,
start: u32,
length: u32,
indent: usize,
) -> Option<String> {
if file.is_empty() || length == 0 {
return None;
}
let (line_num, column) = self.position_for(file, start)?;
let source = self.sources.get(file)?;
let lines: Vec<&str> = source.lines().collect();
let line_idx = (line_num - 1) as usize;
if line_idx >= lines.len() {
return None;
}
let line_text = lines[line_idx];
let indent_str = " ".repeat(indent);
let line_num_str = line_num.to_string();
let line_num_width = line_num_str.len();
let mut snippet = String::new();
snippet.push('\n');
if self.color {
snippet.push_str(&indent_str);
snippet.push_str(&line_num_str.reversed().to_string());
snippet.push(' ');
snippet.push_str(line_text);
snippet.push('\n');
snippet.push_str(&indent_str);
snippet.push_str(&" ".repeat(line_num_width).reversed().to_string());
snippet.push(' ');
let underline = self.build_underline(line_text, column, length);
snippet.push_str(&underline.red().to_string());
} else {
snippet.push_str(&indent_str);
snippet.push_str(&line_num_str);
snippet.push(' ');
snippet.push_str(line_text);
snippet.push('\n');
snippet.push_str(&indent_str);
snippet.push_str(&" ".repeat(line_num_width));
snippet.push(' ');
let underline = self.build_underline(line_text, column, length);
snippet.push_str(&underline);
}
Some(snippet)
}
fn build_underline(&self, line_text: &str, column: u32, length: u32) -> String {
let mut underline = String::new();
let col_0 = (column - 1) as usize;
for (i, ch) in line_text.chars().enumerate() {
if i < col_0 {
if ch == '\t' {
underline.push_str(" ");
} else {
underline.push(' ');
}
} else if i < col_0 + length as usize {
if ch == '\t' {
underline.push_str("~~~~");
} else {
underline.push('~');
}
} else {
break;
}
}
if underline.trim().is_empty() && length > 0 {
underline = " ".repeat(col_0) + "~";
}
underline
}
fn format_related_plain(&mut self, out: &mut String, related: &DiagnosticRelatedInformation) {
let file_display = self.relative_path(&related.file);
if let Some((line, col)) = self.position_for(&related.file, related.start) {
out.push_str(&format!(" {file_display}({line},{col})"));
} else if !related.file.is_empty() {
out.push_str(&format!(" {file_display}"));
}
out.push_str(": ");
let message = self.translate_message(related.code, &related.message_text);
out.push_str(&message);
}
fn format_related_pretty(&mut self, out: &mut String, related: &DiagnosticRelatedInformation) {
let file_display = self.relative_path(&related.file);
out.push_str(" ");
if let Some((line, col)) = self.position_for(&related.file, related.start) {
if self.color {
out.push_str(&file_display.cyan().to_string());
out.push(':');
out.push_str(&line.to_string().yellow().to_string());
out.push(':');
out.push_str(&col.to_string().yellow().to_string());
} else {
out.push_str(&format!("{file_display}:{line}:{col}"));
}
} else if !related.file.is_empty() {
if self.color {
out.push_str(&file_display.cyan().to_string());
} else {
out.push_str(&file_display);
}
}
if let Some(snippet) =
self.format_snippet_pretty_related(&related.file, related.start, related.length)
{
out.push_str(&snippet);
}
out.push('\n');
out.push_str(" ");
let message = self.translate_message(related.code, &related.message_text);
out.push_str(&message);
}
fn format_snippet_pretty_related(
&mut self,
file: &str,
start: u32,
length: u32,
) -> Option<String> {
if file.is_empty() || length == 0 {
return None;
}
let (line_num, column) = self.position_for(file, start)?;
let source = self.sources.get(file)?;
let lines: Vec<&str> = source.lines().collect();
let line_idx = (line_num - 1) as usize;
if line_idx >= lines.len() {
return None;
}
let line_text = lines[line_idx];
let line_num_str = line_num.to_string();
let line_num_width = line_num_str.len();
let mut snippet = String::new();
snippet.push('\n');
if self.color {
snippet.push_str(" ");
snippet.push_str(&line_num_str.reversed().to_string());
snippet.push(' ');
snippet.push_str(line_text);
snippet.push('\n');
snippet.push_str(" ");
snippet.push_str(&" ".repeat(line_num_width).reversed().to_string());
snippet.push(' ');
let underline = self.build_underline(line_text, column, length);
snippet.push_str(&underline.cyan().to_string());
} else {
snippet.push_str(" ");
snippet.push_str(&line_num_str);
snippet.push(' ');
snippet.push_str(line_text);
snippet.push('\n');
snippet.push_str(" ");
snippet.push_str(&" ".repeat(line_num_width));
snippet.push(' ');
let underline = self.build_underline(line_text, column, length);
snippet.push_str(&underline);
}
Some(snippet)
}
fn format_summary(&self, out: &mut String, diagnostics: &[Diagnostic]) {
let error_count = diagnostics
.iter()
.filter(|d| d.category == DiagnosticCategory::Error)
.count();
if error_count == 0 {
return;
}
let mut file_errors: Vec<(String, u32)> = Vec::new();
let mut seen_files: FxHashMap<String, usize> = FxHashMap::default();
for diag in diagnostics {
if diag.category != DiagnosticCategory::Error {
continue;
}
let file_display = self.relative_path(&diag.file);
if let Some(&idx) = seen_files.get(&file_display) {
file_errors[idx].1 += 1;
} else {
seen_files.insert(file_display.clone(), file_errors.len());
file_errors.push((file_display, 1));
}
}
let mut first_error_lines: FxHashMap<String, u32> = FxHashMap::default();
for diag in diagnostics {
if diag.category != DiagnosticCategory::Error {
continue;
}
let file_display = self.relative_path(&diag.file);
if let std::collections::hash_map::Entry::Vacant(entry) =
first_error_lines.entry(file_display.clone())
&& let Some((line, _)) = self.line_maps.get(&diag.file).and_then(|lm| {
let source = self.sources.get(&diag.file)?;
let pos = lm.offset_to_position(diag.start, source);
Some((pos.line + 1, pos.character + 1))
})
{
entry.insert(line);
}
}
let error_word = if error_count == 1 { "error" } else { "errors" };
let unique_file_count = file_errors.len();
if unique_file_count == 1 {
let (ref file, _count) = file_errors[0];
let first_line = first_error_lines.get(file).copied().unwrap_or(1);
if error_count == 1 {
if self.color {
out.push_str(&format!(
"Found 1 error in {}{}\n",
file,
format!(":{first_line}").dimmed()
));
} else {
out.push_str(&format!("Found 1 error in {file}:{first_line}\n"));
}
} else {
if self.color {
out.push_str(&format!(
"Found {} errors in the same file, starting at: {}{}\n",
error_count,
file,
format!(":{first_line}").dimmed()
));
} else {
out.push_str(&format!(
"Found {error_count} errors in the same file, starting at: {file}:{first_line}\n"
));
}
}
out.push('\n');
} else {
out.push_str(&format!(
"Found {error_count} {error_word} in {unique_file_count} files."
));
out.push('\n');
out.push('\n');
out.push_str("Errors Files");
for (file, count) in &file_errors {
let first_line = first_error_lines.get(file).copied().unwrap_or(1);
out.push('\n');
out.push_str(&format!("{count:>6} {file}:{first_line}"));
}
out.push('\n');
}
}
fn relative_path(&self, file: &str) -> String {
if file.is_empty() {
return file.to_string();
}
if let Some(ref cwd) = self.cwd {
let file_path = Path::new(file);
let cwd_path = Path::new(cwd);
if let Ok(relative) = file_path.strip_prefix(cwd_path) {
return relative.to_string_lossy().into_owned();
}
}
file.to_string()
}
fn position_for(&mut self, file: &str, offset: u32) -> Option<(u32, u32)> {
self.ensure_source(file)?;
if !self.line_maps.contains_key(file) {
let source = self.sources.get(file)?;
let map = LineMap::build(source);
self.line_maps.insert(file.to_string(), map);
}
let source = self.sources.get(file)?;
let line_map = self.line_maps.get(file)?;
let position = line_map.offset_to_position(offset, source);
Some((position.line + 1, position.character + 1))
}
fn ensure_source(&mut self, file: &str) -> Option<()> {
if !self.sources.contains_key(file) {
let path = Path::new(file);
let contents = std::fs::read_to_string(path).ok()?;
self.sources.insert(file.to_string(), contents);
}
Some(())
}
fn format_category_label(&self, category: DiagnosticCategory) -> String {
let label = match category {
DiagnosticCategory::Error => "error",
DiagnosticCategory::Warning => "warning",
DiagnosticCategory::Suggestion => "suggestion",
DiagnosticCategory::Message => "message",
};
if !self.color {
return label.to_string();
}
match category {
DiagnosticCategory::Error => label.red().bold().to_string(),
DiagnosticCategory::Warning => label.yellow().bold().to_string(),
DiagnosticCategory::Suggestion => label.blue().bold().to_string(),
DiagnosticCategory::Message => label.cyan().bold().to_string(),
}
}
fn format_code_label(&self, code: u32) -> String {
if code == 0 {
return String::new();
}
let label = format!("TS{code}");
if self.color {
label.bright_blue().to_string()
} else {
label
}
}
fn translate_message(&self, code: u32, message: &str) -> String {
locale::translate(code, message)
}
}