Skip to main content

cpp_linter/clang_tools/
mod.rs

1#![deny(clippy::unwrap_used)]
2//! This module holds the functionality related to running clang-format and/or
3//! clang-tidy.
4
5use std::{
6    fs,
7    path::{Path, PathBuf},
8    sync::{Arc, Mutex},
9};
10
11// non-std crates
12use anyhow::{Context, Result, anyhow};
13use clang_installer::{ClangTool, RequestedVersion};
14use git_bot_feedback::ReviewComment;
15use git2::{DiffOptions, Patch};
16use semver::Version;
17use tokio::task::JoinSet;
18
19// project-specific modules/crates
20use super::common_fs::FileObj;
21use crate::error::SuggestionError;
22use crate::{
23    cli::ClangParams,
24    rest_client::{RestClient, USER_OUTREACH},
25};
26pub mod clang_format;
27use clang_format::run_clang_format;
28pub mod clang_tidy;
29use clang_tidy::{CompilationUnit, run_clang_tidy};
30
31/// This creates a task to run clang-tidy and clang-format on a single file.
32///
33/// Returns a Future that infallibly resolves to a 2-tuple that contains
34///
35/// 1. The file's path.
36/// 2. A collections of cached logs. A [`Vec`] of tuples that hold
37///    - log level
38///    - messages
39fn analyze_single_file(
40    file: Arc<Mutex<FileObj>>,
41    clang_params: Arc<ClangParams>,
42) -> Result<(PathBuf, Vec<(log::Level, String)>)> {
43    let mut file = file
44        .lock()
45        .map_err(|_| anyhow!("Failed to lock file mutex"))?;
46    let mut logs = vec![];
47    if clang_params.clang_format_command.is_some() {
48        if clang_params
49            .format_filter
50            .as_ref()
51            .is_some_and(|f| f.is_qualified(file.name.as_path()))
52            || clang_params.format_filter.is_none()
53        {
54            let format_result = run_clang_format(&mut file, &clang_params)?;
55            logs.extend(format_result);
56        } else {
57            logs.push((
58                log::Level::Info,
59                format!(
60                    "{} not scanned by clang-format due to `--ignore-format`",
61                    file.name.as_os_str().to_string_lossy()
62                ),
63            ));
64        }
65    }
66    if clang_params.clang_tidy_command.is_some() {
67        if clang_params
68            .tidy_filter
69            .as_ref()
70            .is_some_and(|f| f.is_qualified(file.name.as_path()))
71            || clang_params.tidy_filter.is_none()
72        {
73            let tidy_result = run_clang_tidy(&mut file, &clang_params)?;
74            logs.extend(tidy_result);
75        } else {
76            logs.push((
77                log::Level::Info,
78                format!(
79                    "{} not scanned by clang-tidy due to `--ignore-tidy`",
80                    file.name.as_os_str().to_string_lossy()
81                ),
82            ));
83        }
84    }
85    Ok((file.name.clone(), logs))
86}
87
88/// A struct to contain the version numbers of the clang-tools used
89#[derive(Debug, Default)]
90pub struct ClangVersions {
91    /// The clang-format version used.
92    pub format_version: Option<Version>,
93
94    /// The clang-tidy version used.
95    pub tidy_version: Option<Version>,
96}
97
98/// Runs clang-tidy and/or clang-format and returns the parsed output from each.
99///
100/// If `tidy_checks` is `"-*"` then clang-tidy is not executed.
101/// If `style` is a blank string (`""`), then clang-format is not executed.
102pub async fn capture_clang_tools_output(
103    files: &[Arc<Mutex<FileObj>>],
104    version: &RequestedVersion,
105    mut clang_params: ClangParams,
106    rest_api_client: &RestClient,
107) -> Result<ClangVersions> {
108    let mut clang_versions = ClangVersions::default();
109    // find the executable paths for clang-tidy and/or clang-format and show version
110    // info as debugging output.
111    if clang_params.tidy_checks != "-*" {
112        let tool = ClangTool::ClangTidy;
113        let tool_info = version.eval_tool(&tool, false, None).await?.ok_or(anyhow!(
114            "Failed to find {tool} or install a suitable version"
115        ))?;
116        clang_versions.tidy_version = Some(tool_info.version);
117        clang_params.clang_tidy_command = Some(tool_info.path);
118    }
119    if !clang_params.style.is_empty() {
120        let tool = ClangTool::ClangFormat;
121        let tool_info = version.eval_tool(&tool, false, None).await?.ok_or(anyhow!(
122            "Failed to find {tool} or install a suitable version"
123        ))?;
124        clang_versions.format_version = Some(tool_info.version);
125        clang_params.clang_format_command = Some(tool_info.path);
126    }
127
128    // parse database (if provided) to match filenames when parsing clang-tidy's stdout
129    if let Some(db_path) = &clang_params.database
130        && let Ok(db_str) = fs::read(db_path.join("compile_commands.json"))
131    {
132        clang_params.database_json = Some(
133            // A compilation database should be UTF-8 encoded, but file paths are not; use lossy conversion.
134            serde_json::from_str::<Vec<CompilationUnit>>(&String::from_utf8_lossy(&db_str))
135                .with_context(|| "Failed to parse compile_commands.json")?,
136        )
137    };
138
139    let mut executors = JoinSet::new();
140    let arc_params = Arc::new(clang_params);
141    // iterate over the discovered files and run the clang tools
142    for file in files {
143        let arc_file = file.clone();
144        let arc_params = arc_params.clone();
145        executors.spawn(async move { analyze_single_file(arc_file, arc_params) });
146    }
147
148    while let Some(output) = executors.join_next().await {
149        // output?? acts as a fast-fail for any error encountered.
150        // This includes any `spawn()` error and any `analyze_single_file()` error.
151        // Any unresolved tasks are aborted and dropped when an error is returned here.
152        let (file_name, logs) = output??;
153        let log_group_name = format!("Analyzing {}", file_name.to_string_lossy());
154        rest_api_client.start_log_group(&log_group_name);
155        for (level, msg) in logs {
156            log::log!(level, "{}", msg);
157        }
158        rest_api_client.end_log_group(&log_group_name);
159    }
160    Ok(clang_versions)
161}
162
163/// A struct to describe a single suggestion in a pull_request review.
164pub struct Suggestion {
165    /// The file's line number in the diff that begins the suggestion.
166    pub line_start: u32,
167    /// The file's line number in the diff that ends the suggestion.
168    pub line_end: u32,
169    /// The actual suggestion.
170    pub suggestion: String,
171    /// The file that this suggestion pertains to.
172    pub path: String,
173}
174
175impl Suggestion {
176    pub(crate) fn as_review_comment(&self) -> ReviewComment {
177        ReviewComment {
178            line_start: Some(self.line_start),
179            line_end: self.line_end,
180            comment: self.suggestion.clone(),
181            path: self.path.clone(),
182        }
183    }
184}
185
186/// A struct to describe the Pull Request review suggestions.
187#[derive(Default)]
188pub struct ReviewComments {
189    /// The total count of suggestions from clang-tidy and clang-format.
190    ///
191    /// This differs from `comments.len()` because some suggestions may
192    /// not fit within the file's diff.
193    pub tool_total: [Option<u32>; 2],
194    /// A list of comment suggestions to be posted.
195    ///
196    /// These suggestions are guaranteed to fit in the file's diff.
197    pub comments: Vec<Suggestion>,
198    /// The complete patch of changes to all files scanned.
199    ///
200    /// This includes changes from both clang-tidy and clang-format
201    /// (assembled in that order).
202    pub full_patch: [String; 2],
203}
204
205impl ReviewComments {
206    pub fn summarize(
207        &self,
208        clang_versions: &ClangVersions,
209        comments: &Vec<ReviewComment>,
210    ) -> String {
211        let mut body = String::from("## Cpp-linter Review\n");
212        for t in 0_usize..=1 {
213            let mut total = 0;
214            let (tool_name, tool_version) = if t == 0 {
215                ("clang-format", clang_versions.format_version.as_ref())
216            } else {
217                ("clang-tidy", clang_versions.tidy_version.as_ref())
218            };
219            if tool_version.is_none() {
220                // this tool was not used at all
221                continue;
222            }
223            let tool_total = self.tool_total[t].unwrap_or_default();
224
225            // If the tool's version is unknown, then we don't need to output this line.
226            // NOTE: If the tool was invoked at all, then the tool's version shall be known.
227            if let Some(ver_str) = tool_version {
228                body.push_str(format!("\n### Used {tool_name} v{ver_str}\n").as_str());
229            }
230            for comment in comments {
231                if comment
232                    .comment
233                    .contains(format!("### {tool_name}").as_str())
234                {
235                    total += 1;
236                }
237            }
238
239            if total != tool_total {
240                body.push_str(
241                    format!(
242                        "\nOnly {total} out of {tool_total} {tool_name} concerns fit within this pull request's diff.\n",
243                    )
244                    .as_str(),
245                );
246            }
247            if !self.full_patch[t].is_empty() {
248                body.push_str(
249                    format!(
250                        "\n<details><summary>Click here for the full {tool_name} patch</summary>\n\n```diff\n{}```\n\n</details>\n",
251                        self.full_patch[t]
252                    ).as_str()
253                );
254            } else {
255                body.push_str(
256                    format!(
257                        "\nNo concerns reported by {}. Great job! :tada:\n",
258                        tool_name
259                    )
260                    .as_str(),
261                )
262            }
263        }
264        body.push_str(USER_OUTREACH);
265        body
266    }
267
268    pub fn is_comment_in_suggestions(&mut self, comment: &Suggestion) -> bool {
269        for s in &mut self.comments {
270            if s.path == comment.path
271                && s.line_end == comment.line_end
272                && s.line_start == comment.line_start
273            {
274                s.suggestion.push('\n');
275                s.suggestion.push_str(comment.suggestion.as_str());
276                return true;
277            }
278        }
279        false
280    }
281}
282
283pub fn make_patch<'buffer>(
284    path: &Path,
285    patched: &'buffer [u8],
286    original_content: &'buffer [u8],
287) -> Result<Patch<'buffer>, git2::Error> {
288    let mut diff_opts = &mut DiffOptions::new();
289    diff_opts = diff_opts.indent_heuristic(true);
290    diff_opts = diff_opts.context_lines(0);
291    Patch::from_buffers(
292        original_content,
293        Some(path),
294        patched,
295        Some(path),
296        Some(diff_opts),
297    )
298}
299
300/// A trait for generating suggestions from a [`FileObj`]'s advice's generated `patched` buffer.
301pub trait MakeSuggestions {
302    /// Create some user-facing helpful info about what the suggestion aims to resolve.
303    fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String;
304
305    /// Get the tool's name which generated the advice.
306    fn get_tool_name(&self) -> String;
307
308    /// Create a bunch of suggestions from a [`FileObj`]'s advice's generated `patched` buffer.
309    fn get_suggestions(
310        &self,
311        review_comments: &mut ReviewComments,
312        file_obj: &FileObj,
313        patch: &mut Patch,
314        summary_only: bool,
315    ) -> Result<(), SuggestionError> {
316        let is_tidy_tool = (&self.get_tool_name() == "clang-tidy") as usize;
317        let hunks_total = patch.num_hunks();
318        let mut hunks_in_patch = 0u32;
319        let file_name = file_obj
320            .name
321            .to_string_lossy()
322            .replace("\\", "/")
323            .trim_start_matches("./")
324            .to_owned();
325        let patch_buf = &patch
326            .to_buf()
327            .map_err(|e| SuggestionError::PatchIntoBytesFailed {
328                file_name: file_name.clone(),
329                source: e,
330            })?
331            .to_vec();
332        review_comments.full_patch[is_tidy_tool].push_str(
333            String::from_utf8(patch_buf.to_owned())
334                .map_err(|e| SuggestionError::PatchIntoStringFailed {
335                    file_name: file_name.clone(),
336                    source: e,
337                })?
338                .as_str(),
339        );
340        if summary_only {
341            review_comments.tool_total[is_tidy_tool].get_or_insert(0);
342            return Ok(());
343        }
344        for hunk_id in 0..hunks_total {
345            let (hunk, line_count) =
346                patch
347                    .hunk(hunk_id)
348                    .map_err(|e| SuggestionError::GetHunkFailed {
349                        hunk_id,
350                        file_name: file_name.clone(),
351                        source: e,
352                    })?;
353            hunks_in_patch += 1;
354            let hunk_range = file_obj.is_hunk_in_diff(&hunk);
355            match hunk_range {
356                None => continue,
357                Some((start_line, end_line)) => {
358                    let mut suggestion = String::new();
359                    let suggestion_help = self.get_suggestion_help(start_line, end_line);
360                    let mut removed = vec![];
361                    for line_index in 0..line_count {
362                        let diff_line = patch.line_in_hunk(hunk_id, line_index).map_err(|e| {
363                            SuggestionError::GetHunkLineFailed {
364                                line_index,
365                                hunk_id,
366                                file_name: file_name.clone(),
367                                source: e,
368                            }
369                        })?;
370                        let line =
371                            String::from_utf8(diff_line.content().to_owned()).map_err(|e| {
372                                SuggestionError::HunkLineIntoStringFailed {
373                                    line_index,
374                                    hunk_id,
375                                    file_name: file_name.clone(),
376                                    source: e,
377                                }
378                            })?;
379                        if ['+', ' '].contains(&diff_line.origin()) {
380                            suggestion.push_str(line.as_str());
381                        } else {
382                            removed.push(
383                                diff_line
384                                    .old_lineno()
385                                    .expect("Removed line should have a line number"),
386                            );
387                        }
388                    }
389                    if suggestion.is_empty() && !removed.is_empty() {
390                        suggestion.push_str(
391                            format!(
392                                "Please remove the line(s)\n- {}",
393                                removed
394                                    .iter()
395                                    .map(|l| l.to_string())
396                                    .collect::<Vec<String>>()
397                                    .join("\n- ")
398                            )
399                            .as_str(),
400                        )
401                    } else {
402                        suggestion = format!("```suggestion\n{suggestion}```");
403                    }
404                    let comment = Suggestion {
405                        line_start: start_line,
406                        line_end: end_line,
407                        suggestion: format!("{suggestion_help}\n{suggestion}"),
408                        path: file_name.clone(),
409                    };
410                    if !review_comments.is_comment_in_suggestions(&comment) {
411                        review_comments.comments.push(comment);
412                    }
413                }
414            }
415        }
416        review_comments.tool_total[is_tidy_tool] =
417            Some(review_comments.tool_total[is_tidy_tool].unwrap_or_default() + hunks_in_patch);
418        Ok(())
419    }
420}