clang-tidy, y u so bad?

If you’ve ever watched clang++ compile your code without issues, then run clang-tidy only to get:

fatal error: 'iostream' file not found

and been left scratching your head in confusion…

Welcome to the club.

What’s going on?

  graph TD;
    A["User Code"] --> B{"clang++ Driver"};
    B -- "Adds System Includes" --> C["Clang Frontend"];
    C --> D["Compile Object File"];

    E["User Code"] --> F{"clang-tidy Tool"};
    F -- "Calls Clang Frontend Directly" --> G["Clang Frontend (Missing System Includes)"];
    G -- "Fails to Find Headers" --> H["Fatal Error: File Not Found"];

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style D fill:#9f9,stroke:#333,stroke-width:2px

    style E fill:#f9f,stroke:#333,stroke-width:2px
    style F fill:#fbb,stroke:#333,stroke-width:2px
    style G fill:#fcc,stroke:#333,stroke-width:2px
    style H fill:#f66,stroke:#333,stroke-width:2px

clang++ acts as a compiler driver. It automatically determines the locations of your system headers, how to find the macOS SDK, and which include paths to add behind the scenes. This involves a set of automatic configurations you don’t see by default.

clang-tidy, in contrast, is primarily a static analysis tool that calls Clang’s frontend directly, bypassing the driver’s automatic configurations. It relies solely on the information provided to it — typically, the compile_commands.json generated by CMake.

The problem is that compile_commands.json often doesn’t contain those vital system include paths.

Why doesn’t CMake export those system paths?

Because CMake assumes the compiler driver (clang++) will sort it out during the build. The compile commands are meant to capture your explicit flags, not system defaults.

This disconnect means clang-tidy sees an incomplete picture and ends up unable to find <iostream> or any standard headers.

What does clang++ really do?

To see what clang++ is truly doing, simply run:

clang++ -v main.cpp

You’ll see output that should include something like this somewhere:

-I/Library/Developer/CommandLineTools/usr/include/c++/v1
-isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk

These paths are silently injected by the driver, remaining hidden from clang-tidy unless you explicitly provide them.

Common Workarounds for This Problem

The most common solutions are:

  • Manually specify system paths explicitly when running clang-tidy:

      clang-tidy main.cpp -- -std=c++17 \
        -I/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1 \
        -isysroot /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk
    
  • Post-process compile_commands.json to inject those flags (see tools like fix-compile-commands.py).

  • Use wrapper scripts that query clang++ for the system includes and dynamically add them when invoking clang-tidy on the fly.

Solutions aside, none of these approaches are truly elegant; they often feel like temporary fixes rather than proper solutions.

The “correct” fix

The real solution is for clang-tidy to call the driver internally or otherwise reconstruct the full compiler command, including system includes.

Potential approaches include:

  • Leveraging Clang’s Driver APIs to generate a fully expanded command line containing system paths.
  • Introducing a flag, perhaps --infer-system-includes, that triggers a dry-run of clang++ to automatically detect and add missing flags.
  • Deep integration with existing tools like clangd, which already possesses a comprehensive understanding of the full compiler invocation.

None of these have been done clang-tidy yet, to my knowledge.

Why does this matter?

Enabling CMAKE_EXPORT_COMPILE_COMMANDS=ON gives the impression that all necessary flags and configurations would be exported but instead, it doesn’t fully capture the necessary compiler context (at least for tools like clang-tidy), which means we’re just left confused with a seemingly nonsensical error, since the exported commands don’t actually include all the system paths and flags the driver would inject, leading to extra troubleshooting and brittle setups.

Until somebody bridges this gap in clang, we have two options:

  • Manually manage system paths across all their tooling, or
  • Develop custom scripts to patch or wrap their existing tools.

To conclude

While the problem of clang-tidy not finding system headers is a persistent challenge, understanding the distinction between the compiler driver and the frontend is key to navigating these toolchain complexities.

Until a more integrated solution is available, manual configuration and custom scripting remain necessary for robust static analysis setups.