Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ stardoc(
symbol_names = [
"rust_clippy",
"rust_clippy_aspect",
"rust_clippy_test",
"rust_clippy_test_aspect",
],
table_of_contents_template = "@stardoc//stardoc:templates/markdown_tables/table_of_contents.vm",
deps = [":all_docs"],
Expand All @@ -134,6 +136,7 @@ stardoc(
symbol_names = [
"rustfmt_aspect",
"rustfmt_test",
"rustfmt_test_aspect",
],
table_of_contents_template = "@stardoc//stardoc:templates/markdown_tables/table_of_contents.vm",
deps = [":all_docs"],
Expand Down
24 changes: 24 additions & 0 deletions docs/rust_clippy.vm
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,27 @@ the upstream implementation of clippy, this file must be named either `.clippy.t
```text
build --@rules_rust//rust/settings:clippy.toml=//:clippy.toml
```

#[[
### Running clippy over a tree of targets as a test
]]#

`rust_clippy_test` is a `test = True` rule that runs clippy over the targets you list and
transitively across their `deps`, `proc_macro_deps`, and `crate`. The clippy actions run
during the build phase, so any clippy failure fails `bazel test` before the test executable
is invoked. The test itself simply prints the paths of the collected clippy marker files.

This is the recommended way to enforce clippy in CI without wiring up `--aspects` /
`--output_groups` flags.

```python
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_clippy_test", "rust_library")

rust_library(name = "lib", srcs = ["src/lib.rs"], edition = "2021")
rust_binary(name = "app", srcs = ["src/main.rs"], edition = "2021", deps = [":lib"])

rust_clippy_test(
name = "clippy_tree_test",
targets = [":app"],
)
```
31 changes: 31 additions & 0 deletions docs/rust_fmt.vm
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,37 @@ file is used whenever `rustfmt` is run:
build --@rules_rust//rust/settings:rustfmt.toml=//:rustfmt.toml
```

#[[
### Running rustfmt over a tree of targets as a test
]]#

`rustfmt_test` runs `rustfmt --check` over a set of Rust targets. By default it walks each
listed target's `deps`, `proc_macro_deps`, and `crate` transitively, so listing a top-level
target formats its whole crate graph. Set `transitive = False` to check only the exact targets
listed. An optional `platform` attribute transitions `targets` to the given platform.

The rustfmt actions run during the build phase, so any formatting failure fails `bazel test`
before the test executable is invoked. The test itself simply prints the paths of the collected
`.rustfmt.ok` marker files.

```python
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rustfmt_test")

rust_library(name = "lib", srcs = ["src/lib.rs"], edition = "2021")
rust_binary(name = "app", srcs = ["src/main.rs"], edition = "2021", deps = [":lib"])

rustfmt_test(
name = "fmt_tree_test",
targets = [":app"],
)

rustfmt_test(
name = "fmt_app_only_test",
targets = [":app"],
transitive = False,
)
```

