use std::{
env::{consts::OS, current_dir},
fs,
path::PathBuf,
process::Command,
sync::{Arc, Mutex, MutexGuard},
};
use anyhow::{Context, Result, anyhow};
use clang_tools_manager::utils::normalize_path;
use regex::Regex;
use serde::Deserialize;
use super::MakeSuggestions;
use crate::{cli::ClangParams, common_fs::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();
}
if let Some((category, name)) = if self.diagnostic.starts_with("clang-analyzer-") {
self.diagnostic
.strip_prefix("clang-analyzer-")
.map(|n| ("clang-analyzer", n))
} else {
self.diagnostic.split_once('-')
} {
debug_assert!(!category.is_empty() && !name.is_empty());
format!(
"[{}](https://clang.llvm.org/extra/clang-tidy/checks/{category}/{name}.html)",
self.diagnostic
)
} else {
self.diagnostic.clone()
}
}
}
#[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.rationale,
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()
}
}
const NOTE_HEADER: &str = r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+),?[^\]]*\]$";
fn parse_tidy_output(
tidy_stdout: &[u8],
database_json: &Option<Vec<CompilationUnit>>,
) -> Result<TidyAdvice> {
let note_header = Regex::new(NOTE_HEADER)
.with_context(|| "Failed to compile RegExp pattern for note header")?;
let fixed_note = Regex::new(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$")
.with_context(|| "Failed to compile RegExp pattern for fixed note")?;
let mut found_fix = false;
let mut notification = None;
let mut result = Vec::new();
let cur_dir = current_dir().with_context(|| "Failed to access current working directory")?;
for line in String::from_utf8(tidy_stdout.to_vec())
.with_context(|| "Failed to convert clang-tidy stdout to UTF-8 string")?
.lines()
{
if let Some(captured) = note_header.captures(line) {
if captured
.get(6)
.is_some_and(|diag| !diag.as_str().contains(' ') && diag.as_str().contains('-'))
{
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]));
}
debug_assert!(filename.is_absolute());
if filename.is_absolute()
&& let Ok(file_n) = filename.strip_prefix(&cur_dir)
{
filename = file_n.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
&& !note.fixed_lines.contains(&fixed_line)
{
note.fixed_lines.push(fixed_line);
}
found_fix = true;
} else if !found_fix && 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>>]) -> Result<u64, String> {
let mut total = 0;
for file in files {
let file = file.lock().map_err(|e| e.to_string())?;
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;
}
}
}
}
Ok(total)
}
pub fn run_clang_tidy(
file: &mut MutexGuard<FileObj>,
clang_params: &ClangParams,
) -> Result<Vec<(log::Level, std::string::String)>> {
let cmd_path = clang_params
.clang_tidy_command
.as_ref()
.ok_or(anyhow!("clang-tidy command not located"))?;
let mut cmd = Command::new(cmd_path);
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();
let ranges = file.get_ranges(&clang_params.lines_changed_only);
if !ranges.is_empty() {
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 original_content = if !clang_params.tidy_review {
None
} else {
cmd.arg("--fix-errors");
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_string_lossy())
.collect::<Vec<_>>()
.join(" ")
),
));
let output = cmd.output().with_context(|| {
format!(
"Failed to execute clang-tidy on file: {}",
file_name.clone()
)
})?;
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
&& let Some(original_content) = &original_content
{
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)
.with_context(|| format!("Failed to restore file's original content: {file_name}"))?;
}
Ok(logs)
}
#[cfg(test)]
mod test {
#![allow(clippy::unwrap_used)]
use std::{
env,
path::PathBuf,
str::FromStr,
sync::{Arc, Mutex},
};
use clang_tools_manager::RequestedVersion;
use regex::Regex;
use crate::{
clang_tools::{ClangTool, clang_tidy::parse_tidy_output},
cli::{ClangParams, LinesChangedOnly},
common_fs::FileObj,
};
use super::{NOTE_HEADER, TidyNotification, run_clang_tidy};
#[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 invalid_diagnostic_link() {
let expected = "no_diagnostic_name".to_string();
let note = TidyNotification {
filename: String::from("some_src.cpp"),
line: 1504,
cols: 9,
rationale: String::from("some rationale"),
severity: String::from("warning"),
diagnostic: expected.clone(),
suggestion: vec![],
fixed_lines: vec![],
};
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,-warnings-as-errors]";
let pat = Regex::new(NOTE_HEADER).unwrap();
let cap = pat.captures(src).unwrap();
assert_eq!(
cap.get(0).unwrap().as_str(),
format!(
"{}:{}:{}: {}:{}[{},-warnings-as-errors]",
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 = ClangTool::ClangTidy
.get_exe_path(
&RequestedVersion::from_str(
env::var("CLANG_VERSION").unwrap_or("".to_string()).as_str(),
)
.unwrap(),
)
.unwrap();
let file = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
let arc_file = 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_file.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()));
}
}
#[test]
fn skip_parse_tidy_diagnostic_rationale() {
let tidy_out = r#"
TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:46:19: error: use of undeclared identifier 'readFreeImageTexture' [clang-diagnostic-error]
46 | return readFreeImageTexture(reader);
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:659:32: note: in instantiation of function template specialization 'tb::mdl::(anonymous namespace)::loadTexture(const std::string &)::(anonymous class)::operator()<std::shared_ptr<tb::fs::File>>' requested here
659 | using Fn_Result = decltype(f(std::declval<Value&&>()));
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:2949:29: note: in instantiation of function template specialization 'kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>::and_then<(lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
2949 | return std::forward<R>(r).and_then(t.and_then);
| ^
TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:32: note: in instantiation of function template specialization 'kdl::detail::operator|<kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>, (lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:661:19: error: static assertion failed due to requirement 'is_result_v<int>': Function must return a result type [clang-diagnostic-error]
661 | static_assert(is_result_v<Fn_Result>, "Function must return a result type");
| ^~~~~~~~~~~~~~~~~~~~~~
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:2949:29: note: in instantiation of function template specialization 'kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>::and_then<(lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
2949 | return std::forward<R>(r).and_then(t.and_then);
| ^
TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:32: note: in instantiation of function template specialization 'kdl::detail::operator|<kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>, (lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:663:77: error: no type named 'type' in 'kdl::detail::chain_results<kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>, int>' [clang-diagnostic-error]
663 | using Cm_Result = typename detail::chain_results<My_Result, Fn_Result>::type;
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:667:48: error: no matching function for call to object of type 'const (lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)' [clang-diagnostic-error]
667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:667:29: note: while substituting into a lambda expression here
667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
| ^
TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:2949:29: note: in instantiation of function template specialization 'kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>::and_then<(lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
2949 | return std::forward<R>(r).and_then(t.and_then);
| ^
TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:32: note: in instantiation of function template specialization 'kdl::detail::operator|<kdl::result<std::shared_ptr<tb::fs::File>, kdl::result_error>, (lambda at TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48)>' requested here
44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
| ^
TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48: note: candidate template ignored: substitution failure [with file:auto = typename std::remove_reference<shared_ptr<File> &>::type]
44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
| ^
"#;
let advice = parse_tidy_output(tidy_out.as_bytes(), &None).unwrap();
assert_eq!(advice.notes.len(), 4);
for note in advice.notes {
assert!(note.diagnostic.contains('-'));
assert!(!note.diagnostic.contains(' '));
}
}
}