Skip to content

Commit 41fcd9e

Browse files
authored
feat: get clang-tidy fixes regardless of tidy-review value (#354)
This is another step toward presenting auto-fixed patches for consumers to commit/push. This basically does what `--tidy-review` conditionally did before. But now it is done every time clang-tidy is invoked. Even if not using the PR review feature, this allows us to boast if a clang-tidy note/warning can be auto-fixed. For example, the new thread comment may contain the following for something that can be auto-fixed: ```md - **src/demo/demo.cpp:10:3** note: [readability] :zap: auto-fix available > {rationale} {concerned code snippet if any} ```
1 parent 793c310 commit 41fcd9e

6 files changed

Lines changed: 153 additions & 58 deletions

File tree

cpp-linter/src/clang_tools/clang_tidy.rs

Lines changed: 104 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ use serde::Deserialize;
1616

1717
// project-specific modules/crates
1818
use super::MakeSuggestions;
19-
use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
19+
use crate::{
20+
clang_tools::CACHE_DIR, cli::ClangParams, common_fs::FileObj, error::ClangCaptureError,
21+
};
2022

2123
/// Used to deserialize a json compilation database's translation unit.
2224
///
@@ -103,8 +105,8 @@ pub struct TidyAdvice {
103105
/// A list of notifications parsed from clang-tidy stdout.
104106
pub notes: Vec<TidyNotification>,
105107

106-
/// A buffer to hold the contents of the file after applying clang-tidy fixes.
107-
pub patched: Option<Vec<u8>>,
108+
/// A path to the cached contents of the file after applying clang-tidy fixes.
109+
pub patched: PathBuf,
108110
}
109111

110112
impl MakeSuggestions for TidyAdvice {
@@ -148,7 +150,7 @@ fn parse_tidy_output(
148150
tidy_stdout: &[u8],
149151
database_json: &Option<Vec<CompilationUnit>>,
150152
repo_root: &Path,
151-
) -> Result<TidyAdvice, ClangCaptureError> {
153+
) -> Result<Vec<TidyNotification>, ClangCaptureError> {
152154
let note_header = Regex::new(NOTE_HEADER)?;
153155
let fixed_note = Regex::new(r"^.+:(\d+):\d+:\snote: FIX-IT applied suggested code changes$")?;
154156
let mut found_fix = false;
@@ -243,10 +245,7 @@ fn parse_tidy_output(
243245
if let Some(note) = notification {
244246
result.push(note);
245247
}
246-
Ok(TidyAdvice {
247-
notes: result,
248-
patched: None,
249-
})
248+
Ok(result)
250249
}
251250

252251
/// Get a total count of clang-tidy advice from the given list of [FileObj]s.
@@ -266,6 +265,26 @@ pub fn tally_tidy_advice(files: &[Arc<Mutex<FileObj>>]) -> Result<u64, String> {
266265
Ok(total)
267266
}
268267

268+
/// RAII guard that restores a file's original content on drop.
269+
///
270+
/// This is a best-effort when an error gets propagated before
271+
/// we can explicitly restore a file's contents.
272+
struct RestoreOnDrop<'a> {
273+
path: &'a Path,
274+
content: String,
275+
// lock:
276+
armed: bool,
277+
}
278+
279+
impl Drop for RestoreOnDrop<'_> {
280+
fn drop(&mut self) {
281+
if self.armed {
282+
// We have to ignore any error here and hope for the best.
283+
let _ = fs::write(self.path, &self.content);
284+
}
285+
}
286+
}
287+
269288
/// Run clang-tidy, then parse and return it's output.
270289
pub fn run_clang_tidy(
271290
file: &mut MutexGuard<FileObj>,
@@ -301,17 +320,7 @@ pub fn run_clang_tidy(
301320
cmd.args(["--line-filter", filter.as_str()]);
302321
}
303322
let repo_file_path = clang_params.repo_root.join(&file.name);
304-
let original_content = if !clang_params.tidy_review {
305-
None
306-
} else {
307-
cmd.arg("--fix-errors");
308-
Some(fs::read_to_string(&repo_file_path).map_err(|e| {
309-
ClangCaptureError::ReadFileFailed {
310-
file_name: file_name.clone(),
311-
source: e,
312-
}
313-
})?)
314-
};
323+
cmd.arg("--fix-errors");
315324
if !clang_params.style.is_empty() {
316325
cmd.args(["--format-style", clang_params.style.as_str()]);
317326
}
@@ -327,12 +336,50 @@ pub fn run_clang_tidy(
327336
.join(" ")
328337
),
329338
));
339+
let cache_patch_path = clang_params
340+
.repo_root
341+
.join(CACHE_DIR)
342+
.join(file.name.with_added_extension("tidy"));
343+
fs::create_dir_all(
344+
cache_patch_path
345+
.parent()
346+
.ok_or(ClangCaptureError::UnknownCacheParentPath)?,
347+
)
348+
.map_err(ClangCaptureError::MkDirFailed)?;
349+
let mut drop_guard = RestoreOnDrop {
350+
content: fs::read_to_string(&repo_file_path).map_err(|e| {
351+
ClangCaptureError::ReadFileFailed {
352+
file_name: file_name.clone(),
353+
source: e,
354+
}
355+
})?,
356+
armed: true,
357+
path: repo_file_path.as_path(),
358+
};
359+
// run clang-tidy to patch the file in-place.
330360
let output = cmd
331361
.output()
332362
.map_err(|e| ClangCaptureError::FailedToRunCommand {
333363
task: format!("execute clang-tidy on file {file_name}"),
334364
source: e,
335365
})?;
366+
// move the patched file content into cache
367+
fs::copy(&repo_file_path, &cache_patch_path).map_err(|e| {
368+
ClangCaptureError::WriteFileFailed {
369+
file_name: cache_patch_path.to_string_lossy().to_string(),
370+
source: e,
371+
}
372+
})?;
373+
// restore the original file content to avoid unexpected behavior in later steps of dev workflow
374+
fs::write(&repo_file_path, &drop_guard.content).map_err(|e| {
375+
ClangCaptureError::WriteFileFailed {
376+
file_name: file_name.clone(),
377+
source: e,
378+
}
379+
})?;
380+
// disarm the drop guard since we've already restored the original content
381+
drop_guard.armed = false;
382+
336383
logs.push((
337384
log::Level::Debug,
338385
format!(
@@ -349,32 +396,18 @@ pub fn run_clang_tidy(
349396
),
350397
));
351398
}
352-
file.tidy_advice = Some(parse_tidy_output(
399+
400+
let notes = parse_tidy_output(
353401
&output.stdout,
354402
&clang_params.database_json,
355403
&clang_params.repo_root,
356-
)?);
357-
if clang_params.tidy_review
358-
&& let Some(original_content) = &original_content
359-
{
360-
if let Some(tidy_advice) = &mut file.tidy_advice {
361-
// cache file changes in a buffer and restore the original contents for further analysis
362-
tidy_advice.patched =
363-
Some(
364-
fs::read(&repo_file_path).map_err(|e| ClangCaptureError::ReadFileFailed {
365-
file_name: file_name.clone(),
366-
source: e,
367-
})?,
368-
);
369-
}
370-
// original_content is guaranteed to be Some() value at this point
371-
fs::write(&repo_file_path, original_content).map_err(|e| {
372-
ClangCaptureError::WriteFileFailed {
373-
file_name,
374-
source: e,
375-
}
376-
})?;
377-
}
404+
)?;
405+
406+
let tidy_advice = TidyAdvice {
407+
notes,
408+
patched: cache_patch_path.to_path_buf(),
409+
};
410+
file.tidy_advice = Some(tidy_advice);
378411
Ok(logs)
379412
}
380413