[rustfmt]: https://github.com/rust-lang/rustfmt#readme
[rsg]: https://github.com/rust-lang-nursery/fmt-rfcs/blob/master/guide/guide.md
[rfcp]: https://github.com/rust-lang-nursery/fmt-rfcs
Expand Down
12 changes: 12 additions & 0 deletions rust/defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ load(
_rust_clippy = "rust_clippy",
_rust_clippy_action = "rust_clippy_action",
_rust_clippy_aspect = "rust_clippy_aspect",
_rust_clippy_test = "rust_clippy_test",
_rust_clippy_test_aspect = "rust_clippy_test_aspect",
)
load("//rust/private:common.bzl", _rust_common = "rust_common")
load(
Expand Down Expand Up @@ -71,6 +73,7 @@ load(
"//rust/private:rustfmt.bzl",
_rustfmt_aspect = "rustfmt_aspect",
_rustfmt_test = "rustfmt_test",
_rustfmt_test_aspect = "rustfmt_test_aspect",
)
load(
"//rust/private:unpretty.bzl",
Expand Down Expand Up @@ -118,6 +121,12 @@ rust_clippy_aspect = _rust_clippy_aspect
rust_clippy = _rust_clippy
# See @rules_rust//rust/private:clippy.bzl for a complete description.

rust_clippy_test_aspect = _rust_clippy_test_aspect
# See @rules_rust//rust/private:clippy.bzl for a complete description.

rust_clippy_test = _rust_clippy_test
# See @rules_rust//rust/private:clippy.bzl for a complete description.

capture_clippy_output = _capture_clippy_output
# See @rules_rust//rust/private:clippy.bzl for a complete description.

Expand Down Expand Up @@ -166,6 +175,9 @@ rustfmt_aspect = _rustfmt_aspect
rustfmt_test = _rustfmt_test
# See @rules_rust//rust/private:rustfmt.bzl for a complete description.

rustfmt_test_aspect = _rustfmt_test_aspect
# See @rules_rust//rust/private:rustfmt.bzl for a complete description.

rust_stdlib_filegroup = _rust_stdlib_filegroup
# See @rules_rust//rust:toolchain.bzl for a complete description.

Expand Down
81 changes: 81 additions & 0 deletions rust/private/clippy.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@

load("@bazel_skylib//lib:structs.bzl", "structs")
load("//rust/private:common.bzl", "rust_common")
load(
"//rust/private:lint_test.bzl",
"LINT_TEST_COMMON_ATTRS",
"lint_test_aspect_impl",
"lint_test_rule_impl",
"platform_transition",
)
load(
"//rust/private:providers.bzl",
"CaptureClippyOutputInfo",
Expand Down Expand Up @@ -452,6 +459,80 @@ rust_clippy(
""",
)

RustClippyTestInfo = provider(
doc = "Clippy check outputs surfaced by `rust_clippy_test_aspect`.",
fields = {
"checks": "depset[File]: Clippy markers for the visited target plus every crate reached via `deps`, `proc_macro_deps`, and `crate`.",
"direct_markers": "list[File]: Clippy markers for the visited target only.",
},
)

# clippy contributes to two output groups: `clippy_checks` (`.clippy.ok`
# marker in the default config, `.clippy.out` when `capture_clippy_output`
# is on) and `clippy_output` (`.clippy.diagnostics` JSON when
# `clippy_output_diagnostics` is on). Capture modes make clippy exit 0 even
# on real issues, so the runner inspects file contents to decide pass/fail.
_CLIPPY_OUTPUT_GROUPS = ["clippy_checks", "clippy_output"]

def _rust_clippy_test_aspect_impl(target, ctx):
return lint_test_aspect_impl(target, ctx, RustClippyTestInfo, _CLIPPY_OUTPUT_GROUPS)

rust_clippy_test_aspect = aspect(
implementation = _rust_clippy_test_aspect_impl,
attr_aspects = ["deps", "proc_macro_deps", "crate"],
requires = [rust_clippy_aspect],
provides = [RustClippyTestInfo],
doc = "Walks `deps`/`proc_macro_deps`/`crate` and rolls up the markers produced by `rust_clippy_aspect` into a transitive `RustClippyTestInfo`.",
)

def _rust_clippy_test_impl(ctx):
return lint_test_rule_impl(ctx, RustClippyTestInfo)

rust_clippy_test = rule(
implementation = _rust_clippy_test_impl,
attrs = dict(LINT_TEST_COMMON_ATTRS, **{
"targets": attr.label_list(
doc = "Rust targets to run clippy on.",
providers = [
[rust_common.crate_info],
[rust_common.test_crate_info],
],
aspects = [rust_clippy_test_aspect],
cfg = platform_transition,
),
}),
test = True,
doc = """\
A test rule that runs `clippy` over a set of Rust targets.

By default (`transitive = True`), the aspect walks `deps`, `proc_macro_deps`, and `crate`
transitively so that listing a top-level target checks its whole crate graph. Set
`transitive = False` to run clippy only on the exact targets listed.

The clippy actions run during the build phase, so a clippy failure fails `bazel test` before
the test executable is invoked. When `capture_clippy_output` or `clippy_output_diagnostics` is
set globally clippy exits 0 even on real issues; in that case the runner inspects the
captured stderr / JSON diagnostics and reports the verdict.

An optional `platform` attribute transitions `targets` to the given platform before running
clippy.

Example:

```python
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_clippy_test", "rust_library")

rust_library(name = "lib", srcs = ["src/lib.rs"], edition = "2021")
rust_binary(name = "app", srcs = ["src/main.rs"], edition = "2021", deps = [":lib"])

rust_clippy_test(name = "clippy_tree_test", targets = [":app"])
rust_clippy_test(name = "clippy_app_only_test", targets = [":app"], transitive = False)
```

Targets tagged `no_clippy`, `no_lint`, `nolint`, or `noclippy` are skipped.
""",
)

def _capture_clippy_output_impl(ctx):
"""Implementation of the `capture_clippy_output` rule

Expand Down
143 changes: 143 additions & 0 deletions rust/private/lint_test.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Shared helpers for `rust_clippy_test` and `rustfmt_test`.

Both rules follow the same shape: a thin wrapper aspect that walks
`deps`/`proc_macro_deps`/`crate` and collects the output-group markers
produced by the underlying real aspect (`rust_clippy_aspect` /
`rustfmt_aspect`), plus a rule impl that symlinks a shared runner binary
and hands it the collected marker rlocationpaths via `RUST_LINT_TEST_MARKERS`.

The pieces exposed here — `rlocationpath`, `platform_transition`,
`LINT_TEST_COMMON_ATTRS`, `lint_test_aspect_impl`, `lint_test_rule_impl` —
let each rule file supply only what actually differs (the provider type
and the output-group names it collects).
"""

def rlocationpath(file, workspace_name):
"""Compute the runfile rlocationpath for a File."""
if file.short_path.startswith("../"):
return file.short_path[len("../"):]
return "{}/{}".format(workspace_name, file.short_path)

def _platform_transition_impl(_settings, attr):
if not attr.platform:
return {}
platform = str(attr.platform)
if not platform.startswith("@"):
platform = "@" + platform
return {"//command_line_option:platforms": platform}

platform_transition = transition(
implementation = _platform_transition_impl,
inputs = [],
outputs = ["//command_line_option:platforms"],
)

# Attrs every lint-test rule needs alongside its own `targets`. Callers
# merge this dict into their `attrs = {...}`.
LINT_TEST_COMMON_ATTRS = {
"platform": attr.label(
doc = "Optional platform to transition `targets` to before running the aspect. When set, `--platforms` is switched to this label for the duration of this rule's aspect actions.",
),
"transitive": attr.bool(
doc = "If True (default), lint `targets` and every crate reachable via `deps`, `proc_macro_deps`, and `crate`. If False, lint only the exact targets listed.",
default = True,
),
"_allowlist_function_transition": attr.label(
default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
),
"_runner": attr.label(
doc = "The shared runner (prints/inspects collected marker paths).",
cfg = "exec",
executable = True,
default = Label("//rust/private/lint_test_runner"),
),
}

def lint_test_aspect_impl(target, ctx, info_provider, output_group_names):
"""Thin collector: walk deps and roll up the markers the underlying aspect produced.

Args:
target: Aspect target.
ctx: Aspect ctx.
info_provider: Provider type to read from deps and return.
output_group_names: List[str] of `OutputGroupInfo` field names to
collect from the current target (e.g. `["clippy_checks", "clippy_output"]`
or `["rustfmt_checks"]`).

Returns:
A single-element list with a `info_provider(direct_markers, checks)`.
"""
transitive = []
for attr_name in ("deps", "proc_macro_deps"):
for dep in getattr(ctx.rule.attr, attr_name, []):
if info_provider in dep:
transitive.append(dep[info_provider].checks)
crate_dep = getattr(ctx.rule.attr, "crate", None)
if crate_dep and info_provider in crate_dep:
transitive.append(crate_dep[info_provider].checks)

direct = []
if OutputGroupInfo in target:
og = target[OutputGroupInfo]
for name in output_group_names:
if hasattr(og, name):
direct = direct + getattr(og, name).to_list()

return [info_provider(
direct_markers = direct,
checks = depset(direct, transitive = transitive),
)]

def lint_test_rule_impl(ctx, info_provider):
"""Symlink the shared runner and hand it the collected marker rlocationpaths.

Args:
ctx: Rule ctx.
info_provider: Provider carrying `direct_markers` / `checks`.

Returns:
DefaultInfo + RunEnvironmentInfo for the test.
"""
is_windows = ctx.executable._runner.extension == ".exe"
runner = ctx.actions.declare_file("{}{}".format(
ctx.label.name,
".exe" if is_windows else "",
))
ctx.actions.symlink(
output = runner,
target_file = ctx.executable._runner,
is_executable = True,
)

marker_files = []
check_depsets = []
for target in ctx.attr.targets:
if info_provider not in target:
continue
info = target[info_provider]
if ctx.attr.transitive:
check_depsets.append(info.checks)
else:
marker_files.extend(info.direct_markers)

checks = depset(marker_files, transitive = check_depsets)
runfiles = ctx.runfiles(transitive_files = checks).merge(
ctx.attr._runner[DefaultInfo].default_runfiles,
)

markers_env = ctx.configuration.host_path_separator.join([
rlocationpath(f, ctx.workspace_name)
for f in checks.to_list()
])

return [
DefaultInfo(
files = depset([runner]),
runfiles = runfiles,
executable = runner,
),
RunEnvironmentInfo(environment = {
"RUST_BACKTRACE": "1",
"RUST_LINT_TEST_MARKERS": markers_env,
}),
]
11 changes: 11 additions & 0 deletions rust/private/lint_test_runner/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
load("//rust/private:rust.bzl", "rust_binary")

rust_binary(
name = "lint_test_runner",
srcs = ["lint_test_runner.rs"],
edition = "2021",
visibility = ["//visibility:public"],
deps = [
"//rust/runfiles",
],
)
Loading
Loading