Added `rust_unpretty_aspect` and `rust_unpretty` rules (#2356)

Proposal for https://github.com/bazelbuild/rules_rust/issues/1642
Duplicates https://github.com/bazelbuild/rules_rust/pull/1643 (special
thanks to @freeformstu)

### Summary

Rustc can be used to expand all macros so that you can inspect the
generated source files easier.

This feature is enabled via `-Zunpretty={mode}`. The `-Z` flag is only
available in the nightly
version of `rustc` (https://github.com/rust-lang/rust/issues/43364).


### Unprettying

Build and test your targets normally.

```
bazel build //:ok_binary   
INFO: Analyzed target //:ok_binary (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //:ok_binary up-to-date:
  bazel-bin/ok_binary
INFO: Elapsed time: 0.081s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
```

Use the aspect to generate the expanded files in as a one-off build.

(`.bazelrc`)
```
# Enable unpretty for all targets in the workspace
build:unpretty --aspects=@rules_rust//rust:defs.bzl%rust_unpretty_aspect
build:unpretty --output_groups=+rust_unpretty

# `unpretty` requires the nightly toolchain. See tracking issue:
# https://github.com/rust-lang/rust/issues/43364
build:unpretty --@rules_rust//rust/toolchain/channel=nightly
```

```
bazel build --config=unpretty //:ok_binary
INFO: Analyzed target //:ok_binary (1 packages loaded, 2 targets configured).
INFO: Found 1 target...
Aspect @rules_rust//rust/private:unpretty.bzl%rust_unpretty_aspect of //:ok_binary up-to-date:
  bazel-bin/ok_binary.expand.rs
INFO: Elapsed time: 0.149s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
```

Targeting tests is valid as well.

```
bazel build --config=unpretty //:ok_test  
INFO: Analyzed target //:ok_test (0 packages loaded, 2 targets configured).
INFO: Found 1 target...
Aspect @rules_rust//rust/private:unpretty.bzl%rust_expand_aspect of //:ok_test up-to-date:
  bazel-bin/test-397521499/ok_test.expand.rs
INFO: Elapsed time: 0.113s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
```

Finally, manually wire up a `rust_unpretty` target explicitly if you
want a target to build. This rule is unique compared to the aspect in
that it forces a transition to a nightly toolchain so that `-Zunpretty`
can be used.

```starlark
load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_unpretty")

rust_binary(
    name = "ok_binary",
    srcs = ["src/main.rs"],
    edition = "2021",
)

rust_unpretty(
    name = "ok_binary_expand",
    deps = [":ok_binary"],
)
```

```
bazel build //:ok_binary_expand
INFO: Analyzed target //:ok_binary_expand (0 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //:ok_binary_expand up-to-date:
  bazel-bin/ok_binary.expand.rs
INFO: Elapsed time: 0.090s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
```
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 9d23e7b..80f88d4 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -15,6 +15,7 @@
   - "//..."
   # These tests are expected to fail as they require both a nightly and stable toolchain.
   - "-//test/unit/channel_transitions/..."
+  - "-//test/unpretty/..."
 default_linux_targets: &default_linux_targets
   - "--"
   - "//..."
@@ -169,6 +170,13 @@
     test_targets: *default_windows_targets
     soft_fail: yes
     bazel: "rolling"
+  ubuntu2004_unpretty:
+    name: Unpretty
+    platform: ubuntu2004
+    build_targets: *default_linux_targets
+    test_targets: *default_linux_targets
+    build_flags:
+      - "--config=unpretty"
   ubuntu2004_bzlmod_only:
     name: With bzlmod
     platform: ubuntu2004
diff --git a/.bazelrc b/.bazelrc
index 00e698c..0667486 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -32,6 +32,14 @@
 build:clippy --aspects=//rust:defs.bzl%rust_clippy_aspect
 build:clippy --output_groups=+clippy_checks
 
+# Enable unpretty for all targets in the workspace
+build:unpretty --aspects=//rust:defs.bzl%rust_unpretty_aspect
+build:unpretty --output_groups=+rust_unpretty
+
+# `unpretty` requires the nightly toolchain. See tracking issue:
+# https://github.com/rust-lang/rust/issues/43364
+build:unpretty --//rust/toolchain/channel=nightly
+
 ###############################################################################
 ## Incompatibility flags
 ###############################################################################
diff --git a/rust/defs.bzl b/rust/defs.bzl
index 8573048..3cee1af 100644
--- a/rust/defs.bzl
+++ b/rust/defs.bzl
@@ -65,6 +65,11 @@
     _rustfmt_aspect = "rustfmt_aspect",
     _rustfmt_test = "rustfmt_test",
 )
