Bazel module for C/C++ and Bazel developer tools: formatting, linting, static analysis, code navigation, Visual Studio solution generation, CMake export, and BUILD file tooling. One use_extension call wires up everything.
rules_devtools is not yet published in the Bazel Central Registry. Add the
module with a git_override. Tracking main is convenient during development;
replace branch with a full commit pin for reproducible builds.
# MODULE.bazel
bazel_dep(name = "rules_devtools", version = "1.9.17")
git_override(
module_name = "rules_devtools",
remote = "https://github.com/onurpaca/rules_devtools.git",
branch = "main",
)
devtools = use_extension("@rules_devtools//extension:devtools_ext.bzl", "devtools_extension")
devtools.configure(
name = "dev",
targets = "//...",
llvm = "default", # hermetic clang-format, clang-tidy, and clangd
buildtools = "default", # for Bazel: buildifier, buildozer
format_srcs = ["src/**/*.cpp", "src/**/*.hpp", "src/**/*.h"],
)
use_repo(devtools, "dev")Then invoke any tool:
bazel run @dev//:format_all
bazel run @dev//:lint_all
bazel run @dev//:compile_commandsllvm = "default" currently selects LLVM 22.1.8; buildtools = "default"
selects buildifier/buildozer 8.5.1. Downloads are integrity-checked. IWYU,
ctags, cppcheck, semgrep, scan-build, and Graphviz are system tools unless you
provide an explicit hermetic label for a supported attribute.
Run these commands from the workspace whose MODULE.bazel contains the setup
above. Generated or modified files are written to that workspace, not to the
external module cache.
Run a target with bazel run @dev//:TARGET_NAME -- [args]. The wrapper
documents which arguments it consumes. The combined targets route tool-specific
flags through explicit, repeatable channels, so a flag never leaks to the other
tool:
bazel run @dev//:format_all -- \
--clang-format-arg=--style=LLVM \
--buildifier-arg=-v
bazel run @dev//:lint_all -- \
--clangd-arg=--log=verbose \
--buildifier-arg=-warnings=all
bazel run @dev//:lint_all -- --fix \
--clang-tidy-arg=--checks=modernize-*--check and --fix remain combined-target flags. Unknown unscoped flags are
rejected with an error that names the available channel options.
Use --changed during branch development to select only files that differ from
the merge base with origin/main. The selection also includes staged,
unstaged, and untracked non-ignored files. Use --base=<ref> when the target
branch is not origin/main:
bazel run @dev//:format_all -- --changed
bazel run @dev//:format_all -- --check --changed
bazel run @dev//:lint_all -- --changed
bazel run @dev//:lint_all -- --changed --base=upstream/mainclang_format, clang_tidy, and buildifier accept the same scope options.
An explicit path list and --changed are mutually exclusive. With no scope
option, each target keeps its whole-workspace behavior for CI.
If a workspace already downloads a hermetic LLVM distribution that exports the three tooling binaries, reuse those labels instead of downloading a second LLVM archive:
devtools.configure(
name = "dev",
buildtools = "default",
clang_format_hermetic = "@tc//llvm:bin/clang-format",
clang_tidy_hermetic = "@tc//llvm:bin/clang-tidy",
clangd_hermetic = "@tc//llvm:bin/clangd",
)External hermetic-tool attributes are real labels, so repository mappings are
preserved across module extensions. Keep llvm = "default" instead when the
same devtools targets must run natively on Windows as well as Linux.
To make an editor use the same clangd build as the Bazel lint targets, install it in a stable per-user path and configure the editor with the printed path:
bazel run @dev//:install_editor_clangdThe installer copies both the clangd executable and its builtin resource headers. Files whose content is already current are not copied again.
| Target | Purpose | Check | Auto-fix |
|---|---|---|---|
format_all |
Format C/C++ (clang-format) + Bazel (buildifier) | -- --check |
(default) |
lint_all |
Lint C/C++ with editor-parity clangd diagnostics + Bazel with buildifier lint | (default) | -- --fix |
| Target | Purpose | Check | Auto-fix |
|---|---|---|---|
clang_format |
Format C/C++ source | -- --check |
(default) |
clang_tidy |
Lint C/C++ source | — | -- --fix |
clangd |
Expose the hermetic clangd binary to generated tools | — | — |
install_editor_clangd |
Copy hermetic clangd to a stable, versioned editor path | — | (default) |
compile_commands |
Generate compile_commands.json for clangd |
— | — |
iwyu |
Analyze #include usage |
— | applies fix |
unused_deps |
Find stale BUILD deps | — | — |
sast |
Multi-analyzer: cppcheck + semgrep | — | — |
ctags |
Generate code index for navigation | — | — |
vs_solution |
Generate a Visual Studio .sln + .vcxproj |
— | — |
cmake |
Generate a standalone CMakeLists.txt |
— | — |
lint_all reads textDocument/publishDiagnostics from the configured clangd.
This is the same LSP message that Zed displays. The check uses the configured
compile_commands.json, .clangd, and .clang-tidy files. It does not run a
separate clang-tidy frontend for check mode.
Install the hermetic binary in a stable editor path:
bazel run @dev//:install_editor_clangdUse the path that the command prints as the Zed clangd binary. Do not point Zed at a file in the Bazel output base. Zed can lock that file on Windows and prevent Bazel from refreshing its repository cache. The installed file and the CLI file have the same SHA-256 value, but they have separate paths.
Use the same profile that lint_all uses:
--background-index
--clang-tidy
--enable-config
--header-insertion=iwyu
--completion-style=detailed
--function-arg-placeholders=false
Add this setting to each repository .clangd file so clangd does not skip
checks based on its speed classification. Keep this file platform-neutral;
the compiler mode, standard-library paths, generated includes, and C++ standard
come from the host-generated compile_commands.json:
CompileFlags:
Remove:
- "-fno-canonical-system-headers"
Diagnostics:
# These clangd-only include-cleaner diagnostics are not part of lint_all.
# The shared clang-tidy policy remains authoritative in editors and CI.
UnusedIncludes: None
MissingIncludes: None
ClangTidy:
FastCheckFilter: NoneDo not commit host-specific --driver-mode, _WIN32_WINNT, bazel-out, or
configuration-directory paths to .clangd. Generate the ignored compilation
database separately on each host/configuration instead.
Run bazel run @dev//:compile_commands after a build configuration change.
Zed and lint_all then use the same compiler arguments and diagnostic engine.
If the database is missing, malformed, or empty, lint_all now stops with the
generation command instead of allowing clangd to guess flags and report a
cascade of unrelated missing-header and language-mode errors.
The standalone clang_tidy target remains available for direct fixes and
advanced tool-specific use. It is not the editor-parity check target.
| Target | Purpose | Check | Auto-fix |
|---|---|---|---|
buildifier |
Format BUILD/.bzl files | -- --check |
(default) |
buildozer |
Edit BUILD files programmatically | — | — |
depgraph |
Visualize dependency graph | — | — |
bep_report |
Parse BEP JSON into a report | — | — |
Capture an invocation with Bazel's Build Event Protocol and turn it into a compact timing, action, test, execution-strategy, and cache report:
bazel test //... --build_event_json_file=/tmp/build.bep.json
bazel run @dev//:bep_report -- /tmp/build.bep.json
bazel run @dev//:bep_report -- \
/tmp/build.bep.json --json-out /tmp/build-summary.jsonThe JSON summary deliberately excludes command-line options and client environment variables. Raw BEP includes command-line events and may therefore contain credentials passed through the environment; keep it temporary, never publish it as a CI artifact without redaction, and delete it after producing the summary.
# Format files changed on the current branch (C/C++ + Bazel in one pass)
bazel run @dev//:format_all -- --changed
# CI: verify nothing needs formatting
bazel run @dev//:format_all -- --check
# Check the same clangd findings that Zed displays, plus Bazel lint findings
bazel run @dev//:lint_all -- --changed
# Auto-fix lint issues
bazel run @dev//:lint_all -- --fix
# Install the managed pre-push quality gate (once per clone)
bazel run @dev//:install_hooks
# Run just clang-format check on C/C++
bazel run @dev//:clang_format -- --check
# Generate compile_commands.json for clangd
bazel run @dev//:compile_commands
# Install the exact hermetic clangd build in a stable path for Zed
bazel run @dev//:install_editor_clangd
# Generate a Visual Studio solution (opens in full Visual Studio, not VS Code)
bazel run @dev//:vs_solution
# Generate a standalone CMakeLists.txt from the Bazel cc_* targets
bazel run @dev//:cmakeThe managed hook rejects a push unless hermetic clang-format/buildifier checks
and the platform build pass. Formatting and lint use --changed by default.
On Linux the hook also refreshes compile_commands.json and runs
clang-tidy/buildifier lint. Set RULES_DEVTOOLS_HOOK_LINT_ALL=1 for the whole-repo format
and lint pass that CI runs. It refuses to overwrite a pre-existing unmanaged hook,
and it exits 0 in a repo without MODULE.bazel (docs, registries). Do not
bypass it with git push --no-verify.
Note on the repo name: the examples above use @dev, matching
use_repo(devtools, "dev"). If you named it differently, substitute your own
apparent name.
Install once per machine, not per clone. The installer writes to
git rev-parse --git-path hooks/pre-push, which honours core.hooksPath:
git config --global core.hooksPath ~/.config/git/hooks
bazel run @dev//:install_hooks # from any Bazel repo; covers every clonevs_solution generates a Visual Studio solution for opening a Bazel workspace in
full Visual Studio (not VS Code):
bazel run @dev//:vs_solutionIt scans the configured targets for cc_binary, cc_library, and cc_test rules
and writes, at the workspace root, a <solution_name>.sln plus one
.vcxproj + .vcxproj.filters per target under vs_projects/.
The projects are NMake/Makefile style, so Bazel stays the single source of truth:
- Build —
Build/Rebuild/Cleanshell out tobazel build/bazel clean(run from the workspace root).Debugmaps to-c dbg,Releaseto-c opt. - IntelliSense — include search paths, preprocessor definitions, forced includes
(
/FI), and compiler options are taken fromcompile_commands.json. Build-only flags (optimization, output paths, etc.) are stripped; everything that affects IntelliSense (/std,/EHsc,/W4,/wd####,/MD,/Zc:*, …) is forwarded. The siblingcompile_commandstarget is run first automatically so the data is always current (disable withrefresh_compile_commands = Falseon the macro). - Debugging —
cc_binaryandcc_testtargets get anNMakeOutput/LocalDebuggerCommandpointing at thebazel-binexecutable, so F5 launches the built binary. - External dependencies — each target's direct external-dependency headers
(e.g. googletest) are listed under an
External Dependencies\<repo>filter in Solution Explorer for browsing.
Generated files (*.sln, vs_projects/, .vs/, *.vcxproj.user) are build output —
add them to .gitignore.
This target is useful on Windows. The generated NMake projects still require
bazel to be available on the PATH used by Visual Studio.
Set the solution file name via the extension:
devtools.configure(
targets = "//...",
vs_solution_name = "myproject", # -> myproject.sln (default: "workspace")
)cmake generates a standalone CMakeLists.txt from a Bazel workspace's cc_*
targets — for Bazel-native projects whose users or community consume them via CMake
(the way googletest ships both a BUILD.bazel and a hand-maintained CMakeLists.txt):
bazel run @dev//:cmake # writes CMakeLists.txt to the workspace rootIt reads the targets' build attributes (bazel query --output=xml) and translates the
subset of Bazel that maps cleanly to CMake:
cc_library→add_library(STATIC, or INTERFACE when header-only);cc_binary/cc_test→add_executable.strip_include_prefix/includes→target_include_directories.copts→target_compile_options, guarded by compiler so GCC/Clang (-…) and MSVC (/…) flags coexist correctly.defines→target_compile_definitions.- Intra-repo
deps→target_link_libraries;linkopts→ linked libraries. Implicit Bazel toolchain deps (e.g.link_extra_lib) are dropped. - Curated external deps (currently googletest) → a
find_package-or-FetchContentblock;cc_test→ CTest registration withgtest_discover_testswhen applicable.
Honest by design — no silent gaps. Bazel and CMake do not map one-to-one (this is
why projects like googletest hand-maintain both). Anything that cannot be translated
faithfully is emitted as an explicit # TODO(bazel2cmake): … comment and reported on
the console, so the output is a correct starting point a maintainer finishes, not a
guaranteed build. Known limits:
genruleand generated headers are not translated (a genrule's command is an arbitrary script) — flagged with a TODO.select()is resolved to the current platform by--output=xml, so platform-specificsrcs/copts/linkoptsreflect the build host; other branches are not emitted.- External deps without a curated mapping are flagged rather than guessed.
The generated files are intentionally not a replacement for a maintained CMake build. Review the emitted TODOs and validate the result with CMake before publishing it.
compile_commands writes a database that clang-based tools can consume as it is
emitted -- no post-processing wrapper needed:
# Repeat the build configuration after `--` so the internal aquery uses it too.
bazel run --config=llvm @dev//:compile_commands -- --config=llvmThe generator reuses the enclosing bazel run output base by default, retaining
the warm analysis graph and avoiding a second hermetic toolchain extraction.
--output-base=<path> or RULES_DEVTOOLS_NESTED_OUTPUT_BASE remains available
when an intentionally separate analysis base is required.
Windows clang-cl actions store most arguments in intermediate @*.params
files. Bazel 8 aquery exposes those references without the native C++ contents,
so the generator first performs a cached build with
--materialize_param_files, then expands the retained files. The first run may
therefore take as long as a normal build; subsequent runs reuse Bazel's action
and remote caches. Platforms whose compile actions are already inline skip this
step.
- Absolute paths. Every
-I/-isystem/-iquoteand source path is resolved against the execution root, output base, or workspace. - Toolchain system headers. A Bazel compile action never spells out the
toolchain's builtin include directories; the driver knows them implicitly.
clang-tidy and clangd replay the command with their driver, so against a
hermetic GCC toolchain they would miss the standard library entirely -- and a
missing
<format>does not just fail, it makes error recovery invent cascading findings that look like real defects. Each distinct driver invocation is therefore probed once (-x <lang> -E -von an empty translation unit under the action's own--sysroot/-std/-nostdinc*/--targetflags) and the resulting search list is written into the entry as explicit-isystemflags. Probing the actual driver -- rather than guessing a layout -- keeps this correct across toolchain upgrades and across GCC, Clang, and cross-compilation. - One C++ standard library per entry. When a Clang action explicitly selects
libstdc++ and records a versioned libstdc++ include tree, the generator adds
-nostdinc++and removes the now-redundant-stdlib=libstdc++selector. This prevents clangd/clang-tidy from also discovering the host GCC and mixing its headers with the selected tree without producing an unused-driver-argument diagnostic; all recorded-isystempaths remain available and C/compiler resource headers are unchanged. - Compiler-portable flags. GCC-only driver flags that clang rejects outright
(
-fno-canonical-system-headers) are dropped, and-Wno-unknown-warning-optionis appended so GCC-only warning flags plus-Werrorcannot turn into clang errors. On MSVC,cl.exeis swapped forclang-cland the MSVC/Windows SDK includes are added as-imsvc.
System-header injection is controlled by the RULES_DEVTOOLS_SYSTEM_INCLUDES
environment variable: auto (default) injects the standard library and sysroot
directories, all also injects the compiler-internal resource directories
(lib/gcc/<triple>/<ver>/include, lib/clang/<ver>/include, which ship the
compiler's own builtins and are best left to the consuming clang), and off
emits the raw aquery arguments. If the driver cannot be probed the database is
emitted unchanged.
Tools that accept both a hermetic label and a system binary follow this order:
- Hermetic binary supplied by the extension or explicitly configured.
- System PATH fallback, with a warning when a configured hermetic runfile is missing.
The default LLVM download supplies only clang-format and clang-tidy. The
default buildtools download supplies buildifier and buildozer.
devtools.configure(
name = "devtools", # apparent repo used by use_repo
targets = "//...", # compile DB, unused deps, VS, CMake
llvm = "default", # version, "default", or "" for PATH
buildtools = "default", # version, "default", or "" for PATH
format_srcs = ["src/**/*.cpp", "src/**/*.h"], # for clang_format
lint_srcs = ["src/**/*.cpp"], # for clang_tidy (default: format_srcs)
sast_srcs = ["src/**/*.cpp"], # for sast (default: format_srcs)
ctags_srcs = ["src/**/*.cpp"], # for ctags (default: format_srcs)
buildifier_srcs = ["BUILD.bazel", "**/*.bzl"],
analyzers = ["cppcheck"], # cppcheck, semgrep, scan-build
exclude_headers = "", # compile_commands header filtering
exclude_external_sources = False,
enable_cscope = False,
buildifier_mode = "fix",
buildifier_lint = "warn",
buildifier_warnings = "",
vs_solution_name = "workspace", # name of the generated .sln
)To use system tools without hermetic downloads:
devtools.configure(
llvm = "", # use system clang-format and clang-tidy
buildtools = "", # use system buildifier and buildozer
)An explicit version such as llvm = "22.1.8" or
buildtools = "8.5.1" selects that entry from tools/versions.bzl.
Linux (x86_64/arm64), macOS arm64, Windows x86_64. macOS x86_64 LLVM not available (no upstream prebuilt).
The public mirror's CI runs the unit tests and target analysis on Linux and Windows; Stardoc generation runs on Linux because its upstream Maven rules invoke Bash.
bazel test //tests:all --test_output=errors
bazel build //:all //cc:all //editor:all //format_all:all //hooks:all //lint_all:all //tools:all
bazel build //docs:allThe smoke test additionally builds every bundled and module-extension target:
bash tests/smoke_test.shNever commit credentials, private network endpoints, or organization-specific automation. GitHub Actions dependencies in this repository are pinned to full commit SHAs.
Apache 2.0. See LICENSE and NOTICE for third-party attributions.
compile_commands generation is powered by the aquery-based approach from
hedronvision/bazel-compile-commands-extractor.
See NOTICE for details.