Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,23 @@ Adding `exclude_external_sources = True` and `exclude_headers = "external"` can

For now, we'd suggest continuing on to set up `clangd` (below). Thereafter, if you your project proves to be large enough that it stretches the capacity of `clangd` and/or this tool to index quickly, take a look at the docs at the top of [`refresh_compile_commands.bzl`](./refresh_compile_commands.bzl) for instructions on how to tune those flags and others.

### ⚠️ EXPERIMENTAL FEATURE: --symlink-prefix support

Bazel allows use of the --symlink-prefix argument, commonly in `.bazelrc`, ie `build --symlink_prefix=build/bazel-`. Using this can help keep your workspace tidy by changing the names of the generated symlinks or even putting them in a subdirectory. Experimental support for this feature can be used by adding `experimental_symlink_prefix = <your_prefix>` to your rule. Make sure the prefix you add here matches what you use with bazel commands/.bazelrc! For example:

```Starlark
refresh_compile_commands(
name = "refresh_compile_commands",
experimental_symlink_prefix = "build/bazel-",
)
```

This will tell the tool to expect symlinks with the prefix `build/bazel-` instead of `bazel-`. It will also place the `external` directory symlink in the subdirectory associated with the prefix (if any), ie `build/` to keep things tidy and the subdirectory matching bazel's build workspace.

**IMPORTANT ADDITIONAL REQUIREMENT:**

If you use a symlink prefix with a subdirectory, the `external` folder will no longer be in the project root. Bazel will no longer ignore it by default, and will try to look for targets inside it too, which will cause many commands to fail. To avoid this, you'll need to add a `.bazelignore` file to the root of your project, and add `build` or `build/external` to it.

## Editor Setup — for autocomplete based on `compile_commands.json`


Expand Down
57 changes: 48 additions & 9 deletions refresh.template.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
import types
import typing # MIN_PY=3.9: Switch e.g. typing.List[str] -> List[str]

symlink_prefix = {experimental_symlink_prefix} or "bazel-"
external_symlink_path = ({experimental_symlink_prefix}.rsplit("/", 1)[0] + "/external" if {experimental_symlink_prefix} and "/" in {experimental_symlink_prefix} else "external") # External should be moved to the same directory as the symlink_prefix files if they have been moved to a subdirectory


@enum.unique
class SGR(enum.Enum):
Expand Down Expand Up @@ -507,7 +510,7 @@ def _file_is_in_main_workspace_and_not_external(file_str: str):

# some/file.h, but not external/some/file.h
# also allows for things like bazel-out/generated/file.h
if _is_relative_to(file_path, pathlib.PurePath("external")):
if _is_relative_to(file_path, pathlib.PurePath(external_symlink_path)):
return False

# ... but, ignore files in e.g. bazel-out/<configuration>/bin/external/
Expand Down Expand Up @@ -1107,6 +1110,39 @@ def _get_cpp_command_for_files(compile_action):
if 'PATH' not in compile_action.environmentVariables: # Bazel only adds if --incompatible_strict_action_env is passed--and otherwise inherits.
compile_action.environmentVariables['PATH'] = os.environ['PATH']

if {experimental_symlink_prefix}:
# Regex matching for experimentally aliased symlinks
top_level_symlinks = r"(?P<prefix>bazel-out|external)" # The symlinks we can expect to be used as the start of paths in compile_commands.json
flag = (
r"[-/][a-zA-Z0-9\-_]+" # A flag like "-I" or "-frandom-seed" or "-something_weird"
)
full_regex = re.compile(
r"""
^ # Start at the beginning of the line
(?P<content> # Capture content before prefix
(?:{flag})? # Optionally match a single flag, ie for "-Ibazel-out/..."
=? # Optionally match a single equals sign, ie for "-frandom-seed=bazel-out/..."
) # End capture content before prefix
{top_level_symlinks} # Match one of the top level symlinks
/ # End with the OS path separator
""".format(
flag=flag,
top_level_symlinks=top_level_symlinks,
),
re.VERBOSE,
)

def replace_prefix(match):
content = match.group('content')
prefix = match.group('prefix')
if prefix == "bazel-out":
return f"{content}{symlink_prefix}out/"
elif prefix == "external":
return f"{content}{external_symlink_path}/"

for idx, arg in enumerate(compile_action.arguments):
compile_action.arguments[idx] = re.sub(full_regex, replace_prefix, arg)

# Patch command by platform, revealing any hidden arguments.
compile_action.arguments = _apple_platform_patch(compile_action.arguments)
compile_action.arguments = _emscripten_platform_patch(compile_action)
Expand Down Expand Up @@ -1279,19 +1315,22 @@ def _get_commands(target: str, flags: str):
def _ensure_external_workspaces_link_exists():
"""Postcondition: Either //external points into Bazel's fullest set of external workspaces in output_base, or we've exited with an error that'll help the user resolve the issue."""
is_windows = os.name == 'nt'
source = pathlib.Path('external')
source = pathlib.Path(external_symlink_path)

if not os.path.lexists('bazel-out'):
log_error(">>> //bazel-out is missing. Please remove --symlink_prefix and --experimental_convenience_symlinks, so the workspace mirrors the compilation environment.")
if not os.path.lexists(f'{symlink_prefix}out'):
if {experimental_symlink_prefix}:
log_error(f">>> //{symlink_prefix}out is missing. Double check your --experimental_symlink_prefix location or disable this experimental feature.")
else:
log_error(">>> //bazel-out is missing. Please remove --symlink_prefix and --experimental_convenience_symlinks, so the workspace mirrors the compilation environment. Alternatively, you can try adding --experimental_symlink_prefix=<your_prefix> to the extractor command to experimentally support the --symlink_prefix bazel flag.")
# Crossref: https://github.com/hedronvision/bazel-compile-commands-extractor/issues/14 https://github.com/hedronvision/bazel-compile-commands-extractor/pull/65
# Note: experimental_no_product_name_out_symlink is now enabled by default. See https://github.com/bazelbuild/bazel/commit/06bd3e8c0cd390f077303be682e9dec7baf17af2
sys.exit(1)

