diff --git a/crates/oxc_cli/src/command/lint.rs b/crates/oxc_cli/src/command/lint.rs index 0bd39c07e..a2a6b3aeb 100644 --- a/crates/oxc_cli/src/command/lint.rs +++ b/crates/oxc_cli/src/command/lint.rs @@ -145,6 +145,7 @@ pub enum OutputFormat { Default, Json, Unix, + Checkstyle, } impl FromStr for OutputFormat { @@ -154,6 +155,7 @@ impl FromStr for OutputFormat { "json" => Ok(Self::Json), "default" => Ok(Self::Default), "unix" => Ok(Self::Unix), + "checkstyle" => Ok(Self::Checkstyle), _ => Err(format!("'{s}' is not a known format")), } } diff --git a/crates/oxc_cli/src/lint/mod.rs b/crates/oxc_cli/src/lint/mod.rs index f79f35dfd..0970859bf 100644 --- a/crates/oxc_cli/src/lint/mod.rs +++ b/crates/oxc_cli/src/lint/mod.rs @@ -164,8 +164,8 @@ impl LintRunner { OutputFormat::Default => {} OutputFormat::Json => diagnostic_service.set_json_reporter(), OutputFormat::Unix => diagnostic_service.set_unix_reporter(), + OutputFormat::Checkstyle => diagnostic_service.set_checkstyle_reporter(), } - diagnostic_service } } diff --git a/crates/oxc_diagnostics/src/reporter.rs b/crates/oxc_diagnostics/src/reporter.rs index a18061f7a..36e047c43 100644 --- a/crates/oxc_diagnostics/src/reporter.rs +++ b/crates/oxc_diagnostics/src/reporter.rs @@ -1,4 +1,8 @@ -use std::io::{BufWriter, Stdout, Write}; +use std::{ + borrow::Cow, + collections::HashMap, + io::{BufWriter, Stdout, Write}, +}; use crate::{ miette::{Error, JSONReportHandler}, @@ -18,6 +22,7 @@ pub enum DiagnosticReporter { Graphical { handler: GraphicalReportHandler, writer: BufWriter }, Json { diagnostics: Vec }, Unix { total: usize, writer: BufWriter }, + Checkstyle { diagnostics: Vec }, } impl DiagnosticReporter { @@ -33,6 +38,10 @@ impl DiagnosticReporter { Self::Unix { total: 0, writer: writer() } } + pub fn new_checkstyle() -> Self { + Self::Checkstyle { diagnostics: vec![] } + } + pub fn finish(&mut self) { match self { Self::Graphical { writer, .. } => { @@ -50,6 +59,9 @@ impl DiagnosticReporter { } writer.flush().unwrap(); } + Self::Checkstyle { diagnostics } => { + format_checkstyle(diagnostics); + } } } @@ -58,7 +70,7 @@ impl DiagnosticReporter { Self::Graphical { writer, .. } | Self::Unix { writer, .. } => { writer.write_all(s).unwrap(); } - Self::Json { .. } => {} + Self::Json { .. } | Self::Checkstyle { .. } => {} } } @@ -69,7 +81,7 @@ impl DiagnosticReporter { handler.render_report(&mut output, error.as_ref()).unwrap(); Some(output) } - Self::Json { diagnostics } => { + Self::Json { diagnostics } | Self::Checkstyle { diagnostics } => { diagnostics.push(error); None } @@ -81,6 +93,49 @@ impl DiagnosticReporter { } } +struct Info { + line: usize, + column: usize, + filename: String, + message: String, + severity: Severity, + rule_id: Option, +} + +impl Info { + fn new(diagnostic: &Error) -> Self { + let mut line = 0; + let mut column = 0; + let mut filename = String::new(); + let mut message = String::new(); + let mut severity = Severity::Warning; + let mut rule_id = None; + if let Some(mut labels) = diagnostic.labels() { + if let Some(source) = diagnostic.source_code() { + if let Some(label) = labels.next() { + if let Ok(span_content) = source.read_span(label.inner(), 0, 0) { + line = span_content.line() + 1; + column = span_content.column() + 1; + if let Some(name) = span_content.name() { + filename = name.to_string(); + }; + if matches!(diagnostic.severity(), Some(Severity::Error)) { + severity = Severity::Error; + } + let msg = diagnostic.to_string(); + // Our messages usually comes with `eslint(rule): message` + (rule_id, message) = msg.split_once(':').map_or_else( + || (None, msg.to_string()), + |(id, msg)| (Some(id.to_string()), msg.trim().to_string()), + ); + } + } + } + } + Self { line, column, filename, message, severity, rule_id } + } +} + /// fn format_json(diagnostics: &mut Vec) { let handler = JSONReportHandler::new(); @@ -98,33 +153,92 @@ fn format_json(diagnostics: &mut Vec) { /// fn format_unix(diagnostic: &Error) -> String { - let mut line = 0; - let mut column = 0; - let mut filename = String::new(); - let mut message = String::new(); - let mut severity = "Warning"; - let mut rule_id = String::new(); - if let Some(mut labels) = diagnostic.labels() { - if let Some(source) = diagnostic.source_code() { - if let Some(label) = labels.next() { - if let Ok(span_content) = source.read_span(label.inner(), 0, 0) { - line = span_content.line() + 1; - column = span_content.column() + 1; - if let Some(name) = span_content.name() { - filename = name.to_string(); - }; - if matches!(diagnostic.severity(), Some(Severity::Error)) { - severity = "Warning"; - } - let msg = diagnostic.to_string(); - // Our messages usually comes with `eslint(rule): message` - (rule_id, message) = msg.split_once(':').map_or_else( - || (String::new(), msg.to_string()), - |(id, msg)| (id.to_string(), msg.trim().to_string()), - ); - } - } - } - } - format!("{filename}:{line}:{column}: {message} [{severity}/{rule_id}]\n") + let Info { line, column, filename, message, severity, rule_id } = Info::new(diagnostic); + let severity = match severity { + Severity::Error => "Error", + _ => "Warning", + }; + let rule_id = + rule_id.map_or_else(|| Cow::Borrowed(""), |rule_id| Cow::Owned(format!("/{rule_id}"))); + format!("{filename}:{line}:{column}: {message} [{severity}{rule_id}]\n") +} + +fn format_checkstyle(diagnostics: &[Error]) { + let infos = diagnostics.iter().map(Info::new).collect::>(); + let mut grouped: HashMap> = HashMap::new(); + for info in infos { + grouped.entry(info.filename.clone()).or_default().push(info); + } + let messages = grouped.into_values().map(|infos| { + let messages = infos + .iter() + .fold(String::new(), |mut acc, info| { + let Info { line, column, message, severity, rule_id, .. } = info; + let severity = match severity { + Severity::Error => "error", + _ => "warning", + }; + let message = rule_id.as_ref().map_or_else(|| xml_escape(message), |rule_id| Cow::Owned(format!("{} ({rule_id})", xml_escape(message)))); + let source = rule_id.as_ref().map_or_else(|| Cow::Borrowed(""), |rule_id| Cow::Owned(format!("eslint.rules.{rule_id}"))); + let line = format!(r#""#); + acc.push_str(&line); + acc + }); + let filename = &infos[0].filename; + format!(r#"{messages}"#) + }).collect::>().join(" "); + println!( + r#"{messages}"# + ); +} + +/// +fn xml_escape(raw: &str) -> Cow { + xml_escape_impl(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"')) +} + +fn xml_escape_impl bool>(raw: &str, escape_chars: F) -> Cow { + let bytes = raw.as_bytes(); + let mut escaped = None; + let mut iter = bytes.iter(); + let mut pos = 0; + while let Some(i) = iter.position(|&b| escape_chars(b)) { + if escaped.is_none() { + escaped = Some(Vec::with_capacity(raw.len())); + } + let escaped = escaped.as_mut().expect("initialized"); + let new_pos = pos + i; + escaped.extend_from_slice(&bytes[pos..new_pos]); + match bytes[new_pos] { + b'<' => escaped.extend_from_slice(b"<"), + b'>' => escaped.extend_from_slice(b">"), + b'\'' => escaped.extend_from_slice(b"'"), + b'&' => escaped.extend_from_slice(b"&"), + b'"' => escaped.extend_from_slice(b"""), + + // This set of escapes handles characters that should be escaped + // in elements of xs:lists, because those characters works as + // delimiters of list elements + b'\t' => escaped.extend_from_slice(b" "), + b'\n' => escaped.extend_from_slice(b" "), + b'\r' => escaped.extend_from_slice(b" "), + b' ' => escaped.extend_from_slice(b" "), + _ => unreachable!( + "Only '<', '>','\', '&', '\"', '\\t', '\\r', '\\n', and ' ' are escaped" + ), + } + pos = new_pos + 1; + } + + if let Some(mut escaped) = escaped { + if let Some(raw) = bytes.get(pos..) { + escaped.extend_from_slice(raw); + } + #[allow(unsafe_code)] + // SAFETY: we operate on UTF-8 input and search for an one byte chars only, + // so all slices that was put to the `escaped` is a valid UTF-8 encoded strings + Cow::Owned(unsafe { String::from_utf8_unchecked(escaped) }) + } else { + Cow::Borrowed(raw) + } } diff --git a/crates/oxc_diagnostics/src/service.rs b/crates/oxc_diagnostics/src/service.rs index 4fe96fdc1..5d5773009 100644 --- a/crates/oxc_diagnostics/src/service.rs +++ b/crates/oxc_diagnostics/src/service.rs @@ -56,6 +56,10 @@ impl DiagnosticService { self.reporter = DiagnosticReporter::new_unix(); } + pub fn set_checkstyle_reporter(&mut self) { + self.reporter = DiagnosticReporter::new_checkstyle(); + } + pub fn is_graphical_output(&self) -> bool { matches!(self.reporter, DiagnosticReporter::Graphical { .. }) }