Skip to main content

big_code_analysis/output/
warning_line.rs

1//! Compiler-warning line writers for [`OffenderRecord`] batches.
2//!
3//! Editor- and CI-annotator-friendly inline warnings: one offender per
4//! line, in the conventional Clang/GCC and MSVC formats that quickfix
5//! parsers (VS Code, IntelliJ, Vim) and CI annotators (GitHub Actions
6//! `::warning::`, GitLab, Jenkins warnings-ng) recognize out of the
7//! box.
8//!
9//! Clang/GCC ([`write_clang_warning`]):
10//!
11//! ```text
12//! path/to/file.rs:42:5: warning: cyclomatic 17 exceeds limit 15 [big-code-analysis-cyclomatic]
13//! ```
14//!
15//! MSVC ([`write_msvc_warning`]):
16//!
17//! ```text
18//! path\to\file.rs(42,5): warning : cyclomatic 17 exceeds limit 15
19//! ```
20//!
21//! Both writers emit one line per offender. An empty offender slice
22//! produces empty output (zero bytes), not a blank line. Offenders
23//! whose path is not valid UTF-8 are skipped with a warning to stderr.
24
25#![allow(clippy::doc_markdown)]
26
27use std::io::{self, Write};
28
29use crate::output::offenders::{OffenderRecord, TOOL_ID, warn_non_utf8_path};
30
31/// Default column when an offender has no [`OffenderRecord::start_col`].
32/// Both formats require a column; `1` is the conventional placeholder
33/// (matching how Clang itself reports diagnostics whose column is
34/// unknown).
35const DEFAULT_COL: u32 = 1;
36
37/// Write Clang/GCC-style warning lines for `offenders` to `writer`.
38///
39/// Format: `{path}:{line}:{col}: {severity}: {message} [{rule}]`,
40/// terminated by `\n`. Non-UTF-8 paths are skipped with a stderr
41/// warning. An empty `offenders` slice writes nothing.
42///
43/// # Errors
44///
45/// Returns any [`io::Error`] produced by `writer` while emitting a
46/// warning line. Stops at the first error; partially-written output
47/// may have reached the writer before the failure.
48pub fn write_clang_warning<W: Write>(
49    offenders: &[OffenderRecord],
50    mut writer: W,
51) -> io::Result<()> {
52    for record in offenders {
53        let Some(path) = warn_non_utf8_path("clang-warning", &record.path) else {
54            continue;
55        };
56        let line = record.start_line.max(1);
57        let col = record.start_col.unwrap_or(DEFAULT_COL).max(1);
58        writeln!(
59            writer,
60            "{path}:{line}:{col}: {severity}: {message} [{prefix}-{metric}]",
61            severity = record.severity.as_str(),
62            message = record.default_message(),
63            prefix = TOOL_ID,
64            metric = record.metric,
65        )?;
66    }
67    Ok(())
68}
69
70/// Write MSVC-style warning lines for `offenders` to `writer`.
71///
72/// Format: `{path}({line},{col}): {severity} : {message}`, terminated
73/// by `\n`. Note the space before the colon after `severity` — that is
74/// the MSVC convention. On Windows the path uses `\` separators
75/// (matching cl.exe output); on other platforms it is emitted as-is.
76/// Non-UTF-8 paths are skipped with a stderr warning. An empty
77/// `offenders` slice writes nothing.
78///
79/// # Errors
80///
81/// Returns any [`io::Error`] produced by `writer` while emitting a
82/// warning line. Stops at the first error; partially-written output
83/// may have reached the writer before the failure.
84pub fn write_msvc_warning<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
85    for record in offenders {
86        let Some(raw_path) = warn_non_utf8_path("msvc-warning", &record.path) else {
87            continue;
88        };
89        let path = msvc_path(raw_path);
90        let line = record.start_line.max(1);
91        let col = record.start_col.unwrap_or(DEFAULT_COL).max(1);
92        writeln!(
93            writer,
94            "{path}({line},{col}): {severity} : {message}",
95            severity = record.severity.as_str(),
96            message = record.default_message(),
97        )?;
98    }
99    Ok(())
100}
101
102/// On Windows, normalize forward slashes to backslashes so the output
103/// matches the path style cl.exe emits (and quickfix parsers in
104/// Windows IDEs expect). Elsewhere the input is returned untouched —
105/// CI logs running on Linux/macOS keep `/` separators, which the
106/// quickfix parsers also accept.
107#[cfg(windows)]
108fn msvc_path(raw: &str) -> std::borrow::Cow<'_, str> {
109    if raw.contains('/') {
110        std::borrow::Cow::Owned(raw.replace('/', "\\"))
111    } else {
112        std::borrow::Cow::Borrowed(raw)
113    }
114}
115
116#[cfg(not(windows))]
117fn msvc_path(raw: &str) -> &str {
118    raw
119}
120
121#[cfg(test)]
122#[allow(
123    clippy::float_cmp,
124    clippy::cast_precision_loss,
125    clippy::cast_possible_truncation,
126    clippy::cast_sign_loss,
127    clippy::similar_names,
128    clippy::doc_markdown,
129    clippy::needless_raw_string_hashes,
130    clippy::too_many_lines
131)]
132mod tests {
133    use super::*;
134    use crate::output::offenders::Severity;
135    use std::path::PathBuf;
136
137    fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
138        OffenderRecord {
139            path: PathBuf::from(path),
140            function: Some("f".into()),
141            start_line: 42,
142            end_line: 50,
143            start_col: Some(5),
144            metric: metric.into(),
145            value,
146            limit,
147            severity: Severity::Warning,
148        }
149    }
150
151    fn render_clang(offenders: &[OffenderRecord]) -> String {
152        let mut buf = Vec::new();
153        write_clang_warning(offenders, &mut buf).expect("writing to Vec is infallible");
154        String::from_utf8(buf).expect("output is UTF-8")
155    }
156
157    fn render_msvc(offenders: &[OffenderRecord]) -> String {
158        let mut buf = Vec::new();
159        write_msvc_warning(offenders, &mut buf).expect("writing to Vec is infallible");
160        String::from_utf8(buf).expect("output is UTF-8")
161    }
162
163    #[test]
164    fn clang_empty_writes_nothing() {
165        assert_eq!(render_clang(&[]), "");
166    }
167
168    #[test]
169    fn msvc_empty_writes_nothing() {
170        assert_eq!(render_msvc(&[]), "");
171    }
172
173    #[test]
174    fn clang_single_offender() {
175        let out = render_clang(&[rec("src/foo.rs", "cyclomatic", 17.0, 15.0)]);
176        assert_eq!(
177            out,
178            "src/foo.rs:42:5: warning: cyclomatic 17 exceeds limit 15 [big-code-analysis-cyclomatic]\n"
179        );
180    }
181
182    #[test]
183    fn msvc_single_offender() {
184        let out = render_msvc(&[rec("src/foo.rs", "cyclomatic", 17.0, 15.0)]);
185        // On non-Windows, separators are preserved as-is.
186        #[cfg(not(windows))]
187        assert_eq!(
188            out,
189            "src/foo.rs(42,5): warning : cyclomatic 17 exceeds limit 15\n"
190        );
191        #[cfg(windows)]
192        assert_eq!(
193            out,
194            "src\\foo.rs(42,5): warning : cyclomatic 17 exceeds limit 15\n"
195        );
196    }
197
198    #[test]
199    fn clang_missing_column_defaults_to_one() {
200        let mut r = rec("a.rs", "cognitive", 30.0, 15.0);
201        r.start_col = None;
202        let out = render_clang(&[r]);
203        assert!(out.starts_with("a.rs:42:1: warning: "), "{out}");
204    }
205
206    #[test]
207    fn msvc_missing_column_defaults_to_one() {
208        let mut r = rec("a.rs", "cognitive", 30.0, 15.0);
209        r.start_col = None;
210        let out = render_msvc(&[r]);
211        assert!(out.starts_with("a.rs(42,1): warning : "), "{out}");
212    }
213
214    #[test]
215    fn clang_error_severity_renders_error_token() {
216        let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
217        r.severity = Severity::Error;
218        let out = render_clang(&[r]);
219        assert!(out.contains(": error: "), "{out}");
220    }
221
222    #[test]
223    fn msvc_error_severity_renders_error_token() {
224        let mut r = rec("a.rs", "cyclomatic", 99.0, 15.0);
225        r.severity = Severity::Error;
226        let out = render_msvc(&[r]);
227        // Note the space before the colon: "error :" not "error:".
228        assert!(out.contains("): error : "), "{out}");
229    }
230
231    #[test]
232    fn clang_integer_value_has_no_decimal_point() {
233        let out = render_clang(&[rec("a.rs", "cyclomatic", 17.0, 15.0)]);
234        assert!(out.contains("cyclomatic 17 exceeds limit 15"), "{out}");
235        assert!(!out.contains("17.0"), "{out}");
236        assert!(!out.contains("15.0"), "{out}");
237    }
238
239    #[test]
240    fn clang_fractional_value_renders_decimals() {
241        let out = render_clang(&[rec("a.rs", "halstead.volume", 12.5, 10.0)]);
242        assert!(
243            out.contains("halstead.volume 12.5 exceeds limit 10"),
244            "{out}"
245        );
246    }
247
248    #[test]
249    fn clang_zero_start_line_clamps_to_one() {
250        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
251        r.start_line = 0;
252        let out = render_clang(&[r]);
253        assert!(out.starts_with("a.rs:1:5: "), "{out}");
254    }
255
256    #[test]
257    fn msvc_zero_start_line_clamps_to_one() {
258        let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
259        r.start_line = 0;
260        let out = render_msvc(&[r]);
261        assert!(out.starts_with("a.rs(1,5): "), "{out}");
262    }
263
264    #[test]
265    fn clang_multi_offender_one_line_each() {
266        let offenders = vec![
267            rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
268            rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
269            rec("src/zeta.rs", "cognitive", 30.0, 15.0),
270        ];
271        let out = render_clang(&offenders);
272        assert_eq!(out.lines().count(), 3);
273        assert!(out.ends_with('\n'));
274    }
275
276    #[test]
277    fn clang_function_name_does_not_appear_in_line() {
278        // The Clang format has no field for a function name; the
279        // writer must not silently smuggle it in.
280        let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
281        // function is Some("f")
282        let out = render_clang(&[r]);
283        // The bare token "f" could appear inside other words; assert
284        // the structural shape instead.
285        assert_eq!(
286            out,
287            "a.rs:42:5: warning: cyclomatic 17 exceeds limit 15 [big-code-analysis-cyclomatic]\n"
288        );
289    }
290
291    #[test]
292    fn clang_empty_snapshot() {
293        insta::assert_snapshot!("clang_warning_empty", render_clang(&[]));
294    }
295
296    #[test]
297    fn clang_multi_snapshot() {
298        let mut err = rec("src/zeta.rs", "cognitive", 30.0, 15.0);
299        err.severity = Severity::Error;
300        err.start_col = None;
301        err.function = None;
302        let offenders = vec![
303            rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
304            rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
305            err,
306        ];
307        insta::assert_snapshot!("clang_warning_multi", render_clang(&offenders));
308    }
309
310    #[test]
311    fn msvc_empty_snapshot() {
312        insta::assert_snapshot!("msvc_warning_empty", render_msvc(&[]));
313    }
314
315    // The committed snapshot pins forward-slash separators. On Windows
316    // `render_msvc` renders backslashes (verified by
317    // `msvc_path_uses_backslashes_on_windows`), so gate the multi-offender
318    // snapshot to non-Windows.
319    #[cfg(not(windows))]
320    #[test]
321    fn msvc_multi_snapshot() {
322        let mut err = rec("src/zeta.rs", "cognitive", 30.0, 15.0);
323        err.severity = Severity::Error;
324        err.start_col = None;
325        err.function = None;
326        let offenders = vec![
327            rec("src/alpha.rs", "cyclomatic", 17.0, 15.0),
328            rec("src/alpha.rs", "loc.lloc", 250.0, 100.0),
329            err,
330        ];
331        insta::assert_snapshot!("msvc_warning_multi", render_msvc(&offenders));
332    }
333
334    #[cfg(windows)]
335    #[test]
336    fn msvc_path_uses_backslashes_on_windows() {
337        let out = render_msvc(&[rec("src/foo/bar.rs", "cyclomatic", 17.0, 15.0)]);
338        assert!(out.starts_with("src\\foo\\bar.rs("), "{out}");
339    }
340
341    #[cfg(not(windows))]
342    #[test]
343    fn msvc_path_keeps_forward_slashes_off_windows() {
344        let out = render_msvc(&[rec("src/foo/bar.rs", "cyclomatic", 17.0, 15.0)]);
345        assert!(out.starts_with("src/foo/bar.rs("), "{out}");
346    }
347}