#![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 {
let mut buf = [0u8; 4];
for ch in self.0.chars() {
let escaped: &str = match ch {
'&' => "&",
'<' => "<",
'>' => ">",
'"' => """,
'\'' => "'",
'\t' => "	",
'\n' => "
",
'\r' => "
",
c if (c as u32) < 0x20 => "?",
c => c.encode_utf8(&mut buf),
};
f.write_str(escaped)?;
}
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}");
}
#[test]
fn whitespace_in_attribute_round_trips_via_numeric_refs() {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let r = OffenderRecord {
path: PathBuf::from("src/weird\npath\twith\rwhitespace.rs"),
function: None,
start_line: 1,
end_line: 1,
start_col: None,
metric: "cyclomatic".into(),
value: 1.0,
limit: 0.0,
severity: Severity::Warning,
};
let out = render(&[r]);
assert!(out.contains("
"), "missing 
 (LF) in {out}");
assert!(out.contains("	"), "missing 	 (TAB) in {out}");
assert!(out.contains("
"), "missing 
 (CR) in {out}");
let name_open = out.find("name=\"").expect("name attribute present");
let after_open = &out[name_open + b"name=\"".len()..];
let name_close = after_open.find('"').expect("name attribute closed");
let attr_lit = &after_open[..name_close];
assert!(
!attr_lit.contains('\n') && !attr_lit.contains('\t') && !attr_lit.contains('\r'),
"raw whitespace leaked into attribute literal: {attr_lit:?}"
);
let mut reader = Reader::from_str(&out);
let mut buf = Vec::new();
let mut roundtripped: Option<String> = None;
loop {
match reader.read_event_into(&mut buf).expect("well-formed XML") {
Event::Start(start) | Event::Empty(start) if start.name().as_ref() == b"file" => {
for attr in start.attributes().with_checks(false).flatten() {
if attr.key.as_ref() == b"name" {
roundtripped = Some(
attr.unescape_value()
.expect("attribute value decodes")
.into_owned(),
);
}
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
let roundtripped = roundtripped.expect("found <file name=...>");
assert_eq!(roundtripped, "src/weird\npath\twith\rwhitespace.rs");
}
#[test]
fn predefined_entities_still_escape_after_whitespace_fix() {
let r = OffenderRecord {
path: PathBuf::from("a&b<c>d\"e'f.rs"),
function: None,
start_line: 1,
end_line: 1,
start_col: None,
metric: "cyclomatic".into(),
value: 1.0,
limit: 0.0,
severity: Severity::Warning,
};
let out = render(&[r]);
assert!(
out.contains(r#"name="a&b<c>d"e'f.rs""#),
"predefined-entity escapes regressed: {out}"
);
}
}