Skip to content

Commit 997dc2e

Browse files
ntBreMichaReiser
andauthored
Move JUnit rendering to ruff_db (#19370)
Summary -- This PR moves the JUnit output format to the new rendering infrastructure. As I mention in a TODO in the code, there's some code that will be shared with the `grouped` output format. Hopefully I'll have that PR up too by the time this one is reviewed. Test Plan -- Existing tests moved to `ruff_db` --------- Co-authored-by: Micha Reiser <micha@reiser.io>
1 parent 4aee039 commit 997dc2e

File tree

12 files changed

+222
-134
lines changed

12 files changed

+222
-134
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ruff/src/printer.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use ruff_db::diagnostic::{
1515
use ruff_linter::fs::relativize_path;
1616
use ruff_linter::logging::LogLevel;
1717
use ruff_linter::message::{
18-
Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, JunitEmitter,
19-
SarifEmitter, TextEmitter,
18+
Emitter, EmitterContext, GithubEmitter, GitlabEmitter, GroupedEmitter, SarifEmitter,
19+
TextEmitter,
2020
};
2121
use ruff_linter::notify_user;
2222
use ruff_linter::settings::flags::{self};
@@ -252,7 +252,11 @@ impl Printer {
252252
write!(writer, "{value}")?;
253253
}
254254
OutputFormat::Junit => {
255-
JunitEmitter.emit(writer, &diagnostics.inner, &context)?;
255+
let config = DisplayDiagnosticConfig::default()
256+
.format(DiagnosticFormat::Junit)
257+
.preview(preview);
258+
let value = DisplayDiagnostics::new(&context, &config, &diagnostics.inner);
259+
write!(writer, "{value}")?;
256260
}
257261
OutputFormat::Concise | OutputFormat::Full => {
258262
TextEmitter::default()

crates/ruff/tests/snapshots/lint__output_format_junit.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ exit_code: 1
2525
<testcase name="org.ruff.F821" classname="[TMP]/input" line="2" column="5">
2626
<failure message="Undefined name `y`">line 2, col 5, Undefined name `y`</failure>
2727
</testcase>
28-
<testcase name="org.ruff" classname="[TMP]/input" line="3" column="1">
28+
<testcase name="org.ruff.invalid-syntax" classname="[TMP]/input" line="3" column="1">
2929
<failure message="SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)">line 3, col 1, SyntaxError: Cannot use `match` statement on Python 3.9 (syntax was added in Python 3.10)</failure>
3030
</testcase>
3131
</testsuite>

crates/ruff_db/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ glob = { workspace = true }
3434
ignore = { workspace = true, optional = true }
3535
matchit = { workspace = true }
3636
path-slash = { workspace = true }
37+
quick-junit = { workspace = true, optional = true }
3738
rustc-hash = { workspace = true }
3839
salsa = { workspace = true }
3940
schemars = { workspace = true, optional = true }
@@ -56,6 +57,7 @@ tempfile = { workspace = true }
5657

5758
[features]
5859
cache = ["ruff_cache"]
60+
junit = ["dep:quick-junit"]
5961
os = ["ignore", "dep:etcetera"]
6062
serde = ["camino/serde1", "dep:serde", "dep:serde_json", "ruff_diagnostics/serde"]
6163
# Exposes testing utilities.

crates/ruff_db/src/diagnostic/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,9 @@ pub enum DiagnosticFormat {
12821282
Rdjson,
12831283
/// Print diagnostics in the format emitted by Pylint.
12841284
Pylint,
1285+
/// Print diagnostics in the format expected by JUnit.
1286+
#[cfg(feature = "junit")]
1287+
Junit,
12851288
}
12861289

12871290
/// A representation of the kinds of messages inside a diagnostic.

crates/ruff_db/src/diagnostic/render.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ mod azure;
3030
mod json;
3131
#[cfg(feature = "serde")]
3232
mod json_lines;
33+
#[cfg(feature = "junit")]
34+
mod junit;
3335
mod pylint;
3436
#[cfg(feature = "serde")]
3537
mod rdjson;
@@ -196,6 +198,10 @@ impl std::fmt::Display for DisplayDiagnostics<'_> {
196198
DiagnosticFormat::Pylint => {
197199
PylintRenderer::new(self.resolver).render(f, self.diagnostics)?;
198200
}
201+
#[cfg(feature = "junit")]
202+
DiagnosticFormat::Junit => {
203+
junit::JunitRenderer::new(self.resolver).render(f, self.diagnostics)?;
204+
}
199205
}
200206

201207
Ok(())
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
use std::{collections::BTreeMap, ops::Deref, path::Path};
2+
3+
use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite, XmlString};
4+
5+
use ruff_source_file::LineColumn;
6+
7+
use crate::diagnostic::{Diagnostic, SecondaryCode, render::FileResolver};
8+
9+
/// A renderer for diagnostics in the [JUnit] format.
10+
///
11+
/// See [`junit.xsd`] for the specification in the JUnit repository and an annotated [version]
12+
/// linked from the [`quick_junit`] docs.
13+
///
14+
/// [JUnit]: https://junit.org/
15+
/// [`junit.xsd`]: https://github.com/junit-team/junit-framework/blob/2870b7d8fd5bf7c1efe489d3991d3ed3900e82bb/platform-tests/src/test/resources/jenkins-junit.xsd
16+
/// [version]: https://llg.cubic.org/docs/junit/
17+
/// [`quick_junit`]: https://docs.rs/quick-junit/latest/quick_junit/
18+
pub struct JunitRenderer<'a> {
19+
resolver: &'a dyn FileResolver,
20+
}
21+
22+
impl<'a> JunitRenderer<'a> {
23+
pub fn new(resolver: &'a dyn FileResolver) -> Self {
24+
Self { resolver }
25+
}
26+
27+
pub(super) fn render(
28+
&self,
29+
f: &mut std::fmt::Formatter,
30+
diagnostics: &[Diagnostic],
31+
) -> std::fmt::Result {
32+
let mut report = Report::new("ruff");
33+
34+
if diagnostics.is_empty() {
35+
let mut test_suite = TestSuite::new("ruff");
36+
test_suite
37+
.extra
38+
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
39+
let mut case = TestCase::new("No errors found", TestCaseStatus::success());
40+
case.set_classname("ruff");
41+
test_suite.add_test_case(case);
42+
report.add_test_suite(test_suite);
43+
} else {
44+
for (filename, diagnostics) in group_diagnostics_by_filename(diagnostics, self.resolver)
45+
{
46+
let mut test_suite = TestSuite::new(filename);
47+
test_suite
48+
.extra
49+
.insert(XmlString::new("package"), XmlString::new("org.ruff"));
50+
51+
let classname = Path::new(filename).with_extension("");
52+
53+
for diagnostic in diagnostics {
54+
let DiagnosticWithLocation {
55+
diagnostic,
56+
start_location: location,
57+
} = diagnostic;
58+
let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
59+
status.set_message(diagnostic.body());
60+
61+
if let Some(location) = location {
62+
status.set_description(format!(
63+
"line {row}, col {col}, {body}",
64+
row = location.line,
65+
col = location.column,
66+
body = diagnostic.body()
67+
));
68+
} else {
69+
status.set_description(diagnostic.body());
70+
}
71+
72+
let code = diagnostic
73+
.secondary_code()
74+
.map_or_else(|| diagnostic.name(), SecondaryCode::as_str);
75+
let mut case = TestCase::new(format!("org.ruff.{code}"), status);
76+
case.set_classname(classname.to_str().unwrap());
77+
78+
if let Some(location) = location {
79+
case.extra.insert(
80+
XmlString::new("line"),
81+
XmlString::new(location.line.to_string()),
82+
);
83+
case.extra.insert(
84+
XmlString::new("column"),
85+
XmlString::new(location.column.to_string()),
86+
);
87+
}
88+
89+
test_suite.add_test_case(case);
90+
}
91+
report.add_test_suite(test_suite);
92+
}
93+
}
94+
95+
let adapter = FmtAdapter { fmt: f };
96+
report.serialize(adapter).map_err(|_| std::fmt::Error)
97+
}
98+
}
99+
100+
// TODO(brent) this and `group_diagnostics_by_filename` are also used by the `grouped` output
101+
// format. I think they'd make more sense in that file, but I started here first. I'll move them to
102+
// that module when adding the `grouped` output format.
103+
struct DiagnosticWithLocation<'a> {
104+
diagnostic: &'a Diagnostic,
105+
start_location: Option<LineColumn>,
106+
}
107+
108+
impl Deref for DiagnosticWithLocation<'_> {
109+
type Target = Diagnostic;
110+
111+
fn deref(&self) -> &Self::Target {
112+
self.diagnostic
113+
}
114+
}
115+
116+
fn group_diagnostics_by_filename<'a>(
117+
diagnostics: &'a [Diagnostic],
118+
resolver: &'a dyn FileResolver,
119+
) -> BTreeMap<&'a str, Vec<DiagnosticWithLocation<'a>>> {
120+
let mut grouped_diagnostics = BTreeMap::default();
121+
for diagnostic in diagnostics {
122+
let (filename, start_location) = diagnostic
123+
.primary_span_ref()
124+
.map(|span| {
125+
let file = span.file();
126+
let start_location =
127+
span.range()
128+
.filter(|_| !resolver.is_notebook(file))
129+
.map(|range| {
130+
file.diagnostic_source(resolver)
131+
.as_source_code()
132+
.line_column(range.start())
133+
});
134+
135+
(span.file().path(resolver), start_location)
136+
})
137+
.unwrap_or_default();
138+
139+
grouped_diagnostics
140+
.entry(filename)
141+
.or_insert_with(Vec::new)
142+
.push(DiagnosticWithLocation {
143+
diagnostic,
144+
start_location,
145+
});
146+
}
147+
grouped_diagnostics
148+
}
149+
150+
struct FmtAdapter<'a> {
151+
fmt: &'a mut dyn std::fmt::Write,
152+
}
153+
154+
impl std::io::Write for FmtAdapter<'_> {
155+
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
156+
self.fmt
157+
.write_str(std::str::from_utf8(buf).map_err(|_| {
158+
std::io::Error::new(
159+
std::io::ErrorKind::InvalidData,
160+
"Invalid UTF-8 in JUnit report",
161+
)
162+
})?)
163+
.map_err(std::io::Error::other)?;
164+
165+
Ok(buf.len())
166+
}
167+
168+
fn flush(&mut self) -> std::io::Result<()> {
169+
Ok(())
170+
}
171+
172+
fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
173+
self.fmt.write_fmt(args).map_err(std::io::Error::other)
174+
}
175+
}
176+
177+
#[cfg(test)]
178+
mod tests {
179+
use crate::diagnostic::{
180+
DiagnosticFormat,
181+
render::tests::{create_diagnostics, create_syntax_error_diagnostics},
182+
};
183+
184+
#[test]
185+
fn output() {
186+
let (env, diagnostics) = create_diagnostics(DiagnosticFormat::Junit);
187+
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
188+
}
189+
190+
#[test]
191+
fn syntax_errors() {
192+
let (env, diagnostics) = create_syntax_error_diagnostics(DiagnosticFormat::Junit);
193+
insta::assert_snapshot!(env.render_diagnostics(&diagnostics));
194+
}
195+
}

