1use std::{
5 env::consts::OS,
6 fs,
7 path::{Path, PathBuf},
8 process::Command,
9 sync::{Arc, Mutex, MutexGuard},
10};
11
12use clang_tools_manager::utils::normalize_path;
14use regex::Regex;
15use serde::Deserialize;
16
17use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
19
20#[derive(Deserialize, Debug, Clone)]
25pub struct CompilationUnit {
26 directory: String,
28
29 file: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct TidyNotification {
42 pub filename: String,
44
45 pub line: u32,
47
48 pub cols: u32,
50
51 pub severity: String,
54
55 pub rationale: String,
57
58 pub diagnostic: String,
60
61 pub suggestion: Vec<String>,
66
67 pub fixed_lines: Vec<u32>,
69}
70
71impl TidyNotification {
72 pub fn diagnostic_link(&self) -> String {
74 if self.diagnostic.starts_with("clang-diagnostic-") {
75 return self.diagnostic.clone();
78 }
79 if let Some((category, name)) = if self.diagnostic.starts_with("clang-analyzer-") {
80 self.diagnostic
81 .strip_prefix("clang-analyzer-")
82 .map(|n| ("clang-analyzer", n))
83 } else {
84 self.diagnostic.split_once('-')
85 } {
86 debug_assert!(!category.is_empty() && !name.is_empty());
89 format!(
90 "[{}](https://clang.llvm.org/extra/clang-tidy/checks/{category}/{name}.html)",
91 self.diagnostic
92 )
93 } else {
94 self.diagnostic.clone()
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
101pub struct TidyAdvice {
102 pub notes: Vec<TidyNotification>,
104}
105
106impl TidyAdvice {
107 pub(crate) fn get_suggestion_help(&self, start_line: u32, end_line: u32) -> String {
108 let mut diagnostics = vec![];
109 for note in &self.notes {
110 for fixed_line in ¬e.fixed_lines {
111 if (start_line..=end_line).contains(fixed_line) {
112 diagnostics.push(format!(
113 "- {} [{}]\n",
114 note.rationale,
115 note.diagnostic_link()
116 ));
117 }
118 }
119 }
120 format!(
121 "### clang-tidy {}\n{}",
122 if diagnostics.is_empty() {
123 "suggestion"
124 } else {
125 "diagnostic(s)"
126 },
127 diagnostics.join("")
128 )
129 }
130}
131
132const NOTE_HEADER: &str = r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+),?[^\]]*\]$";
134
135fn parse_tidy_output(
140 tidy_stdout: &[u8],
141 database_json: &Option<Vec<CompilationUnit>>,
142 repo_root: &Path,
143) -> Result<Vec<TidyNotification>, ClangCaptureError> {
144 let note_header = Regex::new(NOTE_HEADER)?;
145 let fixed_note = Regex::new(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$")?;
146 let mut found_fix = false;
147 let mut notification = None;
148 let mut result = Vec::new();
149 for line in String::from_utf8(tidy_stdout.to_vec())
150 .map_err(|e| ClangCaptureError::NonUtf8Output {
151 task: "convert clang-tidy stdout".to_string(),
152 source: e,
153 })?
154 .lines()
155 {
156 if let Some(captured) = note_header.captures(line) {
157 if captured
162 .get(6)
163 .is_some_and(|diag| !diag.as_str().contains(' ') && diag.as_str().contains('-'))
164 {
165 if let Some(note) = notification {
167 result.push(note);
168 }
169
170 let mut filename = PathBuf::from(&captured[1]);
172 if let Some(db_json) = &database_json {
174 let mut found_unit = false;
175 for unit in db_json {
176 let unit_path =
177 PathBuf::from_iter([unit.directory.as_str(), unit.file.as_str()]);
178 if unit_path == filename {
179 filename =
180 normalize_path(&PathBuf::from_iter([&unit.directory, &unit.file]));
181 found_unit = true;
182 break;
183 }
184 }
185 if !found_unit {
186 filename = normalize_path(&PathBuf::from_iter([repo_root, &filename]));
190 }
191 } else {
192 filename = normalize_path(&PathBuf::from_iter([repo_root, &filename]));
195 }
196
197 if filename.is_absolute()
198 && let Ok(file_n) = filename.strip_prefix(repo_root)
199 {
200 filename = file_n.to_path_buf();
203 }
204
205 notification = Some(TidyNotification {
206 filename: filename.to_string_lossy().to_string().replace('\\', "/"),
207 line: captured[2].parse()?,
208 cols: captured[3].parse()?,
209 severity: String::from(&captured[4]),
210 rationale: String::from(&captured[5]).trim().to_string(),
211 diagnostic: String::from(&captured[6]),
212 suggestion: Vec::new(),
213 fixed_lines: Vec::new(),
214 });
215 found_fix = false;
217 }
218 } else if let Some(capture) = fixed_note.captures(line) {
219 let fixed_line = capture[1].parse()?;
220 if let Some(note) = &mut notification
221 && !note.fixed_lines.contains(&fixed_line)
222 {
223 note.fixed_lines.push(fixed_line);
224 }
225 found_fix = true;
229 } else if !found_fix && let Some(note) = &mut notification {
230 note.suggestion.push(line.to_string());
233 }
234 }
235 if let Some(note) = notification {
236 result.push(note);
237 }
238 Ok(result)
239}
240
241pub fn tally_tidy_advice(files: &[Arc<Mutex<FileObj>>]) -> Result<u64, String> {
243 let mut total = 0;
244 for file in files {
245 let file = file.lock().map_err(|e| e.to_string())?;
246 if let Some(advice) = &file.tidy_advice {
247 for tidy_note in &advice.notes {
248 let file_path = PathBuf::from(&tidy_note.filename);
249 if file_path == file.name {
250 total += 1;
251 }
252 }
253 }
254 }
255 Ok(total)
256}
257
258struct RestoreOnDrop<'a> {
263 path: &'a Path,
264 content: String,
265 armed: bool,
267}
268
269impl Drop for RestoreOnDrop<'_> {
270 fn drop(&mut self) {
271 if self.armed {
272 let _ = fs::write(self.path, &self.content);
274 }
275 }
276}
277
278pub fn run_clang_tidy(
280 file: &mut MutexGuard<FileObj>,
281 clang_params: &ClangParams,
282) -> Result<Vec<(log::Level, String)>, ClangCaptureError> {
283 let cmd_path = clang_params
284 .clang_tidy_command
285 .as_ref()
286 .ok_or(ClangCaptureError::ToolPathUnknown("clang-tidy"))?;
287 let mut cmd = Command::new(cmd_path);
288 cmd.current_dir(&clang_params.repo_root);
289 let mut logs = vec![];
290 if !clang_params.tidy_checks.is_empty() {
291 cmd.args(["-checks", &clang_params.tidy_checks]);
292 }
293 if let Some(db) = &clang_params.database {
294 cmd.args(["-p", &db.to_string_lossy()]);
295 }
296 for arg in &clang_params.extra_args {
297 cmd.args(["--extra-arg", format!("\"{}\"", arg).as_str()]);
298 }
299 let file_name = file.name.to_string_lossy().to_string();
300 let ranges = file.get_ranges(&clang_params.lines_changed_only);
301 if !ranges.is_empty() {
302 let filter = format!(
303 "[{{\"name\":{:?},\"lines\":{:?}}}]",
304 &file_name.replace('/', if OS == "windows" { "\\" } else { "/" }),
305 ranges
306 .iter()
307 .map(|r| [r.start(), r.end()])
308 .collect::<Vec<_>>()
309 );
310 cmd.args(["--line-filter", filter.as_str()]);
311 }
312 let repo_file_path = clang_params.repo_root.join(&file.name);
313 cmd.arg("--fix-errors");
314 if !clang_params.style.is_empty() {
315 cmd.args(["--format-style", clang_params.style.as_str()]);
316 }
317 cmd.arg(file.name.to_string_lossy().as_ref());
318 logs.push((
319 log::Level::Info,
320 format!(
321 "Running \"{} {}\"",
322 cmd.get_program().to_string_lossy(),
323 cmd.get_args()
324 .map(|x| x.to_string_lossy())
325 .collect::<Vec<_>>()
326 .join(" ")
327 ),
328 ));
329 let cache_path = clang_params.get_cache_path();
330 let cache_patch_path = cache_path.join(&file.name);
331 fs::create_dir_all(
332 cache_patch_path
333 .parent()
334 .ok_or(ClangCaptureError::UnknownCacheParentPath)?,
335 )
336 .map_err(ClangCaptureError::MkDirFailed)?;
337 let mut drop_guard = RestoreOnDrop {
338 content: fs::read_to_string(&repo_file_path).map_err(|e| {
339 ClangCaptureError::ReadFileFailed {
340 file_name: file_name.clone(),
341 source: e,
342 }
343 })?,
344 armed: true,
345 path: repo_file_path.as_path(),
346 };
347 let output = cmd
349 .output()
350 .map_err(|e| ClangCaptureError::FailedToRunCommand {
351 task: format!("execute clang-tidy on file {file_name}"),
352 source: e,
353 })?;
354 fs::copy(&repo_file_path, &cache_patch_path).map_err(|e| {
356 ClangCaptureError::WriteFileFailed {
357 file_name: cache_patch_path.to_string_lossy().to_string(),
358 source: e,
359 }
360 })?;
361 fs::write(&repo_file_path, &drop_guard.content).map_err(|e| {
363 ClangCaptureError::WriteFileFailed {
364 file_name: file_name.clone(),
365 source: e,
366 }
367 })?;
368 drop_guard.armed = false;
370
371 logs.push((
372 log::Level::Debug,
373 format!(
374 "Output from clang-tidy:\n{}",
375 String::from_utf8_lossy(&output.stdout)
376 ),
377 ));
378 if !output.stderr.is_empty() {
379 logs.push((
380 log::Level::Debug,
381 format!(
382 "clang-tidy made the following summary:\n{}",
383 String::from_utf8_lossy(&output.stderr)
384 ),
385 ));
386 }
387
388 let notes = parse_tidy_output(
389 &output.stdout,
390 &clang_params.database_json,
391 &clang_params.repo_root,
392 )?;
393
394 let tidy_advice = TidyAdvice { notes };
395 file.patched_path = Some(cache_patch_path.to_path_buf());
396 file.tidy_advice = Some(tidy_advice);
397 Ok(logs)
398}
399
400#[cfg(test)]
401mod test {
402 #![allow(clippy::unwrap_used, clippy::expect_used)]
403
404 use std::{
405 env, fs,
406 path::PathBuf,
407 str::FromStr,
408 sync::{Arc, Mutex},
409 };
410
411 use clang_tools_manager::RequestedVersion;
412 use regex::Regex;
413
414 use crate::{
415 clang_tools::{ClangTool, clang_tidy::parse_tidy_output},
416 cli::{ClangParams, LinesChangedOnly},
417 common_fs::FileObj,
418 };
419
420 use super::{NOTE_HEADER, RestoreOnDrop, TidyNotification, run_clang_tidy};
421
422 #[test]
423 fn clang_diagnostic_link() {
424 let note = TidyNotification {
425 filename: String::from("some_src.cpp"),
426 line: 1504,
427 cols: 9,
428 rationale: String::from("file not found"),
429 severity: String::from("error"),
430 diagnostic: String::from("clang-diagnostic-error"),
431 suggestion: vec![],
432 fixed_lines: vec![],
433 };
434 assert_eq!(note.diagnostic_link(), note.diagnostic);
435 }
436
437 #[test]
438 fn clang_analyzer_link() {
439 let note = TidyNotification {
440 filename: String::from("some_src.cpp"),
441 line: 1504,
442 cols: 9,
443 rationale: String::from(
444 "Dereference of null pointer (loaded from variable 'pipe_num')",
445 ),
446 severity: String::from("warning"),
447 diagnostic: String::from("clang-analyzer-core.NullDereference"),
448 suggestion: vec![],
449 fixed_lines: vec![],
450 };
451 let expected = format!(
452 "[{}](https://clang.llvm.org/extra/clang-tidy/checks/{}/{}.html)",
453 note.diagnostic, "clang-analyzer", "core.NullDereference",
454 );
455 assert_eq!(note.diagnostic_link(), expected);
456 }
457
458 #[test]
459 fn invalid_diagnostic_link() {
460 let expected = "no_diagnostic_name".to_string();
461 let note = TidyNotification {
462 filename: String::from("some_src.cpp"),
463 line: 1504,
464 cols: 9,
465 rationale: String::from("some rationale"),
466 severity: String::from("warning"),
467 diagnostic: expected.clone(),
468 suggestion: vec![],
469 fixed_lines: vec![],
470 };
471 assert_eq!(note.diagnostic_link(), expected);
472 }
473
474 #[test]
477 fn test_capture() {
478 let src = "tests/demo/demo.hpp:11:11: \
479 warning: use a trailing return type for this function \
480 [modernize-use-trailing-return-type,-warnings-as-errors]";
481 let pat = Regex::new(NOTE_HEADER).unwrap();
482 let cap = pat.captures(src).unwrap();
483 assert_eq!(
484 cap.get(0).unwrap().as_str(),
485 format!(
486 "{}:{}:{}: {}:{}[{},-warnings-as-errors]",
487 cap.get(1).unwrap().as_str(),
488 cap.get(2).unwrap().as_str(),
489 cap.get(3).unwrap().as_str(),
490 cap.get(4).unwrap().as_str(),
491 cap.get(5).unwrap().as_str(),
492 cap.get(6).unwrap().as_str()
493 )
494 .as_str()
495 )
496 }
497
498 #[test]
499 fn use_extra_args() {
500 let exe_path = ClangTool::ClangTidy
501 .get_exe_path(
502 &RequestedVersion::from_str(
503 env::var("CLANG_VERSION").unwrap_or("".to_string()).as_str(),
504 )
505 .unwrap(),
506 )
507 .unwrap();
508 let tmp_workspace = crate::test_common::setup_tmp_workspace();
509 let file = FileObj::new(PathBuf::from("demo/demo.cpp"));
510 let arc_file = Arc::new(Mutex::new(file));
511 let extra_args = vec!["-std=c++17".to_string(), "-Wall".to_string()];
512 let clang_params = ClangParams {
513 style: "".to_string(),
514 tidy_checks: "".to_string(), lines_changed_only: LinesChangedOnly::Off,
516 database: None,
517 extra_args: extra_args.clone(), database_json: None,
519 format_filter: None,
520 tidy_filter: None,
521 clang_tidy_command: Some(exe_path),
522 clang_format_command: None,
523 repo_root: tmp_workspace.path().to_path_buf(),
524 };
525 let mut file_lock = arc_file.lock().unwrap();
526 fs::create_dir_all(clang_params.get_cache_path()).unwrap();
527 let logs = run_clang_tidy(&mut file_lock, &clang_params)
528 .unwrap()
529 .into_iter()
530 .filter_map(|(_lvl, msg)| {
531 if msg.contains("Running ") {
532 Some(msg)
533 } else {
534 None
535 }
536 })
537 .collect::<Vec<String>>();
538 let args = &logs
539 .first()
540 .expect("expected a log message about invoked clang-tidy command")
541 .split(' ')
542 .collect::<Vec<&str>>();
543 for arg in &extra_args {
544 let extra_arg = format!("\"{arg}\"");
545 assert!(args.contains(&extra_arg.as_str()));
546 }
547 }
548
549 #[test]
550 fn skip_parse_tidy_diagnostic_rationale() {
551 let tidy_out = r#"
552TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:46:19: error: use of undeclared identifier 'readFreeImageTexture' [clang-diagnostic-error]
553 46 | return readFreeImageTexture(reader);
554 | ^
555TrenchBroom/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
556 659 | using Fn_Result = decltype(f(std::declval<Value&&>()));
557 | ^
558TrenchBroom/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
559 2949 | return std::forward<R>(r).and_then(t.and_then);
560 | ^
561TrenchBroom/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
562 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
563 | ^
564TrenchBroom/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]
565 661 | static_assert(is_result_v<Fn_Result>, "Function must return a result type");
566 | ^~~~~~~~~~~~~~~~~~~~~~
567TrenchBroom/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
568 2949 | return std::forward<R>(r).and_then(t.and_then);
569 | ^
570TrenchBroom/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
571 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
572 | ^
573TrenchBroom/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]
574 663 | using Cm_Result = typename detail::chain_results<My_Result, Fn_Result>::type;
575 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~
576TrenchBroom/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]
577 667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
578 | ^
579TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:667:29: note: while substituting into a lambda expression here
580 667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
581 | ^
582TrenchBroom/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
583 2949 | return std::forward<R>(r).and_then(t.and_then);
584 | ^
585TrenchBroom/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
586 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
587 | ^
588TrenchBroom/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]
589 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
590 | ^
591"#;
592 let advice = parse_tidy_output(tidy_out.as_bytes(), &None, &PathBuf::from(".")).unwrap();
593 assert_eq!(advice.len(), 4);
594 for note in advice {
595 assert!(note.diagnostic.contains('-'));
596 assert!(!note.diagnostic.contains(' '));
597 }
598 }
599
600 #[test]
601 fn restore_on_drop_fires() {
602 let tmp = tempfile::tempdir().unwrap();
603 let file_path = tmp.path().join("test_file.txt");
604 let original = "original content";
605 fs::write(&file_path, original).unwrap();
606
607 {
608 let guard = RestoreOnDrop {
609 path: &file_path,
610 content: original.to_string(),
611 armed: true,
612 };
613 fs::write(&file_path, "patched content").unwrap();
615 drop(guard);
617 }
618
619 assert_eq!(fs::read_to_string(&file_path).unwrap(), original);
620 }
621}