diff --git a/apps/oxlint/src/output_formatter/checkstyle.rs b/apps/oxlint/src/output_formatter/checkstyle.rs index 871e9f659755d..5f529f3928893 100644 --- a/apps/oxlint/src/output_formatter/checkstyle.rs +++ b/apps/oxlint/src/output_formatter/checkstyle.rs @@ -7,7 +7,7 @@ use oxc_diagnostics::{ Error, Severity, }; -use crate::output_formatter::InternalFormatter; +use crate::output_formatter::{xml_utils::xml_escape, InternalFormatter}; #[derive(Debug, Default)] pub struct CheckStyleOutputFormatter; @@ -66,57 +66,6 @@ fn format_checkstyle(diagnostics: &[Error]) -> String { ) } -/// -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); - } - - // 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) - } -} - #[cfg(test)] mod test { use oxc_diagnostics::{ diff --git a/apps/oxlint/src/output_formatter/junit.rs b/apps/oxlint/src/output_formatter/junit.rs new file mode 100644 index 0000000000000..9e1faebcd5805 --- /dev/null +++ b/apps/oxlint/src/output_formatter/junit.rs @@ -0,0 +1,122 @@ +use oxc_diagnostics::{ + reporter::{DiagnosticReporter, DiagnosticResult, Info}, + Error, Severity, +}; +use rustc_hash::FxHashMap; + +use super::{xml_utils::xml_escape, InternalFormatter}; + +#[derive(Default)] +pub struct JUnitOutputFormatter; + +impl InternalFormatter for JUnitOutputFormatter { + fn get_diagnostic_reporter(&self) -> Box { + Box::new(JUnitReporter::default()) + } +} + +#[derive(Default)] +struct JUnitReporter { + diagnostics: Vec, +} + +impl DiagnosticReporter for JUnitReporter { + fn finish(&mut self, _: &DiagnosticResult) -> Option { + Some(format_junit(&self.diagnostics)) + } + + fn render_error(&mut self, error: Error) -> Option { + self.diagnostics.push(error); + None + } +} + +fn format_junit(diagnostics: &[Error]) -> String { + let mut grouped: FxHashMap> = FxHashMap::default(); + let mut total_errors = 0; + let mut total_warnings = 0; + + for diagnostic in diagnostics { + let info = Info::new(diagnostic); + grouped.entry(info.filename).or_default().push(diagnostic); + } + + let mut test_suite = String::new(); + for diagnostics in grouped.values() { + let diagnostic = diagnostics[0]; + let filename = Info::new(diagnostic).filename; + let mut test_cases = String::new(); + let mut error = 0; + let mut warning = 0; + + for diagnostic in diagnostics { + let rule = diagnostic.code().map_or_else(String::new, |code| code.to_string()); + let Info { message, start, .. } = Info::new(diagnostic); + + let severity = if let Some(Severity::Error) = diagnostic.severity() { + total_errors += 1; + error += 1; + "error" + } else { + total_warnings += 1; + warning += 1; + "failure" + }; + let description = + format!("line {}, column {}, {}", start.line, start.column, xml_escape(&message)); + + let status = format!( + " <{} message=\"{}\">{}", + severity, + xml_escape(&message), + description, + severity + ); + let test_case = + format!("\n \n{status}\n "); + test_cases = format!("{test_cases}{test_case}"); + } + test_suite = format!(" {}\n ", filename, diagnostics.len(), error, warning, test_cases); + } + let test_suites = format!("\n{}\n\n", total_errors + total_warnings, total_warnings, total_errors, test_suite); + + format!("\n{test_suites}") +} + +#[cfg(test)] +mod test { + use super::*; + use oxc_diagnostics::{reporter::DiagnosticResult, NamedSource, OxcDiagnostic}; + use oxc_span::Span; + + #[test] + fn test_junit_reporter() { + const EXPECTED_REPORT: &str = r#" + + + + line 1, column 1, error message + + + line 1, column 1, warning message + + + +"#; + let mut reporter = JUnitReporter::default(); + + let error = OxcDiagnostic::error("error message") + .with_label(Span::new(0, 8)) + .with_source_code(NamedSource::new("file.js", "let a = ;")); + + let warning = OxcDiagnostic::warn("warning message") + .with_label(Span::new(0, 9)) + .with_source_code(NamedSource::new("file.js", "debugger;")); + + reporter.render_error(error); + reporter.render_error(warning); + + let output = reporter.finish(&DiagnosticResult::default()).unwrap(); + assert_eq!(output.to_string(), EXPECTED_REPORT); + } +} diff --git a/apps/oxlint/src/output_formatter/mod.rs b/apps/oxlint/src/output_formatter/mod.rs index e01e58369a6d9..03af3d09f3ea1 100644 --- a/apps/oxlint/src/output_formatter/mod.rs +++ b/apps/oxlint/src/output_formatter/mod.rs @@ -2,14 +2,17 @@ mod checkstyle; mod default; mod github; mod json; +mod junit; mod stylish; mod unix; +mod xml_utils; use std::str::FromStr; use std::time::Duration; use checkstyle::CheckStyleOutputFormatter; use github::GithubOutputFormatter; +use junit::JUnitOutputFormatter; use stylish::StylishOutputFormatter; use unix::UnixOutputFormatter; @@ -27,6 +30,7 @@ pub enum OutputFormat { Unix, Checkstyle, Stylish, + JUnit, } impl FromStr for OutputFormat { @@ -40,6 +44,7 @@ impl FromStr for OutputFormat { "checkstyle" => Ok(Self::Checkstyle), "github" => Ok(Self::Github), "stylish" => Ok(Self::Stylish), + "junit" => Ok(Self::JUnit), _ => Err(format!("'{s}' is not a known format")), } } @@ -93,6 +98,7 @@ impl OutputFormatter { OutputFormat::Unix => Box::::default(), OutputFormat::Default => Box::new(DefaultOutputFormatter), OutputFormat::Stylish => Box::::default(), + OutputFormat::JUnit => Box::::default(), } } @@ -162,4 +168,11 @@ mod test { Tester::new().with_cwd(TEST_CWD.into()).test_and_snapshot(args); } + + #[test] + fn test_output_formatter_diagnostic_junit() { + let args = &["--format=junit", "test.js"]; + + Tester::new().with_cwd(TEST_CWD.into()).test_and_snapshot(args); + } } diff --git a/apps/oxlint/src/output_formatter/xml_utils.rs b/apps/oxlint/src/output_formatter/xml_utils.rs new file mode 100644 index 0000000000000..6acde7e359636 --- /dev/null +++ b/apps/oxlint/src/output_formatter/xml_utils.rs @@ -0,0 +1,52 @@ +use std::borrow::Cow; + +/// +pub 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); + } + + // 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/apps/oxlint/src/snapshots/fixtures__output_formatter_diagnostic_--format=junit test.js@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__output_formatter_diagnostic_--format=junit test.js@oxlint.snap new file mode 100644 index 0000000000000..81221705dc542 --- /dev/null +++ b/apps/oxlint/src/snapshots/fixtures__output_formatter_diagnostic_--format=junit test.js@oxlint.snap @@ -0,0 +1,25 @@ +--- +source: apps/oxlint/src/tester.rs +assertion_line: 95 +--- +########## +arguments: --format=junit test.js +working directory: fixtures/output_formatter_diagnostic +---------- + + + + + line 5, column 1, `debugger` statement is not allowed + + + line 1, column 10, Function 'foo' is declared but never used. + + + line 1, column 17, Parameter 'b' is declared but never used. Unused parameters should start with a '_'. + + + +---------- +CLI result: LintFoundErrors +----------