big_code_analysis/output/checkstyle.rs
1//! Checkstyle 4.3 XML writer for [`OffenderRecord`] batches.
2//!
3//! Checkstyle is the de-facto interchange format for Jenkins, SonarQube,
4//! GitLab, and most "warnings plugin" CI integrations. We emit a single
5//! XML document covering every offender, grouped by source path:
6//!
7//! ```xml
8//! <?xml version="1.0" encoding="UTF-8"?>
9//! <checkstyle version="4.3">
10//! <file name="src/foo.rs">
11//! <error line="42" column="5" severity="warning"
12//! message="cyclomatic 17 exceeds limit 15"
13//! source="big-code-analysis.cyclomatic"/>
14//! </file>
15//! </checkstyle>
16//! ```
17//!
18//! XML escaping is hand-rolled because the surface is tiny (five
19//! entities in attribute values) and adding a new dependency is not
20//! worth it for that.
21
22#![allow(clippy::doc_markdown)]
23
24use std::collections::BTreeMap;
25use std::io::{self, Write};
26
27use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
28
29/// Write Checkstyle 4.3 XML for `offenders` to `writer`.
30///
31/// Offenders are grouped by `path` (sorted lexicographically by the
32/// UTF-8 representation; non-UTF-8 paths are skipped with a warning to
33/// stderr) so the output is deterministic and snapshot-friendly. Within
34/// a file, errors retain their input order.
35///
36/// The empty case still emits a well-formed `<checkstyle version="4.3"/>`
37/// document so consumers can rely on a non-empty file always being
38/// parseable.
39///
40/// # Errors
41///
42/// Propagates any [`io::Error`] returned by `writer` while emitting
43/// the XML envelope, the per-file `<file>` blocks, or their contained
44/// `<error>` elements.
45pub fn write_checkstyle<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
46 writer.write_all(b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")?;
47
48 // Group while preserving per-file insertion order. BTreeMap key is
49 // the UTF-8 path; this also gives us deterministic file ordering.
50 let mut by_file: BTreeMap<&str, Vec<&OffenderRecord>> = BTreeMap::new();
51 for record in offenders {
52 let Some(path_str) = warn_non_utf8_path("Checkstyle", &record.path) else {
53 continue;
54 };
55 by_file.entry(path_str).or_default().push(record);
56 }
57
58 // Empty input *and* all-non-UTF-8 input both end up here with an
59 // empty `by_file`, so one branch covers both cases.
60 if by_file.is_empty() {
61 writer.write_all(b"<checkstyle version=\"4.3\"/>\n")?;
62 return Ok(());
63 }
64
65 writer.write_all(b"<checkstyle version=\"4.3\">\n")?;
66 for (path_str, records) in by_file {
67 writeln!(writer, " <file name=\"{}\">", XmlAttr(path_str))?;
68 for record in records {
69 write_error(&mut writer, record)?;
70 }
71 writer.write_all(b" </file>\n")?;
72 }
73 writer.write_all(b"</checkstyle>\n")
74}
75
76fn write_error<W: Write>(writer: &mut W, record: &OffenderRecord) -> io::Result<()> {
77 let message = record.default_message();
78 write!(writer, " <error line=\"{}\"", record.start_line.max(1))?;
79 if let Some(col) = record.start_col {
80 // Checkstyle columns are 1-based; clamp symmetrically with the
81 // `start_line.max(1)` above so a 0-based-column producer can
82 // never emit `column="0"` (#784, mirrors SARIF's clamp #698).
83 write!(writer, " column=\"{}\"", col.max(1))?;
84 }
85 writeln!(
86 writer,
87 " severity=\"{}\" message=\"{}\" source=\"{}.{}\"/>",
88 record.severity.as_str(),
89 XmlAttr(&message),
90 TOOL_ID,
91 XmlAttr(&record.metric),
92 )
93}
94
95/// Format adapter that XML-escapes attribute values. We escape the five
96/// XML predefined entities, emit numeric character references for TAB
97/// / LF / CR (which XML 1.0 §3.3.3 attribute-value normalization would
98/// otherwise collapse to a single space), and replace with `?` every
99/// code point that is either:
100///
101/// - Forbidden by XML 1.0 §2.2's `Char` production: C0 controls minus
102/// TAB/LF/CR, and the BMP non-characters `U+FFFE` / `U+FFFF`.
103/// - A supplementary-plane non-character (`U+nFFFE` / `U+nFFFF` for
104/// plane `n` in `1..=16`). These are technically permitted by XML
105/// 1.0's `Char` production, but strict consumers (libxml2 in
106/// non-character-reject mode, Jenkins, SonarQube) treat them as
107/// fatal. Substituting them aligns the output with the strict
108/// consumer class without changing well-formedness for lenient
109/// parsers.
110///
111/// Note: `U+FDD0`–`U+FDEF` are Unicode non-characters but are
112/// permitted by XML 1.0's `Char` production and accepted by libxml2's
113/// strict mode, so we pass them through.
114struct XmlAttr<'a>(&'a str);
115
116impl std::fmt::Display for XmlAttr<'_> {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 // Unify every per-char escape behind a single `f.write_str` so the
119 // `?` operator fires once per iteration instead of once per arm —
120 // each `?` is a counted exit on the per-function `nexits` budget.
121 // The 4-byte stack buffer covers every UTF-8 scalar; `encode_utf8`
122 // borrows from it for the default arm so all arms unify as `&str`.
123 let mut buf = [0u8; 4];
124 for ch in self.0.chars() {
125 let escaped: &str = match ch {
126 '&' => "&",
127 '<' => "<",
128 '>' => ">",
129 '"' => """,
130 '\'' => "'",
131 // XML 1.0 §3.3.3 mandates attribute-value normalization:
132 // a conforming parser collapses literal TAB / LF / CR
133 // bytes inside an attribute value to a single space on
134 // read. To round-trip these characters intact (POSIX
135 // paths may contain newlines, and future message
136 // templates may span lines), emit numeric character
137 // references — they are exempt from normalization.
138 '\t' => "	",
139 '\n' => "
",
140 '\r' => "
",
141 // XML 1.0 §2.2's `Char` production forbids the remaining
142 // C0 controls (U+0000–U+001F minus TAB/LF/CR). We also
143 // substitute every plane-end non-character (`U+nFFFE` /
144 // `U+nFFFF` for plane `n` in `0..=16`) — the BMP pair
145 // is `Char`-illegal, the 32 supplementary-plane
146 // counterparts are permitted by the spec but rejected
147 // by strict libxml2-based consumers (Jenkins,
148 // SonarQube). The bitmask `(cp & 0xFFFF) >= 0xFFFE`
149 // catches all 34 plane-end non-characters in one test
150 // without touching `U+FDD0`–`U+FDEF` (which the spec
151 // and libxml2 both accept).
152 c if (c as u32) < 0x20 || ((c as u32) & 0xFFFF) >= 0xFFFE => "?",
153 c => c.encode_utf8(&mut buf),
154 };
155 f.write_str(escaped)?;
156 }
157 Ok(())
158 }
159}
160
161#[cfg(test)]
162#[allow(
163 clippy::float_cmp,
164 clippy::cast_precision_loss,
165 clippy::cast_possible_truncation,
166 clippy::cast_sign_loss,
167 clippy::similar_names,
168 clippy::doc_markdown,
169 clippy::needless_raw_string_hashes,
170 clippy::too_many_lines
171)]
172#[path = "checkstyle_tests.rs"]
173mod tests;