1use std::{
5 env::current_dir,
6 fmt::{self, Display},
7 fs,
8 path::{Path, PathBuf},
9 process::Command,
10 sync::{Arc, Mutex},
11};
12
13use anyhow::{anyhow, Context, Result};
15use git2::{DiffOptions, Patch};
16use regex::Regex;
17use semver::Version;
18use tokio::task::JoinSet;
19use which::{which, which_in};
20
21use 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 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 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 RequestedVersion::SystemDefault | RequestedVersion::NoValue => {
69 which(name).map_err(|_| anyhow!("Could not find clang tool by name"))
70 }
71 RequestedVersion::Requirement(req) => {
72 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 highest_major = n.major + 1;
83 }
84 }
85
86 let mut majors = vec![];
88 while highest_major > 0 {
89 if req.matches(&Version::new(highest_major, 0, 0)) {
91 majors.push(highest_major);
92 }
93 highest_major -= 1;
94 }
95
96 for major in majors {
98 if let Ok(cmd) = which(format!("{self}-{major}")) {
99 return Ok(cmd);
100 }
101 }
102 which(name).map_err(|_| anyhow!("Could not find {self} by version"))
113 }
114 }
115 }
116
117 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
130fn 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#[derive(Default)]
189pub struct ClangVersions {
190 pub format_version: Option<String>,
192
193 pub tidy_version: Option<String>,
195}
196
197pub 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 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 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 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 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
263pub struct Suggestion {
265 pub line_start: u32,
267 pub line_end: u32,
269 pub suggestion: String,
271 pub path: String,
273}
274
275#[derive(Default)]
277pub struct ReviewComments {
278 pub tool_total: [Option<u32>; 2],
283 pub comments: Vec<Suggestion>,
287 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 continue;
307 }
308 let tool_total = self.tool_total[t].unwrap_or_default();
309
310 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 fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String;
395
396 fn get_tool_name(&self) -> String;
398
399 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}