#![allow(clippy::doc_markdown)]
use std::collections::BTreeMap;
use std::io::{self, Write};
use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
pub fn write_checkstyle<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
writer.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
let mut by_file: BTreeMap<&str, Vec<&OffenderRecord>> = BTreeMap::new();
for record in offenders {
let Some(path_str) = warn_non_utf8_path("Checkstyle", &record.path) else {
continue;
};
by_file.entry(path_str).or_default().push(record);
}
if by_file.is_empty() {
writer.write_all(b"<checkstyle version=\"4.3\"/>\n")?;
return Ok(());
}
writer.write_all(b"<checkstyle version=\"4.3\">\n")?;
for (path_str, records) in by_file {
writeln!(writer, " <file name=\"{}\">", XmlAttr(path_str))?;
for record in records {
write_error(&mut writer, record)?;
}
writer.write_all(b" </file>\n")?;
}
writer.write_all(b"</checkstyle>\n")
}
fn write_error<W: Write>(writer: &mut W, record: &OffenderRecord) -> io::Result<()> {
let message = record.default_message();
write!(writer, " <error line=\"{}\"", record.start_line.max(1))?;
if let Some(col) = record.start_col {
write!(writer, " column=\"{col}\"")?;
}
writeln!(
writer,
" severity=\"{}\" message=\"{}\" source=\"{}.{}\"/>",
record.severity.as_str(),
XmlAttr(&message),
TOOL_ID,
XmlAttr(&record.metric),
)
}
struct XmlAttr<'a>(&'a str);
impl std::fmt::Display for XmlAttr<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use std::fmt::Write as _;
for ch in self.0.chars() {
match ch {
'&' => f.write_str("&")?,
'<' => f.write_str("<")?,
'>' => f.write_str(">")?,
'"' => f.write_str(""")?,
'\'' => f.write_str("'")?,
'\t' | '\n' | '\r' => f.write_char(ch)?,
c if (c as u32) < 0x20 => f.write_char('?')?,
c => f.write_char(c)?,
}
}
Ok(())
}
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::doc_markdown,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod tests {
use super::*;
use crate::output::offenders::Severity;
use std::path::PathBuf;
fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
OffenderRecord {
path: PathBuf::from(path),
function: Some("f".into()),
start_line: 42,
end_line: 50,
start_col: Some(5),
metric: metric.into(),
value,
limit,
severity: Severity::Warning,
}
}
fn render(offenders: &[OffenderRecord]) -> String {
let mut buf = Vec::new();
write_checkstyle(offenders, &mut buf).expect("writing to Vec is infallible");
String::from_utf8(buf).expect("output is UTF-8")
}
#[test]
fn empty_emits_self_closing_root() {
insta::assert_snapshot!(render(&[]), @r###"
<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="4.3"/>
"###);
}
#[test]
fn single_offender_round_trips() {
let offenders = vec![rec("src/foo.rs", "cyclomatic", 17.0, 15.0)];
insta::assert_snapshot!(render(&offenders), @r###"
<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="4.3">
<file name="src/foo.rs">
<error line="42" column="5" severity="warning" message="cyclomatic 17 exceeds limit 15" source="big-code-analysis.cyclomatic"/>
</file>
</checkstyle>
"###);
}
#[test]
fn multiple_files_grouped_alphabetically() {
let offenders = vec![
rec("src/zeta.rs", "cyclomatic", 20.0, 15.0),
rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
rec("src/alpha.rs", "halstead.volume", 1234.5, 1000.0),
];
insta::assert_snapshot!(render(&offenders), @r###"
<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="4.3">
<file name="src/alpha.rs">
<error line="42" column="5" severity="warning" message="loc.lloc 250 exceeds limit 100" source="big-code-analysis.loc.lloc"/>
<error line="42" column="5" severity="warning" message="halstead.volume 1234.5 exceeds limit 1000" source="big-code-analysis.halstead.volume"/>
</file>
<file name="src/zeta.rs">
<error line="42" column="5" severity="warning" message="cyclomatic 20 exceeds limit 15" source="big-code-analysis.cyclomatic"/>
</file>
</checkstyle>
"###);
}
#[test]
fn error_severity_renders_as_error() {
let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
r.severity = Severity::Error;
let out = render(&[r]);
assert!(out.contains(r#"severity="error""#), "{out}");
}
#[test]
fn missing_column_omits_attribute() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_col = None;
let out = render(&[r]);
assert!(!out.contains("column="), "{out}");
assert!(out.contains(r#"line="42""#), "{out}");
}
#[test]
fn xml_special_chars_in_path_and_metric_are_escaped() {
let r = OffenderRecord {
path: PathBuf::from(r#"src/<a&b>"c'd.rs"#),
function: None,
start_line: 1,
end_line: 1,
start_col: None,
metric: r#"weird"&<metric>"#.into(),
value: 1.0,
limit: 0.0,
severity: Severity::Warning,
};
let out = render(&[r]);
assert!(
out.contains(r#"name="src/<a&b>"c'd.rs""#),
"{out}"
);
assert!(
out.contains(r#"source="big-code-analysis.weird"&<metric>""#),
"{out}"
);
}
#[test]
fn start_line_zero_is_clamped_to_one() {
let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
r.start_line = 0;
let out = render(&[r]);
assert!(out.contains(r#"line="1""#), "{out}");
}
#[test]
fn control_characters_in_message_replaced() {
let r = OffenderRecord {
path: PathBuf::from("a.rs"),
function: None,
start_line: 1,
end_line: 1,
start_col: None,
metric: "weird\u{0001}name".into(),
value: 1.0,
limit: 0.0,
severity: Severity::Warning,
};
let out = render(&[r]);
assert!(out.contains("weird?name"), "{out}");
}
}