+load(
+    "//rust/private:unpretty.bzl",
+    _rust_unpretty = "rust_unpretty",
+    _rust_unpretty_aspect = "rust_unpretty_aspect",
+)
 
 rust_library = _rust_library
 # See @rules_rust//rust/private:rust.bzl for a complete description.
@@ -111,6 +116,12 @@
 rustc_output_diagnostics = _rustc_output_diagnostics
 # See @rules_rust//rust/private:rustc.bzl for a complete description.
 
+rust_unpretty_aspect = _rust_unpretty_aspect
+# See @rules_rust//rust/private:unpretty.bzl for a complete description.
+
+rust_unpretty = _rust_unpretty
+# See @rules_rust//rust/private:unpretty.bzl for a complete description.
+
 error_format = _error_format
 # See @rules_rust//rust/private:rustc.bzl for a complete description.
 
diff --git a/rust/private/rust.bzl b/rust/private/rust.bzl
index 3e89d50..bed6f81 100644
--- a/rust/private/rust.bzl
+++ b/rust/private/rust.bzl
@@ -266,6 +266,22 @@
 
     return providers
 
+def get_rust_test_flags(attr):
+    """Determine the desired rustc flags for test targets.
+
+    Args:
+        attr (dict): Attributes of a rule
+
+    Returns:
+        List: A list of test flags
+    """
+    if getattr(attr, "use_libtest_harness", True):
+        rust_flags = ["--test"]
+    else:
+        rust_flags = ["--cfg", "test"]
+
+    return rust_flags
+
 def _rust_test_impl(ctx):
     """The implementation of the `rust_test` rule.
 
@@ -394,7 +410,7 @@
         attr = ctx.attr,
         toolchain = toolchain,
         crate_info_dict = crate_info_dict,
-        rust_flags = ["--test"] if ctx.attr.use_libtest_harness else ["--cfg", "test"],
+        rust_flags = get_rust_test_flags(ctx.attr),
         skip_expanding_rustc_env = True,
     )
     data = getattr(ctx.attr, "data", [])
@@ -487,6 +503,56 @@
         values = [1, 0, -1],
     )
 
+# Internal attributes core to Rustc actions.
+RUSTC_ATTRS = {
+    "_cc_toolchain": attr.label(
+        doc = (
+            "In order to use find_cc_toolchain, your rule has to depend " +
+            "on C++ toolchain. See `@rules_cc//cc:find_cc_toolchain.bzl` " +
+            "docs for details."
+        ),
+        default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"),
+    ),
+    "_error_format": attr.label(
+        default = Label("//:error_format"),
+    ),
+    "_extra_exec_rustc_flag": attr.label(
+        default = Label("//:extra_exec_rustc_flag"),
+    ),
+    "_extra_exec_rustc_flags": attr.label(
+        default = Label("//:extra_exec_rustc_flags"),
+    ),
+    "_extra_rustc_flag": attr.label(
+        default = Label("//:extra_rustc_flag"),
+    ),
+    "_extra_rustc_flags": attr.label(
+        default = Label("//:extra_rustc_flags"),
+    ),
+    "_import_macro_dep": attr.label(
+        default = Label("//util/import"),
+        cfg = "exec",
+    ),
+    "_is_proc_macro_dep": attr.label(
+        default = Label("//rust/private:is_proc_macro_dep"),
+    ),
+    "_is_proc_macro_dep_enabled": attr.label(
+        default = Label("//rust/private:is_proc_macro_dep_enabled"),
+    ),
+    "_per_crate_rustc_flag": attr.label(
+        default = Label("//:experimental_per_crate_rustc_flag"),
+    ),
+    "_process_wrapper": attr.label(
+        doc = "A process wrapper for running rustc on all platforms.",
+        default = Label("//util/process_wrapper"),
+        executable = True,
+        allow_single_file = True,
+        cfg = "exec",
+    ),
+    "_rustc_output_diagnostics": attr.label(
+        default = Label("//:rustc_output_diagnostics"),
+    ),
+}
+
 _common_attrs = {
     "aliases": attr.label_keyed_string_dict(
         doc = dedent("""\
