Skip to main content

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    fs,
6    path::PathBuf,
7    sync::{Arc, Mutex},
8};
9
10// non-std crates
11use clang_tools_manager::{ClangTool, RequestedVersion};
12use git_bot_feedback::ReviewComment;
13use gix_imara_diff::{Diff, InternedInput};
14use semver::Version;
15use tokio::task::JoinSet;
16
17// project-specific modules/crates
18use super::common_fs::FileObj;
19use crate::{
20    clang_tools::clang_tidy::CompilationUnit,
21    cli::ClangParams,
22    error::{ClangCaptureError, ClangTaskError},
23    rest_client::{RestClient, USER_OUTREACH},
24};
25pub mod clang_format;
26use clang_format::run_clang_format;
27pub mod clang_tidy;
28use clang_tidy::run_clang_tidy;
29
30/// This creates a task to run clang-tidy and clang-format on a single file.
31///
32/// Returns a Future that infallibly resolves to a 2-tuple that contains
33///
34/// 1. The file's path.
35/// 2. A collections of cached logs. A [`Vec`] of tuples that hold
36///    - log level
37///    - messages
38fn analyze_single_file(
39    file: Arc<Mutex<FileObj>>,
40    clang_params: Arc<ClangParams>,
41) -> Result<(PathBuf, Vec<(log::Level, String)>), ClangCaptureError> {
42    let mut file = file.lock().map_err(|_| ClangCaptureError::MutexPoisoned)?;
43    let mut logs = vec![];
44    if clang_params.clang_tidy_command.is_some() {
45        if clang_params
46            .tidy_filter
47            .as_ref()
48            .is_some_and(|f| f.is_qualified(file.name.as_path()))
49            || clang_params.tidy_filter.is_none()
50        {
51            let tidy_result = run_clang_tidy(&mut file, &clang_params)?;
52            logs.extend(tidy_result);
53        } else {
54            logs.push((
55                log::Level::Info,
56                format!(
57                    "{} not scanned by clang-tidy due to `--ignore-tidy`",
58                    file.name.as_os_str().to_string_lossy()
59                ),
60            ));
61        }
62    }
63    if clang_params.clang_format_command.is_some() {
64        if clang_params
65            .format_filter
66            .as_ref()
67            .is_some_and(|f| f.is_qualified(file.name.as_path()))
68            || clang_params.format_filter.is_none()
69        {
70            let format_result = run_clang_format(&mut file, &clang_params)?;
71            logs.extend(format_result);
72        } else {
73            logs.push((
74                log::Level::Info,
75                format!(
76                    "{} not scanned by clang-format due to `--ignore-format`",
77                    file.name.as_os_str().to_string_lossy()
78                ),
79            ));
80        }
81    }
82    Ok((file.name.clone(), logs))
83}
84
85/// A struct to contain the version numbers of the clang-tools used
86#[derive(Debug, Default)]
87pub struct ClangVersions {
88    /// The clang-format version used.
89    pub format_version: Option<Version>,
90
91    /// The clang-tidy version used.
92    pub tidy_version: Option<Version>,
93}
94
95/// Runs clang-tidy and/or clang-format and returns the version used for each.
96///
97/// If [`ClangParams::tidy_checks`] is `"-*"` then clang-tidy is not executed.
98/// If [`ClangParams::style`] is a blank string (`""`), then clang-format is not executed.
99///
100/// The `modify_system` parameter controls whether or not to use a systems' available
101/// package managers when installing the specified `version` of clang tools.
102///
103/// The provided `rest_api_client` is only used for consistent logging messages.
104pub async fn capture_clang_tools_output(
105    files: &[Arc<Mutex<FileObj>>],
106    version: &RequestedVersion,
107    mut clang_params: ClangParams,
108    rest_api_client: &RestClient,
109    modify_system: bool,
110) -> Result<ClangVersions, ClangTaskError> {
111    let mut clang_versions = ClangVersions::default();
112    // find the executable paths for clang-tidy and/or clang-format and show version
113    // info as debugging output.
114    if clang_params.tidy_checks != "-*" {
115        let tool = ClangTool::ClangTidy;
116        let tool_info = version
117            .eval_tool(&tool, false, None, modify_system)
118            .await?
119            .ok_or(ClangTaskError::FindToolError(tool.as_str()))?;
120        log::info!(
121            "Using {tool} version {}.{}.{}",
122            tool_info.version.major,
123            tool_info.version.minor,
124            tool_info.version.patch,
125        );
126        clang_versions.tidy_version = Some(tool_info.version);
127        clang_params.clang_tidy_command = Some(tool_info.path);
128    }
129    if !clang_params.style.is_empty() {
130        let tool = ClangTool::ClangFormat;
131        let tool_info = version
132            .eval_tool(&tool, false, None, modify_system)
133            .await?
134            .ok_or(ClangTaskError::FindToolError(tool.as_str()))?;
135        log::info!(
136            "Using {tool} version {}.{}.{}",
137            tool_info.version.major,
138            tool_info.version.minor,
139            tool_info.version.patch,
140        );
141        clang_versions.format_version = Some(tool_info.version);
142        clang_params.clang_format_command = Some(tool_info.path);
143    }
144    if let Some(db_path) = &clang_params.database {
145        let db_path = db_path.join("compile_commands.json");
146        match fs::read_to_string(&db_path) {
147            Ok(db_str) => match serde_json::from_str::<Vec<CompilationUnit>>(&db_str) {
148                Ok(db_json) => {
149                    clang_params.database_json = Some(db_json);
150                }
151                Err(e) => {
152                    log::warn!(
153                        "Failed to parse compilation database JSON at {}: {e:?}",
154                        db_path.to_string_lossy()
155                    );
156                }
157            },
158            Err(e) => {
159                log::warn!(
160                    "Failed to read compilation database file at {}: {e:?}",
161                    db_path.to_string_lossy()
162                );
163            }
164        }
165    };
166
167    let mut executors = JoinSet::new();
168    let arc_params = Arc::new(clang_params);
169    // iterate over the discovered files and run the clang tools
170    for file in files {
171        let arc_file = file.clone();
172        let arc_params = arc_params.clone();
173        executors.spawn(async move { analyze_single_file(arc_file, arc_params) });
174    }
175
176    while let Some(output) = executors.join_next().await {
177        // output?? acts as a fast-fail for any error encountered.
178        // This includes any `spawn()` error and any `analyze_single_file()` error.
179        // Any unresolved tasks are aborted and dropped when an error is returned here.
180        let (file_name, logs) = output??;
181        let log_group_name = format!("Analyzing {}", file_name.to_string_lossy());
182        rest_api_client.start_log_group(&log_group_name);
183        for (level, msg) in logs {
184            log::log!(level, "{}", msg);
185        }
186        rest_api_client.end_log_group(&log_group_name);
187    }
188    Ok(clang_versions)
189}
190
191/// A struct to describe a single suggestion in a pull_request review.
192pub struct Suggestion {
193    /// The file's line number in the diff that begins the suggestion.
194    pub line_start: u32,
195    /// The file's line number in the diff that ends the suggestion.
196    pub line_end: u32,
197    /// The actual suggestion.
198    pub suggestion: String,
199    /// The file that this suggestion pertains to.
200    pub path: String,
201}
202
203impl Suggestion {
204    pub(crate) fn as_review_comment(&self) -> ReviewComment {
205        ReviewComment {
206            line_start: if self.line_start == self.line_end {
207                None
208            } else {
209                Some(self.line_start)
210            },
211            line_end: self.line_end,
212            comment: self.suggestion.clone(),
213            path: self.path.clone(),
214        }
215    }
216}
217
218/// A struct to describe the Pull Request review suggestions.
219#[derive(Default)]
220pub struct ReviewComments {
221    /// The total count of suggestions from clang-tidy and clang-format.
222    ///
223    /// This differs from `comments.len()` because some suggestions may
224    /// not fit within the file's diff.
225    pub tool_total: u32,
226    /// A list of comment suggestions to be posted.
227    ///
228    /// These suggestions are guaranteed to fit in the file's diff.
229    pub comments: Vec<Suggestion>,
230    /// The complete patch of changes to all files scanned.
231    ///
232    /// This includes changes from both clang-tidy and clang-format
233    /// (assembled in that order).
234    pub full_patch: String,
235}
236
237impl ReviewComments {
238    /// Get a markdown-formatted string that summarizes the given [`ReviewComment`]s.
239    ///
240    /// The total_review_comments parameter describes the number of comments before
241    /// removing duplicates found in previous reviews.
242    pub fn summarize(
243        &self,
244        clang_versions: &ClangVersions,
245        comments: &[ReviewComment],
246        total_review_comments: u32,
247        summary_only: bool,
248    ) -> String {
249        let mut body = String::from("## Cpp-linter Review\n");
250        let versions = [
251            (
252                ClangTool::ClangFormat,
253                clang_versions.format_version.as_ref(),
254            ),
255            (ClangTool::ClangTidy, clang_versions.tidy_version.as_ref()),
256        ];
257        for (tool_name, tool_version) in versions {
258            if let Some(ver) = tool_version {
259                // If a tool was used, then we know it's version at this point.
260                body.push_str(format!("### Used {tool_name} v{ver}\n").as_str());
261            }
262        }
263
264        let total = comments.len() as u32;
265        if summary_only && self.tool_total > 0 {
266            body.push_str(
267                format!(
268                    "\nFound {} areas of concern according to clang tools output.\n",
269                    self.tool_total
270                )
271                .as_str(),
272            );
273        }
274        if !summary_only && total_review_comments != self.tool_total {
275            log::info!(
276                "Only {total_review_comments} out of {} concerns fit within this pull request's diff.",
277                self.tool_total
278            );
279            body.push_str(
280                format!(
281                    "\nOnly {total_review_comments} out of {} concerns fit within this pull request's diff.\n",
282                    self.tool_total,
283                )
284                .as_str(),
285            );
286        }
287        // total number of comments can only go down after culling comments found in previous reviews.
288        if total_review_comments > total {
289            let dupes = total_review_comments - total;
290            log::info!(
291                "Found and removed {dupes} concerns that were duplicates of previous reviews."
292            );
293            body.push_str(
294                format!("\n{dupes} suggestions were duplicates of previous reviews.\n").as_str(),
295            );
296        }
297        // The `full_patch` includes all suggestions that didn't fit in the diff.
298        // It can also contain suggestions that were duplicates of previous reviews.
299        if !self.full_patch.is_empty() {
300            let current_len = body.len() + USER_OUTREACH.len();
301            let mut patch_prefix = "\n<details><summary>Click here for ".to_string();
302            if summary_only {
303                patch_prefix.push_str("the full patch of fixes");
304            } else {
305                patch_prefix.push_str("a patch of fixes outside the diff");
306            }
307            patch_prefix.push_str("</summary><p>\n\n```diff\n");
308            let patch_suffix = "```\n\n</p></details>\n";
309
310            if (current_len + patch_prefix.len() + self.full_patch.len() + patch_suffix.len())
311                > u16::MAX as usize
312            {
313                log::warn!(
314                    "The full patch of fixes is too large to include in the review summary."
315                );
316                body.push_str(
317                    "\nThe full patch of fixes is too large to include in this summary.\n",
318                );
319            } else {
320                body.push_str(&patch_prefix);
321                body.push_str(self.full_patch.as_str());
322                body.push_str(patch_suffix);
323            }
324        } else if total_review_comments == 0 {
325            // Only congratulate if there was no reused comments
326            log::info!("No concerns to report: LGTM");
327            body.push_str("\nNo concerns to report. Great job! :tada:\n");
328        }
329        body.push_str(USER_OUTREACH);
330        body
331    }
332
333    /// Check if a given comment's [`Suggestion`] is already contained within the existing [`Self::comments`].
334    pub fn is_comment_in_suggestions(&mut self, comment: &Suggestion) -> bool {
335        for s in &mut self.comments {
336            if s.path == comment.path
337                && s.line_end == comment.line_end
338                && s.line_start == comment.line_start
339            {
340                s.suggestion.push('\n');
341                s.suggestion.push_str(comment.suggestion.as_str());
342                return true;
343            }
344        }
345        false
346    }
347}
348
349/// A helper function to create a [`Diff`] and its associated [`InternedInput`] from
350/// a `patched` buffer and the `original_content`` of the file.
351pub fn make_patch<'buffer>(
352    patched: &'buffer str,
353    original_content: &'buffer str,
354) -> (Diff, InternedInput<&'buffer str>) {
355    let input = InternedInput::new(original_content, patched);
356    let mut diff = Diff::compute(gix_imara_diff::Algorithm::Histogram, &input);
357    diff.postprocess_lines(&input);
358    (diff, input)
359}
360
361#[cfg(test)]
362mod tests {
363    #![allow(clippy::unwrap_used)]
364
365    use std::{env, fs, path::Path};
366
367    use clang_tools_manager::logger::try_init_logger;
368    use git_bot_feedback::ReviewComment;
369
370    use super::*;
371
372    async fn test_db_parse<P: AsRef<Path>>(path: P) -> Result<ClangVersions, ClangTaskError> {
373        let clang_params = ClangParams {
374            database: Some(path.as_ref().to_path_buf()),
375            repo_root: PathBuf::from("."),
376            ..Default::default()
377        };
378        let version = RequestedVersion::default();
379        // We don't need to use any specific git REST API client for this.
380        unsafe {
381            env::remove_var("GITHUB_ACTIONS");
382        }
383        let rest_client = RestClient::new().unwrap();
384        try_init_logger();
385        capture_clang_tools_output(&[], &version, clang_params, &rest_client, false).await
386    }
387
388    #[tokio::test]
389    async fn bad_db_path() {
390        test_db_parse("nonexistent/path").await.unwrap();
391    }
392
393    #[tokio::test]
394    async fn bad_db_json() {
395        let tmp_dir = tempfile::tempdir().unwrap();
396        let db_path = tmp_dir.path().join("compile_commands.json");
397        fs::write(&db_path, "not a valid json").unwrap();
398        test_db_parse(tmp_dir.path()).await.unwrap();
399    }
400
401    const PSEUDO_VERSION: Version = Version::new(15, 0, 0);
402
403    /// This test simulates removed suggestions that were reused in other PR reviews.
404    ///
405    /// We do this as a arbitrary unit test because different clang tools versions
406    /// produce different suggestions, which makes any attempt in integrations tests
407    /// rather non-deterministic.
408    #[test]
409    fn summarize_reused_reviews() {
410        let comments = vec![ReviewComment {
411            line_start: Some(1),
412            line_end: 1,
413            comment: "First comment".to_string(),
414            path: "src/demo.cpp".to_string(),
415        }];
416        let clang_versions = ClangVersions {
417            format_version: Some(PSEUDO_VERSION.clone()),
418            tidy_version: Some(PSEUDO_VERSION),
419        };
420        let total_review_comments = 2;
421        let summary_only = false;
422        try_init_logger();
423        log::set_max_level(log::LevelFilter::Info);
424        let review_summary = ReviewComments::default().summarize(
425            &clang_versions,
426            &comments,
427            total_review_comments,
428            summary_only,
429        );
430        assert!(review_summary.contains("suggestions were duplicates of previous reviews"));
431    }
432
433    #[test]
434    fn summary_len_truncated() {
435        let comments = vec![ReviewComment {
436            line_start: Some(1),
437            line_end: 1,
438            comment: "First comment".to_string(),
439            path: "src/demo.cpp".to_string(),
440        }];
441        let clang_versions = ClangVersions {
442            format_version: Some(PSEUDO_VERSION.clone()),
443            tidy_version: Some(PSEUDO_VERSION),
444        };
445        let total_review_comments = 2;
446        let summary_only = false;
447        let long_patch = "a".repeat(u16::MAX as usize);
448        let review_summary = ReviewComments {
449            full_patch: long_patch,
450            ..Default::default()
451        }
452        .summarize(
453            &clang_versions,
454            &comments,
455            total_review_comments,
456            summary_only,
457        );
458        assert!(
459            review_summary
460                .contains("The full patch of fixes is too large to include in this summary.")
461        );
462    }
463}