Skip to content

Repository files navigation

rules_devtools

CI License

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.

Installation

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_commands

llvm = "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.

Quick start

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.

All Targets

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/main

clang_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_clangd

The installer copies both the clangd executable and its builtin resource headers. Files whose content is already current are not copied again.

Orchestrators (run all tools in one pass)

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

C/C++

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

Editor diagnostic parity

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_clangd

Use 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: None

Do 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.

Bazel

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

Build observability

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.json

The 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.

Examples

# 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//:cmake

The 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 clone

Visual Studio

vs_solution generates a Visual Studio solution for opening a Bazel workspace in full Visual Studio (not VS Code):

bazel run @dev//:vs_solution

It 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:

  • BuildBuild/Rebuild/Clean shell out to bazel build/bazel clean (run from the workspace root). Debug maps to -c dbg, Release to -c opt.
  • IntelliSense — include search paths, preprocessor definitions, forced includes (/FI), and compiler options are taken from compile_commands.json. Build-only flags (optimization, output paths, etc.) are stripped; everything that affects IntelliSense (/std, /EHsc, /W4, /wd####, /MD, /Zc:*, …) is forwarded. The sibling compile_commands target is run first automatically so the data is always current (disable with refresh_compile_commands = False on the macro).
  • Debuggingcc_binary and cc_test targets get an NMakeOutput / LocalDebuggerCommand pointing at the bazel-bin executable, 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 export

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 root

It reads the targets' build attributes (bazel query --output=xml) and translates the subset of Bazel that maps cleanly to CMake:

  • cc_libraryadd_library (STATIC, or INTERFACE when header-only); cc_binary / cc_testadd_executable.
  • strip_include_prefix / includestarget_include_directories.
  • coptstarget_compile_options, guarded by compiler so GCC/Clang (-…) and MSVC (/…) flags coexist correctly. definestarget_compile_definitions.
  • Intra-repo depstarget_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-FetchContent block; cc_test → CTest registration with gtest_discover_tests when 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:

  • genrule and 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-specific srcs/copts/linkopts reflect 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.

Compilation database

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=llvm

The 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/-iquote and 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 -v on an empty translation unit under the action's own --sysroot/-std/-nostdinc*/--target flags) and the resulting search list is written into the entry as explicit -isystem flags. 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 -isystem paths 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-option is appended so GCC-only warning flags plus -Werror cannot turn into clang errors. On MSVC, cl.exe is swapped for clang-cl and 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.

Tool Resolution

Tools that accept both a hermetic label and a system binary follow this order:

  1. Hermetic binary supplied by the extension or explicitly configured.
  2. 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.

Configuration

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.

Platforms

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.

Development

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:all

The smoke test additionally builds every bundled and module-extension target:

bash tests/smoke_test.sh

Never commit credentials, private network endpoints, or organization-specific automation. GitHub Actions dependencies in this repository are pinned to full commit SHAs.

License

Apache 2.0. See LICENSE and NOTICE for third-party attributions.

Inspiration

compile_commands generation is powered by the aquery-based approach from hedronvision/bazel-compile-commands-extractor. See NOTICE for details.

About

Bazel module for Bazel, C and C++ developer tools: formatting, linting, static analysis, code navigation, and BUILD file tooling. One use_extension call wires up everything.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages