cpp_linter/clang_tools/
mod.rs

1//! This module holds the functionality related to running clang-format and/or
2//! clang-tidy.
3
4use std::{
5    env::current_dir,
6    fmt::{self, Display},
7    fs,
8    path::{Path, PathBuf},
9    process::Command,
10    sync::{Arc, Mutex},
11};
12
13// non-std crates
14use anyhow::{anyhow, Context, Result};
15use git2::{DiffOptions, Patch};
16use regex::Regex;
17use semver::Version;
18use tokio::task::JoinSet;
19use which::{which, which_in};
20
21// project-specific modules/crates
22use super::common_fs::FileObj;
23use crate::{
24    cli::{ClangParams, RequestedVersion},
25    rest_api::{RestApiClient, COMMENT_MARKER, USER_OUTREACH},
26};
27pub mod clang_format;
28use clang_format::run_clang_format;
29pub mod clang_tidy;
30use clang_tidy::{run_clang_tidy, CompilationUnit};
31
32#[derive(Debug)]
33pub enum ClangTool {
34    ClangTidy,
35    ClangFormat,
36}
37
38impl Display for ClangTool {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "{}", self.as_str())
41    }
42}
43
44impl ClangTool {
45    /// Get the string representation of the clang tool's name.
46    pub const fn as_str(&self) -> &'static str {
47        match self {
48            ClangTool::ClangTidy => "clang-tidy",
49            ClangTool::ClangFormat => "clang-format",
50        }
51    }
52
53    /// Fetch the path to an executable clang tool for the specified `version`.
54    ///
55    /// If the executable is not found using the specified `version`, then the tool is
56    /// sought only by it's name ([`Self::as_str()`]).
57    ///
58    /// The only reason this function would return an error is if the specified tool is not
59    /// installed or present on the system (nor in the `PATH` environment variable).
60    pub fn get_exe_path(&self, version: &RequestedVersion) -> Result<PathBuf> {
61        let name = self.as_str();
62        match version {
63            RequestedVersion::Path(path_buf) => {
64                which_in(name, Some(path_buf), current_dir().unwrap())
65                    .map_err(|_| anyhow!("Could not find {self} by path"))
66            }
67            // Thus, we should use whatever is installed and added to $PATH.
68            RequestedVersion::SystemDefault | RequestedVersion::NoValue => {
69                which(name).map_err(|_| anyhow!("Could not find clang tool by name"))
70            }
71            RequestedVersion::Requirement(req) => {
72                // `req.comparators` has at least a major version number for each comparator.
73                // We need to start with the highest major version number first, then
74                // decrement to the lowest that satisfies the requirement.
75
76                // find the highest major version from requirement's boundaries.
77                let mut it = req.comparators.iter();
78                let mut highest_major = it.next().map(|v| v.major).unwrap_or_default() + 1;
79                for n in it {
80                    if n.major > highest_major {
81                        // +1 because we aren't checking the comparator's operator here.
82                        highest_major = n.major + 1;
83                    }
84                }
85
86                // aggregate by decrementing through major versions that satisfy the requirement.
87                let mut majors = vec![];
88                while highest_major > 0 {
89                    // check if the current major version satisfies the requirement.
90                    if req.matches(&Version::new(highest_major, 0, 0)) {
91                        majors.push(highest_major);
92                    }
93                    highest_major -= 1;
94                }
95
96                // now we're ready to search for the binary exe with the major version suffixed.
97                for major in majors {
98                    if let Ok(cmd) = which(format!("{self}-{major}")) {
99                        return Ok(cmd);
100                    }
101                }
102                // failed to find a binary where the major version number is suffixed to the tool name.
103
104                // USERS SHOULD MAKE SURE THE PROPER VERSION IS INSTALLED BEFORE USING CPP-LINTER!!!
105                // This line essentially ignores the version specified as a fail-safe.
106                //
107                // On Windows, the version's major number is typically not appended to the name of
108                // the executable (or symlink for executable), so this is useful in that scenario.
109                // On Unix systems, this line is not likely reached. Typically, installing clang
110                // will produce a symlink to the executable with the major version appended to the
111                // name.
112                which(name).map_err(|_| anyhow!("Could not find {self} by version"))
113            }
114        }
115    }
116
117    /// Run `clang-tool --version`, then extract and return the version number.
118    fn capture_version(clang_tool: &PathBuf) -> Result<String> {
119        let output = Command::new(clang_tool).arg("--version").output()?;
120        let stdout = String::from_utf8_lossy(&output.stdout);
121        let version_pattern = Regex::new(r"(?i)version[^\d]*([\d.]+)").unwrap();
122        let captures = version_pattern.captures(&stdout).ok_or(anyhow!(
123            "Failed to find version number in `{} --version` output",
124            clang_tool.to_string_lossy()
125        ))?;
126        Ok(captures.get(1).unwrap().as_str().to_string())
127    }
128}
129
130/// This creates a task to run clang-tidy and clang-format on a single file.
131///
132/// Returns a Future that infallibly resolves to a 2-tuple that contains
133///
134/// 1. The file's path.
135/// 2. A collections of cached logs. A [`Vec`] of tuples that hold
136///    - log level
137///    - messages
138fn analyze_single_file(
139    file: Arc<Mutex<FileObj>>,
140    clang_params: Arc<ClangParams>,
141) -> Result<(PathBuf, Vec<(log::Level, String)>)> {
142    let mut file = file
143        .lock()
144        .map_err(|_| anyhow!("Failed to lock file mutex"))?;
145    let mut logs = vec![];
146    if clang_params.clang_format_command.is_some() {
147        if clang_params
148            .format_filter
149            .as_ref()
150            .is_some_and(|f| f.is_source_or_ignored(file.name.as_path()))
151            || clang_params.format_filter.is_none()
152        {
153            let format_result = run_clang_format(&mut file, &clang_params)?;
154            logs.extend(format_result);
155        } else {
156            logs.push((
157                log::Level::Info,
158                format!(
159                    "{} not scanned by clang-format due to `--ignore-format`",
160                    file.name.as_os_str().to_string_lossy()
161                ),
162            ));
163        }
164    }
165    if clang_params.clang_tidy_command.is_some() {
166        if clang_params
167            .tidy_filter
168            .as_ref()
169            .is_some_and(|f| f.is_source_or_ignored(file.name.as_path()))
170            || clang_params.tidy_filter.is_none()
171        {
172            let tidy_result = run_clang_tidy(&mut file, &clang_params)?;
173            logs.extend(tidy_result);
174        } else {
175            logs.push((
176                log::Level::Info,
177                format!(
178                    "{} not scanned by clang-tidy due to `--ignore-tidy`",
179                    file.name.as_os_str().to_string_lossy()
180                ),
181            ));
182        }
183    }
184    Ok((file.name.clone(), logs))
185}
186
187/// A struct to contain the version numbers of the clang-tools used
188#[derive(Default)]
189pub struct ClangVersions {
190    /// The clang-format version used.
191    pub format_version: Option<String>,
192
193    /// The clang-tidy version used.
194    pub tidy_version: Option<String>,
195}
196
197/// Runs clang-tidy and/or clang-format and returns the parsed output from each.
198///
199/// If `tidy_checks` is `"-*"` then clang-tidy is not executed.
200/// If `style` is a blank string (`""`), then clang-format is not executed.
201pub async fn capture_clang_tools_output(
202    files: &mut Vec<Arc<Mutex<FileObj>>>,
203    version: &RequestedVersion,
204    clang_params: &mut ClangParams,
205    rest_api_client: &impl RestApiClient,
206) -> Result<ClangVersions> {
207    let mut clang_versions = ClangVersions::default();
208    // find the executable paths for clang-tidy and/or clang-format and show version
209    // info as debugging output.
210    if clang_params.tidy_checks != "-*" {
211        let exe_path = ClangTool::ClangTidy.get_exe_path(version)?;
212        let version_found = ClangTool::capture_version(&exe_path)?;
213        log::debug!(
214            "{} --version: v{version_found}",
215            &exe_path.to_string_lossy()
216        );
217        clang_versions.tidy_version = Some(version_found);
218        clang_params.clang_tidy_command = Some(exe_path);
219    }
220    if !clang_params.style.is_empty() {
221        let exe_path = ClangTool::ClangFormat.get_exe_path(version)?;
222        let version_found = ClangTool::capture_version(&exe_path)?;
223        log::debug!(
224            "{} --version: v{version_found}",
225            &exe_path.to_string_lossy()
226        );
227        clang_versions.format_version = Some(version_found);
228        clang_params.clang_format_command = Some(exe_path);
229    }
230
231    // parse database (if provided) to match filenames when parsing clang-tidy's stdout
232    if let Some(db_path) = &clang_params.database {
233        if let Ok(db_str) = fs::read(db_path.join("compile_commands.json")) {
234            clang_params.database_json = Some(
235                // A compilation database should be UTF-8 encoded, but file paths are not; use lossy conversion.
236                serde_json::from_str::<Vec<CompilationUnit>>(&String::from_utf8_lossy(&db_str))
237                    .with_context(|| "Failed to parse compile_commands.json")?,
238            )
239        }
240    };
241
242    let mut executors = JoinSet::new();
243    // iterate over the discovered files and run the clang tools
244    for file in files {
245        let arc_params = Arc::new(clang_params.clone());
246        let arc_file = Arc::clone(file);
247        executors.spawn(async move { analyze_single_file(arc_file, arc_params) });
248    }
249
250    while let Some(output) = executors.join_next().await {
251        if let Ok(out) = output? {
252            let (file_name, logs) = out;
253            rest_api_client.start_log_group(format!("Analyzing {}", file_name.to_string_lossy()));
254            for (level, msg) in logs {
255                log::log!(level, "{}", msg);
256            }
257            rest_api_client.end_log_group();
258        }
259    }
260    Ok(clang_versions)
261}
262
263/// A struct to describe a single suggestion in a pull_request review.
264pub struct Suggestion {
265    /// The file's line number in the diff that begins the suggestion.
266    pub line_start: u32,
267    /// The file's line number in the diff that ends the suggestion.
268    pub line_end: u32,
269    /// The actual suggestion.
270    pub suggestion: String,
271    /// The file that this suggestion pertains to.
272    pub path: String,
273}
274
275/// A struct to describe the Pull Request review suggestions.
276#[derive(Default)]
277pub struct ReviewComments {
278    /// The total count of suggestions from clang-tidy and clang-format.
279    ///
280    /// This differs from `comments.len()` because some suggestions may
281    /// not fit within the file's diff.
282    pub tool_total: [Option<u32>; 2],
283    /// A list of comment suggestions to be posted.
284    ///
285    /// These suggestions are guaranteed to fit in the file's diff.
286    pub comments: Vec<Suggestion>,
287    /// The complete patch of changes to all files scanned.
288    ///
289    /// This includes changes from both clang-tidy and clang-format
290    /// (assembled in that order).
291    pub full_patch: [String; 2],
292}
293
294impl ReviewComments {
295    pub fn summarize(&self, clang_versions: &ClangVersions) -> String {
296        let mut body = format!("{COMMENT_MARKER}## Cpp-linter Review\n");
297        for t in 0_usize..=1 {
298            let mut total = 0;
299            let (tool_name, tool_version) = if t == 0 {
300                ("clang-format", clang_versions.format_version.as_ref())
301            } else {
302                ("clang-tidy", clang_versions.tidy_version.as_ref())
303            };
304            if tool_version.is_none() {
305                // this tool was not used at all
306                continue;
307            }
308            let tool_total = self.tool_total[t].unwrap_or_default();
309
310            // If the tool's version is unknown, then we don't need to output this line.
311            // NOTE: If the tool was invoked at all, then the tool's version shall be known.
312            if let Some(ver_str) = tool_version {
313                body.push_str(format!("\n### Used {tool_name} v{ver_str}\n").as_str());
314            }
315            for comment in &self.comments {
316                if comment
317                    .suggestion
318                    .contains(format!("### {tool_name}").as_str())
319                {
320                    total += 1;
321                }
322            }
323
324            if total != tool_total {
325                body.push_str(
326                    format!(
327                        "\nOnly {total} out of {tool_total} {tool_name} concerns fit within this pull request's diff.\n",
328                    )
329                    .as_str(),
330                );
331            }
332            if !self.full_patch[t].is_empty() {
333                body.push_str(
334                    format!(
335                        "\n<details><summary>Click here for the full {tool_name} patch</summary>\n\n```diff\n{}```\n\n</details>\n",
336                        self.full_patch[t]
337                    ).as_str()
338                );
339            } else {
340                body.push_str(
341                    format!(
342                        "\nNo concerns reported by {}. Great job! :tada:\n",
343                        tool_name
344                    )
345                    .as_str(),
346                )
347            }
348        }
349        body.push_str(USER_OUTREACH);
350        body
351    }
352
353    pub fn is_comment_in_suggestions(&mut self, comment: &Suggestion) -> bool {
354        for s in &mut self.comments {
355            if s.path == comment.path
356                && s.line_end == comment.line_end
357                && s.line_start == comment.line_start
358            {
359                s.suggestion.push('\n');
360                s.suggestion.push_str(comment.suggestion.as_str());
361                return true;
362            }
363        }
364        false
365    }
366}
367
368pub fn make_patch<'buffer>(
369    path: &Path,
370    patched: &'buffer [u8],
371    original_content: &'buffer [u8],
372) -> Result<Patch<'buffer>> {
373    let mut diff_opts = &mut DiffOptions::new();
374    diff_opts = diff_opts.indent_heuristic(true);
375    diff_opts = diff_opts.context_lines(0);
376    let patch = Patch::from_buffers(
377        original_content,
378        Some(path),
379        patched,
380        Some(path),
381        Some(diff_opts),
382    )
383    .with_context(|| {
384        format!(
385            "Failed to create patch for file {}.",
386            path.to_string_lossy()
387        )
388    })?;
389    Ok(patch)
390}
391
392pub trait MakeSuggestions {
393    /// Create some user-facing helpful info about what the suggestion aims to resolve.
394    fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String;
395
396    /// Get the tool's name which generated the advice.
397    fn get_tool_name(&self) -> String;
398
399    /// Create a bunch of suggestions from a [`FileObj`]'s advice's generated `patched` buffer.
400    fn get_suggestions(
401        &self,
402        review_comments: &mut ReviewComments,
403        file_obj: &FileObj,
404        patch: &mut Patch,
405        summary_only: bool,
406    ) -> Result<()> {
407        let is_tidy_tool = (&self.get_tool_name() == "clang-tidy") as usize;
408        let hunks_total = patch.num_hunks();
409        let mut hunks_in_patch = 0u32;
410        let file_name = file_obj
411            .name
412            .to_string_lossy()
413            .replace("\\", "/")
414            .trim_start_matches("./")
415            .to_owned();
416        let patch_buf = &patch
417            .to_buf()
418            .with_context(|| "Failed to convert patch to byte array")?
419            .to_vec();
420        review_comments.full_patch[is_tidy_tool].push_str(
421            String::from_utf8(patch_buf.to_owned())
422                .with_context(|| format!("Failed to convert patch to string: {file_name}"))?
423                .as_str(),
424        );
425        if summary_only {
426            review_comments.tool_total[is_tidy_tool].get_or_insert(0);
427            return Ok(());
428        }
429        for hunk_id in 0..hunks_total {
430            let (hunk, line_count) = patch.hunk(hunk_id).with_context(|| {
431                format!("Failed to get hunk {hunk_id} from patch for {file_name}")
432            })?;
433            hunks_in_patch += 1;
434            let hunk_range = file_obj.is_hunk_in_diff(&hunk);
435            if hunk_range.is_none() {
436                continue;
437            }
438            let (start_line, end_line) = hunk_range.unwrap();
439            let mut suggestion = String::new();
440            let suggestion_help = self.get_suggestion_help(start_line, end_line);
441            let mut removed = vec![];
442            for line_index in 0..line_count {
443                let diff_line = patch
444                    .line_in_hunk(hunk_id, line_index)
445                    .with_context(|| format!("Failed to get line {line_index} in a hunk {hunk_id} of patch for {file_name}"))?;
446                let line = String::from_utf8(diff_line.content().to_owned())
447                    .with_context(|| format!("Failed to convert line {line_index} buffer to string in hunk {hunk_id} of patch for {file_name}"))?;
448                if ['+', ' '].contains(&diff_line.origin()) {
449                    suggestion.push_str(line.as_str());
450                } else {
451                    removed.push(
452                        diff_line
453                            .old_lineno()
454                            .expect("Removed line should have a line number"),
455                    );
456                }
457            }
458            if suggestion.is_empty() && !removed.is_empty() {
459                suggestion.push_str(
460                    format!(
461                        "Please remove the line(s)\n- {}",
462                        removed
463                            .iter()
464                            .map(|l| l.to_string())
465                            .collect::<Vec<String>>()
466                            .join("\n- ")
467                    )
468                    .as_str(),
469                )
470            } else {
471                suggestion = format!("```suggestion\n{suggestion}```");
472            }
473            let comment = Suggestion {
474                line_start: start_line,
475                line_end: end_line,
476                suggestion: format!("{suggestion_help}\n{suggestion}"),
477                path: file_name.clone(),
478            };
479            if !review_comments.is_comment_in_suggestions(&comment) {
480                review_comments.comments.push(comment);
481            }
482        }
483        review_comments.tool_total[is_tidy_tool] =
484            Some(review_comments.tool_total[is_tidy_tool].unwrap_or_default() + hunks_in_patch);
485        Ok(())
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use std::{path::PathBuf, str::FromStr};
492
493    use which::which;
494
495    use super::ClangTool;
496    use crate::cli::RequestedVersion;
497
498    const CLANG_FORMAT: ClangTool = ClangTool::ClangFormat;
499
500    #[test]
501    fn get_exe_by_version() {
502        let requirement = ">=9, <22";
503        let req_version = RequestedVersion::from_str(requirement).unwrap();
504        let tool_exe = CLANG_FORMAT.get_exe_path(&req_version);
505        println!("tool_exe: {:?}", tool_exe);
506        assert!(tool_exe.is_ok_and(|val| val
507            .file_name()
508            .unwrap()
509            .to_string_lossy()
510            .to_string()
511            .contains(CLANG_FORMAT.as_str())));
512    }
513
514    #[test]
515    fn get_exe_by_default() {
516        let tool_exe = CLANG_FORMAT.get_exe_path(&RequestedVersion::from_str("").unwrap());
517        println!("tool_exe: {:?}", tool_exe);
518        assert!(tool_exe.is_ok_and(|val| val
519            .file_name()
520            .unwrap()
521            .to_string_lossy()
522            .to_string()
523            .contains(CLANG_FORMAT.as_str())));
524    }
525
526    #[test]
527    fn get_exe_by_path() {
528        static TOOL_NAME: &'static str = CLANG_FORMAT.as_str();
529        let clang_version = which(TOOL_NAME).unwrap();
530        let bin_path = clang_version.parent().unwrap().to_str().unwrap();
531        println!("binary exe path: {bin_path}");
532        let tool_exe = CLANG_FORMAT.get_exe_path(&RequestedVersion::from_str(bin_path).unwrap());
533        println!("tool_exe: {:?}", tool_exe);
534        assert!(tool_exe.is_ok_and(|val| val
535            .file_name()
536            .unwrap()
537            .to_string_lossy()
538            .to_string()
539            .contains(TOOL_NAME)));
540    }
541
542    #[test]
543    fn get_exe_by_invalid_path() {
544        let tool_exe =
545            CLANG_FORMAT.get_exe_path(&RequestedVersion::Path(PathBuf::from("non-existent-path")));
546        assert!(tool_exe.is_err());
547    }
548}