#![deny(clippy::unwrap_used)]
use std::{
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use anyhow::{Context, Result, anyhow};
use clang_installer::{ClangTool, RequestedVersion};
use git_bot_feedback::ReviewComment;
use git2::{DiffOptions, Patch};
use semver::Version;
use tokio::task::JoinSet;
use super::common_fs::FileObj;
use crate::error::SuggestionError;
use crate::{
cli::ClangParams,
rest_client::{RestClient, USER_OUTREACH},
};
pub mod clang_format;
use clang_format::run_clang_format;
pub mod clang_tidy;
use clang_tidy::{CompilationUnit, run_clang_tidy};
fn analyze_single_file(
file: Arc<Mutex<FileObj>>,
clang_params: Arc<ClangParams>,
) -> Result<(PathBuf, Vec<(log::Level, String)>)> {
let mut file = file
.lock()
.map_err(|_| anyhow!("Failed to lock file mutex"))?;
let mut logs = vec![];
if clang_params.clang_format_command.is_some() {
if clang_params
.format_filter
.as_ref()
.is_some_and(|f| f.is_qualified(file.name.as_path()))
|| clang_params.format_filter.is_none()
{
let format_result = run_clang_format(&mut file, &clang_params)?;
logs.extend(format_result);
} else {
logs.push((
log::Level::Info,
format!(
"{} not scanned by clang-format due to `--ignore-format`",
file.name.as_os_str().to_string_lossy()
),
));
}
}
if clang_params.clang_tidy_command.is_some() {
if clang_params
.tidy_filter
.as_ref()
.is_some_and(|f| f.is_qualified(file.name.as_path()))
|| clang_params.tidy_filter.is_none()
{
let tidy_result = run_clang_tidy(&mut file, &clang_params)?;
logs.extend(tidy_result);
} else {
logs.push((
log::Level::Info,
format!(
"{} not scanned by clang-tidy due to `--ignore-tidy`",
file.name.as_os_str().to_string_lossy()
),
));
}
}
Ok((file.name.clone(), logs))
}
#[derive(Debug, Default)]
pub struct ClangVersions {
pub format_version: Option<Version>,
pub tidy_version: Option<Version>,
}
pub async fn capture_clang_tools_output(
files: &[Arc<Mutex<FileObj>>],
version: &RequestedVersion,
mut clang_params: ClangParams,
rest_api_client: &RestClient,
) -> Result<ClangVersions> {
let mut clang_versions = ClangVersions::default();
if clang_params.tidy_checks != "-*" {
let tool = ClangTool::ClangTidy;
let tool_info = version.eval_tool(&tool, false, None).await?.ok_or(anyhow!(
"Failed to find {tool} or install a suitable version"
))?;
clang_versions.tidy_version = Some(tool_info.version);
clang_params.clang_tidy_command = Some(tool_info.path);
}
if !clang_params.style.is_empty() {
let tool = ClangTool::ClangFormat;
let tool_info = version.eval_tool(&tool, false, None).await?.ok_or(anyhow!(
"Failed to find {tool} or install a suitable version"
))?;
clang_versions.format_version = Some(tool_info.version);
clang_params.clang_format_command = Some(tool_info.path);
}
if let Some(db_path) = &clang_params.database
&& let Ok(db_str) = fs::read(db_path.join("compile_commands.json"))
{
clang_params.database_json = Some(
serde_json::from_str::<Vec<CompilationUnit>>(&String::from_utf8_lossy(&db_str))
.with_context(|| "Failed to parse compile_commands.json")?,
)
};
let mut executors = JoinSet::new();
let arc_params = Arc::new(clang_params);
for file in files {
let arc_file = file.clone();
let arc_params = arc_params.clone();
executors.spawn(async move { analyze_single_file(arc_file, arc_params) });
}
while let Some(output) = executors.join_next().await {
let (file_name, logs) = output??;
let log_group_name = format!("Analyzing {}", file_name.to_string_lossy());
rest_api_client.start_log_group(&log_group_name);
for (level, msg) in logs {
log::log!(level, "{}", msg);
}
rest_api_client.end_log_group(&log_group_name);
}
Ok(clang_versions)
}
pub struct Suggestion {
pub line_start: u32,
pub line_end: u32,
pub suggestion: String,
pub path: String,
}
impl Suggestion {
pub(crate) fn as_review_comment(&self) -> ReviewComment {
ReviewComment {
line_start: Some(self.line_start),
line_end: self.line_end,
comment: self.suggestion.clone(),
path: self.path.clone(),
}
}
}
#[derive(Default)]
pub struct ReviewComments {
pub tool_total: [Option<u32>; 2],
pub comments: Vec<Suggestion>,
pub full_patch: [String; 2],
}
impl ReviewComments {
pub fn summarize(
&self,
clang_versions: &ClangVersions,
comments: &Vec<ReviewComment>,
) -> String {
let mut body = String::from("## Cpp-linter Review\n");
for t in 0_usize..=1 {
let mut total = 0;
let (tool_name, tool_version) = if t == 0 {
("clang-format", clang_versions.format_version.as_ref())
} else {
("clang-tidy", clang_versions.tidy_version.as_ref())
};
if tool_version.is_none() {
continue;
}
let tool_total = self.tool_total[t].unwrap_or_default();
if let Some(ver_str) = tool_version {
body.push_str(format!("\n### Used {tool_name} v{ver_str}\n").as_str());
}
for comment in comments {
if comment
.comment
.contains(format!("### {tool_name}").as_str())
{
total += 1;
}
}
if total != tool_total {
body.push_str(
format!(
"\nOnly {total} out of {tool_total} {tool_name} concerns fit within this pull request's diff.\n",
)
.as_str(),
);
}
if !self.full_patch[t].is_empty() {
body.push_str(
format!(
"\n<details><summary>Click here for the full {tool_name} patch</summary>\n\n```diff\n{}```\n\n</details>\n",
self.full_patch[t]
).as_str()
);
} else {
body.push_str(
format!(
"\nNo concerns reported by {}. Great job! :tada:\n",
tool_name
)
.as_str(),
)
}
}
body.push_str(USER_OUTREACH);
body
}
pub fn is_comment_in_suggestions(&mut self, comment: &Suggestion) -> bool {
for s in &mut self.comments {
if s.path == comment.path
&& s.line_end == comment.line_end
&& s.line_start == comment.line_start
{
s.suggestion.push('\n');
s.suggestion.push_str(comment.suggestion.as_str());
return true;
}
}
false
}
}
pub fn make_patch<'buffer>(
path: &Path,
patched: &'buffer [u8],
original_content: &'buffer [u8],
) -> Result<Patch<'buffer>, git2::Error> {
let mut diff_opts = &mut DiffOptions::new();
diff_opts = diff_opts.indent_heuristic(true);
diff_opts = diff_opts.context_lines(0);
Patch::from_buffers(
original_content,
Some(path),
patched,
Some(path),
Some(diff_opts),
)
}
pub trait MakeSuggestions {
fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String;
fn get_tool_name(&self) -> String;
fn get_suggestions(
&self,
review_comments: &mut ReviewComments,
file_obj: &FileObj,
patch: &mut Patch,
summary_only: bool,
) -> Result<(), SuggestionError> {
let is_tidy_tool = (&self.get_tool_name() == "clang-tidy") as usize;
let hunks_total = patch.num_hunks();
let mut hunks_in_patch = 0u32;
let file_name = file_obj
.name
.to_string_lossy()
.replace("\\", "/")
.trim_start_matches("./")
.to_owned();
let patch_buf = &patch
.to_buf()
.map_err(|e| SuggestionError::PatchIntoBytesFailed {
file_name: file_name.clone(),
source: e,
})?
.to_vec();
review_comments.full_patch[is_tidy_tool].push_str(
String::from_utf8(patch_buf.to_owned())
.map_err(|e| SuggestionError::PatchIntoStringFailed {
file_name: file_name.clone(),
source: e,
})?
.as_str(),
);
if summary_only {
review_comments.tool_total[is_tidy_tool].get_or_insert(0);
return Ok(());
}
for hunk_id in 0..hunks_total {
let (hunk, line_count) =
patch
.hunk(hunk_id)
.map_err(|e| SuggestionError::GetHunkFailed {
hunk_id,
file_name: file_name.clone(),
source: e,
})?;
hunks_in_patch += 1;
let hunk_range = file_obj.is_hunk_in_diff(&hunk);
match hunk_range {
None => continue,
Some((start_line, end_line)) => {
let mut suggestion = String::new();
let suggestion_help = self.get_suggestion_help(start_line, end_line);
let mut removed = vec![];
for line_index in 0..line_count {
let diff_line = patch.line_in_hunk(hunk_id, line_index).map_err(|e| {
SuggestionError::GetHunkLineFailed {
line_index,
hunk_id,
file_name: file_name.clone(),
source: e,
}
})?;
let line =
String::from_utf8(diff_line.content().to_owned()).map_err(|e| {
SuggestionError::HunkLineIntoStringFailed {
line_index,
hunk_id,
file_name: file_name.clone(),
source: e,
}
})?;
if ['+', ' '].contains(&diff_line.origin()) {
suggestion.push_str(line.as_str());
} else {
removed.push(
diff_line
.old_lineno()
.expect("Removed line should have a line number"),
);
}
}
if suggestion.is_empty() && !removed.is_empty() {
suggestion.push_str(
format!(
"Please remove the line(s)\n- {}",
removed
.iter()
.map(|l| l.to_string())
.collect::<Vec<String>>()
.join("\n- ")
)
.as_str(),
)
} else {
suggestion = format!("```suggestion\n{suggestion}```");
}
let comment = Suggestion {
line_start: start_line,
line_end: end_line,
suggestion: format!("{suggestion_help}\n{suggestion}"),
path: file_name.clone(),
};
if !review_comments.is_comment_in_suggestions(&comment) {
review_comments.comments.push(comment);
}
}
}
}
review_comments.tool_total[is_tidy_tool] =
Some(review_comments.tool_total[is_tidy_tool].unwrap_or_default() + hunks_in_patch);
Ok(())
}
}