Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): Junit reporter #8756

Merged
merged 9 commits into from
Feb 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),
Sysix marked this conversation as resolved.
Show resolved Hide resolved
_ => 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
----------