RFA-170 · Case file with fixtures · Case 142 of 694 · Cargo workspace evidence
Why $crate in an Exported Macro Cannot Bypass Privacy
$crate gives macro expansion a hygienic path to the defining crate, including after dependency renaming. It does not grant visibility; externally expanded code can reference only items visible from that context.
- Reviewed
- Rust
- Rust 1.98.1, Cargo 1.98.1, edition 2024
- Targets
- all Cargo targets
- Profiles
- check, dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- $crate provides a hygienic path to the defining crate; it does not bypass Rust visibility rules at the macro invocation site.
- First discriminating check
- Expand the macro path mentally or with tooling, then verify the visibility of every definition referenced through $crate from an external crate.
$crate solves a name-resolution problem for exported macros. It does not turn private implementation details into public API.
The failing two-crate fixture exports a macro that expands to $crate::hidden_value(). Inside the defining library, hidden_value is private. A downstream binary invokes the macro and receives E0603: the function is private.
Expansion location still matters for privacy
The macro hygiene reference explains that $crate refers to the crate defining the macro. This lets exported expansions find sibling macros and items without assuming the downstream dependency name.
After expansion, the downstream code effectively contains a path into another crate:
rfa_macro_library::hidden_value()
The path resolves correctly. Then normal visibility and privacy rules apply. A private function cannot be accessed from that external context.
Hygienic resolution and access permission are two separate checks.
$crate is still the right path tool
Replacing $crate with a literal crate name is not a good repair. Cargo dependencies can be renamed:
[dependencies]
short = { package = "long-library-name", version = "..." }
The invocation refers to short, not necessarily the published package identifier. $crate remains tied to the defining crate and survives that rename.
It also avoids resolving an unqualified helper name in the caller's scope. The Atlas macro-hygiene material covers why generated names and captured names have different resolution contexts.
The failure means the item visibility is wrong for an exported expansion, not that $crate should be removed.
Public but hidden from documentation is one repair
The repaired fixture declares the helper pub so downstream expansion may call it, and adds #[doc(hidden)] so it does not clutter ordinary generated documentation.
This pattern is common for macro support items:
#[doc(hidden)]
pub fn hidden_value() -> u32 { ... }
The important caveat is that doc(hidden) affects documentation presentation, not API visibility or compatibility. The symbol is public. Downstream code can call it, and changing or removing it may break already published macro expansions.
I treat such helpers as an internal-looking but semver-relevant support API.
A public hidden module can contain support items
For several helpers, I place them in a deliberately obscure module:
#[doc(hidden)]
pub mod __macro_support {
pub use core::...;
pub fn helper(...) { ... }
}
The macro uses $crate::__macro_support::helper. The double-underscore name communicates that users should not call it directly, while visibility makes expansion legal.
Re-exporting types and traits here can also stabilize paths used by generated code. I keep the surface minimal because every public support item increases compatibility obligations.
Inline expansion can avoid the helper
Sometimes the helper is tiny enough to generate directly. That removes a public symbol but can duplicate code or expose new resolution issues. Generated code must use robust paths for standard items and avoid assuming imports in the caller.
For complex logic, a support function is easier to test and keeps expansions small. I choose based on API stability, code size, type inference, and diagnostics—not privacy alone.
pub(crate) is still insufficient
pub(crate) makes an item visible everywhere inside the defining crate. The expansion is type-checked as downstream code accessing the defining crate, so crate-only visibility still fails.
This is a common near-repair because the macro source lives beside the helper. The relevant boundary is where the expanded path is used, not where the macro text was written.
The two-crate fixture is essential. A unit test inside the library can pass and miss the real visibility boundary. I keep a downstream fixture or documentation test for exported macros.
My macro API checklist
For an exported macro_rules! macro, I inspect:
- Every unqualified name produced by the transcriber.
- Every
$cratepath and the target item's external visibility. - Dependency renaming in a downstream test crate.
- Feature combinations controlling support items.
- Semver impact of changing hidden public paths.
- Whether errors point to useful caller spans.
I also test the packaged crate rather than only the workspace source, because missing files or feature definitions can change expansion.
The core principle is that resolution is not authorization. $crate reliably identifies the macro's home, but privacy still protects items across the crate boundary. An exported macro and its support paths together form a public contract, even when part of that contract is hidden from normal documentation.