feat(oxlint): add stylish formatter (#8607)

👋 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 <cameron.clark@hey.com>
This commit is contained in:
Andrew Powell 2025-01-20 20:55:07 -05:00 committed by GitHub
parent 178c2322f5
commit 8a0eb2abb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 151 additions and 2 deletions

View file

@ -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<dyn DiagnosticReporter>;
@ -64,10 +67,10 @@ impl OutputFormatter {
OutputFormat::Github => Box::new(GithubOutputFormatter),
OutputFormat::Unix => Box::<UnixOutputFormatter>::default(),
OutputFormat::Default => Box::new(DefaultOutputFormatter),
OutputFormat::Stylish => Box::<StylishOutputFormatter>::default(),
}
}
// print all rules which are currently supported by oxlint
pub fn all_rules(&mut self, writer: &mut BufWriter<Stdout>) {
self.internal_formatter.all_rules(writer);
}

View file

@ -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<dyn DiagnosticReporter> {
Box::new(StylishReporter::default())
}
}
#[derive(Default)]
struct StylishReporter {
diagnostics: Vec<Error>,
}
impl DiagnosticReporter for StylishReporter {
fn finish(&mut self) -> Option<String> {
Some(format_stylish(&self.diagnostics))
}
fn render_error(&mut self, error: Error) -> Option<String> {
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<String, Vec<&Error>> = FxHashMap::default();
let mut sorted = diagnostics.iter().collect::<Vec<_>>();
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::<Vec<_>>)
.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");
}
}