crates/ruff_linter/src/message/snapshots/ruff_linter__message__junit__tests__output.snap renamed to crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__junit__tests__output.snap

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
---
2-
source: crates/ruff_linter/src/message/junit.rs
3-
expression: content
4-
snapshot_kind: text
2+
source: crates/ruff_db/src/diagnostic/render/junit.rs
3+
expression: env.render_diagnostics(&diagnostics)
54
---
65
<?xml version="1.0" encoding="UTF-8"?>
76
<testsuites name="ruff" tests="3" failures="3" errors="0">

crates/ruff_linter/src/message/snapshots/ruff_linter__message__junit__tests__syntax_errors.snap renamed to crates/ruff_db/src/diagnostic/render/snapshots/ruff_db__diagnostic__render__junit__tests__syntax_errors.snap

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
---
2-
source: crates/ruff_linter/src/message/junit.rs
3-
expression: content
4-
snapshot_kind: text
2+
source: crates/ruff_db/src/diagnostic/render/junit.rs
3+
expression: env.render_diagnostics(&diagnostics)
54
---
65
<?xml version="1.0" encoding="UTF-8"?>
76
<testsuites name="ruff" tests="2" failures="2" errors="0">
87
<testsuite name="syntax_errors.py" tests="2" disabled="0" errors="0" failures="2" package="org.ruff">
9-
<testcase name="org.ruff" classname="syntax_errors" line="1" column="15">
8+
<testcase name="org.ruff.invalid-syntax" classname="syntax_errors" line="1" column="15">
109
<failure message="SyntaxError: Expected one or more symbol names after import">line 1, col 15, SyntaxError: Expected one or more symbol names after import</failure>
1110
</testcase>
12-
<testcase name="org.ruff" classname="syntax_errors" line="3" column="12">
11+
<testcase name="org.ruff.invalid-syntax" classname="syntax_errors" line="3" column="12">
1312
<failure message="SyntaxError: Expected &apos;)&apos;, found newline">line 3, col 12, SyntaxError: Expected &apos;)&apos;, found newline</failure>
1413
</testcase>
1514
</testsuite>

crates/ruff_linter/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ license = { workspace = true }
1515
[dependencies]
1616
ruff_annotate_snippets = { workspace = true }
1717
ruff_cache = { workspace = true }
18-
ruff_db = { workspace = true, features = ["serde"] }
18+
ruff_db = { workspace = true, features = ["junit", "serde"] }
1919
ruff_diagnostics = { workspace = true, features = ["serde"] }
2020
ruff_notebook = { workspace = true }
2121
ruff_macros = { workspace = true }
@@ -55,7 +55,6 @@ path-absolutize = { workspace = true, features = [
5555
pathdiff = { workspace = true }
5656
pep440_rs = { workspace = true }
5757
pyproject-toml = { workspace = true }
58-
quick-junit = { workspace = true }
5958
regex = { workspace = true }
6059
rustc-hash = { workspace = true }
6160
schemars = { workspace = true, optional = true }

0 commit comments

Comments
 (0)