1#![deny(clippy::unwrap_used)]
2use std::{
6 fs,
7 path::{Path, PathBuf},
8 sync::{Arc, Mutex},
9};
10
11use 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
19use 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
31fn 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#[derive(Debug, Default)]
90pub struct ClangVersions {
91 pub format_version: Option<Version>,
93
94 pub tidy_version: Option<Version>,
96}
97
98pub 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 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 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 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 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 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
163pub struct Suggestion {
165 pub line_start: u32,
167 pub line_end: u32,
169 pub suggestion: String,
171 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#[derive(Default)]
188pub struct ReviewComments {
189 pub tool_total: [Option<u32>; 2],
194 pub comments: Vec<Suggestion>,
198 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 continue;
222 }
223 let tool_total = self.tool_total[t].unwrap_or_default();
224
225 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
300pub trait MakeSuggestions {
302 fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String;
304
305 fn get_tool_name(&self) -> String;
307
308 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}