1use std::{
5 fs,
6 path::PathBuf,
7 sync::{Arc, Mutex},
8};
9
10use clang_tools_manager::{ClangTool, RequestedVersion};
12use git_bot_feedback::ReviewComment;
13use gix_imara_diff::{Diff, InternedInput};
14use semver::Version;
15use tokio::task::JoinSet;
16
17use 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
30fn 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#[derive(Debug, Default)]
87pub struct ClangVersions {
88 pub format_version: Option<Version>,
90
91 pub tidy_version: Option<Version>,
93}
94
95pub 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 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 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 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
191pub struct Suggestion {
193 pub line_start: u32,
195 pub line_end: u32,
197 pub suggestion: String,
199 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#[derive(Default)]
220pub struct ReviewComments {
221 pub tool_total: u32,
226 pub comments: Vec<Suggestion>,
230 pub full_patch: String,
235}
236
237impl ReviewComments {
238 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 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 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 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 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 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
349pub 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 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 #[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}