Skip to content

Commit

Permalink
feat(linter): Junit reporter (#8756)
Browse files Browse the repository at this point in the history
closes #7960

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
tapanprakasht and autofix-ci[bot] authored Feb 2, 2025
1 parent 831928d commit 7e8568b
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 52 deletions.
53 changes: 1 addition & 52 deletions apps/oxlint/src/output_formatter/checkstyle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,57 +66,6 @@ fn format_checkstyle(diagnostics: &[Error]) -> String {
)
}

/// <https://github.com/tafia/quick-xml/blob/6e34a730853fe295d68dc28460153f08a5a12955/src/escapei.rs#L84-L86>
fn xml_escape(raw: &str) -> Cow<str> {
xml_escape_impl(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"'))
}

fn xml_escape_impl<F: Fn(u8) -> bool>(raw: &str, escape_chars: F) -> Cow<str> {
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"&lt;"),
b'>' => escaped.extend_from_slice(b"&gt;"),
b'\'' => escaped.extend_from_slice(b"&apos;"),
b'&' => escaped.extend_from_slice(b"&amp;"),
b'"' => escaped.extend_from_slice(b"&quot;"),

// 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"&#9;"),
b'\n' => escaped.extend_from_slice(b"&#10;"),
b'\r' => escaped.extend_from_slice(b"&#13;"),
b' ' => escaped.extend_from_slice(b"&#32;"),
_ => 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::{
Expand Down
122 changes: 122 additions & 0 deletions apps/oxlint/src/output_formatter/junit.rs
Original file line number Diff line number Diff line change
@@ -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<dyn DiagnosticReporter> {
Box::new(JUnitReporter::default())
}
}

#[derive(Default)]
struct JUnitReporter {
diagnostics: Vec<Error>,
}

impl DiagnosticReporter for JUnitReporter {
fn finish(&mut self, _: &DiagnosticResult) -> Option<String> {
Some(format_junit(&self.diagnostics))
}

fn render_error(&mut self, error: Error) -> Option<String> {
self.diagnostics.push(error);
None
}
}

fn format_junit(diagnostics: &[Error]) -> String {
let mut grouped: FxHashMap<String, Vec<&Error>> = 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 <testcase name=\"{rule}\">\n{status}\n </testcase>");
test_cases = format!("{test_cases}{test_case}");
}
test_suite = format!(" <testsuite name=\"{}\" tests=\"{}\" disabled=\"0\" errors=\"{}\" failures=\"{}\">{}\n </testsuite>", filename, diagnostics.len(), error, warning, test_cases);
}
let test_suites = format!("<testsuites name=\"Oxlint\" tests=\"{}\" failures=\"{}\" errors=\"{}\">\n{}\n</testsuites>\n", total_errors + total_warnings, total_warnings, total_errors, test_suite);

format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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#"<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Oxlint" tests="2" failures="1" errors="1">
<testsuite name="file.js" tests="2" disabled="0" errors="1" failures="1">
<testcase name="">
<error message="error message">line 1, column 1, error message</error>
</testcase>
<testcase name="">
<failure message="warning message">line 1, column 1, warning message</failure>
</testcase>
</testsuite>
</testsuites>
"#;
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);
}
}
13 changes: 13 additions & 0 deletions apps/oxlint/src/output_formatter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -27,6 +30,7 @@ pub enum OutputFormat {
Unix,
Checkstyle,
Stylish,
JUnit,
}

impl FromStr for OutputFormat {
Expand All @@ -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")),
}
}
Expand Down Expand Up @@ -93,6 +98,7 @@ impl OutputFormatter {
OutputFormat::Unix => Box::<UnixOutputFormatter>::default(),
OutputFormat::Default => Box::new(DefaultOutputFormatter),
OutputFormat::Stylish => Box::<StylishOutputFormatter>::default(),
OutputFormat::JUnit => Box::<JUnitOutputFormatter>::default(),
}
}

Expand Down Expand Up @@ -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);
}
}
52 changes: 52 additions & 0 deletions apps/oxlint/src/output_formatter/xml_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use std::borrow::Cow;

/// <https://github.com/tafia/quick-xml/blob/6e34a730853fe295d68dc28460153f08a5a12955/src/escapei.rs#L84-L86>
pub fn xml_escape(raw: &str) -> Cow<str> {
xml_escape_impl(raw, |ch| matches!(ch, b'<' | b'>' | b'&' | b'\'' | b'\"'))
}

fn xml_escape_impl<F: Fn(u8) -> bool>(raw: &str, escape_chars: F) -> Cow<str> {
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"&lt;"),
b'>' => escaped.extend_from_slice(b"&gt;"),
b'\'' => escaped.extend_from_slice(b"&apos;"),
b'&' => escaped.extend_from_slice(b"&amp;"),
b'"' => escaped.extend_from_slice(b"&quot;"),

// 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"&#9;"),
b'\n' => escaped.extend_from_slice(b"&#10;"),
b'\r' => escaped.extend_from_slice(b"&#13;"),
b' ' => escaped.extend_from_slice(b"&#32;"),
_ => 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
source: apps/oxlint/src/tester.rs
assertion_line: 95
---
##########
arguments: --format=junit test.js
working directory: fixtures/output_formatter_diagnostic
----------
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Oxlint" tests="3" failures="2" errors="1">
<testsuite name="test.js" tests="3" disabled="0" errors="1" failures="2">
<testcase name="eslint(no-debugger)">
<error message="`debugger` statement is not allowed">line 5, column 1, `debugger` statement is not allowed</error>
</testcase>
<testcase name="eslint(no-unused-vars)">
<failure message="Function &apos;foo&apos; is declared but never used.">line 1, column 10, Function &apos;foo&apos; is declared but never used.</failure>
</testcase>
<testcase name="eslint(no-unused-vars)">
<failure message="Parameter &apos;b&apos; is declared but never used. Unused parameters should start with a &apos;_&apos;.">line 1, column 17, Parameter &apos;b&apos; is declared but never used. Unused parameters should start with a &apos;_&apos;.</failure>
</testcase>
</testsuite>
</testsuites>
----------
CLI result: LintFoundErrors
----------

0 comments on commit 7e8568b

Please sign in to comment.