# Traverse into output_base via bazel-out, keeping the workspace position-independent, so it can be moved without rerunning
dest = pathlib.Path('bazel-out/../../../external')
if is_windows:
dest = pathlib.Path(f'{symlink_prefix}out/../../../external')
if is_windows or {experimental_symlink_prefix}: # When using experimental prefix, resolution needs to be done in two steps as well
# On Windows, unfortunately, bazel-out is a junction, and accessing .. of a junction brings you back out the way you came. So we have to resolve bazel-out first. Not position-independent, but I think the best we can do
dest = (pathlib.Path('bazel-out').resolve()/'../../../external').resolve()
dest = (pathlib.Path(f'{symlink_prefix}out').resolve()/'../../../external').resolve()

# Handle problem cases where //external exists
if os.path.lexists(source): # MIN_PY=3.12: use source.exists(follow_symlinks=False), here and elsewhere.
Expand Down Expand Up @@ -1354,8 +1393,8 @@ def _ensure_gitignore_entries_exist():

# Each (pattern, explanation) will be added to the `.gitignore` file if the pattern isn't present.
needed_entries = [
(f'/{pattern_prefix}external', "# Ignore the `external` link (that is added by `bazel-compile-commands-extractor`). The link differs between macOS/Linux and Windows, so it shouldn't be checked in. The pattern must not end with a trailing `/` because it's a symlink on macOS/Linux."),
(f'/{pattern_prefix}bazel-*', "# Ignore links to Bazel's output. The pattern needs the `*` because people can change the name of the directory into which your repository is cloned (changing the `bazel-<workspace_name>` symlink), and must not end with a trailing `/` because it's a symlink on macOS/Linux. This ignore pattern should almost certainly be checked into a .gitignore in your workspace root, too, for folks who don't use this tool."),
(f'/{pattern_prefix}{external_symlink_path}', "# Ignore the `external` link (that is added by `bazel-compile-commands-extractor`). The link differs between macOS/Linux and Windows, so it shouldn't be checked in. The pattern must not end with a trailing `/` because it's a symlink on macOS/Linux."),
(f'/{pattern_prefix}{symlink_prefix}*', "# Ignore links to Bazel's output. The pattern needs the `*` because people can change the name of the directory into which your repository is cloned (changing the `bazel-<workspace_name>` symlink), and must not end with a trailing `/` because it's a symlink on macOS/Linux. This ignore pattern should almost certainly be checked into a .gitignore in your workspace root, too, for folks who don't use this tool."),
(f'/{pattern_prefix}compile_commands.json', "# Ignore generated output. Although valuable (after all, the primary purpose of `bazel-compile-commands-extractor` is to produce `compile_commands.json`!), it should not be checked in."),
('.cache/', "# Ignore the directory in which `clangd` stores its local index."),
]
Expand Down
5 changes: 4 additions & 1 deletion refresh_compile_commands.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def refresh_compile_commands(
targets = None,
exclude_headers = None,
exclude_external_sources = False,
experimental_symlink_prefix = None,
**kwargs): # For the other common attributes. Tags, compatible_with, etc. https://docs.bazel.build/versions/main/be/common-definitions.html#common-attributes.
# Convert the various, acceptable target shorthands into the dictionary format
# In Python, `type(x) == y` is an antipattern, but [Starlark doesn't support inheritance](https://bazel.build/rules/language), so `isinstance` doesn't exist, and this is the correct way to switch on type.
Expand All @@ -89,7 +90,7 @@ def refresh_compile_commands(

# Generate the core, runnable python script from refresh.template.py
script_name = name + ".py"
_expand_template(name = script_name, labels_to_flags = targets, exclude_headers = exclude_headers, exclude_external_sources = exclude_external_sources, **kwargs)
_expand_template(name = script_name, labels_to_flags = targets, exclude_headers = exclude_headers, exclude_external_sources = exclude_external_sources, experimental_symlink_prefix = experimental_symlink_prefix, **kwargs)

# Combine them so the wrapper calls the main script
native.py_binary(
Expand All @@ -115,6 +116,7 @@ def _expand_template_impl(ctx):
"{exclude_headers}": repr(ctx.attr.exclude_headers),
"{exclude_external_sources}": repr(ctx.attr.exclude_external_sources),
"{print_args_executable}": repr(ctx.executable._print_args_executable.path),
"{experimental_symlink_prefix}": repr(ctx.attr.experimental_symlink_prefix),
},
)
return DefaultInfo(files = depset([script]))
Expand All @@ -124,6 +126,7 @@ _expand_template = rule(
"labels_to_flags": attr.string_dict(mandatory = True), # string keys instead of label_keyed because Bazel doesn't support parsing wildcard target patterns (..., *, :all) in BUILD attributes.
"exclude_external_sources": attr.bool(default = False),
"exclude_headers": attr.string(values = ["all", "external", ""]), # "" needed only for compatibility with Bazel < 3.6.0
"experimental_symlink_prefix": attr.string(),
"_script_template": attr.label(allow_single_file = True, default = "refresh.template.py"),
"_print_args_executable": attr.label(executable = True, cfg = "target", default = "//:print_args"),
# For Windows INCLUDE. If this were eliminated, for example by the resolution of https://github.com/clangd/clangd/issues/123, we'd be able to just use a macro and skylib's expand_template rule: https://github.com/bazelbuild/bazel-skylib/pull/330
Expand Down