use std::{
env::current_dir,
fs,
path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex},
};
use anyhow::{anyhow, Context, Result};
use git2::{DiffOptions, Patch};
use lenient_semver;
use semver::Version;
use tokio::task::JoinSet;
use which::{which, which_in};
use super::common_fs::FileObj;
use crate::{
cli::ClangParams,
logger::{end_log_group, start_log_group},
rest_api::{COMMENT_MARKER, USER_OUTREACH},
};
pub mod clang_format;
use clang_format::run_clang_format;
pub mod clang_tidy;
use clang_tidy::{run_clang_tidy, CompilationUnit};
pub fn get_clang_tool_exe(name: &str, version: &str) -> Result<PathBuf> {
if version.is_empty() {
if let Ok(cmd) = which(name) {
return Ok(cmd);
} else {
return Err(anyhow!("Could not find clang tool by name"));
}
}
if let Ok(semver) = lenient_semver::parse_into::<Version>(version) {
if let Ok(cmd) = which(format!("{}-{}", name, semver.major)) {
Ok(cmd)
} else if let Ok(cmd) = which(name) {
return Ok(cmd);
} else {
return Err(anyhow!("Could not find clang tool by name and version"));
}
} else {
if let Ok(exe_path) = which_in(name, Some(version), current_dir().unwrap()) {
Ok(exe_path)
} else {
Err(anyhow!("Could not find clang tool by path"))
}
}
}
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_tidy_command.is_some() {
if clang_params
.tidy_filter
.as_ref()
.is_some_and(|f| f.is_source_or_ignored(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()
),
));
}
}
if clang_params.clang_format_command.is_some() {
if clang_params
.format_filter
.as_ref()
.is_some_and(|f| f.is_source_or_ignored(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()
),
));
}
}
Ok((file.name.clone(), logs))
}
pub async fn capture_clang_tools_output(
files: &mut Vec<Arc<Mutex<FileObj>>>,
version: &str,
clang_params: &mut ClangParams,
) -> Result<()> {
if clang_params.tidy_checks != "-*" {
clang_params.clang_tidy_command = {
let cmd = get_clang_tool_exe("clang-tidy", version)?;
log::debug!(
"{} --version\n{}",
&cmd.to_string_lossy(),
String::from_utf8_lossy(&Command::new(&cmd).arg("--version").output()?.stdout)
);
Some(cmd)
}
};
if !clang_params.style.is_empty() {
clang_params.clang_format_command = {
let cmd = get_clang_tool_exe("clang-format", version)?;
log::debug!(
"{} --version\n{}",
&cmd.to_string_lossy(),
String::from_utf8_lossy(&Command::new(&cmd).arg("--version").output()?.stdout)
);
Some(cmd)
}
};
if let Some(db_path) = &clang_params.database {
if 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();
for file in files {
let arc_params = Arc::new(clang_params.clone());
let arc_file = Arc::clone(file);
executors.spawn(async move { analyze_single_file(arc_file, arc_params) });
}
while let Some(output) = executors.join_next().await {
if let Ok(out) = output? {
let (file_name, logs) = out;
start_log_group(format!("Analyzing {}", file_name.to_string_lossy()));
for (level, msg) in logs {
log::log!(level, "{}", msg);
}
end_log_group();
}
}
Ok(())
}
pub struct Suggestion {
pub line_start: u32,
pub line_end: u32,
pub suggestion: String,
pub path: String,
}
#[derive(Default)]
pub struct ReviewComments {
pub tool_total: [u32; 2],
pub comments: Vec<Suggestion>,
pub full_patch: [String; 2],
}
impl ReviewComments {
pub fn summarize(&self) -> String {
let mut body = format!("{COMMENT_MARKER}## Cpp-linter Review\n");
for t in 0u8..=1 {
let mut total = 0;
let tool_name = if t == 0 { "clang-format" } else { "clang-tidy" };
for comment in &self.comments {
if comment
.suggestion
.contains(format!("### {tool_name}").as_str())
{
total += 1;
}
}
if total != self.tool_total[t as usize] {
body.push_str(
format!(
"\nOnly {} out of {} {tool_name} concerns fit within this pull request's diff.\n",
self.tool_total[t as usize], total
)
.as_str(),
);
}
if !self.full_patch[t as usize].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 usize]
).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>> {
let mut diff_opts = &mut DiffOptions::new();
diff_opts = diff_opts.indent_heuristic(true);
diff_opts = diff_opts.context_lines(0);
let patch = Patch::from_buffers(
original_content,
Some(path),
patched,
Some(path),
Some(diff_opts),
)
.with_context(|| {
format!(
"Failed to create patch for file {}.",
path.to_string_lossy()
)
})?;
Ok(patch)
}
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<()> {
let tool_name = self.get_tool_name();
let is_tidy_tool = tool_name == "clang-tidy";
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()
.with_context(|| "Failed to convert patch to byte array")?
.to_vec();
review_comments.full_patch[is_tidy_tool as usize].push_str(
String::from_utf8(patch_buf.to_owned())
.with_context(|| format!("Failed to convert patch to string: {file_name}"))?
.as_str(),
);
if summary_only {
return Ok(());
}
for hunk_id in 0..hunks_total {
let (hunk, line_count) = patch.hunk(hunk_id).with_context(|| {
format!("Failed to get hunk {hunk_id} from patch for {file_name}")
})?;
hunks_in_patch += 1;
let hunk_range = file_obj.is_hunk_in_diff(&hunk);
if hunk_range.is_none() {
continue;
}
let (start_line, end_line) = hunk_range.unwrap();
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)
.with_context(|| format!("Failed to get line {line_index} in a hunk {hunk_id} of patch for {file_name}"))?;
let line = String::from_utf8(diff_line.content().to_owned())
.with_context(|| format!("Failed to convert line {line_index} buffer to string in hunk {hunk_id} of patch for {file_name}"))?;
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 as usize] += hunks_in_patch;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::env;
use super::get_clang_tool_exe;
const TOOL_NAME: &str = "clang-format";
#[test]
fn get_exe_by_version() {
let clang_version = env::var("CLANG_VERSION").unwrap_or("16".to_string());
let tool_exe = get_clang_tool_exe(TOOL_NAME, clang_version.as_str());
println!("tool_exe: {:?}", tool_exe);
assert!(tool_exe.is_ok_and(|val| val
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
.contains(TOOL_NAME)));
}
#[test]
fn get_exe_by_default() {
let tool_exe = get_clang_tool_exe(TOOL_NAME, "");
println!("tool_exe: {:?}", tool_exe);
assert!(tool_exe.is_ok_and(|val| val
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
.contains(TOOL_NAME)));
}
use which::which;
#[test]
fn get_exe_by_path() {
let clang_version = which(TOOL_NAME).unwrap();
let bin_path = clang_version.parent().unwrap().to_str().unwrap();
println!("binary exe path: {bin_path}");
let tool_exe = get_clang_tool_exe(TOOL_NAME, bin_path);
println!("tool_exe: {:?}", tool_exe);
assert!(tool_exe.is_ok_and(|val| val
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
.contains(TOOL_NAME)));
}
#[test]
fn get_exe_by_invalid_path() {
let tool_exe = get_clang_tool_exe(TOOL_NAME, "non-existent-path");
assert!(tool_exe.is_err());
}
#[test]
fn get_exe_by_invalid_name() {
let clang_version = env::var("CLANG_VERSION").unwrap_or("16".to_string());
let tool_exe = get_clang_tool_exe("not-a-clang-tool", &clang_version);
assert!(tool_exe.is_err());
}
}