@@ -618,62 +684,18 @@
         """),
         allow_files = [".rs"],
     ),
-    "stamp": _stamp_attribute(default_value = 0),
+    "stamp": _stamp_attribute(
+        default_value = 0,
+    ),
     "version": attr.string(
         doc = "A version to inject in the cargo environment variable.",
         default = "0.0.0",
     ),
-    "_cc_toolchain": attr.label(
-        doc = (
-            "In order to use find_cc_toolchain, your rule has to depend " +
-            "on C++ toolchain. See `@rules_cc//cc:find_cc_toolchain.bzl` " +
-            "docs for details."
-        ),
-        default = Label("@bazel_tools//tools/cpp:current_cc_toolchain"),
-    ),
-    "_error_format": attr.label(
-        default = Label("//:error_format"),
-    ),
-    "_extra_exec_rustc_flag": attr.label(
-        default = Label("//:extra_exec_rustc_flag"),
-    ),
-    "_extra_exec_rustc_flags": attr.label(
-        default = Label("//:extra_exec_rustc_flags"),
-    ),
-    "_extra_rustc_flag": attr.label(
-        default = Label("//:extra_rustc_flag"),
-    ),
-    "_extra_rustc_flags": attr.label(
-        default = Label("//:extra_rustc_flags"),
-    ),
-    "_import_macro_dep": attr.label(
-        default = Label("//util/import"),
-        cfg = "exec",
-    ),
-    "_is_proc_macro_dep": attr.label(
-        default = Label("//rust/private:is_proc_macro_dep"),
-    ),
-    "_is_proc_macro_dep_enabled": attr.label(
-        default = Label("//rust/private:is_proc_macro_dep_enabled"),
-    ),
-    "_per_crate_rustc_flag": attr.label(
-        default = Label("//:experimental_per_crate_rustc_flag"),
-    ),
-    "_process_wrapper": attr.label(
-        doc = "A process wrapper for running rustc on all platforms.",
-        default = Label("//util/process_wrapper"),
-        executable = True,
-        allow_single_file = True,
-        cfg = "exec",
-    ),
-    "_rustc_output_diagnostics": attr.label(
-        default = Label("//:rustc_output_diagnostics"),
-    ),
     "_stamp_flag": attr.label(
         doc = "A setting used to determine whether or not the `--stamp` flag is enabled",
         default = Label("//rust/private:stamp"),
     ),
-}
+} | RUSTC_ATTRS
 
 _coverage_attrs = {
     "_collect_cc_coverage": attr.label(
diff --git a/rust/private/unpretty.bzl b/rust/private/unpretty.bzl
new file mode 100644
index 0000000..81b04e0
--- /dev/null
+++ b/rust/private/unpretty.bzl
@@ -0,0 +1,344 @@
+"""A module defining Rust 'unpretty' rules"""
+
+load("//rust/private:common.bzl", "rust_common")
+load(
+    "//rust/private:rust.bzl",
+    "RUSTC_ATTRS",
+    "get_rust_test_flags",
+)
+load(
+    "//rust/private:rustc.bzl",
+    "collect_deps",
+    "collect_inputs",
+    "construct_arguments",
+)
+load(
+    "//rust/private:utils.bzl",
+    "determine_output_hash",
+    "find_cc_toolchain",
+    "find_toolchain",
+)
+
+# This list is determined by running the following command:
+#
+#   rustc +nightly -Zunpretty=
+#
+_UNPRETTY_MODES = [
+    "ast-tree,expanded",
+    "ast-tree",
+    "expanded,hygiene",
+    "expanded,identified",
+    "expanded",
+    "hir-tree",
+    "hir,identified",
+    "hir,typed",
+    "hir",
+    "identified",
+    "mir-cfg",
+    "mir",
+    "normal",
+]
+
+RustUnprettyInfo = provider(
+    doc = "A provider describing the Rust unpretty mode.",
+    fields = {
+        "modes": "Depset[string]: Can be any of {}".format(["'{}'".format(m) for m in _UNPRETTY_MODES]),
+    },
+)
+
+def _rust_unpretty_flag_impl(ctx):
+    value = ctx.build_setting_value
+    invalid = []
+    for mode in value:
+        if mode not in _UNPRETTY_MODES:
+            invalid.append(mode)
+    if invalid:
+        fail("{} build setting allowed to take values [{}] but was set to unallowed values: {}".format(
+            ctx.label,
+            ", ".join(["'{}'".format(m) for m in _UNPRETTY_MODES]),
+            invalid,
+        ))
+
+    return RustUnprettyInfo(modes = depset(value))
+
+rust_unpretty_flag = rule(
+    doc = "A build setting which represents the Rust unpretty mode. The allowed values are {}".format(_UNPRETTY_MODES),
+    implementation = _rust_unpretty_flag_impl,
+    build_setting = config.string_list(
+        flag = True,
+        repeatable = True,
+    ),
+)
+
+def _nightly_unpretty_transition_impl(settings, attr):
+    mode = settings[str(Label("//rust/settings:unpretty"))]
+
+    # Use the presence of _unpretty_modes as a proxy for whether this is a rust_unpretty target.
+    if hasattr(attr, "_unpretty_modes") and hasattr(attr, "mode"):
+        mode = mode + [attr.mode]
+
+    return {
+        str(Label("//rust/settings:unpretty")): depset(mode).to_list(),
+        str(Label("//rust/toolchain/channel")): "nightly",
+    }
+
+nightly_unpretty_transition = transition(
+    implementation = _nightly_unpretty_transition_impl,
+    inputs = [str(Label("//rust/settings:unpretty"))],
+    outputs = [
+        str(Label("//rust/settings:unpretty")),
+        str(Label("//rust/toolchain/channel")),
+    ],
+)
+
+def _get_unpretty_ready_crate_info(target, aspect_ctx):
+    """Check that a target is suitable for expansion and extract the `CrateInfo` provider from it.
+
+    Args:
+        target (Target): The target the aspect is running on.
+        aspect_ctx (ctx, optional): The aspect's context object.
+
+    Returns:
+        CrateInfo, optional: A `CrateInfo` provider if rust unpretty should be run or `None`.
+    """
+
+    # Ignore external targets
+    if target.label.workspace_root.startswith("external"):
+        return None
+
+    # Targets with specific tags will not be formatted
+    if aspect_ctx:
+        ignore_tags = [
+            "nounpretty",
+            "no-unpretty",
+            "no_unpretty",
+        ]
+
+        for tag in ignore_tags:
+            if tag in aspect_ctx.rule.attr.tags:
+                return None
+
+    # Obviously ignore any targets that don't contain `CrateInfo`
+    if rust_common.crate_info not in target:
+        return None
+
+    return target[rust_common.crate_info]
+
+def _rust_unpretty_aspect_impl(target, ctx):
+    crate_info = _get_unpretty_ready_crate_info(target, ctx)
+    if not crate_info:
+        return []
+
+    toolchain = find_toolchain(ctx)
+    cc_toolchain, feature_configuration = find_cc_toolchain(ctx)
+
+    dep_info, build_info, linkstamps = collect_deps(
+        deps = crate_info.deps,
+        proc_macro_deps = crate_info.proc_macro_deps,
+        aliases = crate_info.aliases,
+        # Rust expand doesn't need to invoke transitive linking, therefore doesn't need linkstamps.
+        are_linkstamps_supported = False,
+    )
+
+    compile_inputs, out_dir, build_env_files, build_flags_files, linkstamp_outs, ambiguous_libs = collect_inputs(
+        ctx,
+        ctx.rule.file,
+        ctx.rule.files,
+        linkstamps,
+        toolchain,
+        cc_toolchain,
+        feature_configuration,
+        crate_info,
+        dep_info,
+        build_info,
+    )
+
+    output_groups = {}
+    outputs = []
+
+    for mode in ctx.attr._unpretty_modes[RustUnprettyInfo].modes.to_list():
+        pretty_mode = mode.replace("-", "_")
+        mnemonic = "RustUnpretty{}".format("".join([
+            o.title()
+            for m in pretty_mode.split(",")
+            for o in m.split("_")
+        ]))
+
+        unpretty_out = ctx.actions.declare_file(
+            "{}.unpretty.{}.rs".format(ctx.label.name, pretty_mode.replace(",", ".")),
+            sibling = crate_info.output,
+        )
+
+        output_groups.update({"rust_unpretty_{}".format(pretty_mode.replace(",", "_")): depset([unpretty_out])})
+        outputs.append(unpretty_out)
+
+        rust_flags = []
+        if crate_info.is_test:
+            rust_flags = get_rust_test_flags(ctx.rule.attr)
+
+        args, env = construct_arguments(
+            ctx = ctx,
+            attr = ctx.rule.attr,
+            file = ctx.file,
+            toolchain = toolchain,
+            tool_path = toolchain.rustc.path,
+            cc_toolchain = cc_toolchain,
+            feature_configuration = feature_configuration,
+            crate_info = crate_info,
+            dep_info = dep_info,
+            linkstamp_outs = linkstamp_outs,
+            ambiguous_libs = ambiguous_libs,
+            output_hash = determine_output_hash(crate_info.root, ctx.label),
+            rust_flags = rust_flags,
+            out_dir = out_dir,
+            build_env_files = build_env_files,
+            build_flags_files = build_flags_files,
+            emit = ["dep-info", "metadata"],
+            skip_expanding_rustc_env = True,
+        )
+
+        args.process_wrapper_flags.add("--stdout-file", unpretty_out)
+
+        # Expand all macros and dump the source to stdout.
+        # Tracking issue: https://github.com/rust-lang/rust/issues/43364
+        args.rustc_flags.add("-Zunpretty={}".format(mode))
+
+        ctx.actions.run(
+            executable = ctx.executable._process_wrapper,
+            inputs = compile_inputs,
+            outputs = [unpretty_out],
+            env = env,
+            arguments = args.all,
+            mnemonic = mnemonic,
+            toolchain = "@rules_rust//rust:toolchain_type",
+        )
+
+    output_groups.update({"rust_unpretty": depset(outputs)})
+
+    return [
+        OutputGroupInfo(**output_groups),
+    ]
+
+# Example: Expand all rust targets in the codebase.
+#   bazel build --aspects=@rules_rust//rust:defs.bzl%rust_unpretty_aspect \
+#               --output_groups=expanded \
+#               //...
+rust_unpretty_aspect = aspect(
+    implementation = _rust_unpretty_aspect_impl,
+    fragments = ["cpp"],
+    host_fragments = ["cpp"],
+    attrs = {
+        "_unpretty_modes": attr.label(
+            doc = "The values to pass to `--unpretty`",
+            providers = [RustUnprettyInfo],
+            default = Label("//rust/settings:unpretty"),
+        ),
+    } | RUSTC_ATTRS,
+    toolchains = [
+        str(Label("//rust:toolchain_type")),
+        "@bazel_tools//tools/cpp:toolchain_type",
+    ],
+    required_providers = [rust_common.crate_info],
+    doc = """\
+Executes Rust expand on specified targets.
+
+This aspect applies to existing rust_library, rust_test, and rust_binary rules.
+
+As an example, if the following is defined in `examples/hello_lib/BUILD.bazel`:
+
+```python
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "hello_lib",
+    srcs = ["src/lib.rs"],
+)
+
+rust_test(
+    name = "greeting_test",
+    srcs = ["tests/greeting.rs"],
+    deps = [":hello_lib"],
+)
+```
+
+Then the targets can be expanded with the following command:
+
+```output
+$ bazel build --aspects=@rules_rust//rust:defs.bzl%rust_unpretty_aspect \
+              --output_groups=rust_unpretty_expanded //hello_lib:all
+```
+""",
+)
+
+def _rust_unpretty_rule_impl(ctx):
+    mode = ctx.attr.mode
+    output_group = "rust_unpretty_{}".format(mode.replace(",", "_").replace("-", "_"))
+    files = []
+    for target in ctx.attr.deps:
+        files.append(getattr(target[OutputGroupInfo], output_group))
+
+    return [DefaultInfo(files = depset(transitive = files))]
+
+rust_unpretty = rule(
+    implementation = _rust_unpretty_rule_impl,
+    cfg = nightly_unpretty_transition,
+    attrs = {
+        "deps": attr.label_list(
+            doc = "Rust targets to run unpretty on.",
+            providers = [rust_common.crate_info],
+            aspects = [rust_unpretty_aspect],
+        ),
+        "mode": attr.string(
+            doc = "The value to pass to `--unpretty`",
+            values = _UNPRETTY_MODES,
+            default = "expanded",
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = Label("//tools/allowlists/function_transition_allowlist"),
+        ),
+        "_unpretty_modes": attr.label(
+            doc = "The values to pass to `--unpretty`",
+            providers = [RustUnprettyInfo],
+            default = Label("//rust/settings:unpretty"),
+        ),
+    },
+    doc = """\
+Executes rust unpretty on a specific target.
+
+Similar to `rust_unpretty_aspect`, but allows specifying a list of dependencies \
+within the build system.
+
+For example, given the following example targets:
+
+```python
+load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")
+
+rust_library(
+    name = "hello_lib",
+    srcs = ["src/lib.rs"],
+)
+
+rust_test(
+    name = "greeting_test",
+    srcs = ["tests/greeting.rs"],
+    deps = [":hello_lib"],
+)
+```
+
+Rust expand can be set as a build target with the following:
+
+```python
+load("@rules_rust//rust:defs.bzl", "rust_unpretty")
+
+rust_unpretty(
+    name = "hello_library_expand",
+    testonly = True,
+    deps = [
+        ":hello_lib",
+        ":greeting_test",
+    ],
+    mode = "expand",
+)
+```
+""",
+)
diff --git a/rust/settings/BUILD.bazel b/rust/settings/BUILD.bazel
index f1ba202..1f23709 100644
--- a/rust/settings/BUILD.bazel
+++ b/rust/settings/BUILD.bazel
@@ -1,5 +1,6 @@
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
 load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_flag")
+load("//rust/private:unpretty.bzl", "rust_unpretty_flag")
 load(":incompatible.bzl", "incompatible_flag")
 
 package(default_visibility = ["//visibility:public"])
@@ -9,6 +10,25 @@
     srcs = glob(["**/*.bzl"]),
 )
 
+rust_unpretty_flag(
+    name = "unpretty",
+    build_setting_default = [
+        "ast-tree,expanded",
+        "ast-tree",
+        "expanded,hygiene",
+        "expanded,identified",
+        "expanded",
+        "hir-tree",
+        "hir,identified",
+        "hir,typed",
+        "hir",
+        "identified",
+        "mir-cfg",
+        "mir",
+        "normal",
+    ],
+)
+
 # A flag controlling whether to rename first-party crates such that their names
 # encode the Bazel package and target name, instead of just the target name.
 #
diff --git a/test/unit/force_all_deps_direct/force_all_deps_direct_test.bzl b/test/unit/force_all_deps_direct/force_all_deps_direct_test.bzl
index 6cb5147..39e705a 100644
--- a/test/unit/force_all_deps_direct/force_all_deps_direct_test.bzl
+++ b/test/unit/force_all_deps_direct/force_all_deps_direct_test.bzl
@@ -36,7 +36,10 @@
     generator(
         name = "generate",
         deps = [":direct"],
-        tags = ["noclippy"],
+        tags = [
+            "no-clippy",
+            "no-unpretty",
+        ],
     )
 
     force_all_deps_direct_test(
diff --git a/test/unpretty/BUILD.bazel b/test/unpretty/BUILD.bazel
new file mode 100644
index 0000000..3f0f773
--- /dev/null
+++ b/test/unpretty/BUILD.bazel
@@ -0,0 +1,49 @@
+load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
+load("//rust:defs.bzl", "rust_proc_macro", "rust_test", "rust_unpretty")
+
+rust_proc_macro(
+    name = "proc_macro",
+    srcs = ["proc_macro.rs"],
+    edition = "2021",
+    visibility = ["//test:__subpackages__"],
+)
+
+rust_unpretty(
+    name = "proc_macro_unpretty",
+    testonly = True,
+    mode = "expanded",
+    deps = [":proc_macro"],
+)
+
+diff_test(
+    name = "proc_macro_unpretty_diff_test",
+    file1 = "proc_macro.unpretty.expanded.rs",
+    file2 = ":proc_macro_unpretty",
+)
+
+rust_test(
+    name = "proc_macro_test",
+    srcs = ["proc_macro_test.rs"],
+    edition = "2021",
+    proc_macro_deps = [":proc_macro"],
+)
+
+rust_unpretty(
+    name = "proc_macro_test_unpretty",
+    testonly = True,
+    mode = "expanded",
+    deps = [":proc_macro_test"],
+)
+
+rust_unpretty(
+    name = "proc_macro_test_unpretty_extra",
+    testonly = True,
+    mode = "normal",
+    deps = [":proc_macro_test"],
+)
+
+diff_test(
+    name = "proc_macro_test_unpretty_diff_test",
+    file1 = "proc_macro.unpretty.expanded.rs",
+    file2 = ":proc_macro_unpretty",
+)
diff --git a/test/unpretty/proc_macro.rs b/test/unpretty/proc_macro.rs
new file mode 100644
index 0000000..9005e0d
--- /dev/null
+++ b/test/unpretty/proc_macro.rs
@@ -0,0 +1,9 @@
+// This differs from the edition 2015 version because it does not have an `extern proc_macro`
+// statement, which became optional in edition 2018.
+
+use proc_macro::TokenStream;
+
+#[proc_macro]
+pub fn make_answer(_item: TokenStream) -> TokenStream {
+    "fn answer() -> u32 { 42 }".parse().unwrap()
+}
diff --git a/test/unpretty/proc_macro.unpretty.expanded.rs b/test/unpretty/proc_macro.unpretty.expanded.rs
new file mode 100644
index 0000000..f167085
--- /dev/null
+++ b/test/unpretty/proc_macro.unpretty.expanded.rs
@@ -0,0 +1,24 @@
+#![feature(prelude_import)]
+#[prelude_import]
+use std::prelude::rust_2021::*;
+#[macro_use]
+extern crate std;
+// This differs from the edition 2015 version because it does not have an `extern proc_macro`
+// statement, which became optional in edition 2018.
+
+use proc_macro::TokenStream;
+
+#[proc_macro]
+pub fn make_answer(_item: TokenStream) -> TokenStream {
+    "fn answer() -> u32 { 42 }".parse().unwrap()
+}
+const _: () =
+    {
+        extern crate proc_macro;
+        #[rustc_proc_macro_decls]
+        #[used]
+        #[allow(deprecated)]
+        static _DECLS: &[proc_macro::bridge::client::ProcMacro] =
+            &[proc_macro::bridge::client::ProcMacro::bang("make_answer",
+                            make_answer)];
+    };
diff --git a/test/unpretty/proc_macro_test.rs b/test/unpretty/proc_macro_test.rs
new file mode 100644
index 0000000..c5f92f0
--- /dev/null
+++ b/test/unpretty/proc_macro_test.rs
@@ -0,0 +1,8 @@
+use proc_macro::make_answer;
+
+make_answer!();
+
+#[test]
+fn test_answer_macro() {
+    println!("{}", answer());
+}
diff --git a/test/unpretty/proc_macro_test.unpretty.expanded.rs b/test/unpretty/proc_macro_test.unpretty.expanded.rs
new file mode 100644
index 0000000..7261e99
--- /dev/null
+++ b/test/unpretty/proc_macro_test.unpretty.expanded.rs
@@ -0,0 +1,9 @@
+#![feature(prelude_import)]
+#[prelude_import]
+use std::prelude::rust_2021::*;
+#[macro_use]
+extern crate std;
+use proc_macro::make_answer;
+
+fn answer() -> u32 { 42 }
+