Skip to main content

cpp_linter/clang_tools/
clang_tidy.rs

1//! This module holds functionality specific to running clang-tidy and parsing it's
2//! output.
3
4use std::{
5    env::consts::OS,
6    fs,
7    path::{Path, PathBuf},
8    process::Command,
9    sync::{Arc, Mutex, MutexGuard},
10};
11
12// non-std crates
13use clang_tools_manager::utils::normalize_path;
14use regex::Regex;
15use serde::Deserialize;
16
17// project-specific modules/crates
18use crate::{cli::ClangParams, common_fs::FileObj, error::ClangCaptureError};
19
20/// Used to deserialize a json compilation database's translation unit.
21///
22/// The only purpose this serves is to normalize relative paths for build systems that
23/// use/need relative paths (ie ninja).
24#[derive(Deserialize, Debug, Clone)]
25pub struct CompilationUnit {
26    /// The directory of the build environment
27    directory: String,
28
29    /// The file path of the translation unit.
30    ///
31    /// Sometimes, this is relative to the build [`CompilationUnit::directory`].
32    ///
33    /// This is typically the path that clang-tidy uses in its stdout (for a dry run).
34    /// So, having this information helps with matching clang-tidy's stdout with the
35    /// repository files.
36    file: String,
37}
38
39/// A structure that represents a single notification parsed from clang-tidy's stdout.
40#[derive(Debug, Clone)]
41pub struct TidyNotification {
42    /// The file's path and name (supposedly relative to the repository root folder).
43    pub filename: String,
44
45    /// The line number from which the notification originated.
46    pub line: u32,
47
48    /// The column offset on the line from which the notification originated.
49    pub cols: u32,
50
51    /// The severity (ie error/warning/note) of the [`TidyNotification::diagnostic`]
52    /// that caused the notification.
53    pub severity: String,
54
55    /// A helpful message explaining why the notification exists.
56    pub rationale: String,
57
58    /// The diagnostic name as used when configuring clang-tidy.
59    pub diagnostic: String,
60
61    /// A code block that points directly to the origin of the notification.
62    ///
63    /// Sometimes, this code block doesn't exist. Sometimes, it contains suggested
64    /// fixes/advice. This information is purely superfluous.
65    pub suggestion: Vec<String>,
66
67    /// The list of line numbers that had fixes applied via `clang-tidy --fix-error`.
68    pub fixed_lines: Vec<u32>,
69}
70
71impl TidyNotification {
72    /// Get a markdown-formatted link to the clang-tidy documentation page for [`Self::diagnostic`].
73    pub fn diagnostic_link(&self) -> String {
74        if self.diagnostic.starts_with("clang-diagnostic-") {
75            // clang-diagnostic-* diagnostics are compiler diagnostics and don't have
76            // dedicated clang-tidy documentation pages, so return the name as-is.
77            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            // In production, both category and name should be non-empty strings.
87            // Clang does not actually have a diagnostic name whose category or name is empty.
88            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/// A struct to hold notification from clang-tidy about a single file
100#[derive(Debug, Clone)]
101pub struct TidyAdvice {
102    /// A list of notifications parsed from clang-tidy stdout.
103    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 &note.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
132/// A regex pattern to capture the clang-tidy note header.
133const NOTE_HEADER: &str = r"^(.+):(\d+):(\d+):\s(\w+):(.*)\[([a-zA-Z\d\-\.]+),?[^\]]*\]$";
134
135/// Parses clang-tidy stdout.
136///
137/// Here it helps to have the JSON database deserialized for normalizing paths present
138/// in the notifications.
139fn 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            // First check that the diagnostic name is a actual diagnostic name.
158            // Sometimes clang-tidy uses square brackets to enclose additional context
159            // about the diagnostic rationale. For example: '[with auto = typename ...]'
160            // We need to ignore such cases as they do not start a diagnostic report.
161            if captured
162                .get(6)
163                .is_some_and(|diag| !diag.as_str().contains(' ') && diag.as_str().contains('-'))
164            {
165                // starting a new TidyNotification; store the previous one (if any) to results
166                if let Some(note) = notification {
167                    result.push(note);
168                }
169
170                // normalize the filename path and try to make it relative to the repo root
171                let mut filename = PathBuf::from(&captured[1]);
172                // if database was given try to use that first
173                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                        // file was not a named unit in the database;
187                        // try to normalize path as if relative to working directory.
188                        // NOTE: This shouldn't happen with a properly formed JSON database
189                        filename = normalize_path(&PathBuf::from_iter([repo_root, &filename]));
190                    }
191                } else {
192                    // still need to normalize the relative path despite missing database info.
193                    // let's assume the file is relative to current working directory.
194                    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                    // if this filename can't be made into a relative path, then it is
201                    // likely not a member of the project's sources (ie /usr/include/stdio.h)
202                    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                // begin capturing subsequent lines as suggestions
216                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            // Suspend capturing subsequent lines as suggestions until
226            // a new notification is constructed. If we found a note about applied fixes,
227            // then the lines of suggestions for that notification have already been parsed.
228            found_fix = true;
229        } else if !found_fix && let Some(note) = &mut notification {
230            // append lines of code that are part of
231            // the previous line's notification
232            note.suggestion.push(line.to_string());
233        }
234    }
235    if let Some(note) = notification {
236        result.push(note);
237    }
238    Ok(result)
239}
240
241/// Get a total count of clang-tidy advice from the given list of [FileObj]s.
242pub 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
258/// RAII guard that restores a file's original content on drop.
259///
260/// This is a best-effort when an error gets propagated before
261/// we can explicitly restore a file's contents.
262struct RestoreOnDrop<'a> {
263    path: &'a Path,
264    content: String,
265    // lock:
266    armed: bool,
267}
268
269impl Drop for RestoreOnDrop<'_> {
270    fn drop(&mut self) {
271        if self.armed {
272            // We have to ignore any error here and hope for the best.
273            let _ = fs::write(self.path, &self.content);
274        }
275    }
276}
277
278/// Run clang-tidy, then parse and return it's output.
279pub 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    // run clang-tidy to patch the file in-place.
348    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    // move the patched file content into cache
355    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    // restore the original file content to avoid unexpected behavior in later steps of dev workflow
362    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    // disarm the drop guard since we've already restored the original content
369    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 for regex parsing of clang-tidy stdout
475
476    #[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(), // use .clang-tidy config file
515            lines_changed_only: LinesChangedOnly::Off,
516            database: None,
517            extra_args: extra_args.clone(), // <---- the reason for this test
518            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            // Simulate clang-tidy mutating the file
614            fs::write(&file_path, "patched content").unwrap();
615            // Explicit drop triggers restoration
616            drop(guard);
617        }
618
619        assert_eq!(fs::read_to_string(&file_path).unwrap(), original);
620    }
621}