From a4666a55b7f777b31904bbd445ab7b9f69425599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onur=20Pa=C3=A7a?= Date: Fri, 19 Jun 2026 00:23:39 +0300 Subject: [PATCH 1/6] feat(vs_solution): generate Visual Studio .sln + .vcxproj from Bazel targets Adds a vs_solution tool that scans cc_binary/cc_library/cc_test targets and writes a Visual Studio solution at the workspace root (.sln plus one .vcxproj/.vcxproj.filters per target under vs_projects/) for opening a Bazel workspace in full Visual Studio. - NMake/Makefile projects: Build/Rebuild/Clean shell out to bazel; Debug maps to -c dbg, Release to -c opt. - IntelliSense (include paths, defines, forced includes, compiler options) is sourced from compile_commands.json; build-only flags are stripped and every IntelliSense-relevant flag is forwarded. The sibling compile_commands target is refreshed first automatically (refresh_compile_commands, default on). - cc_binary/cc_test get NMakeOutput + LocalDebuggerCommand pointing at the bazel-bin executable for F5 debugging. - Each target's direct external-dependency headers are listed under an "External Dependencies\" filter in Solution Explorer. Wired into the cc_devtools bundle and the @devtools extension, exposing a vs_solution_name configure attribute. --- README.md | 49 ++- cc/BUILD.bazel | 2 + cc/cc_devtools.bzl | 11 + cc/vs_solution.bzl | 137 +++++++ cc/vs_solution.template.py | 762 ++++++++++++++++++++++++++++++++++++ extension/devtools_ext.bzl | 2 + extension/devtools_repo.bzl | 10 + 7 files changed, 972 insertions(+), 1 deletion(-) create mode 100644 cc/vs_solution.bzl create mode 100644 cc/vs_solution.template.py diff --git a/README.md b/README.md index 6475e55..247a5f4 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, and BUILD file tooling. One `use_extension` call wires up everything. ## Usage @@ -58,6 +58,7 @@ 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` | — | — | ### Bazel (`//bazel:*`) @@ -88,6 +89,51 @@ 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 +``` + +## 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") +) ``` ## Tool Resolution @@ -107,6 +153,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..82f5007 100644 --- a/cc/BUILD.bazel +++ b/cc/BUILD.bazel @@ -16,6 +16,8 @@ exports_files( "iwyu.template.py", "unused_deps.bzl", "unused_deps.template.py", + "vs_solution.bzl", + "vs_solution.template.py", "cc_devtools.bzl", ], visibility = ["//visibility:public"], diff --git a/cc/cc_devtools.bzl b/cc/cc_devtools.bzl index 01a979d..4b8c3c5 100644 --- a/cc/cc_devtools.bzl +++ b/cc/cc_devtools.bzl @@ -8,6 +8,7 @@ Creates these targets: - {name}_ctags - {name}_compile_commands - {name}_unused_deps + - {name}_vs_solution 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. @@ -24,6 +25,7 @@ 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 +46,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 +69,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 +124,10 @@ 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 + ) 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