use std::{
env::{consts::OS, current_dir},
fs,
path::PathBuf,
process::Command,
sync::{Arc, Mutex, MutexGuard},
};
use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
use super::MakeSuggestions;
use crate::{
cli::{ClangParams, LinesChangedOnly},
common_fs::{normalize_path, FileObj},
};
#[derive(Deserialize, Debug, Clone)]
pub struct CompilationUnit {
directory: String,
file: String,
}
#[derive(Debug, Clone)]
pub struct TidyNotification {
pub filename: String,
pub line: u32,
pub cols: u32,
pub severity: String,
pub rationale: String,
pub diagnostic: String,
pub suggestion: Vec<String>,
pub fixed_lines: Vec<u32>,
}
impl TidyNotification {
pub fn diagnostic_link(&self) -> String {
if self.diagnostic.starts_with("clang-diagnostic") {
return self.diagnostic.clone();
}
let (category, name) = if self.diagnostic.starts_with("clang-analyzer-") {
(
"clang-analyzer",
self.diagnostic.strip_prefix("clang-analyzer-").unwrap(),
)
} else {
self.diagnostic.split_once('-').unwrap()
};
format!(
"[{}](https://clang.llvm.org/extra/clang-tidy/checks/{category}/{name}.html)",
self.diagnostic
)
}
}
#[derive(Debug, Clone)]
pub struct TidyAdvice {
pub notes: Vec<TidyNotification>,
pub patched: Option<Vec<u8>>,
}
impl MakeSuggestions for TidyAdvice {
fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String {
let mut diagnostics = vec![];
for note in &self.notes {
for fixed_line in ¬e.fixed_lines {
if (start_line..=end_line).contains(fixed_line) {
diagnostics.push(format!("- {}\n", note.diagnostic_link()));
}
}
}
format!(
"### clang-tidy {}\n{}",
if diagnostics.is_empty() {
"suggestion"
} else {
"diagnostic(s)"
},
diagnostics.join("")
)
}
fn get_tool_name(&self) -> String {
"clang-tidy".to_string()
}
}
fn parse_tidy_output(
tidy_stdout: &[u8],
database_json: &Option<Vec<CompilationUnit>>,
) -> Result<TidyAdvice> {
let note_header = Regex::new(r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+)\]$").unwrap();
let fixed_note =
Regex::new(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$").unwrap();
let mut found_fix = false;
let mut notification = None;
let mut result = Vec::new();
let cur_dir = current_dir().unwrap();
for line in String::from_utf8(tidy_stdout.to_vec()).unwrap().lines() {
if let Some(captured) = note_header.captures(line) {
if let Some(note) = notification {
result.push(note);
}
let mut filename = PathBuf::from(&captured[1]);
if let Some(db_json) = &database_json {
let mut found_unit = false;
for unit in db_json {
let unit_path =
PathBuf::from_iter([unit.directory.as_str(), unit.file.as_str()]);
if unit_path == filename {
filename =
normalize_path(&PathBuf::from_iter([&unit.directory, &unit.file]));
found_unit = true;
break;
}
}
if !found_unit {
filename = normalize_path(&PathBuf::from_iter([&cur_dir, &filename]));
}
} else {
filename = normalize_path(&PathBuf::from_iter([&cur_dir, &filename]));
}
assert!(filename.is_absolute());
if filename.is_absolute() && filename.starts_with(&cur_dir) {
filename = filename
.strip_prefix(&cur_dir)
.unwrap()
.to_path_buf();
}
notification = Some(TidyNotification {
filename: filename.to_string_lossy().to_string().replace('\\', "/"),
line: captured[2].parse()?,
cols: captured[3].parse()?,
severity: String::from(&captured[4]),
rationale: String::from(&captured[5]).trim().to_string(),
diagnostic: String::from(&captured[6]),
suggestion: Vec::new(),
fixed_lines: Vec::new(),
});
found_fix = false;
} else if let Some(capture) = fixed_note.captures(line) {
let fixed_line = capture[1].parse()?;
if let Some(note) = &mut notification {
if !note.fixed_lines.contains(&fixed_line) {
note.fixed_lines.push(fixed_line);
}
}
found_fix = true;
} else if !found_fix {
if let Some(note) = &mut notification {
note.suggestion.push(line.to_string());
}
}
}
if let Some(note) = notification {
result.push(note);
}
Ok(TidyAdvice {
notes: result,
patched: None,
})
}
pub fn tally_tidy_advice(files: &[Arc<Mutex<FileObj>>]) -> u64 {
let mut total = 0;
for file in files {
let file = file.lock().unwrap();
if let Some(advice) = &file.tidy_advice {
for tidy_note in &advice.notes {
let file_path = PathBuf::from(&tidy_note.filename);
if file_path == file.name {
total += 1;
}
}
}
}
total
}
pub fn run_clang_tidy(
file: &mut MutexGuard<FileObj>,
clang_params: &ClangParams,
) -> Result<Vec<(log::Level, std::string::String)>> {
let mut cmd = Command::new(clang_params.clang_tidy_command.as_ref().unwrap());
let mut logs = vec![];
if !clang_params.tidy_checks.is_empty() {
cmd.args(["-checks", &clang_params.tidy_checks]);
}
if let Some(db) = &clang_params.database {
cmd.args(["-p", &db.to_string_lossy()]);
}
for arg in &clang_params.extra_args {
cmd.args(["--extra-arg", format!("\"{}\"", arg).as_str()]);
}
let file_name = file.name.to_string_lossy().to_string();
if clang_params.lines_changed_only != LinesChangedOnly::Off {
let ranges = file.get_ranges(&clang_params.lines_changed_only);
let filter = format!(
"[{{\"name\":{:?},\"lines\":{:?}}}]",
&file_name.replace('/', if OS == "windows" { "\\" } else { "/" }),
ranges
.iter()
.map(|r| [r.start(), r.end()])
.collect::<Vec<_>>()
);
cmd.args(["--line-filter", filter.as_str()]);
}
let mut original_content = None;
if clang_params.tidy_review {
cmd.arg("--fix-errors");
original_content = Some(fs::read_to_string(&file.name).with_context(|| {
format!(
"Failed to cache file's original content before applying clang-tidy changes: {}",
file_name.clone()
)
})?);
}
if !clang_params.style.is_empty() {
cmd.args(["--format-style", clang_params.style.as_str()]);
}
cmd.arg(file.name.to_string_lossy().as_ref());
logs.push((
log::Level::Info,
format!(
"Running \"{} {}\"",
cmd.get_program().to_string_lossy(),
cmd.get_args()
.map(|x| x.to_str().unwrap())
.collect::<Vec<&str>>()
.join(" ")
),
));
let output = cmd.output().unwrap();
logs.push((
log::Level::Debug,
format!(
"Output from clang-tidy:\n{}",
String::from_utf8_lossy(&output.stdout)
),
));
if !output.stderr.is_empty() {
logs.push((
log::Level::Debug,
format!(
"clang-tidy made the following summary:\n{}",
String::from_utf8_lossy(&output.stderr)
),
));
}
file.tidy_advice = Some(parse_tidy_output(
&output.stdout,
&clang_params.database_json,
)?);
if clang_params.tidy_review {
if let Some(tidy_advice) = &mut file.tidy_advice {
tidy_advice.patched =
Some(fs::read(&file_name).with_context(|| {
format!("Failed to read changes from clang-tidy: {file_name}")
})?);
}
fs::write(&file_name, original_content.unwrap())
.with_context(|| format!("Failed to restore file's original content: {file_name}"))?;
}
Ok(logs)
}
#[cfg(test)]
mod test {
use std::{
env,
path::PathBuf,
sync::{Arc, Mutex},
};
use regex::Regex;
use crate::{
clang_tools::get_clang_tool_exe,
cli::{ClangParams, LinesChangedOnly},
common_fs::FileObj,
};
use super::run_clang_tidy;
use super::TidyNotification;
#[test]
fn clang_diagnostic_link() {
let note = TidyNotification {
filename: String::from("some_src.cpp"),
line: 1504,
cols: 9,
rationale: String::from("file not found"),
severity: String::from("error"),
diagnostic: String::from("clang-diagnostic-error"),
suggestion: vec![],
fixed_lines: vec![],
};
assert_eq!(note.diagnostic_link(), note.diagnostic);
}
#[test]
fn clang_analyzer_link() {
let note = TidyNotification {
filename: String::from("some_src.cpp"),
line: 1504,
cols: 9,
rationale: String::from(
"Dereference of null pointer (loaded from variable 'pipe_num')",
),
severity: String::from("warning"),
diagnostic: String::from("clang-analyzer-core.NullDereference"),
suggestion: vec![],
fixed_lines: vec![],
};
let expected = format!(
"[{}](https://clang.llvm.org/extra/clang-tidy/checks/{}/{}.html)",
note.diagnostic, "clang-analyzer", "core.NullDereference",
);
assert_eq!(note.diagnostic_link(), expected);
}
#[test]
fn test_capture() {
let src = "tests/demo/demo.hpp:11:11: warning: use a trailing return type for this function [modernize-use-trailing-return-type]";
let pat = Regex::new(r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+)\]$").unwrap();
let cap = pat.captures(src).unwrap();
assert_eq!(
cap.get(0).unwrap().as_str(),
format!(
"{}:{}:{}: {}:{}[{}]",
cap.get(1).unwrap().as_str(),
cap.get(2).unwrap().as_str(),
cap.get(3).unwrap().as_str(),
cap.get(4).unwrap().as_str(),
cap.get(5).unwrap().as_str(),
cap.get(6).unwrap().as_str()
)
.as_str()
)
}
#[test]
fn use_extra_args() {
let exe_path = get_clang_tool_exe(
"clang-tidy",
env::var("CLANG_VERSION").unwrap_or("".to_string()).as_str(),
)
.unwrap();
let file = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
let arc_ref = Arc::new(Mutex::new(file));
let extra_args = vec!["-std=c++17".to_string(), "-Wall".to_string()];
let clang_params = ClangParams {
style: "".to_string(),
tidy_checks: "".to_string(), lines_changed_only: LinesChangedOnly::Off,
database: None,
extra_args: extra_args.clone(), database_json: None,
format_filter: None,
tidy_filter: None,
tidy_review: false,
format_review: false,
clang_tidy_command: Some(exe_path),
clang_format_command: None,
};
let mut file_lock = arc_ref.lock().unwrap();
let logs = run_clang_tidy(&mut file_lock, &clang_params)
.unwrap()
.into_iter()
.filter_map(|(_lvl, msg)| {
if msg.contains("Running ") {
Some(msg)
} else {
None
}
})
.collect::<Vec<String>>();
let args = &logs
.first()
.expect("expected a log message about invoked clang-tidy command")
.split(' ')
.collect::<Vec<&str>>();
for arg in &extra_args {
let extra_arg = format!("\"{arg}\"");
assert!(args.contains(&extra_arg.as_str()));
}
}
}