cpp_linter/clang_tools/
clang_tidy.rs

1//! This module holds functionality specific to running clang-tidy and parsing it's
2//! output.
3
4use std::{
5    env::{consts::OS, current_dir},
6    fs,
7    path::PathBuf,
8    process::Command,
9    sync::{Arc, Mutex, MutexGuard},
10};
11
12use anyhow::{Context, Result};
13// non-std crates
14use regex::Regex;
15use serde::Deserialize;
16
17// project-specific modules/crates
18use super::MakeSuggestions;
19use crate::{
20    cli::{ClangParams, LinesChangedOnly},
21    common_fs::{normalize_path, FileObj},
22};
23
24/// Used to deserialize a json compilation database's translation unit.
25///
26/// The only purpose this serves is to normalize relative paths for build systems that
27/// use/need relative paths (ie ninja).
28#[derive(Deserialize, Debug, Clone)]
29pub struct CompilationUnit {
30    /// The directory of the build environment
31    directory: String,
32
33    /// The file path of the translation unit.
34    ///
35    /// Sometimes, this is relative to the build [`CompilationUnit::directory`].
36    ///
37    /// This is typically the path that clang-tidy uses in its stdout (for a dry run).
38    /// So, having this information helps with matching clang-tidy's stdout with the
39    /// repository files.
40    file: String,
41}
42
43/// A structure that represents a single notification parsed from clang-tidy's stdout.
44#[derive(Debug, Clone)]
45pub struct TidyNotification {
46    /// The file's path and name (supposedly relative to the repository root folder).
47    pub filename: String,
48
49    /// The line number from which the notification originated.
50    pub line: u32,
51
52    /// The column offset on the line from which the notification originated.
53    pub cols: u32,
54
55    /// The severity (ie error/warning/note) of the [`TidyNotification::diagnostic`]
56    /// that caused the notification.
57    pub severity: String,
58
59    /// A helpful message explaining why the notification exists.
60    pub rationale: String,
61
62    /// The diagnostic name as used when configuring clang-tidy.
63    pub diagnostic: String,
64
65    /// A code block that points directly to the origin of the notification.
66    ///
67    /// Sometimes, this code block doesn't exist. Sometimes, it contains suggested
68    /// fixes/advice. This information is purely superfluous.
69    pub suggestion: Vec<String>,
70
71    /// The list of line numbers that had fixes applied via `clang-tidy --fix-error`.
72    pub fixed_lines: Vec<u32>,
73}
74
75impl TidyNotification {
76    pub fn diagnostic_link(&self) -> String {
77        if self.diagnostic.starts_with("clang-diagnostic") {
78            return self.diagnostic.clone();
79        }
80        let (category, name) = if self.diagnostic.starts_with("clang-analyzer-") {
81            (
82                "clang-analyzer",
83                self.diagnostic.strip_prefix("clang-analyzer-").unwrap(),
84            )
85        } else {
86            self.diagnostic.split_once('-').unwrap()
87        };
88        format!(
89            "[{}](https://clang.llvm.org/extra/clang-tidy/checks/{category}/{name}.html)",
90            self.diagnostic
91        )
92    }
93}
94
95/// A struct to hold notification from clang-tidy about a single file
96#[derive(Debug, Clone)]
97pub struct TidyAdvice {
98    /// A list of notifications parsed from clang-tidy stdout.
99    pub notes: Vec<TidyNotification>,
100    pub patched: Option<Vec<u8>>,
101}
102
103impl MakeSuggestions for TidyAdvice {
104    fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String {
105        let mut diagnostics = vec![];
106        for note in &self.notes {
107            for fixed_line in &note.fixed_lines {
108                if (start_line..=end_line).contains(fixed_line) {
109                    diagnostics.push(format!("- {}\n", note.diagnostic_link()));
110                }
111            }
112        }
113        format!(
114            "### clang-tidy {}\n{}",
115            if diagnostics.is_empty() {
116                "suggestion"
117            } else {
118                "diagnostic(s)"
119            },
120            diagnostics.join("")
121        )
122    }
123
124    fn get_tool_name(&self) -> String {
125        "clang-tidy".to_string()
126    }
127}
128
129/// Parses clang-tidy stdout.
130///
131/// Here it helps to have the JSON database deserialized for normalizing paths present
132/// in the notifications.
133fn parse_tidy_output(
134    tidy_stdout: &[u8],
135    database_json: &Option<Vec<CompilationUnit>>,
136) -> Result<TidyAdvice> {
137    let note_header = Regex::new(r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+)\]$").unwrap();
138    let fixed_note =
139        Regex::new(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$").unwrap();
140    let mut found_fix = false;
141    let mut notification = None;
142    let mut result = Vec::new();
143    let cur_dir = current_dir().unwrap();
144    for line in String::from_utf8(tidy_stdout.to_vec()).unwrap().lines() {
145        if let Some(captured) = note_header.captures(line) {
146            if let Some(note) = notification {
147                result.push(note);
148            }
149
150            // normalize the filename path and try to make it relative to the repo root
151            let mut filename = PathBuf::from(&captured[1]);
152            // if database was given try to use that first
153            if let Some(db_json) = &database_json {
154                let mut found_unit = false;
155                for unit in db_json {
156                    let unit_path =
157                        PathBuf::from_iter([unit.directory.as_str(), unit.file.as_str()]);
158                    if unit_path == filename {
159                        filename =
160                            normalize_path(&PathBuf::from_iter([&unit.directory, &unit.file]));
161                        found_unit = true;
162                        break;
163                    }
164                }
165                if !found_unit {
166                    // file was not a named unit in the database;
167                    // try to normalize path as if relative to working directory.
168                    // NOTE: This shouldn't happen with a properly formed JSON database
169                    filename = normalize_path(&PathBuf::from_iter([&cur_dir, &filename]));
170                }
171            } else {
172                // still need to normalize the relative path despite missing database info.
173                // let's assume the file is relative to current working directory.
174                filename = normalize_path(&PathBuf::from_iter([&cur_dir, &filename]));
175            }
176            assert!(filename.is_absolute());
177            if filename.is_absolute() && filename.starts_with(&cur_dir) {
178                // if this filename can't be made into a relative path, then it is
179                // likely not a member of the project's sources (ie /usr/include/stdio.h)
180                filename = filename
181                    .strip_prefix(&cur_dir)
182                    // we already checked above that filename.starts_with(current_directory)
183                    .unwrap()
184                    .to_path_buf();
185            }
186
187            notification = Some(TidyNotification {
188                filename: filename.to_string_lossy().to_string().replace('\\', "/"),
189                line: captured[2].parse()?,
190                cols: captured[3].parse()?,
191                severity: String::from(&captured[4]),
192                rationale: String::from(&captured[5]).trim().to_string(),
193                diagnostic: String::from(&captured[6]),
194                suggestion: Vec::new(),
195                fixed_lines: Vec::new(),
196            });
197            // begin capturing subsequent lines as suggestions
198            found_fix = false;
199        } else if let Some(capture) = fixed_note.captures(line) {
200            let fixed_line = capture[1].parse()?;
201            if let Some(note) = &mut notification {
202                if !note.fixed_lines.contains(&fixed_line) {
203                    note.fixed_lines.push(fixed_line);
204                }
205            }
206            // Suspend capturing subsequent lines as suggestions until
207            // a new notification is constructed. If we found a note about applied fixes,
208            // then the lines of suggestions for that notification have already been parsed.
209            found_fix = true;
210        } else if !found_fix {
211            if let Some(note) = &mut notification {
212                // append lines of code that are part of
213                // the previous line's notification
214                note.suggestion.push(line.to_string());
215            }
216        }
217    }
218    if let Some(note) = notification {
219        result.push(note);
220    }
221    Ok(TidyAdvice {
222        notes: result,
223        patched: None,
224    })
225}
226
227/// Get a total count of clang-tidy advice from the given list of [FileObj]s.
228pub fn tally_tidy_advice(files: &[Arc<Mutex<FileObj>>]) -> u64 {
229    let mut total = 0;
230    for file in files {
231        let file = file.lock().unwrap();
232        if let Some(advice) = &file.tidy_advice {
233            for tidy_note in &advice.notes {
234                let file_path = PathBuf::from(&tidy_note.filename);
235                if file_path == file.name {
236                    total += 1;
237                }
238            }
239        }
240    }
241    total
242}
243
244/// Run clang-tidy, then parse and return it's output.
245pub fn run_clang_tidy(
246    file: &mut MutexGuard<FileObj>,
247    clang_params: &ClangParams,
248) -> Result<Vec<(log::Level, std::string::String)>> {
249    let mut cmd = Command::new(clang_params.clang_tidy_command.as_ref().unwrap());
250    let mut logs = vec![];
251    if !clang_params.tidy_checks.is_empty() {
252        cmd.args(["-checks", &clang_params.tidy_checks]);
253    }
254    if let Some(db) = &clang_params.database {
255        cmd.args(["-p", &db.to_string_lossy()]);
256    }
257    for arg in &clang_params.extra_args {
258        cmd.args(["--extra-arg", format!("\"{}\"", arg).as_str()]);
259    }
260    let file_name = file.name.to_string_lossy().to_string();
261    if clang_params.lines_changed_only != LinesChangedOnly::Off {
262        let ranges = file.get_ranges(&clang_params.lines_changed_only);
263        let filter = format!(
264            "[{{\"name\":{:?},\"lines\":{:?}}}]",
265            &file_name.replace('/', if OS == "windows" { "\\" } else { "/" }),
266            ranges
267                .iter()
268                .map(|r| [r.start(), r.end()])
269                .collect::<Vec<_>>()
270        );
271        cmd.args(["--line-filter", filter.as_str()]);
272    }
273    let mut original_content = None;
274    if clang_params.tidy_review {
275        cmd.arg("--fix-errors");
276        original_content = Some(fs::read_to_string(&file.name).with_context(|| {
277            format!(
278                "Failed to cache file's original content before applying clang-tidy changes: {}",
279                file_name.clone()
280            )
281        })?);
282    }
283    if !clang_params.style.is_empty() {
284        cmd.args(["--format-style", clang_params.style.as_str()]);
285    }
286    cmd.arg(file.name.to_string_lossy().as_ref());
287    logs.push((
288        log::Level::Info,
289        format!(
290            "Running \"{} {}\"",
291            cmd.get_program().to_string_lossy(),
292            cmd.get_args()
293                .map(|x| x.to_str().unwrap())
294                .collect::<Vec<&str>>()
295                .join(" ")
296        ),
297    ));
298    let output = cmd.output().unwrap();
299    logs.push((
300        log::Level::Debug,
301        format!(
302            "Output from clang-tidy:\n{}",
303            String::from_utf8_lossy(&output.stdout)
304        ),
305    ));
306    if !output.stderr.is_empty() {
307        logs.push((
308            log::Level::Debug,
309            format!(
310                "clang-tidy made the following summary:\n{}",
311                String::from_utf8_lossy(&output.stderr)
312            ),
313        ));
314    }
315    file.tidy_advice = Some(parse_tidy_output(
316        &output.stdout,
317        &clang_params.database_json,
318    )?);
319    if clang_params.tidy_review {
320        if let Some(tidy_advice) = &mut file.tidy_advice {
321            // cache file changes in a buffer and restore the original contents for further analysis by clang-format
322            tidy_advice.patched =
323                Some(fs::read(&file_name).with_context(|| {
324                    format!("Failed to read changes from clang-tidy: {file_name}")
325                })?);
326        }
327        // original_content is guaranteed to be Some() value at this point
328        fs::write(&file_name, original_content.unwrap())
329            .with_context(|| format!("Failed to restore file's original content: {file_name}"))?;
330    }
331    Ok(logs)
332}
333
334#[cfg(test)]
335mod test {
336    use std::{
337        env,
338        path::PathBuf,
339        sync::{Arc, Mutex},
340    };
341
342    use regex::Regex;
343
344    use crate::{
345        clang_tools::get_clang_tool_exe,
346        cli::{ClangParams, LinesChangedOnly},
347        common_fs::FileObj,
348    };
349
350    use super::run_clang_tidy;
351    use super::TidyNotification;
352
353    #[test]
354    fn clang_diagnostic_link() {
355        let note = TidyNotification {
356            filename: String::from("some_src.cpp"),
357            line: 1504,
358            cols: 9,
359            rationale: String::from("file not found"),
360            severity: String::from("error"),
361            diagnostic: String::from("clang-diagnostic-error"),
362            suggestion: vec![],
363            fixed_lines: vec![],
364        };
365        assert_eq!(note.diagnostic_link(), note.diagnostic);
366    }
367
368    #[test]
369    fn clang_analyzer_link() {
370        let note = TidyNotification {
371            filename: String::from("some_src.cpp"),
372            line: 1504,
373            cols: 9,
374            rationale: String::from(
375                "Dereference of null pointer (loaded from variable 'pipe_num')",
376            ),
377            severity: String::from("warning"),
378            diagnostic: String::from("clang-analyzer-core.NullDereference"),
379            suggestion: vec![],
380            fixed_lines: vec![],
381        };
382        let expected = format!(
383            "[{}](https://clang.llvm.org/extra/clang-tidy/checks/{}/{}.html)",
384            note.diagnostic, "clang-analyzer", "core.NullDereference",
385        );
386        assert_eq!(note.diagnostic_link(), expected);
387    }
388
389    // ***************** test for regex parsing of clang-tidy stdout
390
391    #[test]
392    fn test_capture() {
393        let src = "tests/demo/demo.hpp:11:11: warning: use a trailing return type for this function [modernize-use-trailing-return-type]";
394        let pat = Regex::new(r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+)\]$").unwrap();
395        let cap = pat.captures(src).unwrap();
396        assert_eq!(
397            cap.get(0).unwrap().as_str(),
398            format!(
399                "{}:{}:{}: {}:{}[{}]",
400                cap.get(1).unwrap().as_str(),
401                cap.get(2).unwrap().as_str(),
402                cap.get(3).unwrap().as_str(),
403                cap.get(4).unwrap().as_str(),
404                cap.get(5).unwrap().as_str(),
405                cap.get(6).unwrap().as_str()
406            )
407            .as_str()
408        )
409    }
410
411    #[test]
412    fn use_extra_args() {
413        let exe_path = get_clang_tool_exe(
414            "clang-tidy",
415            env::var("CLANG_VERSION").unwrap_or("".to_string()).as_str(),
416        )
417        .unwrap();
418        let file = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
419        let arc_ref = Arc::new(Mutex::new(file));
420        let extra_args = vec!["-std=c++17".to_string(), "-Wall".to_string()];
421        let clang_params = ClangParams {
422            style: "".to_string(),
423            tidy_checks: "".to_string(), // use .clang-tidy config file
424            lines_changed_only: LinesChangedOnly::Off,
425            database: None,
426            extra_args: extra_args.clone(), // <---- the reason for this test
427            database_json: None,
428            format_filter: None,
429            tidy_filter: None,
430            tidy_review: false,
431            format_review: false,
432            clang_tidy_command: Some(exe_path),
433            clang_format_command: None,
434        };
435        let mut file_lock = arc_ref.lock().unwrap();
436        let logs = run_clang_tidy(&mut file_lock, &clang_params)
437            .unwrap()
438            .into_iter()
439            .filter_map(|(_lvl, msg)| {
440                if msg.contains("Running ") {
441                    Some(msg)
442                } else {
443                    None
444                }
445            })
446            .collect::<Vec<String>>();
447        let args = &logs
448            .first()
449            .expect("expected a log message about invoked clang-tidy command")
450            .split(' ')
451            .collect::<Vec<&str>>();
452        for arg in &extra_args {
453            let extra_arg = format!("\"{arg}\"");
454            assert!(args.contains(&extra_arg.as_str()));
455        }
456    }
457}