@@ -383,7 +416,7 @@ mod test {
383416
#![allow(clippy::unwrap_used, clippy::expect_used)]
384417

385418
use std::{
386-
env,
419+
env, fs,
387420
path::PathBuf,
388421
str::FromStr,
389422
sync::{Arc, Mutex},
@@ -398,7 +431,7 @@ mod test {
398431
common_fs::FileObj,
399432
};
400433

401-
use super::{NOTE_HEADER, TidyNotification, run_clang_tidy};
434+
use super::{NOTE_HEADER, RestoreOnDrop, TidyNotification, run_clang_tidy};
402435

403436
#[test]
404437
fn clang_diagnostic_link() {
@@ -486,7 +519,8 @@ mod test {
486519
.unwrap(),
487520
)
488521
.unwrap();
489-
let file = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
522+
let tmp_workspace = crate::test_common::setup_tmp_workspace();
523+
let file = FileObj::new(PathBuf::from("demo/demo.cpp"));
490524
let arc_file = Arc::new(Mutex::new(file));
491525
let extra_args = vec!["-std=c++17".to_string(), "-Wall".to_string()];
492526
let clang_params = ClangParams {
@@ -502,7 +536,7 @@ mod test {
502536
format_review: false,
503537
clang_tidy_command: Some(exe_path),
504538
clang_format_command: None,
505-
repo_root: PathBuf::from("."),
539+
repo_root: tmp_workspace.path().to_path_buf(),
506540
};
507541
let mut file_lock = arc_file.lock().unwrap();
508542
let logs = run_clang_tidy(&mut file_lock, &clang_params)
@@ -571,10 +605,32 @@ TrenchBroom/TrenchBroom/common/test/src/mdl/tst_ReadFreeImageTexture.cpp:44:48:
571605
| ^
572606
"#;
573607
let advice = parse_tidy_output(tidy_out.as_bytes(), &None, &PathBuf::from(".")).unwrap();
574-
assert_eq!(advice.notes.len(), 4);
575-
for note in advice.notes {
608+
assert_eq!(advice.len(), 4);
609+
for note in advice {
576610
assert!(note.diagnostic.contains('-'));
577611
assert!(!note.diagnostic.contains(' '));
578612
}
579613
}
614+
615+
#[test]
616+
fn restore_on_drop_fires() {
617+
let tmp = tempfile::tempdir().unwrap();
618+
let file_path = tmp.path().join("test_file.txt");
619+
let original = "original content";
620+
fs::write(&file_path, original).unwrap();
621+
622+
{
623+
let guard = RestoreOnDrop {
624+
path: &file_path,
625+
content: original.to_string(),
626+
armed: true,
627+
};
628+
// Simulate clang-tidy mutating the file
629+
fs::write(&file_path, "patched content").unwrap();
630+
// Explicit drop triggers restoration
631+
drop(guard);
632+
}
633+
634+
assert_eq!(fs::read_to_string(&file_path).unwrap(), original);
635+
}
580636
}

cpp-linter/src/common_fs.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,12 +178,9 @@ impl FileObj {
178178
}
179179

180180
if let Some(advice) = &self.tidy_advice {
181-
if let Some(patched) = &advice.patched {
182-
let patched = String::from_utf8(patched.to_vec())
183-
.map_err(|e| FileObjError::FromUtf8Error(file_name.clone(), e))?;
184-
let (diff, input) = make_patch(patched.as_str(), &original_content);
185-
advice.get_suggestions(review_comments, self, &diff, &input, summary_only);
186-
}
181+
let patched = fs::read_to_string(&advice.patched).map_err(FileObjError::ReadFile)?;
182+
let (diff, input) = make_patch(patched.as_str(), &original_content);
183+
advice.get_suggestions(review_comments, self, &diff, &input, summary_only);
187184

188185
if summary_only {
189186
return Ok(());

cpp-linter/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,26 @@ mod git;
2323
pub mod logger;
2424
pub mod rest_client;
2525
pub mod run;
26+
27+
#[cfg(test)]
28+
pub(crate) mod test_common {
29+
#![allow(clippy::unwrap_used)]
30+
31+
use std::{fs, path::PathBuf};
32+
33+
/// helper to avoid concurrent writes by executing (& processing the output of)
34+
/// clang tools on the same test assets.
35+
pub fn setup_tmp_workspace() -> tempfile::TempDir {
36+
let tmp_workspace = tempfile::TempDir::with_prefix("cpp-linter-unit-tests_").unwrap();
37+
let demo_path = tmp_workspace.path().join("demo");
38+
fs::create_dir(&demo_path).unwrap();
39+
for asset in ["demo.cpp", "demo.hpp"] {
40+
fs::copy(
41+
PathBuf::from("tests/demo").join(asset),
42+
demo_path.join(asset),
43+
)
44+
.unwrap();
45+
}
46+
tmp_workspace
47+
}
48+
}

cpp-linter/src/rest_client.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,12 +436,17 @@ fn make_tidy_comment(
436436
if file_path == file.name {
437437
let mut tmp_note = format!("- {}\n\n", tidy_note.filename);
438438
tmp_note.push_str(&format!(
439-
" <strong>{filename}:{line}:{cols}:</strong> {severity}: [{diagnostic}]\n > {rationale}\n{concerned_code}",
439+
" <strong>{filename}:{line}:{cols}:</strong> {severity}: [{diagnostic}]{auto_fixable}\n > {rationale}\n{concerned_code}",
440440
filename = tidy_note.filename,
441441
line = tidy_note.line,
442442
cols = tidy_note.cols,
443443
severity = tidy_note.severity,
444444
diagnostic = tidy_note.diagnostic_link(),
445+
auto_fixable = if tidy_note.fixed_lines.is_empty() {
446+
""
447+
} else {
448+
"\n :zap: auto-fix available"
449+
},
445450
rationale = tidy_note.rationale,
446451
concerned_code = if tidy_note.suggestion.is_empty() {String::from("")} else {
447452
format!("\n ```{ext}\n {suggestion}\n ```\n",
@@ -526,7 +531,7 @@ mod test {
526531
}];
527532
file.tidy_advice = Some(TidyAdvice {
528533
notes,
529-
patched: None,
534+
patched: PathBuf::new(),
530535
});
531536
file.format_advice = Some(FormatAdvice {
532537
#[allow(clippy::single_range_in_vec_init)]

0 commit comments

Comments
 (0)