diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a5c479 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: bazel-contrib/setup-bazel@8cb04a772ab4c1eb984e9c1b493a182e96c5e425 # 0.19.0 + with: + bazelisk-cache: true + disk-cache: ${{ github.workflow }} + repository-cache: true + - name: Unit tests + run: bazel test //tests:all --test_output=errors + - name: Starlark and generated target analysis + run: bazel build //:all //cc:all //format_all:all //lint_all:all + - name: Stardoc + if: runner.os == 'Linux' + run: bazel build //docs:all diff --git a/.gitignore b/.gitignore index dfc30b9..94f6934 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Ignore macOS folder attributes. .DS_Store +# Ignore Python bytecode caches produced by running the tests directly. +__pycache__/ + # Ignore links to Bazel's output. The pattern needs the `*` because people can change the name of the directory into which the repository is cloned (changing the `bazel-` symlink), and must not end with a trailing `/` because it's a symlink on macOS/Linux. /bazel-* diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index c2349ab..2e0576c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1,5 +1,5 @@ { - "lockFileVersion": 26, + "lockFileVersion": 28, "registryFileHashes": { "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", @@ -190,8 +190,8 @@ "moduleExtensions": { "//extension:devtools_ext.bzl%devtools_extension": { "general": { - "bzlTransitiveDigest": "RUOj1In5A+nXLl8/DtXNivC3rRLU/81rtmPUFemeDvI=", - "usagesDigest": "N42wLSXjosokxZ/dTopKRCoq3hFNdPl7PBhJlLbGuMI=", + "bzlTransitiveDigest": "jhzQY2uYflAP1Y1wXhyajRkmERbP+0zPHALwyK8+8cQ=", + "usagesDigest": "Zu1ce6xRsZwwwvnc9BqRaTGiRyr+hZQQAvf6J69cpmo=", "recordedInputs": [], "generatedRepoSpecs": { "buildifier": { @@ -235,29 +235,8 @@ "enable_cscope": false, "buildifier_mode": "fix", "buildifier_lint": "warn", - "buildifier_warnings": "" - } - } - } - } - }, - "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { - "general": { - "bzlTransitiveDigest": "b+RP7Sgl8KN0VHamrgTqzGLuYPcQ/Mo4ptNkkHUIIlA=", - "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", - "recordedInputs": [ - "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools", - "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" - ], - "generatedRepoSpecs": { - "pybind11": { - "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", - "attributes": { - "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", - "strip_prefix": "pybind11-2.12.0", - "urls": [ - "https://github.com/pybind/pybind11/archive/v2.12.0.zip" - ] + "buildifier_warnings": "", + "vs_solution_name": "" } } } @@ -265,7 +244,7 @@ }, "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { "general": { - "bzlTransitiveDigest": "Ga4z8lQy1YQ5rAMy+dOl0dqcCEBnYNCXku8x3YQmDZI=", + "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=", "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=", "recordedInputs": [ "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools" @@ -322,7 +301,7 @@ }, "@@rules_python+//python/extensions:config.bzl%config": { "general": { - "bzlTransitiveDigest": "iibnRYgg8LpcfmH7EAnVwYePC3jsVaJ6Id8XxUjSZps=", + "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=", "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=", "recordedInputs": [ "REPO_MAPPING:rules_python+,bazel_tools bazel_tools", @@ -519,5 +498,6 @@ } } }, - "facts": {} + "facts": {}, + "factsVersions": {} } diff --git a/README.md b/README.md index 6475e55..9667cad 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # rules_devtools -Bazel module for C/C++ and Bazel developer tools: formatting, linting, static analysis, code navigation, and BUILD file tooling. One `use_extension` call wires up everything. +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. ## Usage @@ -9,11 +9,11 @@ Add to your `MODULE.bazel`, pinning to a commit from GitHub. Grab the latest SHA ```starlark # MODULE.bazel -bazel_dep(name = "rules_devtools", version = "1.0.0") +bazel_dep(name = "rules_devtools", version = "1.9.0") git_override( module_name = "rules_devtools", remote = "https://github.com/onurpaca/rules_devtools.git", - commit = "00802668aafdd1959d229a219788416c2c4155a5", + commit = "", ) devtools = use_extension("@rules_devtools//extension:devtools_ext.bzl", "devtools_extension") @@ -58,6 +58,8 @@ Run any target with `bazel run @devtools//:TARGET_NAME -- [args]`. All extra arg | `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` | — | — | ### Bazel (`//bazel:*`) @@ -88,8 +90,124 @@ bazel run @devtools//:clang_format -- --check # Generate compile_commands.json for clangd bazel run @devtools//:compile_commands + +# Generate a Visual Studio solution (opens in full Visual Studio, not VS Code) +bazel run @devtools//:vs_solution + +# Generate a standalone CMakeLists.txt from the Bazel cc_* targets +bazel run @devtools//:cmake +``` + +## Visual Studio + +`vs_solution` generates a Visual Studio solution for opening a Bazel workspace in +**full Visual Studio** (not VS Code): + +```sh +bazel run @devtools//:vs_solution +``` + +It scans the configured `targets` for `cc_binary`, `cc_library`, and `cc_test` rules +and writes, at the workspace root, a `.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`/`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). +- **Debugging** — `cc_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\` filter in + Solution Explorer for browsing. + +Generated files (`*.sln`, `vs_projects/`, `.vs/`, `*.vcxproj.user`) are build output — +add them to `.gitignore`. + +Set the solution file name via the extension: + +```starlark +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`): + +```sh +bazel run @devtools//: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_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-`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. + +## Compilation database + +`compile_commands` writes a database that clang-based tools can consume as it is +emitted -- no post-processing wrapper needed: + +- **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 `` 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 -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. +- **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///include`, `lib/clang//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 All tools follow the same resolution order: @@ -107,6 +225,7 @@ devtools.configure( 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) + vs_solution_name = "workspace", # name of the generated .sln ) ``` diff --git a/cc/BUILD.bazel b/cc/BUILD.bazel index 829893e..8f43421 100644 --- a/cc/BUILD.bazel +++ b/cc/BUILD.bazel @@ -16,6 +16,10 @@ exports_files( "iwyu.template.py", "unused_deps.bzl", "unused_deps.template.py", + "vs_solution.bzl", + "vs_solution.template.py", + "cmake.bzl", + "cmake.template.py", "cc_devtools.bzl", ], visibility = ["//visibility:public"], diff --git a/cc/cc_devtools.bzl b/cc/cc_devtools.bzl index 01a979d..6640ea6 100644 --- a/cc/cc_devtools.bzl +++ b/cc/cc_devtools.bzl @@ -8,6 +8,8 @@ Creates these targets: - {name}_ctags - {name}_compile_commands - {name}_unused_deps + - {name}_vs_solution + - {name}_cmake Use this when you only want the C/C++ portion. For the full set (C/C++ plus Bazel meta-tools and orchestrators) use //:devtools.bzl. @@ -19,11 +21,13 @@ Usage: load("//cc:clang_format.bzl", "clang_format") load("//cc:clang_tidy.bzl", "clang_tidy") +load("//cc:cmake.bzl", "cmake") load("//cc:compile_commands.bzl", "compile_commands") load("//cc:ctags.bzl", "ctags") load("//cc:iwyu.bzl", "iwyu") load("//cc:sast.bzl", "sast") load("//cc:unused_deps.bzl", "unused_deps") +load("//cc:vs_solution.bzl", "vs_solution") def cc_devtools( name, @@ -44,6 +48,7 @@ def cc_devtools( exclude_headers = None, exclude_external_sources = False, enable_cscope = False, + vs_solution_name = None, **kwargs): """Create the C/C++ devtools target set. @@ -66,6 +71,7 @@ def cc_devtools( exclude_headers: Header exclusion mode for compile_commands. exclude_external_sources: Exclude external sources from compile_commands. enable_cscope: Generate cscope database alongside ctags. + vs_solution_name: Base name for the generated Visual Studio .sln. Default "workspace". **kwargs: Additional common attributes passed to all targets. """ compile_commands( @@ -120,3 +126,16 @@ def cc_devtools( targets = targets if type(targets) == "string" else "//...", **kwargs ) + + vs_solution( + name = name + "_vs_solution", + targets = targets, + solution_name = vs_solution_name, + **kwargs + ) + + cmake( + name = name + "_cmake", + targets = targets, + **kwargs + ) diff --git a/cc/cmake.bzl b/cc/cmake.bzl new file mode 100644 index 0000000..887982c --- /dev/null +++ b/cc/cmake.bzl @@ -0,0 +1,82 @@ +"""cmake() macro — generate a standalone CMakeLists.txt from a Bazel workspace's cc_* targets. + +For Bazel-native projects whose users/community consume them via CMake (like googletest). +Translates the subset of Bazel that maps cleanly to CMake and flags the rest with explicit +TODOs (see cmake.template.py). NOT a magic "fully correct for arbitrary Bazel" generator — +it produces an honest starting point a maintainer finishes. + +Usage: + load("@rules_devtools//cc:cmake.bzl", "cmake") + cmake(name = "cmake", targets = "//...") + + bazel run //:cmake # writes CMakeLists.txt to the workspace root +""" + +load("@rules_python//python:py_binary.bzl", "py_binary") + +_TEMPLATE_LABEL = "//cc:cmake.template.py" +_FORWARDED_KWARGS = ("tags", "visibility", "compatible_with", "target_compatible_with") + +def _normalize_patterns(targets): + """Produce a list of target patterns from user input (string or list).""" + if not targets: + return ["//..."] + if type(targets) == "string": + return [targets] + if type(targets) == "list": + return list(targets) + fail("cmake: 'targets' must be a string or list, got {}".format(type(targets))) + +def cmake(name, targets = None, **kwargs): + """Create a CMakeLists.txt generation target. + + Args: + name: Target name. + targets: Bazel target patterns to scan for cc_binary/cc_library/cc_test. + Accepts a single label string or a list. Defaults to "//...". + **kwargs: Additional common attributes (visibility, tags, etc.). + """ + target_patterns = _normalize_patterns(targets) + + template_target = name + ".py" + forwarded = {k: v for k, v in kwargs.items() if k in _FORWARDED_KWARGS} + + _expand_cmake_template( + name = template_target, + target_patterns = target_patterns, + **forwarded + ) + + py_binary( + name = name, + main = template_target, + srcs = [template_target], + imports = [""], + **kwargs + ) + +def _expand_cmake_template_impl(ctx): + """Render the runtime script with baked-in configuration.""" + pattern_lines = "\n".join([ + " {},".format(repr(pattern)) + for pattern in ctx.attr.target_patterns + ]) + + out = ctx.actions.declare_file(ctx.attr.name) + ctx.actions.expand_template( + output = out, + is_executable = True, + template = ctx.file._template, + substitutions = { + " # __TARGET_PATTERNS__": pattern_lines, + }, + ) + return DefaultInfo(files = depset([out])) + +_expand_cmake_template = rule( + implementation = _expand_cmake_template_impl, + attrs = { + "target_patterns": attr.string_list(mandatory = True), + "_template": attr.label(allow_single_file = True, default = _TEMPLATE_LABEL), + }, +) diff --git a/cc/cmake.template.py b/cc/cmake.template.py new file mode 100644 index 0000000..9539317 --- /dev/null +++ b/cc/cmake.template.py @@ -0,0 +1,547 @@ +"""Generate a standalone CMakeLists.txt from a Bazel workspace's cc_* targets. + +Intent: produce a complete, *correct* CMakeLists.txt for the subset of Bazel that maps +cleanly to CMake (vanilla cc_library/cc_binary/cc_test, intra-repo deps, +strip_include_prefix, copts/defines/linkopts, curated external deps via find_or_fetch). + +NO SILENT GAPS: anything that cannot be translated faithfully (genrule commands, +generated headers, select() branches lost to the current platform, unmapped external +deps) is emitted as an explicit `# TODO(bazel2cmake): ...` comment AND reported on the +console, so the output is an honest starting point a maintainer finishes — never +silently wrong. + +Template variables (baked in by cmake.bzl): + __TARGET_PATTERNS__ — list of Bazel target patterns to scan +""" + +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + + +TARGET_PATTERNS = [ + # __TARGET_PATTERNS__ +] + +# Curated Bazel-repo -> CMake mapping. find_or_fetch emits find_package() first and +# falls back to FetchContent. Target labels map by (repo, target-name) -> CMake target. +EXTERNAL_MAP = { + "googletest": { + "package": "GTest", + "fetch_name": "googletest", + "git": "https://github.com/google/googletest.git", + "tag": "v1.17.0", + "targets": { + "gtest": "GTest::gtest", + "gtest_main": "GTest::gtest_main", + "gmock": "GTest::gmock", + "gmock_main": "GTest::gmock_main", + }, + "test_discovery": True, + }, +} + + +def _err(msg): + print("\033[0;31mError:\033[0m " + msg, file=sys.stderr) + + +def _info(msg): + print("\033[0;34m" + msg + "\033[0m", file=sys.stderr) + + +def _warn(msg): + print("\033[0;33mWarning:\033[0m " + msg, file=sys.stderr) + + +def get_workspace_root(): + root = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if not root: + _err("Must be run via 'bazel run'.") + sys.exit(1) + return os.path.normpath(root) + + +def project_name(workspace_root): + """Read module(name=...) from MODULE.bazel; fall back to the directory name.""" + module = os.path.join(workspace_root, "MODULE.bazel") + if os.path.isfile(module): + try: + text = open(module, "r", encoding="utf-8").read() + m = re.search(r'\bname\s*=\s*"([^"]+)"', text) + if m: + return m.group(1) + except (IOError, OSError): + pass + return os.path.basename(workspace_root) or "project" + + +def project_version(workspace_root): + """Read module(version=...) from MODULE.bazel for project(VERSION ...). None if absent.""" + module = os.path.join(workspace_root, "MODULE.bazel") + if os.path.isfile(module): + try: + text = open(module, "r", encoding="utf-8").read() + m = re.search(r'\bversion\s*=\s*"(\d+(?:\.\d+)*)"', text) + if m: + return m.group(1) + except (IOError, OSError): + pass + return None + + +def run_query(workspace_root, expr, output): + cmd = ["bazel", "query", expr, "--output=" + output, "--noshow_progress"] + result = subprocess.run(cmd, capture_output=True, text=True, cwd=workspace_root) + if result.returncode != 0: + _err("bazel query failed: {}".format(expr)) + print(result.stderr, file=sys.stderr) + sys.exit(1) + return result.stdout + + +# ─── Label helpers ───────────────────────────────────────────────────────────── + +def label_to_relpath(label): + """//:source/log.cpp -> source/log.cpp ; //tests:a.cpp -> tests/a.cpp ; @x//.. -> None.""" + if not label.startswith("//"): + return None + body = label[2:] + pkg, _, name = body.partition(":") + rel = (pkg + "/" + name) if pkg else name + return rel.strip("/") or None + + +def cmake_target_name(label): + """//tests:log_test -> tests_log_test ; //:log -> log. Globally unique CMake name.""" + body = label[2:] if label.startswith("//") else label.lstrip("@") + body = body.replace("//", "/") + name = body.replace("/", "_").replace(":", "_").strip("_") + return name or "target" + + +def short_target(label): + return label.rsplit(":", 1)[-1] if ":" in label else label.rsplit("/", 1)[-1] + + +def is_external_label(label): + return label.startswith("@") and not label.lstrip("@").startswith("//") + + +def external_repo(label): + repo = label.lstrip("@").split("//", 1)[0].rstrip("+") + if "+" in repo: + repo = repo.split("+")[-1] or repo + return repo + + +def dedupe(seq): + """Order-preserving de-duplication.""" + seen, out = set(), [] + for x in seq: + if x not in seen: + seen.add(x) + out.append(x) + return out + + +# Implicit toolchain deps that Bazel injects into every cc_* target — pure plumbing, +# never a real library to link in CMake. Filtered out (not flagged as gaps). +_INFRA_DEP_REPOS = frozenset(("rules_cc", "bazel_tools", "platforms")) + + +def is_infra_dep(label): + if "link_extra_lib" in label: + return True + if is_external_label(label): + repo = external_repo(label) + return repo in _INFRA_DEP_REPOS or repo.startswith("local_config_") + return False + + +# ─── XML parsing ──────────────────────────────────────────────────────────────── + +def _str_list(rule, attr): + for lst in rule.findall("list"): + if lst.get("name") == attr: + return [e.get("value") for e in lst.findall("string")] + return [] + + +def _label_list(rule, attr): + for lst in rule.findall("list"): + if lst.get("name") == attr: + return [e.get("value") for e in lst.findall("label")] + return [] + + +def _string(rule, attr): + for s in rule.findall("string"): + if s.get("name") == attr: + return s.get("value") + return None + + +def _visibility(rule): + for lst in rule.findall("list"): + if lst.get("name") == "visibility": + return [e.get("value") for e in lst.findall("label")] + return [] + + +class Target: + def __init__(self, rule): + self.kind = rule.get("class") # cc_library / cc_binary / cc_test + self.label = rule.get("name") + self.name = cmake_target_name(self.label) + self.srcs = _label_list(rule, "srcs") + self.hdrs = _label_list(rule, "hdrs") + self.deps = [d for d in _label_list(rule, "deps") if not is_infra_dep(d)] + self.copts = dedupe(_str_list(rule, "copts")) + self.defines = dedupe(_str_list(rule, "defines") + _str_list(rule, "local_defines")) + self.linkopts = dedupe(_str_list(rule, "linkopts")) + self.includes = _str_list(rule, "includes") + self.strip_include_prefix = _string(rule, "strip_include_prefix") + self.public = any(v == "//visibility:public" for v in _visibility(rule)) + + +# ─── Translation ──────────────────────────────────────────────────────────────── + +CXX_FLAG_LANGS = "GNU,Clang,AppleClang" + + +def split_copts_by_compiler(copts): + """Guard flags by compiler so MSVC (/...) and GCC/Clang (-...) coexist correctly.""" + gnu = [c for c in copts if c.startswith("-")] + msvc = [c for c in copts if c.startswith("/")] + other = [c for c in copts if not c.startswith("-") and not c.startswith("/")] + return gnu, msvc, other + + +def linkopt_to_cmake(opt): + """advapi32.lib -> advapi32 ; -lpthread -> pthread ; pass others through.""" + if opt.lower().endswith(".lib"): + return opt[:-4] + if opt.startswith("-l"): + return opt[2:] + return opt + + +def detect_cxx_standard(workspace_root, targets): + """Best-effort C++ standard from compile_commands.json or copts.""" + for t in targets: + for c in t.copts: + m = re.search(r"(?:/std:|-std=)(?:c\+\+|gnu\+\+)?(\d+)", c) + if m: + return m.group(1) + cc = os.path.join(workspace_root, "compile_commands.json") + if os.path.isfile(cc): + try: + text = open(cc, "r", encoding="utf-8").read() + m = re.search(r"(?:/std:|-std=)c\+\+(\d+)", text) + if m: + return m.group(1) + except (IOError, OSError): + pass + return None + + +def render(workspace_root, targets, genrule_outputs, gaps): + """Return (cmakelists_text, config_in_text_or_None). The latter is the package + config template written next to CMakeLists when there are exported libraries.""" + proj = project_name(workspace_root) + version = project_version(workspace_root) + std = detect_cxx_standard(workspace_root, targets) + + public_libs = [t for t in targets if t.kind == "cc_library" and t.public] + + # Which external repos are actually used (to emit find_or_fetch blocks). + used_repos = set() + for t in targets: + for d in t.deps: + if is_external_label(d): + used_repos.add(external_repo(d)) + + L = [] + L.append("# Auto-generated from Bazel by rules_devtools (cmake). Review before use.") + L.append("cmake_minimum_required(VERSION 3.20)") + if version: + L.append("project({} VERSION {} LANGUAGES CXX)".format(proj, version)) + else: + L.append("project({} LANGUAGES CXX)".format(proj)) + L.append("") + if std: + L.append("set(CMAKE_CXX_STANDARD {})".format(std)) + L.append("set(CMAKE_CXX_STANDARD_REQUIRED ON)") + else: + L.append("# TODO(bazel2cmake): C++ standard not detected — set it explicitly.") + gaps.append("C++ standard could not be detected (no /std or -std in copts/compile_commands).") + if public_libs: + L.append("include(GNUInstallDirs)") + L.append("") + + # External dependencies (find_or_fetch). + for repo in sorted(used_repos): + spec = EXTERNAL_MAP.get(repo) + if not spec: + L.append("# TODO(bazel2cmake): external dependency '{}' has no CMake mapping — add find_package/FetchContent manually.".format(repo)) + gaps.append("External dependency '{}' is not in the curated map — emitted as TODO.".format(repo)) + L.append("") + continue + L.append("# --- external dependency: {} ---".format(repo)) + L.append("find_package({} QUIET)".format(spec["package"])) + L.append("if(NOT {}_FOUND)".format(spec["package"])) + L.append(" include(FetchContent)") + L.append(" FetchContent_Declare({}".format(spec["fetch_name"])) + L.append(" GIT_REPOSITORY {}".format(spec["git"])) + L.append(" GIT_TAG {})".format(spec["tag"])) + L.append(" FetchContent_MakeAvailable({})".format(spec["fetch_name"])) + L.append("endif()") + L.append("") + + has_tests = any(t.kind == "cc_test" for t in targets) + if has_tests: + L.append("enable_testing()") + L.append("") + + for t in targets: + L.extend(render_target(t, workspace_root, genrule_outputs, gaps)) + L.append("") + + config_in = None + if public_libs: + install_lines, config_in = render_install(proj, version, public_libs, gaps) + L.extend(install_lines) + + return "\n".join(L).rstrip() + "\n", config_in + + +def render_install(proj, version, public_libs, gaps): + """Emit install()/export rules + the Config.cmake.in so the libraries are + consumable downstream via find_package(). Returns (lines, config_in_text).""" + targets_cmake = "lib/cmake/" + proj # ${CMAKE_INSTALL_LIBDIR}/cmake/ + + # Unique header roots to install (strip_include_prefix / includes of public libs). + inc_dirs = [] + for t in public_libs: + if t.strip_include_prefix: + inc_dirs.append(t.strip_include_prefix.strip("/")) + for inc in t.includes: + inc_dirs.append(inc.strip("/")) + inc_dirs = dedupe(inc_dirs) + + # External deps that exported libraries expose -> find_dependency() in the config. + find_deps = [] + for t in public_libs: + for d in t.deps: + if is_external_label(d): + spec = EXTERNAL_MAP.get(external_repo(d)) + if spec and spec["package"] not in find_deps: + find_deps.append(spec["package"]) + + L = [] + L.append("# --- install / export: consumable via find_package({}) ---".format(proj)) + L.append("install(TARGETS {}".format(" ".join(t.name for t in public_libs))) + L.append(" EXPORT {}Targets".format(proj)) + L.append(" ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}") + L.append(" LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}") + L.append(" RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}") + L.append(" INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})") + for d in inc_dirs: + L.append("install(DIRECTORY {}/ DESTINATION ${{CMAKE_INSTALL_INCLUDEDIR}})".format(d)) + L.append("install(EXPORT {0}Targets".format(proj)) + L.append(" FILE {}Targets.cmake".format(proj)) + L.append(" NAMESPACE {}::".format(proj)) + L.append(" DESTINATION {})".format(targets_cmake)) + L.append("include(CMakePackageConfigHelpers)") + if not version: + L.append("# TODO(bazel2cmake): no project version found in MODULE.bazel — using 0.0.0.") + gaps.append("No project version in MODULE.bazel — package version file uses 0.0.0.") + L.append('write_basic_package_version_file(') + L.append(' "${{CMAKE_CURRENT_BINARY_DIR}}/{}ConfigVersion.cmake"'.format(proj)) + L.append(" VERSION {}".format(version or "0.0.0")) + L.append(" COMPATIBILITY SameMajorVersion)") + L.append("configure_package_config_file(") + L.append(' "${{CMAKE_CURRENT_SOURCE_DIR}}/cmake/{}Config.cmake.in"'.format(proj)) + L.append(' "${{CMAKE_CURRENT_BINARY_DIR}}/{}Config.cmake"'.format(proj)) + L.append(" INSTALL_DESTINATION {})".format(targets_cmake)) + L.append("install(FILES") + L.append(' "${{CMAKE_CURRENT_BINARY_DIR}}/{}Config.cmake"'.format(proj)) + L.append(' "${{CMAKE_CURRENT_BINARY_DIR}}/{}ConfigVersion.cmake"'.format(proj)) + L.append(" DESTINATION {})".format(targets_cmake)) + + cfg = ["@PACKAGE_INIT@", ""] + if find_deps: + cfg.append("include(CMakeFindDependencyMacro)") + for pkg in find_deps: + cfg.append("find_dependency({})".format(pkg)) + cfg.append("") + cfg.append('include("${{CMAKE_CURRENT_LIST_DIR}}/{}Targets.cmake")'.format(proj)) + cfg.append("check_required_components({})".format(proj)) + return L, "\n".join(cfg) + "\n" + + +def map_dep(label, gaps): + """Map a Bazel dep label to a CMake target name, or None (with a gap note).""" + if not is_external_label(label): + return cmake_target_name(label) # intra-repo target + repo = external_repo(label) + spec = EXTERNAL_MAP.get(repo) + tgt = short_target(label) + if spec and tgt in spec["targets"]: + return spec["targets"][tgt] + gaps.append("Dependency '{}' has no CMake mapping — left as TODO.".format(label)) + return None + + +def render_target(t, workspace_root, genrule_outputs, gaps): + L = [] + src_paths = [label_to_relpath(s) for s in t.srcs] + src_paths = [s for s in src_paths if s] + + # Generated headers (genrule outputs) referenced in hdrs cannot be produced by CMake. + gen_hdrs = [h for h in t.hdrs if h in genrule_outputs] + for gh in gen_hdrs: + L.append("# TODO(bazel2cmake): target '{}' needs generated header '{}' (from a genrule) — port it (e.g. configure_file).".format( + t.label, gh)) + gaps.append("Target '{}' depends on generated header '{}' (genrule) — manual port required.".format(t.label, gh)) + + header_only = not src_paths + + if t.kind == "cc_library": + if header_only: + L.append("add_library({} INTERFACE)".format(t.name)) + scope = "INTERFACE" + else: + L.append("add_library({} STATIC".format(t.name)) + for s in src_paths: + L.append(" {}".format(s)) + L.append(")") + scope = "PUBLIC" + elif t.kind in ("cc_binary", "cc_test"): + L.append("add_executable({}".format(t.name)) + for s in src_paths: + L.append(" {}".format(s)) + L.append(")") + scope = "PRIVATE" + else: + L.append("# TODO(bazel2cmake): unsupported rule kind '{}' for {}".format(t.kind, t.label)) + gaps.append("Unsupported rule kind '{}' for {}.".format(t.kind, t.label)) + return L + + # Include directories from strip_include_prefix / includes. Exported public + # libraries also get an INSTALL_INTERFACE so installed consumers resolve headers. + inc_scope = "INTERFACE" if header_only else "PUBLIC" + inc_dirs = [] + if t.strip_include_prefix: + inc_dirs.append(t.strip_include_prefix.strip("/")) + for inc in t.includes: + inc_dirs.append(inc.strip("/")) + genexprs = ["$".format(d) for d in inc_dirs] + if t.kind == "cc_library" and t.public: + genexprs.append("$") + if len(genexprs) == 1: + L.append("target_include_directories({} {} {})".format(t.name, inc_scope, genexprs[0])) + elif genexprs: + L.append("target_include_directories({} {}".format(t.name, inc_scope)) + for g in genexprs: + L.append(" {}".format(g)) + L.append(")") + + # Compile definitions. + if t.defines: + L.append("target_compile_definitions({} {} {})".format( + t.name, scope, " ".join(t.defines))) + + # Compile options, guarded by compiler family so MSVC and GCC/Clang coexist. + gnu, msvc, other = split_copts_by_compiler(t.copts) + opt_scope = "INTERFACE" if header_only else "PRIVATE" + if gnu: + L.append("target_compile_options({} {} $<$:{}>)".format( + t.name, opt_scope, CXX_FLAG_LANGS, " ".join(gnu))) + if msvc: + L.append("target_compile_options({} {} $<$:{}>)".format( + t.name, opt_scope, " ".join(msvc))) + if other: + L.append("target_compile_options({} {} {})".format(t.name, opt_scope, " ".join(other))) + + # Dependencies (intra-repo + mapped external) and link options. + link_libs = [] + for d in t.deps: + mapped = map_dep(d, gaps) + if mapped: + link_libs.append(mapped) + else: + L.append("# TODO(bazel2cmake): unmapped dependency {}".format(d)) + link_libs.extend(linkopt_to_cmake(o) for o in t.linkopts) + if link_libs: + L.append("target_link_libraries({} {} {})".format(t.name, scope, " ".join(link_libs))) + + # Tests: register with CTest, with framework-native discovery when known. + if t.kind == "cc_test": + discovered = False + for d in t.deps: + if is_external_label(d): + spec = EXTERNAL_MAP.get(external_repo(d)) + if spec and spec.get("test_discovery"): + L.append("include(GoogleTest)") + L.append("gtest_discover_tests({})".format(t.name)) + discovered = True + break + if not discovered: + L.append("add_test(NAME {0} COMMAND {0})".format(t.name)) + + return L + + +def main(): + workspace_root = get_workspace_root() + union = " + ".join(TARGET_PATTERNS) + + _info("Querying cc_* targets...") + cc_xml = run_query(workspace_root, 'kind("cc_(library|binary|test) rule", {})'.format(union), "xml") + targets = [Target(r) for r in ET.fromstring(cc_xml).findall("rule")] + if not targets: + _err("No cc_binary/cc_library/cc_test targets found under: {}".format(", ".join(TARGET_PATTERNS))) + sys.exit(1) + + # Genrule outputs — so we can flag generated headers pulled into hdrs. + gen_xml = run_query(workspace_root, 'kind("genrule rule", {})'.format(union), "xml") + genrule_outputs = set() + genrules = ET.fromstring(gen_xml).findall("rule") + for r in genrules: + # Match both the rule label (how hdrs/srcs reference it) and its output files. + genrule_outputs.add(r.get("name")) + for out in r.findall("output") + r.findall("rule-output"): + genrule_outputs.add(out.get("value") or out.get("name")) + + gaps = [] + content, config_in = render(workspace_root, targets, genrule_outputs, gaps) + + out_path = os.path.join(workspace_root, "CMakeLists.txt") + with open(out_path, "w", encoding="utf-8") as f: + f.write(content) + + if config_in is not None: + proj = project_name(workspace_root) + cmake_dir = os.path.join(workspace_root, "cmake") + os.makedirs(cmake_dir, exist_ok=True) + with open(os.path.join(cmake_dir, proj + "Config.cmake.in"), "w", encoding="utf-8") as f: + f.write(config_in) + print("\033[0;32mWrote cmake/{}Config.cmake.in (find_package-consumable export)\033[0m".format(proj)) + + print("\033[0;32mWrote CMakeLists.txt ({} target(s))\033[0m".format(len(targets))) + if genrules: + print("\033[0;33m{} genrule(s) present — not translated (see TODOs).\033[0m".format(len(genrules))) + if gaps: + print("\033[0;33m\n{} item(s) need manual attention:\033[0m".format(len(gaps))) + for g in gaps: + print(" - " + g, file=sys.stderr) + else: + print("\033[0;32mNo gaps detected — review still recommended.\033[0m") + + +if __name__ == "__main__": + main() diff --git a/cc/compile_commands.bzl b/cc/compile_commands.bzl index 0375891..0f98c2d 100644 --- a/cc/compile_commands.bzl +++ b/cc/compile_commands.bzl @@ -6,6 +6,12 @@ Usage: bazel run //:compile_commands # writes compile_commands.json to workspace root +The generated database is directly consumable by clang-based tools: paths are +absolute, the toolchain driver's builtin system-header directories are probed +and written out as explicit -isystem flags, and driver flags that clang rejects +are dropped. Set RULES_DEVTOOLS_SYSTEM_INCLUDES=off to emit the raw aquery +arguments instead (see the README). + Attribution: the macro shape, the target-coercion behavior (string/list/dict inputs), and the use of `mnemonic("CppCompile|ObjcCompile|CudaCompile", deps(...))` to enumerate compile actions are derived from diff --git a/cc/compile_commands.template.py b/cc/compile_commands.template.py index 97eadc7..bc514de 100644 --- a/cc/compile_commands.template.py +++ b/cc/compile_commands.template.py @@ -27,6 +27,72 @@ EXCLUDE_HEADERS = __EXCLUDE_HEADERS__ EXCLUDE_EXTERNAL_SOURCES = __EXCLUDE_EXTERNAL_SOURCES__ +# --------------------------------------------------------------------------- +# Toolchain system-header detection +# --------------------------------------------------------------------------- +# +# A Bazel C++ compile action never spells out the toolchain's builtin include +# directories -- the driver knows them implicitly. Clang-based consumers +# (clang-tidy, clangd) replay the database with THEIR own driver, so against a +# hermetic GCC toolchain they hunt for the standard library in the wrong place, +# fail on / , and then emit cascading bogus findings out of +# error recovery. To avoid that, ask the very driver recorded in the action for +# its implicit search list and write it into the database as explicit -isystem +# flags. +# +# Mode is controlled by the RULES_DEVTOOLS_SYSTEM_INCLUDES environment variable: +# auto (default) - inject the driver's standard library and sysroot dirs +# all - also inject the compiler-internal resource dirs +# off - inject nothing (raw aquery arguments) +SYSTEM_INCLUDES_ENV = "RULES_DEVTOOLS_SYSTEM_INCLUDES" + +_SEARCH_LIST_BEGIN = "#include <...> search starts here:" +_SEARCH_LIST_END = "End of search list." + +# Flags that change WHERE the driver looks. Only these are replayed in the +# probe; forwarding anything else (warnings, optimisation, defines) would only +# add ways for the probe to fail. +_PROBE_FLAGS_EXACT = frozenset({ + "-nostdinc", + "-nostdinc++", + "-nostdlibinc", + "-m32", + "-m64", + "-mx32", +}) +_PROBE_FLAGS_PREFIX = ( + "--sysroot=", + "-isysroot=", + "--target=", + "-std=", + "-stdlib=", + "--gcc-toolchain=", +) +_PROBE_FLAGS_WITH_VALUE = ("--sysroot", "-isysroot", "-target", "--target") + +# Compiler-internal resource directories: GCC's lib/gcc///include +# (plus include-fixed) and Clang's lib/clang//include. They ship the +# compiler's OWN builtins (stddef.h, the intrinsic headers, ...). Handing GCC's +# copies to Clang is how "immintrin.h is broken" reports are born, and Clang +# already provides equivalents, so they are dropped in "auto" mode. The C++ +# standard library and sysroot directories -- the ones actually missing -- are +# kept. +_COMPILER_INTERNAL_DIR_RE = re.compile( + r"/lib(?:64)?/(?:gcc(?:-cross)?/[^/]+/[^/]+|clang/[^/]+)/include(?:-fixed)?(?:/|$)") + +# GCC-only DRIVER flags. Clang shrugs at an unknown -W but exits with "unknown +# argument" on an unknown -f, and clang-tidy reports that as a compiler error -- +# failing the lint on a file nobody touched. Dropping them keeps the database +# replayable by clang-based tools. +_GCC_ONLY_DRIVER_FLAGS = frozenset({"-fno-canonical-system-headers"}) + +# GCC-only WARNING flags (-Wduplicated-branches, -Wlogical-op, ...) survive in +# the database. Combined with -Werror they would turn every entry into a hard +# clang error, so tell clang to ignore warning flags it does not know. +_UNKNOWN_WARNING_FLAG = "-Wno-unknown-warning-option" + +_SYSTEM_INCLUDE_CACHE = {} + def get_workspace_root(): """Get the workspace root from environment.""" @@ -277,7 +343,8 @@ def extract_compile_commands(aquery_output, execution_root, workspace_root, outp continue # Clean up the command arguments - cleaned_args = clean_arguments(arguments, execution_root, workspace_root, output_base) + cleaned_args = clean_arguments( + arguments, execution_root, workspace_root, output_base, source=resolved_source) entry = { "directory": execution_root, @@ -326,8 +393,139 @@ def _is_msvc_compiler(compiler_path): return os.path.basename(compiler_path).lower() in ("cl.exe", "cl") -def clean_arguments(arguments, execution_root, workspace_root, output_base=None): - """Clean and resolve paths in compiler arguments for clangd consumption.""" +def _system_includes_mode(): + """Read the injection mode from the environment ("auto" when unset).""" + mode = os.environ.get(SYSTEM_INCLUDES_ENV, "auto").strip().lower() + return mode if mode in ("auto", "all", "off") else "auto" + + +def _is_compiler_internal_dir(path): + """Check whether a search-list entry is a compiler-internal resource dir.""" + return bool(_COMPILER_INTERNAL_DIR_RE.search(path.replace(os.sep, "/") + "/")) + + +def probe_flags(arguments): + """Pick the subset of an action's flags that steers the include search.""" + flags = [] + skip_next = False + for i, arg in enumerate(arguments): + if i == 0 or skip_next: + skip_next = False + continue + if arg in _PROBE_FLAGS_WITH_VALUE: + if i + 1 < len(arguments): + flags.extend([arg, arguments[i + 1]]) + skip_next = True + elif arg in _PROBE_FLAGS_EXACT or arg.startswith(_PROBE_FLAGS_PREFIX): + flags.append(arg) + return flags + + +def query_driver_search_list(compiler, flags, execution_root, language): + """Ask a compiler driver for its implicit `#include <...>` search list. + + Runs the driver on an empty translation unit with -E -v and parses the + directories it reports. Relative entries are resolved against the execution + root, which is also the probe's working directory, so execroot-relative + sysroots behave exactly as they do in the real build. + + Returns an empty list if the driver cannot be run or prints no search list; + the caller then simply emits the database unchanged. + """ + cmd = [compiler] + list(flags) + ["-x", language, "-E", "-v", os.devnull, "-o", os.devnull] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, + cwd=execution_root or None, timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return [] + + output = result.stderr or "" + if _SEARCH_LIST_BEGIN not in output or _SEARCH_LIST_END not in output: + return [] + body = output.split(_SEARCH_LIST_BEGIN, 1)[1].split(_SEARCH_LIST_END, 1)[0] + + dirs = [] + for line in body.splitlines(): + if not line.startswith((" ", "\t")): + continue + entry = line.strip() + # macOS framework directories need -iframework, not -isystem; the + # clang-based consumer knows its own frameworks, so skip them. + if not entry or entry.endswith("(framework directory)"): + continue + if not os.path.isabs(entry) and execution_root: + entry = os.path.join(execution_root, entry) + entry = os.path.normpath(entry) + if os.path.isdir(entry) and entry not in dirs: + dirs.append(entry) + return dirs + + +def system_include_flags(compiler, arguments, execution_root, language): + """Build the -isystem flags that make the driver's builtin dirs explicit. + + The result is cached per (compiler, language, search-affecting flags), so a + database with thousands of entries probes each distinct driver invocation + once. + """ + mode = _system_includes_mode() + if mode == "off": + return [] + + flags = probe_flags(arguments) + key = (compiler, language, tuple(flags), mode) + if key not in _SYSTEM_INCLUDE_CACHE: + dirs = query_driver_search_list(compiler, flags, execution_root, language) + if mode == "auto": + dirs = [d for d in dirs if not _is_compiler_internal_dir(d)] + _SYSTEM_INCLUDE_CACHE[key] = dirs + + result = [] + for directory in _SYSTEM_INCLUDE_CACHE[key]: + result.extend(["-isystem", directory]) + return result + + +def probe_language(source_path, arguments=None): + """Determine the driver's -x language name for a compile action. + + An explicit -x in the action wins; otherwise the source suffix decides. + Anything that is not plainly C is probed as C++, whose search list is a + superset -- injecting an unused C++ directory is harmless, missing one is + exactly the failure this detection exists to prevent. + """ + for i, arg in enumerate(arguments or []): + if arg == "-x" and i + 1 < len(arguments): + return arguments[i + 1] + if arg.startswith("-x") and len(arg) > 2: + return arg[2:] + suffix = pathlib.Path(source_path).suffix + if suffix == ".c": + return "c" + if suffix == ".m": + return "objective-c" + if suffix == ".mm": + return "objective-c++" + return "c++" + + +def clean_arguments(arguments, execution_root, workspace_root, output_base=None, source=None): + """Clean and resolve paths in compiler arguments for clangd consumption. + + Args: + arguments: The raw action command line, compiler first. + execution_root: Bazel's execution root (the entry's "directory"). + workspace_root: The workspace root. + output_base: Bazel's output base, used to find external repos on Windows. + source: The action's source file, used to pick the probe language. + + Besides resolving paths this makes the GCC toolchain's implicit system + header directories explicit and drops driver flags that clang-based + consumers reject, so the database is usable by clang-tidy and clangd as + emitted -- no post-processing wrapper required. + """ if not arguments: return arguments @@ -362,6 +560,8 @@ def clean_arguments(arguments, execution_root, workspace_root, output_base=None) # Skip /c (clangd adds its own) if arg == "/c": continue + elif arg in _GCC_ONLY_DRIVER_FLAGS: + continue # Resolve -I, -isystem, -iquote, /I paths for prefix in ("-I", "-isystem", "-iquote", "/I"): @@ -384,6 +584,16 @@ def clean_arguments(arguments, execution_root, workspace_root, output_base=None) else: cleaned.append(arg) + if not is_msvc: + # Appended last so the project's own -I flags keep priority and the + # warning override outranks any -Werror already on the command line. + compiler = arguments[0] + if "/" in compiler or os.sep in compiler: + compiler = resolve_path(compiler, execution_root, workspace_root, output_base) + language = probe_language(source or "", arguments) + cleaned.extend(system_include_flags(compiler, arguments, execution_root, language)) + cleaned.append(_UNKNOWN_WARNING_FLAG) + return cleaned diff --git a/cc/vs_solution.bzl b/cc/vs_solution.bzl new file mode 100644 index 0000000..b7f9a6b --- /dev/null +++ b/cc/vs_solution.bzl @@ -0,0 +1,137 @@ +"""vs_solution() macro — generate a Visual Studio (.sln + .vcxproj) solution for a Bazel workspace. + +Generates NMake/Makefile-style Visual Studio projects that shell out to Bazel for +building. IntelliSense (include paths, preprocessor defines, compiler options) is +sourced from the workspace's compile_commands.json, and cc_binary / cc_test targets +get an NMakeOutput so F5 debugging launches the bazel-bin executable. + +Usage: + load("@rules_devtools//cc:vs_solution.bzl", "vs_solution") + vs_solution(name = "vs_solution", targets = "//...") + + bazel run //:compile_commands # produce IntelliSense data first + bazel run //:vs_solution # writes .sln + vs_projects/*.vcxproj + +This is full Visual Studio (not VS Code): open the generated .sln directly. +""" + +load("@rules_python//python:py_binary.bzl", "py_binary") + +_TEMPLATE_LABEL = "//cc:vs_solution.template.py" +_FORWARDED_KWARGS = ("tags", "visibility", "compatible_with", "target_compatible_with") + +def _normalize_patterns(targets): + """Produce a list of target patterns from user input (string or list).""" + if not targets: + return ["//..."] + if type(targets) == "string": + return [targets] + if type(targets) == "list": + return list(targets) + fail("vs_solution: 'targets' must be a string or list, got {}".format(type(targets))) + +def _default_compile_commands_label(name): + """Derive the sibling compile_commands target created alongside this one. + + The bundle/extension name a vs_solution target "vs_solution" right next + to "compile_commands" (e.g. "vs_solution"/"compile_commands" in @dev, or + "dev_vs_solution"/"dev_compile_commands" in the macro path). Build the label from + native.repository_name()/package_name() so it resolves to the package where THIS + target is instantiated (e.g. @dev), not where this .bzl lives. The canonical + @@repo//pkg:name form is accepted by `bazel run` from anywhere. + """ + suffix = "vs_solution" + if not name.endswith(suffix): + return "" + cc_name = name[:-len(suffix)] + "compile_commands" + + # `bazel run` from the command line resolves a repo only via its apparent name + # or the canonical double-@ form. native.repository_name() yields a single-@ + # canonical name, so promote it to @@ (single-@ is read as an apparent name and + # fails with "No repository visible"). + repo = native.repository_name() + if not repo.startswith("@@"): + repo = "@" + repo + return "{repo}//{pkg}:{cc}".format(repo = repo, pkg = native.package_name(), cc = cc_name) + +def vs_solution( + name, + targets = None, + solution_name = None, + projects_subdir = None, + refresh_compile_commands = True, + compile_commands_target = None, + **kwargs): + """Create a Visual Studio solution generation target. + + Args: + name: Target name. + targets: Bazel target patterns to scan for cc_binary/cc_library/cc_test. + Accepts a single label string or a list. Defaults to "//...". + solution_name: Base name for the .sln file. Defaults to "workspace". + projects_subdir: Directory (relative to workspace root) for the generated + .vcxproj files. Defaults to "vs_projects". + refresh_compile_commands: If True (default), run the sibling compile_commands + target first so IntelliSense data is always current. Best-effort: a failure + is non-fatal and falls back to any existing compile_commands.json. + compile_commands_target: Explicit label to run for the refresh. Defaults to the + sibling "*_compile_commands" target. Ignored if refresh is disabled. + **kwargs: Additional common attributes (visibility, tags, etc.). + """ + target_patterns = _normalize_patterns(targets) + + cc_target = "" + if refresh_compile_commands: + cc_target = compile_commands_target or _default_compile_commands_label(name) + + template_target = name + ".py" + forwarded = {k: v for k, v in kwargs.items() if k in _FORWARDED_KWARGS} + + _expand_vs_solution_template( + name = template_target, + target_patterns = target_patterns, + solution_name = solution_name or "workspace", + projects_subdir = projects_subdir or "vs_projects", + compile_commands_target = cc_target, + **forwarded + ) + + py_binary( + name = name, + main = template_target, + srcs = [template_target], + imports = [""], + **kwargs + ) + +def _expand_vs_solution_template_impl(ctx): + """Render the runtime script with baked-in configuration.""" + pattern_lines = "\n".join([ + " {},".format(repr(pattern)) + for pattern in ctx.attr.target_patterns + ]) + + out = ctx.actions.declare_file(ctx.attr.name) + ctx.actions.expand_template( + output = out, + is_executable = True, + template = ctx.file._template, + substitutions = { + " # __TARGET_PATTERNS__": pattern_lines, + "__SOLUTION_NAME__": repr(ctx.attr.solution_name), + "__PROJECTS_SUBDIR__": repr(ctx.attr.projects_subdir), + "__COMPILE_COMMANDS_TARGET__": repr(ctx.attr.compile_commands_target), + }, + ) + return DefaultInfo(files = depset([out])) + +_expand_vs_solution_template = rule( + implementation = _expand_vs_solution_template_impl, + attrs = { + "target_patterns": attr.string_list(mandatory = True), + "solution_name": attr.string(default = "workspace"), + "projects_subdir": attr.string(default = "vs_projects"), + "compile_commands_target": attr.string(default = ""), + "_template": attr.label(allow_single_file = True, default = _TEMPLATE_LABEL), + }, +) diff --git a/cc/vs_solution.template.py b/cc/vs_solution.template.py new file mode 100644 index 0000000..e438d15 --- /dev/null +++ b/cc/vs_solution.template.py @@ -0,0 +1,762 @@ +"""Generate a Visual Studio solution (.sln + .vcxproj + .vcxproj.filters) for a Bazel workspace. + +Projects are NMake/Makefile style: builds shell out to `bazel build`, IntelliSense is +fed from compile_commands.json, and cc_binary / cc_test targets get an NMakeOutput so +F5 debugging launches the bazel-bin executable. + +Template variables (baked in by vs_solution.bzl): + __TARGET_PATTERNS__ — list of Bazel target patterns to scan + __SOLUTION_NAME__ — base name for the .sln file + __PROJECTS_SUBDIR__ — directory (relative to workspace) for .vcxproj files + __COMPILE_COMMANDS_TARGET__ — sibling compile_commands target to refresh first ("" = skip) +""" + +import json +import os +import pathlib +import re +import shlex +import subprocess +import sys +import uuid + + +# Baked-in configuration +TARGET_PATTERNS = [ + # __TARGET_PATTERNS__ +] +SOLUTION_NAME = __SOLUTION_NAME__ +PROJECTS_SUBDIR = __PROJECTS_SUBDIR__ +COMPILE_COMMANDS_TARGET = __COMPILE_COMMANDS_TARGET__ + +# Deterministic GUID namespace so regenerating a solution keeps stable project GUIDs +# (VS tracks per-user state, breakpoints, and startup project by GUID). +_GUID_NAMESPACE = uuid.UUID("6f1d3e2a-9c4b-4f8e-bf2a-1b6d5c0e7a90") +# C++ project type GUID — required by the .sln for VC++ projects. +_VCXPROJ_TYPE_GUID = "8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942" + +CC_KINDS = ("cc_binary", "cc_library", "cc_test") +RUNNABLE_KINDS = ("cc_binary", "cc_test") + +SOURCE_EXTS = {'.c', '.cc', '.cpp', '.cxx', '.c++', '.C'} +HEADER_EXTS = {'.h', '.hh', '.hpp', '.hxx', '.h++', '.H', '.inc', '.ipp', '.tcc'} + + +def is_source_file(path): + """True if a path looks like a C/C++ translation unit or header.""" + return pathlib.Path(path).suffix in SOURCE_EXTS or pathlib.Path(path).suffix in HEADER_EXTS + + +# ─── Console helpers ────────────────────────────────────────────────────────── + +def _err(msg): + print("\033[0;31mError:\033[0m " + msg, file=sys.stderr) + + +def _info(msg): + print("\033[0;34m" + msg + "\033[0m", file=sys.stderr) + + +def _warn(msg): + print("\033[0;33mWarning:\033[0m " + msg, file=sys.stderr) + + +# ─── Bazel plumbing ───────────────────────────────────────────────────────── + +def get_workspace_root(): + root = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if not root: + _err("Must be run via 'bazel run'.") + sys.exit(1) + return root + + +def bazel_info(workspace_root, key): + result = subprocess.run( + ["bazel", "info", key], + capture_output=True, text=True, cwd=workspace_root, + ) + if result.returncode != 0: + _err("Failed to get '{}' from bazel info.".format(key)) + print(result.stderr, file=sys.stderr) + sys.exit(1) + return result.stdout.strip() + + +def query_cc_targets(workspace_root): + """Return a list of (label, kind) for cc_* rules under the configured patterns.""" + kind_re = "^({}) rule$".format("|".join(CC_KINDS)) + union = " + ".join(TARGET_PATTERNS) + query = 'kind("{}", {})'.format(kind_re, union) + + cmd = ["bazel", "query", query, "--output=label_kind", "--noshow_progress"] + _info("Querying cc_* targets...") + result = subprocess.run(cmd, capture_output=True, text=True, cwd=workspace_root) + if result.returncode != 0: + _err("bazel query failed.") + print(result.stderr, file=sys.stderr) + sys.exit(1) + + targets = [] + for line in result.stdout.splitlines(): + line = line.strip() + if not line: + continue + # Format: " rule