From 8a0eb2abb77c14bbf906028931ed285b85beb76d Mon Sep 17 00:00:00 2001 From: Andrew Powell Date: Mon, 20 Jan 2025 20:55:07 -0500 Subject: [PATCH] feat(oxlint): add stylish formatter (#8607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 👋 This implements a reporter for `--format` on `oxlint` which aims to be visually similar to https://eslint.org/docs/latest/use/formatters/#stylish Please note that this is my first time working with Rust and my knowledge is very limited. I'm unlikely to understand best-practice or best-pattern references outside of what clippy/cargo lint has already had me change. If this needs modification, please help me out by making code suggestions that can be merged to this PR. Resolves #8422 --------- Co-authored-by: Cameron --- apps/oxlint/src/output_formatter/mod.rs | 7 +- apps/oxlint/src/output_formatter/stylish.rs | 146 ++++++++++++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 apps/oxlint/src/output_formatter/stylish.rs diff --git a/apps/oxlint/src/output_formatter/mod.rs b/apps/oxlint/src/output_formatter/mod.rs index 4b437eb69e9dd..31d87f02de956 100644 --- a/apps/oxlint/src/output_formatter/mod.rs +++ b/apps/oxlint/src/output_formatter/mod.rs @@ -2,6 +2,7 @@ mod checkstyle; mod default; mod github; mod json; +mod stylish; mod unix; use std::io::{BufWriter, Stdout, Write}; @@ -9,6 +10,7 @@ use std::str::FromStr; use checkstyle::CheckStyleOutputFormatter; use github::GithubOutputFormatter; +use stylish::StylishOutputFormatter; use unix::UnixOutputFormatter; use oxc_diagnostics::reporter::DiagnosticReporter; @@ -24,6 +26,7 @@ pub enum OutputFormat { Json, Unix, Checkstyle, + Stylish, } impl FromStr for OutputFormat { @@ -36,13 +39,13 @@ impl FromStr for OutputFormat { "unix" => Ok(Self::Unix), "checkstyle" => Ok(Self::Checkstyle), "github" => Ok(Self::Github), + "stylish" => Ok(Self::Stylish), _ => Err(format!("'{s}' is not a known format")), } } } trait InternalFormatter { - // print all rules which are currently supported by oxlint fn all_rules(&mut self, writer: &mut dyn Write); fn get_diagnostic_reporter(&self) -> Box; @@ -64,10 +67,10 @@ impl OutputFormatter { OutputFormat::Github => Box::new(GithubOutputFormatter), OutputFormat::Unix => Box::::default(), OutputFormat::Default => Box::new(DefaultOutputFormatter), + OutputFormat::Stylish => Box::::default(), } } - // print all rules which are currently supported by oxlint pub fn all_rules(&mut self, writer: &mut BufWriter) { self.internal_formatter.all_rules(writer); } diff --git a/apps/oxlint/src/output_formatter/stylish.rs b/apps/oxlint/src/output_formatter/stylish.rs new file mode 100644 index 0000000000000..e6669d61ed82a --- /dev/null +++ b/apps/oxlint/src/output_formatter/stylish.rs @@ -0,0 +1,146 @@ +use std::io::Write; + +use oxc_diagnostics::{ + reporter::{DiagnosticReporter, Info}, + Error, Severity, +}; +use rustc_hash::FxHashMap; + +use crate::output_formatter::InternalFormatter; + +#[derive(Debug, Default)] +pub struct StylishOutputFormatter; + +impl InternalFormatter for StylishOutputFormatter { + fn all_rules(&mut self, writer: &mut dyn Write) { + writeln!(writer, "flag --rules with flag --format=stylish is not allowed").unwrap(); + } + + fn get_diagnostic_reporter(&self) -> Box { + Box::new(StylishReporter::default()) + } +} + +#[derive(Default)] +struct StylishReporter { + diagnostics: Vec, +} + +impl DiagnosticReporter for StylishReporter { + fn finish(&mut self) -> Option { + Some(format_stylish(&self.diagnostics)) + } + + fn render_error(&mut self, error: Error) -> Option { + self.diagnostics.push(error); + None + } +} + +fn format_stylish(diagnostics: &[Error]) -> String { + if diagnostics.is_empty() { + return String::new(); + } + + let mut output = String::new(); + let mut total_errors = 0; + let mut total_warnings = 0; + + let mut grouped: FxHashMap> = FxHashMap::default(); + let mut sorted = diagnostics.iter().collect::>(); + + sorted.sort_by_key(|diagnostic| Info::new(diagnostic).line); + + for diagnostic in sorted { + let info = Info::new(diagnostic); + grouped.entry(info.filename).or_default().push(diagnostic); + } + + for diagnostics in grouped.values() { + let diagnostic = diagnostics[0]; + let info = Info::new(diagnostic); + let filename = info.filename; + let filename = if let Some(path) = + std::env::current_dir().ok().and_then(|d| d.join(&filename).canonicalize().ok()) + { + path.display().to_string() + } else { + filename + }; + let max_len_width = diagnostics + .iter() + .filter_map(|diagnostic| diagnostic.labels()) + .flat_map(std::iter::Iterator::collect::>) + .map(|label| format!("{}:{}", label.offset(), label.len()).len()) + .max() + .unwrap_or(0); + + output.push_str(&format!("\n\u{1b}[4m{filename}\u{1b}[0m\n")); + + for diagnostic in diagnostics { + match diagnostic.severity() { + Some(Severity::Error) => total_errors += 1, + _ => total_warnings += 1, + } + + let severity_str = if diagnostic.severity() == Some(Severity::Error) { + "\u{1b}[31merror\u{1b}[0m" + } else { + "\u{1b}[33mwarning\u{1b}[0m" + }; + + if let Some(label) = diagnostic.labels().expect("should have labels").next() { + let rule = diagnostic.code().map_or_else(String::new, |code| code.to_string()); + let position = format!("{}:{}", label.offset(), label.len()); + output.push_str( + &format!(" \u{1b}[2m{position:max_len_width$}\u{1b}[0m {severity_str} {diagnostic} \u{1b}[2m{rule}\u{1b}[0m\n"), + ); + } + } + } + + let total = total_errors + total_warnings; + if total > 0 { + let summary_color = if total_errors > 0 { "\u{1b}[31m" } else { "\u{1b}[33m" }; + output.push_str(&format!( + "\n{summary_color}✖ {total} problem{} ({total_errors} error{}, {total_warnings} warning{})\u{1b}[0m\n", + if total == 1 { "" } else { "s" }, + if total_errors == 1 { "" } else { "s" }, + if total_warnings == 1 { "" } else { "s" } + )); + } + + output +} + +#[cfg(test)] +mod test { + use super::*; + use oxc_diagnostics::{NamedSource, OxcDiagnostic}; + use oxc_span::Span; + + #[test] + fn test_stylish_reporter() { + let mut reporter = StylishReporter::default(); + + let error = OxcDiagnostic::error("error message") + .with_label(Span::new(0, 8)) + .with_source_code(NamedSource::new("file.js", "code")); + + let warning = OxcDiagnostic::warn("warning message") + .with_label(Span::new(0, 8)) + .with_source_code(NamedSource::new("file.js", "code")); + + reporter.render_error(error); + reporter.render_error(warning); + + let output = reporter.finish().unwrap(); + + assert!(output.contains("error message"), "Output should contain 'error message'"); + assert!(output.contains("warning message"), "Output should contain 'warning message'"); + assert!(output.contains("\u{2716}"), "Output should contain the ✖ character"); + assert!(output.contains("2 problems"), "Output should mention total problems"); + assert!(output.contains("1 error"), "Output should mention error count"); + assert!(output.contains("1 warning"), "Output should mention warning count"); + } +}