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.arg(format!("--extra-arg={arg}").as_str());
303 }
304 let file_name = file.name.to_string_lossy().to_string();
305 let ranges = file.get_ranges(&clang_params.lines_changed_only);
306 if !ranges.is_empty() {
307 let filter = format!(
308 "[{{\"name\":{:?},\"lines\":{:?}}}]",
309 &file_name.replace('/', if OS == "windows" { "\\" } else { "/" }),
310 ranges
311 .iter()
312 .map(|r| [r.start(), r.end()])
313 .collect::<Vec<_>>()
314 );
315 cmd.args(["--line-filter", filter.as_str()]);
316 }
317 let repo_file_path = clang_params.repo_root.join(&file.name);
318 cmd.arg("--fix-errors");
319 if !clang_params.style.is_empty() {
320 cmd.args(["--format-style", clang_params.style.as_str()]);
321 }
322 cmd.arg(file.name.to_string_lossy().as_ref());
323 logs.push((
324 log::Level::Info,
325 format!(
326 "Running \"{} {}\"",
327 cmd.get_program().to_string_lossy(),
328 cmd.get_args()
329 .map(|x| x.to_string_lossy())
330 .collect::<Vec<_>>()
331 .join(" ")
332 ),
333 ));
334 let cache_path = clang_params.get_cache_path();
335 let cache_patch_path = cache_path.join(&file.name);
336 fs::create_dir_all(
337 cache_patch_path
338 .parent()
339 .ok_or(ClangCaptureError::UnknownCacheParentPath)?,
340 )
341 .map_err(ClangCaptureError::MkDirFailed)?;
342 let mut drop_guard = RestoreOnDrop {
343 content: fs::read_to_string(&repo_file_path).map_err(|e| {
344 ClangCaptureError::ReadFileFailed {
345 file_name: file_name.clone(),
346 source: e,
347 }
348 })?,
349 armed: true,
350 path: repo_file_path.as_path(),
351 };
352 let output = cmd
354 .output()
355 .map_err(|e| ClangCaptureError::FailedToRunCommand {
356 task: format!("execute clang-tidy on file {file_name}"),
357 source: e,
358 })?;
359 fs::copy(&repo_file_path, &cache_patch_path).map_err(|e| {
361 ClangCaptureError::WriteFileFailed {
362 file_name: cache_patch_path.to_string_lossy().to_string(),
363 source: e,
364 }
365 })?;
366 fs::write(&repo_file_path, &drop_guard.content).map_err(|e| {
368 ClangCaptureError::WriteFileFailed {
369 file_name: file_name.clone(),
370 source: e,
371 }
372 })?;
373 drop_guard.armed = false;
375
376 logs.push((
377 log::Level::Debug,
378 format!(
379 "Output from clang-tidy:\n{}",
380 String::from_utf8_lossy(&output.stdout)
381 ),
382 ));
383 if !output.stderr.is_empty() {
384 logs.push((
385 log::Level::Debug,
386 format!(
387 "clang-tidy made the following summary:\n{}",
388 String::from_utf8_lossy(&output.stderr)
389 ),
390 ));
391 }
392
393 let notes = parse_tidy_output(
394 &output.stdout,
395 &clang_params.database_json,
396 &clang_params.repo_root,
397 )?;
398
399 let tidy_advice = TidyAdvice { notes };
400 file.patched_path = Some(cache_patch_path.to_path_buf());
401 file.tidy_advice = Some(tidy_advice);
402 Ok(logs)
403}
404
405#[cfg(test)]
406mod test {
407 #![allow(clippy::unwrap_used, clippy::expect_used)]
408
409 use std::{
410 env, fs,
411 path::PathBuf,
412 str::FromStr,
413 sync::{Arc, Mutex},
414 };
415
416 use clang_tools_manager::RequestedVersion;
417 use regex::Regex;
418
419 use crate::{
420 clang_tools::{ClangTool, clang_tidy::parse_tidy_output},
421 cli::{ClangParams, LinesChangedOnly},
422 common_fs::FileObj,
423 };
424
425 use super::{NOTE_HEADER, RestoreOnDrop, TidyNotification, run_clang_tidy};
426
427 #[test]
428 fn clang_diagnostic_link() {
429 let note = TidyNotification {
430 filename: String::from("some_src.cpp"),
431 line: 1504,
432 cols: 9,
433 rationale: String::from("file not found"),
434 severity: String::from("error"),
435 diagnostic: String::from("clang-diagnostic-error"),
436 suggestion: vec![],
437 fixed_lines: vec![],
438 };
439 assert_eq!(note.diagnostic_link(), note.diagnostic);
440 }
441
442 #[test]
443 fn clang_analyzer_link() {
444 let note = TidyNotification {
445 filename: String::from("some_src.cpp"),
446 line: 1504,
447 cols: 9,
448 rationale: String::from(
449 "Dereference of null pointer (loaded from variable 'pipe_num')",
450 ),
451 severity: String::from("warning"),
452 diagnostic: String::from("clang-analyzer-core.NullDereference"),
453 suggestion: vec![],
454 fixed_lines: vec![],
455 };
456 let expected = format!(
457 "[{}](https://clang.llvm.org/extra/clang-tidy/checks/{}/{}.html)",
458 note.diagnostic, "clang-analyzer", "core.NullDereference",
459 );
460 assert_eq!(note.diagnostic_link(), expected);
461 }
462
463 #[test]
464 fn invalid_diagnostic_link() {
465 let expected = "no_diagnostic_name".to_string();
466 let note = TidyNotification {
467 filename: String::from("some_src.cpp"),
468 line: 1504,
469 cols: 9,
470 rationale: String::from("some rationale"),
471 severity: String::from("warning"),
472 diagnostic: expected.clone(),
473 suggestion: vec![],
474 fixed_lines: vec![],
475 };
476 assert_eq!(note.diagnostic_link(), expected);
477 }
478
479 #[test]
482 fn regex_capture() {
483 let src = "tests/demo/demo.hpp:11:11: \
484 warning: use a trailing return type for this function \
485 [modernize-use-trailing-return-type,-warnings-as-errors]";
486 let pat = Regex::new(NOTE_HEADER).unwrap();
487 let cap = pat.captures(src).unwrap();
488 assert_eq!(
489 cap.get(0).unwrap().as_str(),
490 format!(
491 "{}:{}:{}: {}:{}[{},-warnings-as-errors]",
492 cap.get(1).unwrap().as_str(),
493 cap.get(2).unwrap().as_str(),
494 cap.get(3).unwrap().as_str(),
495 cap.get(4).unwrap().as_str(),
496 cap.get(5).unwrap().as_str(),
497 cap.get(6).unwrap().as_str()
498 )
499 .as_str()
500 )
501 }
502
503 #[test]
504 fn use_extra_args() {
505 let exe_path = ClangTool::ClangTidy
506 .get_exe_path(
507 &RequestedVersion::from_str(
508 env::var("CLANG_VERSION").unwrap_or("".to_string()).as_str(),
509 )
510 .unwrap(),
511 )
512 .unwrap();
513 let tmp_workspace = crate::test_common::setup_tmp_workspace();
514 let file = FileObj::new(PathBuf::from("demo/demo.cpp"));
515 let arc_file = Arc::new(Mutex::new(file));
516 let extra_args = vec!["-std=c++17".to_string(), "-Wall".to_string()];
517 let clang_params = ClangParams {
518 style: "".to_string(),
519 tidy_checks: "".to_string(), lines_changed_only: LinesChangedOnly::Off,
521 database: None,
522 extra_args: extra_args.clone(), database_json: None,
524 format_filter: None,
525 tidy_filter: None,
526 clang_tidy_command: Some(exe_path),
527 clang_format_command: None,
528 repo_root: tmp_workspace.path().to_path_buf(),
529 };
530 let mut file_lock = arc_file.lock().unwrap();
531 fs::create_dir_all(clang_params.get_cache_path()).unwrap();
532 let logs = run_clang_tidy(&mut file_lock, &clang_params)
533 .unwrap()
534 .into_iter()
535 .filter_map(|(_lvl, msg)| {
536 if msg.contains("Running ") {
537 Some(msg)
538 } else {
539 None
540 }
541 })
542 .collect::<Vec<String>>();
543 let args = &logs
544 .first()
545 .expect("expected a log message about invoked clang-tidy command")
546 .split(' ')
547 .collect::<Vec<&str>>();
548 println!("args: {:?}", args);
549 for arg in &extra_args {
550 let extra_arg = format!("--extra-arg={arg}");
551 assert!(args.contains(&extra_arg.as_str()));
552 }
553 }
554
555 #[test]
556 fn skip_parse_tidy_diagnostic_rationale() {
557 let tidy_out = r#"
558TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:46:19: error: use of undeclared identifier 'readFreeImageTexture' [clang-diagnostic-error]
559 46 | return readFreeImageTexture(reader);
560 | ^
561TrenchBroom/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
562 659 | using Fn_Result = decltype(f(std::declval<Value&&>()));
563 | ^
564TrenchBroom/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
565 2949 | return std::forward<R>(r).and_then(t.and_then);
566 | ^
567TrenchBroom/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
568 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
569 | ^
570TrenchBroom/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]
571 661 | static_assert(is_result_v<Fn_Result>, "Function must return a result type");
572 | ^~~~~~~~~~~~~~~~~~~~~~
573TrenchBroom/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
574 2949 | return std::forward<R>(r).and_then(t.and_then);
575 | ^
576TrenchBroom/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
577 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
578 | ^
579TrenchBroom/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]
580 663 | using Cm_Result = typename detail::chain_results<My_Result, Fn_Result>::type;
581 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~
582TrenchBroom/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]
583 667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
584 | ^
585TrenchBroom/TrenchBroom/lib/KdLib/include/kd/result.h:667:29: note: while substituting into a lambda expression here
586 667 | [&](value_type&& v) { return Cm_Result{f(std::move(v))}; },
587 | ^
588TrenchBroom/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
589 2949 | return std::forward<R>(r).and_then(t.and_then);
590 | ^
591TrenchBroom/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
592 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
593 | ^
594TrenchBroom/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]
595 44 | return diskFS.openFile(name) | kdl::and_then([](const auto& file) {
596 | ^
597"#;
598 let advice = parse_tidy_output(tidy_out.as_bytes(), &None, &PathBuf::from(".")).unwrap();
599 assert_eq!(advice.len(), 4);
600 for note in advice {
601 assert!(note.diagnostic.contains('-'));
602 assert!(!note.diagnostic.contains(' '));
603 }
604 }
605
606 #[test]
607 fn restore_on_drop_fires() {
608 let tmp = tempfile::tempdir().unwrap();
609 let file_path = tmp.path().join("test_file.txt");
610 let original = "original content";
611 fs::write(&file_path, original).unwrap();
612
613 {
614 let guard = RestoreOnDrop {
615 path: &file_path,
616 content: original.to_string(),
617 armed: true,
618 };
619 fs::write(&file_path, "patched content").unwrap();
621 drop(guard);
623 }
624
625 assert_eq!(fs::read_to_string(&file_path).unwrap(), original);
626 }
627}